From d680563274684297c8f0f650f454c5e781b0cd8d Mon Sep 17 00:00:00 2001 From: fruitriin Date: Fri, 5 Jun 2026 14:44:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(backend):=20FTT=20=E3=81=AB=E6=AD=AF?= =?UTF-8?q?=E6=8A=9C=E3=81=91=E3=81=8C=E3=81=82=E3=82=8B=E3=81=A8=E3=81=8D?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3API?= =?UTF-8?q?=E3=81=8CDB=E3=81=AE=E3=83=8E=E3=83=BC=E3=83=88=E3=82=92?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E3=81=93=E3=81=BC=E3=81=99=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FanoutTimelineEndpointService の最終 dbFallback が Redis 由来の最古/最新 ID を境界に 使っていたため、Redis 上に飛び石の歯抜け (3分ガード拒否, TTL evict, LREM 等) があると その歯抜け範囲のノートが DB クエリにも含まれず取りこぼす問題があった。 ps.untilId / ps.sinceId の全範囲を DB に問い合わせ、Redis 由来と id で重複排除して 再ソート + slice する方式に変更する。Redis 満杯 + フィルタ全通過のホットパスでは 既存の早期 return が効くため追加 DB クエリは発生しない。fail-safe 層として機能する。 回帰テストを e2e に 3 本追加 (HTL / Channel TL / Channel TL pagination)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../src/core/FanoutTimelineEndpointService.ts | 22 ++--- packages/backend/test/e2e/timelines.ts | 95 +++++++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) 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..9a54d446d7c 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -188,18 +188,16 @@ export class FanoutTimelineEndpointService { } // まだ足りない分は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]; + // Redisの最古/最新を境界に使うと、Redis上に飛び石の歯抜け + // (3分ガードでpushが拒否されたノート、TTL evict、LREM等)があった場合に、 + // その歯抜け範囲のノートがDBクエリにも含まれず取りこぼされる。 + // そのためps.untilId/ps.sinceIdの全範囲をDBに問い合わせ、 + // Redis由来のノートと重複排除した上で再ソートする。 + 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))]; + 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); + }); + }); }); From 87ad495a7efa6f995ecd2bc83d720845efe78bbc Mon Sep 17 00:00:00 2001 From: fruitriin Date: Fri, 5 Jun 2026 15:28:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(backend):=20=E6=AD=AF=E6=8A=9C=E3=81=91?= =?UTF-8?q?=E6=99=82=E3=81=AB=20Redis=20=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E3=81=AE=20early=20return=20=E3=81=8C=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=8D=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E5=A2=83?= =?UTF-8?q?=E7=95=8C=E3=82=92=E7=8B=82=E3=82=8F=E3=81=9B=E3=82=8B=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前コミットの最終 dbFallback 全範囲化だけでは、Redis ループが「歯抜け補完のため Redis 内の古い ID を消費してフィルタ後 limit 件を満たし early return」してしまい、 返却の最後の ID が本来の上位 limit 範囲より古い側に下がる。結果、次ページ以降で 本来あるべき範囲がスキップされ、ページネーションで取りこぼしが残っていた。 redisResultIds.length >= ps.limit (= 上位 limit 件範囲内に歯抜けなしと見なせる) の場合のみ early return を許可し、歯抜けまたはキャッシュ未飽和時はループを抜けて 最終 dbFallback の全範囲取得 + dedupe + slice で上位 limit 件を再構築する。 これにより e2e の Channel TL ページネーションテストが通る。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/src/core/FanoutTimelineEndpointService.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 9a54d446d7c..59659c3f775 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -168,6 +168,13 @@ export class FanoutTimelineEndpointService { let readFromRedis = 0; let lastSuccessfulRate = 1; // rateをキャッシュする? + // Redis 上に「上位 limit 件範囲を満たす素材」があるか + // (= 歯抜けなしと見なせるか) を early return 条件に組み込む。 + // redisResultIds.length < ps.limit の場合は歯抜けまたはキャッシュ未飽和なので、 + // ループ内で limit 件を満たしても early return せず、最終 dbFallback で + // 全範囲を取り直して上位 limit 件を再構築する (歯抜け範囲のスキップを防ぐ)。 + const hasFullRedisCache = redisResultIds.length >= ps.limit; + while ((redisResultIds.length - readFromRedis) !== 0) { const remainingToRead = ps.limit - redisTimeline.length; @@ -181,7 +188,7 @@ export class FanoutTimelineEndpointService { redisTimeline.push(...gotFromDb); lastSuccessfulRate = gotFromDb.length / noteIds.length; - if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { + if (ps.allowPartial ? redisTimeline.length !== 0 : (redisTimeline.length >= ps.limit && hasFullRedisCache)) { // 十分Redisからとれた return redisTimeline.slice(0, ps.limit); } From a75591b44583e28b041c67f40cc858da8c9c1287 Mon Sep 17 00:00:00 2001 From: fruitriin Date: Fri, 5 Jun 2026 16:01:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(backend):=20FTT=20=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=81=AE=20early=20return=20=E3=82=92=E5=BB=83?= =?UTF-8?q?=E6=AD=A2=E3=81=97=E3=80=81=E6=9C=80=E7=B5=82=20dbFallback=20?= =?UTF-8?q?=E3=81=AE=E7=B5=90=E6=9E=9C=E3=81=AB=E3=82=82=20filter=20?= =?UTF-8?q?=E3=82=92=E9=81=A9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前々コミット (歯抜け時の最終 dbFallback 全範囲化) と前コミット (early return への hasFullRedisCache 追加) では以下 2 件が解消していなかった: 1. ユーザー TL の withReplies:false 等で「他人への返信」が含まれる regression 原因: 全範囲化した dbFallback の結果 (gotFromDb) を filter を通さず merge していたため、Redis 経由でフィルタされていたノートが DB から無フィルタで 結果に紛れ込んでいた。 2. ページネーションテストで歯抜け範囲が永久にスキップされる 原因: Redis 上位 limit 件範囲内の歯抜けは Redis 内データだけからは判別不能 (歯抜けの ID は最初から Redis に存在しないため、検出シグナルがない)。 redisResultIds.length >= ps.limit による近似判定では、Redis 自身が 歯抜けを内包したまま「上位 limit 件揃った」と誤判定して early return し、 次ページの境界が本来より古い側に下がっていた。 対応: - allowPartial ケースを除き、ループ内の early return を廃止し、常に最終 dbFallback (全範囲 + dedupe + sort + slice) に進ませる。これで上位 limit 件 が常に正しく再構築され、ページネーション境界も正しい位置になる。 - gotFromDb にも filter を適用し、Redis 経由のフィルタ (excludeReplies, excludeNoFiles, ミュート/ブロック等) を DB 由来のノートにも揃える。 性能: 毎リクエストで dbFallback が 1 回追加されるが、id < untilId ORDER BY id DESC LIMIT n は B-tree index で高速なので fail-safe 層の代償としては許容範囲。 派生PR (3分ガード等) で歯抜けがなくなれば、Redis 結果と DB 結果は一致し dedupe で吸収される。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/core/FanoutTimelineEndpointService.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 59659c3f775..810cd2baa2a 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -168,13 +168,6 @@ export class FanoutTimelineEndpointService { let readFromRedis = 0; let lastSuccessfulRate = 1; // rateをキャッシュする? - // Redis 上に「上位 limit 件範囲を満たす素材」があるか - // (= 歯抜けなしと見なせるか) を early return 条件に組み込む。 - // redisResultIds.length < ps.limit の場合は歯抜けまたはキャッシュ未飽和なので、 - // ループ内で limit 件を満たしても early return せず、最終 dbFallback で - // 全範囲を取り直して上位 limit 件を再構築する (歯抜け範囲のスキップを防ぐ)。 - const hasFullRedisCache = redisResultIds.length >= ps.limit; - while ((redisResultIds.length - readFromRedis) !== 0) { const remainingToRead = ps.limit - redisTimeline.length; @@ -188,21 +181,28 @@ export class FanoutTimelineEndpointService { redisTimeline.push(...gotFromDb); lastSuccessfulRate = gotFromDb.length / noteIds.length; - if (ps.allowPartial ? redisTimeline.length !== 0 : (redisTimeline.length >= ps.limit && hasFullRedisCache)) { - // 十分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にフォールバック - // Redisの最古/最新を境界に使うと、Redis上に飛び石の歯抜け - // (3分ガードでpushが拒否されたノート、TTL evict、LREM等)があった場合に、 - // その歯抜け範囲のノートがDBクエリにも含まれず取りこぼされる。 - // そのためps.untilId/ps.sinceIdの全範囲をDBに問い合わせ、 - // Redis由来のノートと重複排除した上で再ソートする。 + // 常に最終 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))]; + 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); }