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 = {