Skip to content

fix(backend): FTTL に歯抜けがあるときタイムラインAPIがDBのノートを取りこぼす問題を修正#17549

Draft
fruitriin wants to merge 3 commits into
misskey-dev:developfrom
fruitriin:fix/fanout-timeline-redis-gap
Draft

fix(backend): FTTL に歯抜けがあるときタイムラインAPIがDBのノートを取りこぼす問題を修正#17549
fruitriin wants to merge 3 commits into
misskey-dev:developfrom
fruitriin:fix/fanout-timeline-redis-gap

Conversation

@fruitriin
Copy link
Copy Markdown
Contributor

@fruitriin fruitriin commented Jun 5, 2026

What

FanoutTimelineEndpointService の最終 DB フォールバック処理が、 DB クエリの境界として Redis 結果の最古 ID を使用していたため、 Redis 上のキャッシュリストの中間にギャップがある (= 本来そこに存在するはずのノート ID が抜けた状態) と、 そのギャップ範囲のノートが DB に存在しても永久に取得不能になっていた。

ps.untilId / ps.sinceId全範囲 を DB に問い合わせ、 Redis 由来と重複排除した上で再ソートする方式に変更する。

Before

// まだ足りない分は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];  // ← Redis 最古を境界に
  dbSince = ps.sinceId;
}
const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead);
return [...redisTimeline, ...gotFromDb];

After

// まだ足りない分はDBにフォールバック
// Redisの最古/最新を境界に使うと、Redis上に中間ギャップ
// (enableFanoutTimelineのON/OFF切り替え、antennas/remove-note等の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);

Why

enableFanoutTimeline を管理画面から ON → OFF → ON のように切り替える運用 (FTT の挙動調査、 一時的なメンテ、 障害対応等で一般に行われる) を経ると、 OFF 期間中の投稿が Redis 上のキャッシュリストに乗らないまま ON 復帰後の新規投稿だけが追加される状態になる。

その結果として Redis 上に中間ギャップが生じ、 現状の最終 DB フォールバックの境界式 (Redis 最古 ID を dbUntil に使う) では、 そのギャップ範囲のノートが DB クエリにも含まれず、 タイムライン API のレスポンスから永久に欠落する。

詳細・再現手順 (UI 操作)・原因・用語解説は Issue #XXXXX 参照。

Additional info (optional)

Tests added

packages/backend/test/e2e/timelines.ts に新 describe ブロック FTT Redis gap fallback を追加 (3 ケース):

  1. Home TL: homeTimeline Redis リスト 20 件中 中間 7 件を LREM で削除して中間ギャップを作り、 notes/timeline (limit=20) で DB の全 20 件が返ることを確認
  2. Channel TL: 同様に channelTimeline のギャップで channels/timeline (limit=20) を検証
  3. Channel TL 連続ページネーション: 60 件投稿 + 中間 7 件ギャップ で 4 ページ走査により 60 件すべて取得できることを確認

テストでは LREM で人為的に Redis 状態を作っているが、 Issue 本文のとおり管理画面の enableFanoutTimeline トグル ON → OFF → ON で同じ状態を作ることもできる。

修正前後の検証結果

ケース 修正前 修正後
Home TL untilId 未指定 (limit=20) NG (7 件 missing) OK (20/20)
Channel TL untilId 未指定 (limit=20) NG (7 件 missing) OK (20/20)
Channel TL 連続ページネーション (60 件) NG (53/60、 7 件スキップ) OK (60/60)

Trade-off

最終フォールバックパスで DB クエリの limit が remainingToRead から ps.limit に増える分のコスト。 ただし元コードでも最終フォールバックで DB を叩いており、 追加コストは限定的。 正しさを保証する代償として妥当と判断。

useDbFallback=false の場合は ps.dbFallback = () => Promise.resolve([]) に置換されるため (FanoutTimelineEndpointService.ts:73)、 修正後も追加 DB クエリは発生しない (既存運用設定を尊重)。

