diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4cd6fac41..a4e13aba4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fix: 「D」キーでダークモードを切り替える際にsyncDeviceDarkModeのチェックがバイパスされる問題を修正 ### Server +- Fix: FTT (Fanout Timeline) 上に飛び石の歯抜けがあるとき、 タイムラインAPI が DB に存在するノートを取りこぼす問題を修正 - Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善 - Fix: PerUserDriveChart がシステム所有ファイル (userId が null) の更新で `"group"` の非NULL制約違反によりクラッシュする問題を修正 (#17498) - Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善 diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index e39d70d683a..810cd2baa2a 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -181,25 +181,30 @@ export class FanoutTimelineEndpointService { redisTimeline.push(...gotFromDb); lastSuccessfulRate = gotFromDb.length / noteIds.length; - if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { - // 十分Redisからとれた + // allowPartial のみ早期 return 許可。 + // それ以外は Redis 上位 limit 件範囲内の歯抜け検出が原理的に不可能なため + // (Redis 内に存在しない歯抜け ID の有無を Redis データだけからは判別できない)、 + // 常に最終 dbFallback の全範囲取り直しに進ませて上位 limit 件を正しく再構築する。 + if (ps.allowPartial && redisTimeline.length !== 0) { return redisTimeline.slice(0, ps.limit); } + if (redisTimeline.length >= ps.limit) { + break; + } } - // まだ足りない分はDBにフォールバック - const remainingToRead = ps.limit - redisTimeline.length; - let dbUntil: string | null; - let dbSince: string | null; - if (ascending) { - dbUntil = ps.untilId; - dbSince = noteIds[noteIds.length - 1]; - } else { - dbUntil = noteIds[noteIds.length - 1]; - dbSince = ps.sinceId; - } - const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead); - return [...redisTimeline, ...gotFromDb]; + // 常に最終 dbFallback で全範囲を取り直し、Redis 由来と dedupe + sort + slice する。 + // Redis 最古/最新を境界に使うと、Redis 上の飛び石歯抜け + // (3分ガード拒否, TTL evict, LREM 等) や、Redis ループでの古い ID 消費による + // ページネーション境界のずれで取りこぼしが発生するため、ps.untilId/ps.sinceId の + // 全範囲を引いて上位 limit 件を再構築する。 + // gotFromDb にも filter を適用するのは、Redis 経由のフィルタ (excludeReplies, + // excludeNoFiles, ミュート/ブロック等) を DB 由来のノートにも揃えるため。 + const gotFromDb = await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); + const seen = new Set(redisTimeline.map(n => n.id)); + const merged = [...redisTimeline, ...gotFromDb.filter(n => !seen.has(n.id) && filter(n))]; + merged.sort((a, b) => idCompare(a.id, b.id)); + return merged.slice(0, ps.limit); } return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 8a23657772a..df8ca994453 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3326,4 +3326,99 @@ describe('Timelines', () => { // TODO: リノートミュート済みユーザーのテスト // TODO: ページネーションのテスト }); + + // FanoutTimelineEndpointServiceの最終DBフォールバックがRedis最古を境界に使うため、 + // Redis上に飛び石の歯抜け (3分ガードでpushが拒否されたノート, TTL evict, LREM等) + // があると、その歯抜け範囲のノートがDBクエリにも含まれず取りこぼされる問題に対する回帰テスト。 + describe('FTT Redis gap fallback', () => { + beforeAll(async () => { + await api('admin/update-meta', { enableFanoutTimeline: true }, root); + }, 1000 * 60 * 2); + + test('Home TL: Redis上のhomeTimeline FTTに飛び石の歯抜けがあっても、DBの全ノートを取りこぼさず返す', async () => { + const alice = await signup(); + + // alwaysIncludeMyNotes対象 = 自分宛投稿でHTLを埋める + const noteIds: string[] = []; + for (let i = 0; i < 20; i++) { + const n = await post(alice, { text: `seed ${i}` }); + noteIds.push(n.id); + } + await setTimeout(500); + + // Redis FTTから中間7件をLREMで抜いて飛び石歯抜けを作る + // (本番では3分ガード拒否, TTL evict, 編集起因のID変動などで自然発生する状態) + const gapTargets = noteIds.slice(5, 12); + for (const id of gapTargets) { + await redisForTimelines.lrem(`list:homeTimeline:${alice.id}`, 1, id); + } + + const res = await api('notes/timeline', { limit: 20 }, alice); + const returnedIds = (res.body as Note[]).map(n => n.id); + + // 期待: DBに存在する全20件が返る (Redis歯抜けにかかわらず取りこぼし0) + for (const id of noteIds) { + assert.ok(returnedIds.includes(id), `missing ${id} in HTL result`); + } + assert.strictEqual(returnedIds.length, 20); + }); + + test('Channel TL: Redis上のchannelTimeline FTTに飛び石の歯抜けがあっても、DBの全ノートを取りこぼさず返す', async () => { + const alice = await signup(); + const channel = await createChannel('fttl-gap-' + randomString(), alice); + + const noteIds: string[] = []; + for (let i = 0; i < 20; i++) { + const n = await post(alice, { text: `seed ${i}`, channelId: channel.id }); + noteIds.push(n.id); + } + await setTimeout(500); + + const gapTargets = noteIds.slice(5, 12); + for (const id of gapTargets) { + await redisForTimelines.lrem(`list:channelTimeline:${channel.id}`, 1, id); + } + + const res = await api('channels/timeline', { channelId: channel.id, limit: 20 }); + const returnedIds = (res.body as Note[]).map(n => n.id); + + for (const id of noteIds) { + assert.ok(returnedIds.includes(id), `missing ${id} in channel TL result`); + } + assert.strictEqual(returnedIds.length, 20); + }); + + test('Channel TL: 連続ページネーションで歯抜けを横断しても全60件を取りこぼさず取得できる', async () => { + const alice = await signup(); + const channel = await createChannel('fttl-gap-paginate-' + randomString(), alice); + + const noteIds: string[] = []; + for (let i = 0; i < 60; i++) { + const n = await post(alice, { text: `seed ${i}`, channelId: channel.id }); + noteIds.push(n.id); + } + await setTimeout(500); + + // Redis FTT 60件のうち中間7件を抜く (noteIdsは古い順なので新しい側から数えてindex 13-19) + const gapTargets = noteIds.slice(40, 47); + for (const id of gapTargets) { + await redisForTimelines.lrem(`list:channelTimeline:${channel.id}`, 1, id); + } + + const seenIds = new Set(); + let untilId: string | undefined; + for (let page = 0; page < 6; page++) { + const res = await api('channels/timeline', { channelId: channel.id, limit: 20, ...(untilId ? { untilId } : {}) }); + const ids = (res.body as Note[]).map(n => n.id); + if (ids.length === 0) break; + for (const id of ids) seenIds.add(id); + untilId = ids[ids.length - 1]; + } + + for (const id of noteIds) { + assert.ok(seenIds.has(id), `missing ${id} after pagination`); + } + assert.strictEqual(seenIds.size, 60); + }); + }); });