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