Affected endpoints

FanoutTimelineEndpointService.timeline を呼ぶ全 endpoint:

  • notes/timeline (HTL)
  • notes/local-timeline (LTL)
  • notes/hybrid-timeline
  • channels/timeline
  • notes/user-list-timeline

なお antennas/notes 等は FanoutTimelineEndpointService を経由せず独自実装なので、 本 PR の修正は影響しません。

Related

Checklist

  • Read the contribution guide
  • Test working in a local environment
  • (If needed) Update CHANGELOG.md
  • (If possible) Add tests

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) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

このPRによるapi.jsonの差分
差分はありません。
Get diff files from Workflow Page

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 5, 2026

Codecov Report

❌ Patch coverage is 0% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 22.40%. Comparing base (e2bcd9c) to head (a75591b).
⚠️ Report is 5 commits behind head on develop.

Files with missing lines Patch % Lines
.../backend/src/core/FanoutTimelineEndpointService.ts 0.00% 5 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop   #17549      +/-   ##
===========================================
+ Coverage    15.14%   22.40%   +7.25%     
===========================================
  Files          248     1159     +911     
  Lines        12412    39593   +27181     
  Branches      4214    11033    +6819     
===========================================
+ Hits          1880     8870    +6990     
- Misses        8241    24666   +16425     
- Partials      2291     6057    +3766     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@anatawa12
Copy link
Copy Markdown
Member

anatawa12 commented Jun 5, 2026

#13495 とある程度被る感じかな

FTTを on => off => on のときは on => off のタイミングで全て FTT を消すとかの方が対処として良さそうな気もする

EDIT: 消すのは #17548

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

Backend memory usage comparison

Before GC

Metric base (MB) head (MB) Diff (MB) Diff (%)
VmRSS 306.56 MB 307.49 MB +0.93 MB +0.30%
VmHWM 306.56 MB 307.49 MB +0.93 MB +0.30%
VmSize 23173.16 MB 23172.09 MB -1.07 MB 0%
VmData 1373.22 MB 1372.64 MB -0.58 MB -0.04%

After GC

Metric base (MB) head (MB) Diff (MB) Diff (%)
VmRSS 306.73 MB 307.50 MB +0.76 MB +0.25%
VmHWM 306.73 MB 307.50 MB +0.76 MB +0.25%
VmSize 23173.33 MB 23172.26 MB -1.07 MB 0%
VmData 1373.39 MB 1372.80 MB -0.58 MB -0.04%

After Request

Metric base (MB) head (MB) Diff (MB) Diff (%)
VmRSS 307.13 MB 307.98 MB +0.85 MB +0.27%
VmHWM 307.13 MB 307.98 MB +0.85 MB +0.27%
VmSize 23173.41 MB 23172.34 MB -1.07 MB 0%
VmData 1373.47 MB 1372.89 MB -0.58 MB -0.04%

See workflow logs for details

@fruitriin
Copy link
Copy Markdown
Contributor Author

fruitriin commented Jun 5, 2026

あー、 #13495 に気づいてなかったけど、やりたいことが全く同じっぽい

原因が全く異なる模様

@fruitriin
Copy link
Copy Markdown
Contributor Author

このPRでは 同一ソース内の歯抜けの対処なので、 #13495 の目的ケースでは救えないものが救えたりするし、 #13495 がないと救えないものがあるっぽい
ただコンフリクトはする模様

@fruitriin fruitriin changed the title fix(backend): FTT に歯抜けがあるときタイムラインAPIがDBのノートを取りこぼす問題を修正 fix(backend): FTTL に歯抜けがあるときタイムラインAPIがDBのノートを取りこぼす問題を修正 Jun 5, 2026
fruitriin and others added 2 commits June 5, 2026 15:28
前コミットの最終 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) <noreply@anthropic.com>
前々コミット (歯抜け時の最終 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

packages/backend:test packages/backend Server side specific issue/PR

Projects

Development

Successfully merging this pull request may close these issues.

2 participants