fix(backend): FTT ループの early return を廃止し、最終 dbFallback の結果にも filter を適用

前々コミット (歯抜け時の最終 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) <noreply@anthropic.com>
This commit is contained in:
fruitriin 2026-06-05 16:01:25 +09:00
commit a75591b445

View file

@ -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);
}