Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- Fix: 「D」キーでダークモードを切り替える際にsyncDeviceDarkModeのチェックがバイパスされる問題を修正

### Server
- Fix: FTT (Fanout Timeline) 上に飛び石の歯抜けがあるとき、 タイムラインAPI が DB に存在するノートを取りこぼす問題を修正
- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善
- Fix: PerUserDriveChart がシステム所有ファイル (userId が null) の更新で `"group"` の非NULL制約違反によりクラッシュする問題を修正 (#17498)
- Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善
Expand Down
35 changes: 20 additions & 15 deletions packages/backend/src/core/FanoutTimelineEndpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
95 changes: 95 additions & 0 deletions packages/backend/test/e2e/timelines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2846,7 +2846,7 @@
const res = await api('users/notes', { userId: bob.id }, alice);

assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false);

Check failure on line 2849 in packages/backend/test/e2e/timelines.ts

View workflow job for this annotation

GitHub Actions / E2E tests (backend) (.github/min.node-version)

test/e2e/timelines.ts > Timelines > Timelines (enableFanoutTimeline: true) > User TL > [withReplies: false] 他人への返信が含まれない

AssertionError: Expected values to be strictly equal: true !== false - Expected + Received - false + true ❯ test/e2e/timelines.ts:2849:12

Check failure on line 2849 in packages/backend/test/e2e/timelines.ts

View workflow job for this annotation

GitHub Actions / E2E tests (backend) (.node-version)

test/e2e/timelines.ts > Timelines > Timelines (enableFanoutTimeline: true) > User TL > [withReplies: false] 他人への返信が含まれない

AssertionError: Expected values to be strictly equal: true !== false - Expected + Received - false + true ❯ test/e2e/timelines.ts:2849:12
});

test('[withReplies: true] 他人への返信が含まれる', async () => {
Expand Down Expand Up @@ -3326,4 +3326,99 @@
// 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<string>();
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);
});
});
});
Loading