From 82bec76cd4e1fc8fb3fea0b7c6a60c02707f3caf Mon Sep 17 00:00:00 2001 From: taichanne30 Date: Sat, 2 Mar 2024 15:18:40 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix(backend):=20DB=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E6=9C=89=E5=8A=B9?= =?UTF-8?q?=E6=99=82=E3=80=81=E8=A4=87=E6=95=B0=E3=81=AEFTT=E3=82=BD?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=81=8B=E3=82=89=E5=8F=96=E5=BE=97=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=A7=E5=8F=96=E5=BE=97=E6=BC=8F=E3=82=8C=E3=81=8C=E8=B5=B7?= =?UTF-8?q?=E3=81=8D=E3=82=8B=E7=8F=BE=E8=B1=A1=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../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 9c239b4dfc..dab9f74f76 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -66,8 +66,15 @@ export class FanoutTimelineEndpointService { const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); + // 取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする + shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); + + // 取得したresultの中で最古のIDのうち、最も新しいものを取得 + // shouldPrependがtrueの場合は最も新しいものを、falseの場合は最も古いものを取得 + const thresholdId = shouldPrepend ? redisResult.map(ids => ids[ids.length - 1]).sort(idCompare)[0] : redisResult.map(ids => ids[0]).sort(idCompare)[0]; + // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい - const redisResultIds = Array.from(new Set(redisResult.flat(1))); + const redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1))).filter(id => idCompare(id, thresholdId) === 1); redisResultIds.sort(idCompare); noteIds = redisResultIds.slice(0, ps.limit); From 561567599180dc657b8e9de8c9e32fce3937cfd2 Mon Sep 17 00:00:00 2001 From: taichanne30 Date: Sat, 2 Mar 2024 15:35:39 +0900 Subject: [PATCH 02/14] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d223db819a..1dc6e2664d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - Fix: カスタム絵文字の画像読み込みに失敗した際はテキストではなくダミー画像を表示 #13487 ### Server -- +- Fix: FTT有効かつDBフォールバック有効時、STLのようにタイムラインのソースが複数だとFTTとDBのフォールバック間で取得されないノートがある問題 ## 2024.3.0 From 4a8ffe20a7ebe064fa9e970fb417000672b7ac27 Mon Sep 17 00:00:00 2001 From: taichanne30 Date: Thu, 7 Mar 2024 01:47:41 +0900 Subject: [PATCH 03/14] Fix timeline fetch when using sinceId --- packages/backend/src/core/FanoutTimelineEndpointService.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index dab9f74f76..c3298ec5b9 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -70,14 +70,13 @@ export class FanoutTimelineEndpointService { shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); // 取得したresultの中で最古のIDのうち、最も新しいものを取得 - // shouldPrependがtrueの場合は最も新しいものを、falseの場合は最も古いものを取得 - const thresholdId = shouldPrepend ? redisResult.map(ids => ids[ids.length - 1]).sort(idCompare)[0] : redisResult.map(ids => ids[0]).sort(idCompare)[0]; + const thresholdId = redisResult.map(ids => ids[0]).sort()[0]; // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい - const redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1))).filter(id => idCompare(id, thresholdId) === 1); + const redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1))); redisResultIds.sort(idCompare); - noteIds = redisResultIds.slice(0, ps.limit); + noteIds = redisResultIds.filter(id => id >= thresholdId).slice(0, ps.limit); shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); From 685fc2bd9d2283ac2a04894ec7e50adc1e4c3e5d Mon Sep 17 00:00:00 2001 From: taichanne30 Date: Thu, 25 Jul 2024 14:30:10 +0900 Subject: [PATCH 04/14] =?UTF-8?q?Fix:=20shouldFallbackToDb=E3=81=8C?= =?UTF-8?q?=E3=81=99=E3=81=A7=E3=81=ABtrue=E3=81=AE=E5=A0=B4=E5=90=88?= =?UTF-8?q?=E3=81=AB=E3=81=9D=E3=82=8C=E3=81=8C=E7=84=A1=E8=A6=96=E3=81=95?= =?UTF-8?q?=E3=82=8C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/core/FanoutTimelineEndpointService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index ff8b22bde9..55ef7abf3c 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -75,7 +75,7 @@ export class FanoutTimelineEndpointService { let noteIds = redisResultIds.filter(id => id >= thresholdId).slice(0, ps.limit); const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; - shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; + shouldFallbackToDb ||= noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; if (!shouldFallbackToDb) { let filter = ps.noteFilter ?? (_note => true); From 3564bf5c66382b92a2ac7b23c300f8854058b700 Mon Sep 17 00:00:00 2001 From: taichanne30 Date: Thu, 25 Jul 2024 14:37:09 +0900 Subject: [PATCH 05/14] Refactor: const naming --- packages/backend/src/core/FanoutTimelineEndpointService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 55ef7abf3c..6c874db9d1 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -67,12 +67,12 @@ export class FanoutTimelineEndpointService { let shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); // 取得したresultの中で最古のIDのうち、最も新しいものを取得 - const thresholdId = redisResult.map(ids => ids[0]).sort()[0]; + const fttThresholdId = redisResult.map(ids => ids[0]).sort()[0]; // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい const redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1))).sort(idCompare); - let noteIds = redisResultIds.filter(id => id >= thresholdId).slice(0, ps.limit); + let noteIds = redisResultIds.filter(id => id >= fttThresholdId).slice(0, ps.limit); const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; shouldFallbackToDb ||= noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; From 48232ca57b0e8e5a1aa950527ae6ef3a3eb9d0d8 Mon Sep 17 00:00:00 2001 From: taichanne30 Date: Thu, 25 Jul 2024 16:01:41 +0900 Subject: [PATCH 06/14] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BCTL?= =?UTF-8?q?=E3=81=A7=E3=81=AFFTT=E3=81=AE=E3=82=BD=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=81=8C=E7=A9=BA=E3=81=AE=E9=9A=9B=E3=81=ABDB=E3=81=ABFallbac?= =?UTF-8?q?k=E3=81=97=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/FanoutTimelineEndpointService.ts | 8 +++++--- packages/backend/src/server/api/endpoints/users/notes.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 6c874db9d1..4f513110b4 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -34,6 +34,7 @@ type TimelineOptions = { excludeReplies?: boolean; excludePureRenotes: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, + preventEmptyTimelineDbFallback?: boolean; }; @Injectable() @@ -63,8 +64,9 @@ export class FanoutTimelineEndpointService { const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); - // 取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする - let shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); + // オプション無効時、取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする + let shouldFallbackToDb = ps.useDbFallback && + (ps.preventEmptyTimelineDbFallback !== true && redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); // 取得したresultの中で最古のIDのうち、最も新しいものを取得 const fttThresholdId = redisResult.map(ids => ids[0]).sort()[0]; @@ -75,7 +77,7 @@ export class FanoutTimelineEndpointService { let noteIds = redisResultIds.filter(id => id >= fttThresholdId).slice(0, ps.limit); const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; - shouldFallbackToDb ||= noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; + shouldFallbackToDb ||= ps.useDbFallback && (noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId); if (!shouldFallbackToDb) { let filter = ps.noteFilter ?? (_note => true); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index cc76c12f1d..cbd14cf608 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -143,6 +143,7 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me), + preventEmptyTimelineDbFallback: true, }); return timeline; From 4a2970fdb049eac805fe1d5ac3b39496c3d7890b Mon Sep 17 00:00:00 2001 From: taichan <40626578+tai-cha@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:48:11 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(backend):=20DB=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E6=9C=89=E5=8A=B9?= =?UTF-8?q?=E6=99=82=E3=80=81=E8=A4=87=E6=95=B0=E3=81=AEFTT=E3=82=BD?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=81=8B=E3=82=89=E5=8F=96=E5=BE=97=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=A7=E5=8F=96=E5=BE=97=E6=BC=8F=E3=82=8C=E3=81=8C=E8=B5=B7?= =?UTF-8?q?=E3=81=8D=E3=82=8B=E7=8F=BE=E8=B1=A1=E3=81=AE=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20(=E5=86=8D)=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: DBフォールバック有効時、複数のFTTソースから取得するタイムラインで取得漏れが起きる現象の修正 https://github.com/nadesskey/nadesskey/pull/35 の修正 * fix: revert unnecessary changes * refactor(test): FTTEndpointのテストを改善 * fix: ids自体が空配列の時を考慮 * Fix: typerror * fix: 昇順のページネーションにおけるfttThresholdIdの計算を修正し、関連するテストを追加しました。 * Fix: Type Error * fix: `useDbFallback` が false の場合に `fttThresholdId` によるタイムラインノートの意図しないフィルタリングを防止 --- .../src/core/FanoutTimelineEndpointService.ts | 12 +- .../unit/FanoutTimelineEndpointService.ts | 245 ++++++++++++++++++ 2 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 packages/backend/test/unit/FanoutTimelineEndpointService.ts diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index b5bbc0c3cf..2d6b7eab06 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -83,12 +83,18 @@ export class FanoutTimelineEndpointService { (ps.preventEmptyTimelineDbFallback !== true && redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); // 取得したresultの中で最古のIDのうち、最も新しいものを取得 - const fttThresholdId = redisResult.map(ids => ids[0]).sort()[0]; + // ids自体が空配列の場合、ids[ids.length - 1]はundefinedになるため、filterでnullを除外する + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const fttThresholdId = redisResult.map(ids => ascending ? ids[0] : ids[ids.length - 1]).filter(id => id != null).sort().pop(); // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい - const redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1))).sort(idCompare); + let redisResultIds = shouldFallbackToDb ? [] : Array.from(new Set(redisResult.flat(1))); + if (ps.useDbFallback && fttThresholdId != null) { + redisResultIds = redisResultIds.filter(id => id >= fttThresholdId); + } + redisResultIds.sort(idCompare); - let noteIds = redisResultIds.filter(id => id >= fttThresholdId).slice(0, ps.limit); + let noteIds = redisResultIds.slice(0, ps.limit); const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; shouldFallbackToDb ||= ps.useDbFallback && (noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId); diff --git a/packages/backend/test/unit/FanoutTimelineEndpointService.ts b/packages/backend/test/unit/FanoutTimelineEndpointService.ts new file mode 100644 index 0000000000..375bf5a97a --- /dev/null +++ b/packages/backend/test/unit/FanoutTimelineEndpointService.ts @@ -0,0 +1,245 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, jest, test, expect, beforeEach, afterEach, beforeAll, afterAll } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { FanoutTimelineService, FanoutTimelineName } from '@/core/FanoutTimelineService.js'; +import { IdService } from '@/core/IdService.js'; +import { NotesRepository, UsersRepository, UserProfilesRepository, MiUser, MiNote } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +describe('FanoutTimelineEndpointService', () => { + let app: TestingModule; + let service: FanoutTimelineEndpointService; + let fanoutTimelineService: jest.Mocked; + let notesRepository: NotesRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let idService: IdService; + + let alice: MiUser; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createNote(data: Partial = {}) { + return await notesRepository + .insert({ + id: idService.gen(), + userId: alice.id, + text: 'test', + visibility: 'public', + localOnly: false, + ...data, + }) + .then(x => notesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + FanoutTimelineEndpointService, + ], + }) + .overrideProvider(FanoutTimelineService) + .useValue({ + getMulti: jest.fn(), + }) + .compile(); + + app.enableShutdownHooks(); + + service = app.get(FanoutTimelineEndpointService); + fanoutTimelineService = app.get(FanoutTimelineService) as jest.Mocked; + notesRepository = app.get(DI.notesRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + idService = app.get(IdService); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await notesRepository.deleteAll(); + await userProfilesRepository.deleteAll(); + await usersRepository.deleteAll(); + }); + + test('should use correctly calculated threshold (Max of Oldest) when merging disjoint timelines', async () => { + const now = Date.now(); + // HTL: Recent (T-2m to T-4m) + const htlNote1 = await createNote({ id: idService.gen(now - 1000 * 60 * 2) }); + const htlNote2 = await createNote({ id: idService.gen(now - 1000 * 60 * 3) }); + const htlNote3 = await createNote({ id: idService.gen(now - 1000 * 60 * 4) }); // End of HTL (T-4m) + + const htlIds = [htlNote1.id, htlNote2.id, htlNote3.id]; + + // LTL: Old (T-60m to T-62m) + const ltlNote1 = await createNote({ id: idService.gen(now - 1000 * 60 * 60) }); + const ltlNote2 = await createNote({ id: idService.gen(now - 1000 * 60 * 61) }); + const ltlNote3 = await createNote({ id: idService.gen(now - 1000 * 60 * 62) }); + + const ltlIds = [ltlNote1.id, ltlNote2.id, ltlNote3.id]; + + // Mock FanoutTimelineService to return these IDs + fanoutTimelineService.getMulti.mockResolvedValue([htlIds, ltlIds]); + + // dbFallback spy + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], + useDbFallback: true, + limit: 10, + allowPartial: false, + excludePureRenotes: false, + dbFallback, + noteFilter: () => false, // Simulate strict filtering (force fallback) + untilId: null, + sinceId: null, + }; + + // See comments in original file for logic explanation. + // Essentially, we expect the fallback to start from the end of the most recent reliable timeline (HTL). + + await service.getMiNotes(ps); + + expect(dbFallback).toHaveBeenCalled(); + const callArgs = dbFallback.mock.calls[0]; + const untilId = callArgs[0] as string; + + // We expect untilId to be the HTL oldest (htlNote3.id), NOT the LTL newest (ltlNote1.id). + expect(untilId).toBe(htlNote3.id); + expect(untilId > ltlNote1.id).toBe(true); + }); + + test('should maintain correct pagination cursor when using sinceId (ascending)', async () => { + const now = Date.now(); + // Ascending: Oldest to Newest. + const note1 = await createNote({ id: idService.gen(now - 3000) }); + const note2 = await createNote({ id: idService.gen(now - 2000) }); + const note3 = await createNote({ id: idService.gen(now - 1000) }); + + const ids = [note1.id, note2.id, note3.id]; + + fanoutTimelineService.getMulti.mockResolvedValue([ids]); + + // Mock dbFallback to return empty array + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[], + useDbFallback: false, // Disable fallback to check Redis filtering logic directly + limit: 2, + allowPartial: true, + excludePureRenotes: false, + dbFallback, + untilId: null, + sinceId: idService.gen(now - 4000), + }; + + const result = await service.getMiNotes(ps); + + // With the fix, we should get note1 and note2. + // Without the fix, we would get only note3 (or empty if limit blocked it). + expect(result).toHaveLength(2); + expect(result[0].id).toBe(note1.id); + expect(result[1].id).toBe(note2.id); + }); + + test('should not fallback to DB when useDbFallback is false even if insufficient notes', async () => { + const now = Date.now(); + const note1 = await createNote({ id: idService.gen(now) }); + const ids = [note1.id]; + + fanoutTimelineService.getMulti.mockResolvedValue([ids]); + + const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[], + useDbFallback: false, + limit: 10, + allowPartial: false, + excludePureRenotes: false, + dbFallback, + noteFilter: () => false, // Filter out everything + untilId: null, + sinceId: null, + }; + + const result = await service.getMiNotes(ps); + + expect(dbFallback).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + test('should merge disjoint timelines correctly when useDbFallback is false', async () => { + const now = Date.now(); + // TL1: Recent + const note1 = await createNote({ id: idService.gen(now - 1000) }); + const note2 = await createNote({ id: idService.gen(now - 2000) }); + // TL2: Old + const note3 = await createNote({ id: idService.gen(now - 5000) }); + const note4 = await createNote({ id: idService.gen(now - 6000) }); + + const ids1 = [note1.id, note2.id]; + const ids2 = [note3.id, note4.id]; + + fanoutTimelineService.getMulti.mockResolvedValue([ids1, ids2]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], + useDbFallback: false, + limit: 10, + allowPartial: true, + excludePureRenotes: false, + dbFallback, + noteFilter: () => true, // Accept all + untilId: null, + sinceId: null, + }; + + const result = await service.getMiNotes(ps); + + // With the previous logic, note3 and note4 would be filtered out because they are older than the "threshold" (end of TL1). + // With the fixed logic (skipping filter when !useDbFallback), all notes should be present. + expect(result).toHaveLength(4); + expect(result.map(n => n.id)).toEqual([note1.id, note2.id, note3.id, note4.id]); + }); +}); From 1022df5325ce2794725b1284646c7a0efd151386 Mon Sep 17 00:00:00 2001 From: tai-cha Date: Tue, 27 Jan 2026 14:36:12 +0900 Subject: [PATCH 08/14] =?UTF-8?q?perf:=20=E7=A9=BA=E3=81=AE=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=E6=99=82=E3=81=AE=E7=84=A1=E9=A7=84=E3=81=AA?= =?UTF-8?q?DB=E3=82=A2=E3=82=AF=E3=82=BB=E3=82=B9=E3=82=92=E5=89=8A?= =?UTF-8?q?=E6=B8=9B=E3=81=99=E3=82=8B=E3=81=9F=E3=82=81=E3=80=81Redis?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=AB?= =?UTF-8?q?=E3=83=80=E3=83=9F=E3=83=BCID=E3=82=92=E6=8C=BF=E5=85=A5?= =?UTF-8?q?=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/FanoutTimelineEndpointService.ts | 22 +++++- .../backend/src/core/FanoutTimelineService.ts | 5 ++ .../unit/FanoutTimelineEndpointService.ts | 75 +++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 2d6b7eab06..c3c7ead201 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -14,6 +14,7 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; @@ -60,6 +61,7 @@ export class FanoutTimelineEndpointService { private fanoutTimelineService: FanoutTimelineService, private utilityService: UtilityService, private channelMutingService: ChannelMutingService, + private idService: IdService, ) { } @@ -217,7 +219,25 @@ export class FanoutTimelineEndpointService { return [...redisTimeline, ...gotFromDb]; } - return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); + // RedisおよびDBが空の場合、次回以降の無駄なDBアクセスを防ぐためダミーIDを保存する + const gotFromDb = await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); + if ( + redisResultIds.length === 0 && + ps.sinceId == null && ps.untilId == null && + gotFromDb.length === 0 + ) { + const dummyId = this.idService.gen(); + + Promise.all(ps.redisTimelines.map((tl, i) => { + // 有効なソースかつ結果が空だった場合のみダミーを入れる + if (redisResult[i] && redisResult[i].length === 0) { + return this.fanoutTimelineService.injectDummy(tl, dummyId); + } + return Promise.resolve(); + })); + } + + return gotFromDb; } private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise { diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 24999bf4da..619fd7a89d 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -108,6 +108,11 @@ export class FanoutTimelineService { }); } + @bindThis + public injectDummy(tl: FanoutTimelineName, id: string) { + return this.redisForTimelines.lpush('list:' + tl, id); + } + @bindThis public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); diff --git a/packages/backend/test/unit/FanoutTimelineEndpointService.ts b/packages/backend/test/unit/FanoutTimelineEndpointService.ts index 375bf5a97a..1dc605648f 100644 --- a/packages/backend/test/unit/FanoutTimelineEndpointService.ts +++ b/packages/backend/test/unit/FanoutTimelineEndpointService.ts @@ -67,6 +67,7 @@ describe('FanoutTimelineEndpointService', () => { .overrideProvider(FanoutTimelineService) .useValue({ getMulti: jest.fn(), + injectDummy: jest.fn(), }) .compile(); @@ -119,7 +120,7 @@ describe('FanoutTimelineEndpointService', () => { const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); const ps = { - redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], + redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], useDbFallback: true, limit: 10, allowPartial: false, @@ -130,9 +131,6 @@ describe('FanoutTimelineEndpointService', () => { sinceId: null, }; - // See comments in original file for logic explanation. - // Essentially, we expect the fallback to start from the end of the most recent reliable timeline (HTL). - await service.getMiNotes(ps); expect(dbFallback).toHaveBeenCalled(); @@ -173,7 +171,7 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); // With the fix, we should get note1 and note2. - // Without the fix, we would get only note3 (or empty if limit blocked it). + expect(result).toHaveLength(2); expect(result[0].id).toBe(note1.id); expect(result[1].id).toBe(note2.id); @@ -224,7 +222,7 @@ describe('FanoutTimelineEndpointService', () => { const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[])); const ps = { - redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], + redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], useDbFallback: false, limit: 10, allowPartial: true, @@ -237,9 +235,72 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); - // With the previous logic, note3 and note4 would be filtered out because they are older than the "threshold" (end of TL1). // With the fixed logic (skipping filter when !useDbFallback), all notes should be present. expect(result).toHaveLength(4); expect(result.map(n => n.id)).toEqual([note1.id, note2.id, note3.id, note4.id]); }); + + // Test for dummy ID optimization + test('should inject dummy ID when DB fallback returns empty on initial load', async () => { + const redisResult: string[][] = [[], []]; // Empty timelines + + fanoutTimelineService.getMulti.mockResolvedValue(redisResult); + + // Mock dbFallback to return empty array + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], + useDbFallback: true, + limit: 10, + allowPartial: true, + excludePureRenotes: false, + dbFallback, + noteFilter: () => true, + untilId: null, + sinceId: null, + }; + + const result = await service.getMiNotes(ps); + + expect(result).toEqual([]); + // Should have tried to inject dummy ID for both empty timelines + expect(fanoutTimelineService.injectDummy).toHaveBeenCalledTimes(2); + expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith(`homeTimeline:${alice.id}`, expect.any(String)); + expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith('localTimeline', expect.any(String)); + }); + + // Test for behavior when dummy ID exists + test('should return empty result when only dummy ID exists in Redis and DB has no newer data', async () => { + const now = Date.now(); + const dummyId = idService.gen(now); + // Redis has only dummy ID + const redisResult: string[][] = [[dummyId]]; + + fanoutTimelineService.getMulti.mockResolvedValue(redisResult); + + // Mock dbFallback (should be called to check for newer notes than the dummy ID) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[], + useDbFallback: true, + limit: 10, + allowPartial: false, + excludePureRenotes: false, + dbFallback, + noteFilter: () => true, + untilId: null, + sinceId: null, + }; + + const result = await service.getMiNotes(ps); + + expect(result).toEqual([]); + // Fallback should be called to check for newer notes (ascending check from dummy ID) + expect(dbFallback).toHaveBeenCalled(); + }); }); From b99ed65051fa7fcade5108181f801c63e8e96493 Mon Sep 17 00:00:00 2001 From: tai-cha Date: Tue, 27 Jan 2026 15:06:28 +0900 Subject: [PATCH 09/14] =?UTF-8?q?Revert=20"perf:=20=E7=A9=BA=E3=81=AE?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E8=BE=BC=E3=81=BF=E6=99=82=E3=81=AE=E7=84=A1=E9=A7=84?= =?UTF-8?q?=E3=81=AADB=E3=82=A2=E3=82=AF=E3=82=BB=E3=82=B9=E3=82=92?= =?UTF-8?q?=E5=89=8A=E6=B8=9B=E3=81=99=E3=82=8B=E3=81=9F=E3=82=81=E3=80=81?= =?UTF-8?q?Redis=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=AB=E3=83=80=E3=83=9F=E3=83=BCID=E3=82=92=E6=8C=BF?= =?UTF-8?q?=E5=85=A5=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1022df5325ce2794725b1284646c7a0efd151386. --- .../src/core/FanoutTimelineEndpointService.ts | 22 +----- .../backend/src/core/FanoutTimelineService.ts | 5 -- .../unit/FanoutTimelineEndpointService.ts | 75 ++----------------- 3 files changed, 8 insertions(+), 94 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index c3c7ead201..2d6b7eab06 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -14,7 +14,6 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; @@ -61,7 +60,6 @@ export class FanoutTimelineEndpointService { private fanoutTimelineService: FanoutTimelineService, private utilityService: UtilityService, private channelMutingService: ChannelMutingService, - private idService: IdService, ) { } @@ -219,25 +217,7 @@ export class FanoutTimelineEndpointService { return [...redisTimeline, ...gotFromDb]; } - // RedisおよびDBが空の場合、次回以降の無駄なDBアクセスを防ぐためダミーIDを保存する - const gotFromDb = await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); - if ( - redisResultIds.length === 0 && - ps.sinceId == null && ps.untilId == null && - gotFromDb.length === 0 - ) { - const dummyId = this.idService.gen(); - - Promise.all(ps.redisTimelines.map((tl, i) => { - // 有効なソースかつ結果が空だった場合のみダミーを入れる - if (redisResult[i] && redisResult[i].length === 0) { - return this.fanoutTimelineService.injectDummy(tl, dummyId); - } - return Promise.resolve(); - })); - } - - return gotFromDb; + return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); } private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise { diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 619fd7a89d..24999bf4da 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -108,11 +108,6 @@ export class FanoutTimelineService { }); } - @bindThis - public injectDummy(tl: FanoutTimelineName, id: string) { - return this.redisForTimelines.lpush('list:' + tl, id); - } - @bindThis public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); diff --git a/packages/backend/test/unit/FanoutTimelineEndpointService.ts b/packages/backend/test/unit/FanoutTimelineEndpointService.ts index 1dc605648f..375bf5a97a 100644 --- a/packages/backend/test/unit/FanoutTimelineEndpointService.ts +++ b/packages/backend/test/unit/FanoutTimelineEndpointService.ts @@ -67,7 +67,6 @@ describe('FanoutTimelineEndpointService', () => { .overrideProvider(FanoutTimelineService) .useValue({ getMulti: jest.fn(), - injectDummy: jest.fn(), }) .compile(); @@ -120,7 +119,7 @@ describe('FanoutTimelineEndpointService', () => { const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); const ps = { - redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], + redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], useDbFallback: true, limit: 10, allowPartial: false, @@ -131,6 +130,9 @@ describe('FanoutTimelineEndpointService', () => { sinceId: null, }; + // See comments in original file for logic explanation. + // Essentially, we expect the fallback to start from the end of the most recent reliable timeline (HTL). + await service.getMiNotes(ps); expect(dbFallback).toHaveBeenCalled(); @@ -171,7 +173,7 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); // With the fix, we should get note1 and note2. - + // Without the fix, we would get only note3 (or empty if limit blocked it). expect(result).toHaveLength(2); expect(result[0].id).toBe(note1.id); expect(result[1].id).toBe(note2.id); @@ -222,7 +224,7 @@ describe('FanoutTimelineEndpointService', () => { const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[])); const ps = { - redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], + redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], useDbFallback: false, limit: 10, allowPartial: true, @@ -235,72 +237,9 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); + // With the previous logic, note3 and note4 would be filtered out because they are older than the "threshold" (end of TL1). // With the fixed logic (skipping filter when !useDbFallback), all notes should be present. expect(result).toHaveLength(4); expect(result.map(n => n.id)).toEqual([note1.id, note2.id, note3.id, note4.id]); }); - - // Test for dummy ID optimization - test('should inject dummy ID when DB fallback returns empty on initial load', async () => { - const redisResult: string[][] = [[], []]; // Empty timelines - - fanoutTimelineService.getMulti.mockResolvedValue(redisResult); - - // Mock dbFallback to return empty array - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); - - const ps = { - redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], - useDbFallback: true, - limit: 10, - allowPartial: true, - excludePureRenotes: false, - dbFallback, - noteFilter: () => true, - untilId: null, - sinceId: null, - }; - - const result = await service.getMiNotes(ps); - - expect(result).toEqual([]); - // Should have tried to inject dummy ID for both empty timelines - expect(fanoutTimelineService.injectDummy).toHaveBeenCalledTimes(2); - expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith(`homeTimeline:${alice.id}`, expect.any(String)); - expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith('localTimeline', expect.any(String)); - }); - - // Test for behavior when dummy ID exists - test('should return empty result when only dummy ID exists in Redis and DB has no newer data', async () => { - const now = Date.now(); - const dummyId = idService.gen(now); - // Redis has only dummy ID - const redisResult: string[][] = [[dummyId]]; - - fanoutTimelineService.getMulti.mockResolvedValue(redisResult); - - // Mock dbFallback (should be called to check for newer notes than the dummy ID) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); - - const ps = { - redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[], - useDbFallback: true, - limit: 10, - allowPartial: false, - excludePureRenotes: false, - dbFallback, - noteFilter: () => true, - untilId: null, - sinceId: null, - }; - - const result = await service.getMiNotes(ps); - - expect(result).toEqual([]); - // Fallback should be called to check for newer notes (ascending check from dummy ID) - expect(dbFallback).toHaveBeenCalled(); - }); }); From 33bd93ca400fa4b6914724f7f3e953479ec62bdd Mon Sep 17 00:00:00 2001 From: tai-cha Date: Tue, 27 Jan 2026 17:31:15 +0900 Subject: [PATCH 10/14] =?UTF-8?q?Revert=20"Revert=20"perf:=20=E7=A9=BA?= =?UTF-8?q?=E3=81=AE=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E6=99=82=E3=81=AE=E7=84=A1?= =?UTF-8?q?=E9=A7=84=E3=81=AADB=E3=82=A2=E3=82=AF=E3=82=BB=E3=82=B9?= =?UTF-8?q?=E3=82=92=E5=89=8A=E6=B8=9B=E3=81=99=E3=82=8B=E3=81=9F=E3=82=81?= =?UTF-8?q?=E3=80=81Redis=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AB=E3=83=80=E3=83=9F=E3=83=BCID=E3=82=92?= =?UTF-8?q?=E6=8C=BF=E5=85=A5=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= =?UTF-8?q?""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b99ed65051fa7fcade5108181f801c63e8e96493. --- .../src/core/FanoutTimelineEndpointService.ts | 22 +++++- .../backend/src/core/FanoutTimelineService.ts | 5 ++ .../unit/FanoutTimelineEndpointService.ts | 75 +++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 2d6b7eab06..c3c7ead201 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -14,6 +14,7 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; @@ -60,6 +61,7 @@ export class FanoutTimelineEndpointService { private fanoutTimelineService: FanoutTimelineService, private utilityService: UtilityService, private channelMutingService: ChannelMutingService, + private idService: IdService, ) { } @@ -217,7 +219,25 @@ export class FanoutTimelineEndpointService { return [...redisTimeline, ...gotFromDb]; } - return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); + // RedisおよびDBが空の場合、次回以降の無駄なDBアクセスを防ぐためダミーIDを保存する + const gotFromDb = await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); + if ( + redisResultIds.length === 0 && + ps.sinceId == null && ps.untilId == null && + gotFromDb.length === 0 + ) { + const dummyId = this.idService.gen(); + + Promise.all(ps.redisTimelines.map((tl, i) => { + // 有効なソースかつ結果が空だった場合のみダミーを入れる + if (redisResult[i] && redisResult[i].length === 0) { + return this.fanoutTimelineService.injectDummy(tl, dummyId); + } + return Promise.resolve(); + })); + } + + return gotFromDb; } private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise { diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 24999bf4da..619fd7a89d 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -108,6 +108,11 @@ export class FanoutTimelineService { }); } + @bindThis + public injectDummy(tl: FanoutTimelineName, id: string) { + return this.redisForTimelines.lpush('list:' + tl, id); + } + @bindThis public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); diff --git a/packages/backend/test/unit/FanoutTimelineEndpointService.ts b/packages/backend/test/unit/FanoutTimelineEndpointService.ts index 375bf5a97a..1dc605648f 100644 --- a/packages/backend/test/unit/FanoutTimelineEndpointService.ts +++ b/packages/backend/test/unit/FanoutTimelineEndpointService.ts @@ -67,6 +67,7 @@ describe('FanoutTimelineEndpointService', () => { .overrideProvider(FanoutTimelineService) .useValue({ getMulti: jest.fn(), + injectDummy: jest.fn(), }) .compile(); @@ -119,7 +120,7 @@ describe('FanoutTimelineEndpointService', () => { const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); const ps = { - redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], + redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], useDbFallback: true, limit: 10, allowPartial: false, @@ -130,9 +131,6 @@ describe('FanoutTimelineEndpointService', () => { sinceId: null, }; - // See comments in original file for logic explanation. - // Essentially, we expect the fallback to start from the end of the most recent reliable timeline (HTL). - await service.getMiNotes(ps); expect(dbFallback).toHaveBeenCalled(); @@ -173,7 +171,7 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); // With the fix, we should get note1 and note2. - // Without the fix, we would get only note3 (or empty if limit blocked it). + expect(result).toHaveLength(2); expect(result[0].id).toBe(note1.id); expect(result[1].id).toBe(note2.id); @@ -224,7 +222,7 @@ describe('FanoutTimelineEndpointService', () => { const dbFallback = jest.fn((untilId: string | null, sinceId: string | null, limit: number) => Promise.resolve([] as MiNote[])); const ps = { - redisTimelines: ['homeTimeline', 'localTimeline'] as FanoutTimelineName[], + redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], useDbFallback: false, limit: 10, allowPartial: true, @@ -237,9 +235,72 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); - // With the previous logic, note3 and note4 would be filtered out because they are older than the "threshold" (end of TL1). // With the fixed logic (skipping filter when !useDbFallback), all notes should be present. expect(result).toHaveLength(4); expect(result.map(n => n.id)).toEqual([note1.id, note2.id, note3.id, note4.id]); }); + + // Test for dummy ID optimization + test('should inject dummy ID when DB fallback returns empty on initial load', async () => { + const redisResult: string[][] = [[], []]; // Empty timelines + + fanoutTimelineService.getMulti.mockResolvedValue(redisResult); + + // Mock dbFallback to return empty array + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: [`homeTimeline:${alice.id}`, 'localTimeline'] as FanoutTimelineName[], + useDbFallback: true, + limit: 10, + allowPartial: true, + excludePureRenotes: false, + dbFallback, + noteFilter: () => true, + untilId: null, + sinceId: null, + }; + + const result = await service.getMiNotes(ps); + + expect(result).toEqual([]); + // Should have tried to inject dummy ID for both empty timelines + expect(fanoutTimelineService.injectDummy).toHaveBeenCalledTimes(2); + expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith(`homeTimeline:${alice.id}`, expect.any(String)); + expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith('localTimeline', expect.any(String)); + }); + + // Test for behavior when dummy ID exists + test('should return empty result when only dummy ID exists in Redis and DB has no newer data', async () => { + const now = Date.now(); + const dummyId = idService.gen(now); + // Redis has only dummy ID + const redisResult: string[][] = [[dummyId]]; + + fanoutTimelineService.getMulti.mockResolvedValue(redisResult); + + // Mock dbFallback (should be called to check for newer notes than the dummy ID) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); + + const ps = { + redisTimelines: [`homeTimeline:${alice.id}`] as FanoutTimelineName[], + useDbFallback: true, + limit: 10, + allowPartial: false, + excludePureRenotes: false, + dbFallback, + noteFilter: () => true, + untilId: null, + sinceId: null, + }; + + const result = await service.getMiNotes(ps); + + expect(result).toEqual([]); + // Fallback should be called to check for newer notes (ascending check from dummy ID) + expect(dbFallback).toHaveBeenCalled(); + }); }); From 3fe1d27927ec2d856aafe2c4b53b583965008bbc Mon Sep 17 00:00:00 2001 From: tai-cha Date: Tue, 27 Jan 2026 15:19:06 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20FanoutTimelineService=E3=81=AB?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=81=8C=E7=A9=BA=E3=81=AE=E5=A0=B4?= =?UTF-8?q?=E5=90=88=E3=81=AE=E3=81=BF=E3=83=80=E3=83=9F=E3=83=BCID?= =?UTF-8?q?=E3=82=92=E6=8C=BF=E5=85=A5=E3=81=99=E3=82=8BinjectDummyIfEmpty?= =?UTF-8?q?=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/FanoutTimelineEndpointService.ts | 2 +- .../backend/src/core/FanoutTimelineService.ts | 12 ++- .../unit/FanoutTimelineEndpointService.ts | 7 +- .../test/unit/FanoutTimelineService.ts | 85 +++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/backend/test/unit/FanoutTimelineService.ts diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index c3c7ead201..d6d409f8f1 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -231,7 +231,7 @@ export class FanoutTimelineEndpointService { Promise.all(ps.redisTimelines.map((tl, i) => { // 有効なソースかつ結果が空だった場合のみダミーを入れる if (redisResult[i] && redisResult[i].length === 0) { - return this.fanoutTimelineService.injectDummy(tl, dummyId); + return this.fanoutTimelineService.injectDummyIfEmpty(tl, dummyId); } return Promise.resolve(); })); diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 619fd7a89d..f6f6ba8f6c 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -109,10 +109,20 @@ export class FanoutTimelineService { } @bindThis - public injectDummy(tl: FanoutTimelineName, id: string) { + injectDummy(tl: FanoutTimelineName, id: string) { return this.redisForTimelines.lpush('list:' + tl, id); } + @bindThis + public injectDummyIfEmpty(tl: FanoutTimelineName, id: string): Promise { + return this.redisForTimelines.eval( + 'if redis.call("LLEN", KEYS[1]) == 0 then redis.call("LPUSH", KEYS[1], ARGV[1]) return 1 else return 0 end', + 1, + 'list:' + tl, + id, + ).then(res => res === 1); + } + @bindThis public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); diff --git a/packages/backend/test/unit/FanoutTimelineEndpointService.ts b/packages/backend/test/unit/FanoutTimelineEndpointService.ts index 1dc605648f..06a9b923b1 100644 --- a/packages/backend/test/unit/FanoutTimelineEndpointService.ts +++ b/packages/backend/test/unit/FanoutTimelineEndpointService.ts @@ -68,6 +68,7 @@ describe('FanoutTimelineEndpointService', () => { .useValue({ getMulti: jest.fn(), injectDummy: jest.fn(), + injectDummyIfEmpty: jest.fn(), }) .compile(); @@ -266,9 +267,9 @@ describe('FanoutTimelineEndpointService', () => { expect(result).toEqual([]); // Should have tried to inject dummy ID for both empty timelines - expect(fanoutTimelineService.injectDummy).toHaveBeenCalledTimes(2); - expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith(`homeTimeline:${alice.id}`, expect.any(String)); - expect(fanoutTimelineService.injectDummy).toHaveBeenCalledWith('localTimeline', expect.any(String)); + expect(fanoutTimelineService.injectDummyIfEmpty).toHaveBeenCalledTimes(2); + expect(fanoutTimelineService.injectDummyIfEmpty).toHaveBeenCalledWith(`homeTimeline:${alice.id}`, expect.any(String)); + expect(fanoutTimelineService.injectDummyIfEmpty).toHaveBeenCalledWith('localTimeline', expect.any(String)); }); // Test for behavior when dummy ID exists diff --git a/packages/backend/test/unit/FanoutTimelineService.ts b/packages/backend/test/unit/FanoutTimelineService.ts new file mode 100644 index 0000000000..4d14400ff6 --- /dev/null +++ b/packages/backend/test/unit/FanoutTimelineService.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, jest, test, expect, afterEach, beforeAll, afterAll } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; + +describe('FanoutTimelineService', () => { + let app: TestingModule; + let service: FanoutTimelineService; + let redisForTimelines: jest.Mocked; + let idService: IdService; + + beforeAll(async () => { + app = await Test.createTestingModule({ + providers: [ + FanoutTimelineService, + { + provide: IdService, + useValue: { + parse: jest.fn(), + gen: jest.fn(), + }, + }, + { + provide: DI.redisForTimelines, + useValue: { + eval: jest.fn(), + lpush: jest.fn(), + lrange: jest.fn(), + del: jest.fn(), + pipeline: jest.fn(() => ({ + lpush: jest.fn(), + ltrim: jest.fn(), + lrange: jest.fn(), + exec: jest.fn(), + })), + }, + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(FanoutTimelineService); + redisForTimelines = app.get(DI.redisForTimelines); + idService = app.get(IdService); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + test('injectDummyIfEmpty should call Redis EVAL with correct script', async () => { + redisForTimelines.eval.mockResolvedValue(1); + + const result = await service.injectDummyIfEmpty('homeTimeline:123', 'dummyId'); + + expect(redisForTimelines.eval).toHaveBeenCalledWith( + expect.stringContaining('if redis.call("LLEN", KEYS[1]) == 0 then'), + 1, + 'list:homeTimeline:123', + 'dummyId', + ); + expect(result).toBe(true); + }); + + test('injectDummyIfEmpty should return false if list is not empty', async () => { + redisForTimelines.eval.mockResolvedValue(0); + + const result = await service.injectDummyIfEmpty('homeTimeline:123', 'dummyId'); + + expect(redisForTimelines.eval).toHaveBeenCalled(); + expect(result).toBe(false); + }); +}); From 6c1bcd9a482701e5b03be6221a6a59dff2d92572 Mon Sep 17 00:00:00 2001 From: tai-cha Date: Tue, 27 Jan 2026 17:47:45 +0900 Subject: [PATCH 12/14] =?UTF-8?q?Revert=20"=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BCTL=E3=81=A7=E3=81=AFFTT=E3=81=AE=E3=82=BD=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E3=81=8C=E7=A9=BA=E3=81=AE=E9=9A=9B=E3=81=ABDB?= =?UTF-8?q?=E3=81=ABFallback=E3=81=97=E3=81=AA=E3=81=84=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 48232ca57b0e8e5a1aa950527ae6ef3a3eb9d0d8. --- .../backend/src/core/FanoutTimelineEndpointService.ts | 8 +++----- packages/backend/src/server/api/endpoints/users/notes.ts | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index d6d409f8f1..b751381599 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -44,7 +44,6 @@ type TimelineOptions = { excludePureRenotes: boolean; ignoreAuthorFromUserSuspension?: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, - preventEmptyTimelineDbFallback?: boolean; }; @Injectable() @@ -80,9 +79,8 @@ export class FanoutTimelineEndpointService { const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); - // オプション無効時、取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする - let shouldFallbackToDb = ps.useDbFallback && - (ps.preventEmptyTimelineDbFallback !== true && redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); + // 取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする + let shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); // 取得したresultの中で最古のIDのうち、最も新しいものを取得 // ids自体が空配列の場合、ids[ids.length - 1]はundefinedになるため、filterでnullを除外する @@ -99,7 +97,7 @@ export class FanoutTimelineEndpointService { let noteIds = redisResultIds.slice(0, ps.limit); const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; - shouldFallbackToDb ||= ps.useDbFallback && (noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId); + shouldFallbackToDb ||= noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; if (!shouldFallbackToDb) { let filter = ps.noteFilter ?? (_note => true) as NoteFilter; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 5034c7986b..b9710250cf 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -151,7 +151,6 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me), - preventEmptyTimelineDbFallback: true, }); return timeline; From 7ad8861c64bde6102d0444e46f4177b8a37ddefd Mon Sep 17 00:00:00 2001 From: tai-cha Date: Tue, 27 Jan 2026 19:59:47 +0900 Subject: [PATCH 13/14] =?UTF-8?q?enhance:=20=E3=83=95=E3=82=A1=E3=83=B3?= =?UTF-8?q?=E3=82=A2=E3=82=A6=E3=83=88=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=AE=E7=A9=BA=E7=B5=90=E6=9E=9C=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=82=92=E6=94=B9=E5=96=84=E3=81=97=E3=80=81=E6=A4=9C?= =?UTF-8?q?=E5=87=BA=E5=8F=AF=E8=83=BD=E3=81=AA=E3=83=80=E3=83=9F=E3=83=BC?= =?UTF-8?q?ID=E3=81=A8DB=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF=E3=82=92?= =?UTF-8?q?=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/FanoutTimelineEndpointService.ts | 66 ++++++++++++++++--- .../backend/src/core/FanoutTimelineService.ts | 6 +- .../unit/FanoutTimelineEndpointService.ts | 5 -- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index b751381599..7904225e0c 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -80,7 +80,19 @@ export class FanoutTimelineEndpointService { const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); // 取得したredisResultのうち、2つ以上ソースがあり、1つでも空であればDBにフォールバックする - let shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some(ids => ids.length === 0)); + const trustedEmptyIndices = new Set(); + for (let i = 0; i < redisResult.length; i++) { + const ids = redisResult[i]; + const dummyIdIndex = ids.findIndex(id => this.idService.parse(id).date.getTime() === 1); + if (dummyIdIndex !== -1) { + ids.splice(dummyIdIndex, 1); + if (ids.length === 0) { + trustedEmptyIndices.add(i); + } + } + } + + let shouldFallbackToDb = ps.useDbFallback && (redisResult.length > 1 && redisResult.some((ids, i) => ids.length === 0 && !trustedEmptyIndices.has(i))); // 取得したresultの中で最古のIDのうち、最も新しいものを取得 // ids自体が空配列の場合、ids[ids.length - 1]はundefinedになるため、filterでnullを除外する @@ -97,7 +109,7 @@ export class FanoutTimelineEndpointService { let noteIds = redisResultIds.slice(0, ps.limit); const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; - shouldFallbackToDb ||= noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; + shouldFallbackToDb ||= ps.useDbFallback && (noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId); if (!shouldFallbackToDb) { let filter = ps.noteFilter ?? (_note => true) as NoteFilter; @@ -219,17 +231,25 @@ export class FanoutTimelineEndpointService { // RedisおよびDBが空の場合、次回以降の無駄なDBアクセスを防ぐためダミーIDを保存する const gotFromDb = await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); - if ( - redisResultIds.length === 0 && - ps.sinceId == null && ps.untilId == null && - gotFromDb.length === 0 - ) { - const dummyId = this.idService.gen(); + const canInject = ( + (redisResultIds.length === 0 && ps.sinceId == null && ps.untilId == null) && + (gotFromDb.length < ps.limit) + ); + + if (canInject) { + const dummyId = this.idService.gen(1); // 1 = Detectable Dummy Timestamp Promise.all(ps.redisTimelines.map((tl, i) => { // 有効なソースかつ結果が空だった場合のみダミーを入れる if (redisResult[i] && redisResult[i].length === 0) { - return this.fanoutTimelineService.injectDummyIfEmpty(tl, dummyId); + let isEmpty = true; + if (gotFromDb.length > 0) { + isEmpty = !gotFromDb.some(n => this.accepts(tl, n)); + } + + if (isEmpty) { + return this.fanoutTimelineService.injectDummyIfEmpty(tl, dummyId); + } } return Promise.resolve(); })); @@ -254,4 +274,32 @@ export class FanoutTimelineEndpointService { return notes; } + + private accepts(tl: FanoutTimelineName, note: MiNote): boolean { + if (tl === 'localTimeline') { + return !note.userHost && !note.replyId && note.visibility === 'public'; + } else if (tl === 'localTimelineWithFiles') { + return !note.userHost && !note.replyId && note.visibility === 'public' && note.fileIds.length > 0; + } else if (tl === 'localTimelineWithReplies') { + return !note.userHost && note.replyId != null && note.visibility === 'public'; + } else if (tl.startsWith('localTimelineWithReplyTo:')) { + const id = tl.split(':')[1]; + return !note.userHost && note.replyId != null && note.replyUserId === id; + } else if (tl.startsWith('userTimeline:')) { + const id = tl.split(':')[1]; + return note.userId === id && !note.replyId; + } else if (tl.startsWith('userTimelineWithFiles:')) { + const id = tl.split(':')[1]; + return note.userId === id && !note.replyId && note.fileIds.length > 0; + } else if (tl.startsWith('userTimelineWithReplies:')) { + const id = tl.split(':')[1]; + return note.userId === id && note.replyId != null; + } else if (tl.startsWith('userTimelineWithChannel:')) { + const id = tl.split(':')[1]; + return note.userId === id && note.channelId != null; + } else { + // TODO: homeTimeline系 + return true; + } + } } diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index f6f6ba8f6c..0517142bb7 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -14,9 +14,9 @@ export type FanoutTimelineName = ( | `homeTimeline:${string}` | `homeTimelineWithFiles:${string}` // only notes with files are included // local timeline - | `localTimeline` // replies are not included - | `localTimelineWithFiles` // only non-reply notes with files are included - | `localTimelineWithReplies` // only replies are included + | 'localTimeline' // replies are not included + | 'localTimelineWithFiles' // only non-reply notes with files are included + | 'localTimelineWithReplies' // only replies are included | `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. // antenna diff --git a/packages/backend/test/unit/FanoutTimelineEndpointService.ts b/packages/backend/test/unit/FanoutTimelineEndpointService.ts index 06a9b923b1..7bf55b4ab9 100644 --- a/packages/backend/test/unit/FanoutTimelineEndpointService.ts +++ b/packages/backend/test/unit/FanoutTimelineEndpointService.ts @@ -117,7 +117,6 @@ describe('FanoutTimelineEndpointService', () => { fanoutTimelineService.getMulti.mockResolvedValue([htlIds, ltlIds]); // dbFallback spy - // eslint-disable-next-line @typescript-eslint/no-unused-vars const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); const ps = { @@ -171,8 +170,6 @@ describe('FanoutTimelineEndpointService', () => { const result = await service.getMiNotes(ps); - // With the fix, we should get note1 and note2. - expect(result).toHaveLength(2); expect(result[0].id).toBe(note1.id); expect(result[1].id).toBe(note2.id); @@ -248,7 +245,6 @@ describe('FanoutTimelineEndpointService', () => { fanoutTimelineService.getMulti.mockResolvedValue(redisResult); // Mock dbFallback to return empty array - // eslint-disable-next-line @typescript-eslint/no-unused-vars const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); const ps = { @@ -283,7 +279,6 @@ describe('FanoutTimelineEndpointService', () => { // Mock dbFallback (should be called to check for newer notes than the dummy ID) - // eslint-disable-next-line @typescript-eslint/no-unused-vars const dbFallback = jest.fn((_untilId: string | null, _sinceId: string | null, _limit: number) => Promise.resolve([] as MiNote[])); const ps = { From dba4a116df9c312b0b1102b23b93fd78867a0c87 Mon Sep 17 00:00:00 2001 From: tai-cha Date: Wed, 28 Jan 2026 01:50:08 +0900 Subject: [PATCH 14/14] =?UTF-8?q?enhance:=20=E3=82=BD=E3=83=BC=E3=82=B7?= =?UTF-8?q?=E3=83=A3=E3=83=ABTL=E3=81=AEDB=E3=83=95=E3=82=A9=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/UserFollowingService.ts | 2 +- .../api/endpoints/notes/hybrid-timeline.ts | 44 ++++++++++++++++--- packages/backend/test/e2e/timelines.ts | 15 ++----- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index e7a6be99fb..972063fa52 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -732,7 +732,7 @@ export class UserFollowingService implements OnModuleInit { @bindThis public getFollowees(userId: MiUser['id']) { return this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') + .select(['following.followeeId', 'following.withReplies']) .where('following.followerId = :followerId', { followerId: userId }) .getMany(); } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 0a3602df20..76cb0ead64 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -197,6 +197,10 @@ export default class extends Endpoint { // eslint- withReplies: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); + const followeeIds = followees.map(f => f.followeeId); + const meOrFolloweeIds = [me.id, ...followeeIds]; + const followeeWithRepliesIds = followees.filter(f => f.withReplies).map(f => f.followeeId); + const meOrFolloweeWithRepliesIds = [...meOrFolloweeIds, ...followeeWithRepliesIds]; const mutingChannelIds = await this.channelMutingService .list({ requestUserId: me.id }, { idOnly: true }) @@ -207,14 +211,39 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { + // 自分自身 + qb.where('note.userId = :meId', { meId: me.id }); + + // フォローしている人 if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } else { - qb.where('note.userId = :meId', { meId: me.id }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + qb.orWhere(new Brackets(qb => { + qb.where('note.userId IN (:...followeeIds)', { followeeIds }); + + // 自身に関係ないリプライを除外 + if (ps.withReplies) { + qb.andWhere(new Brackets(qb => { + qb.where('note.replyId IS NULL') + .orWhere('note.replyUserId IN (:...meOrFolloweeWithRepliesIds)', { meOrFolloweeWithRepliesIds }); + + if (followeeWithRepliesIds.length > 0) { + qb.orWhere(new Brackets(qb => { + qb.where('note.userId IN (:...followeeWithRepliesIds)', { followeeWithRepliesIds }) + .andWhere(new Brackets(qb => { + qb.where('reply.visibility != \'followers\'') + .orWhere('note.replyUserId IN (:...followeeIds)', { followeeIds }); + })); + })); + } + })); + } + })); } + + // ローカルのpublicノート + qb.orWhere(new Brackets(qb => { + qb.where('note.visibility = \'public\'') + .andWhere('note.userHost IS NULL'); + })); })) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') @@ -246,7 +275,8 @@ export default class extends Endpoint { // eslint- qb // 返信だけど投稿者自身への返信 .where('note.replyId IS NOT NULL') .andWhere('note.replyUserId = note.userId'); - })); + })) + .orWhere('note.replyUserId = :meId', { meId: me.id }); // 自分への返信 })); } diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 4fd826100d..4118e7299a 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -1664,9 +1664,6 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ - if (!enableFanoutTimeline) return; - const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1694,16 +1691,13 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ - if (!enableFanoutTimeline) return; - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1716,7 +1710,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); @@ -1724,9 +1718,6 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ - if (!enableFanoutTimeline) return; - const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1738,7 +1729,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);