diff --git a/changes/anilist-retry-dedupe.md b/changes/anilist-retry-dedupe.md new file mode 100644 index 00000000..6cd190f5 --- /dev/null +++ b/changes/anilist-retry-dedupe.md @@ -0,0 +1,4 @@ +type: fixed +area: anilist + +- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode. diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts index 54a56836..f0d08011 100644 --- a/src/core/services/anilist/anilist-update-queue.test.ts +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -71,7 +71,7 @@ test('anilist update queue applies retry backoff and dead-letter', () => { assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000); for (let attempt = 2; attempt <= 8; attempt += 1) { - queue.markFailure('k2', `fail-${attempt}`, now); + queue.markFailure('k2', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000); } const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); @@ -83,6 +83,52 @@ test('anilist update queue applies retry backoff and dead-letter', () => { ); }); +test('anilist update queue ignores duplicate failures while retry is cooling down', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + + const now = 1_700_000 * 1_000_000; + queue.enqueue('k2', 'Backoff Demo', 2); + queue.markFailure('k2', 'fail-1', now); + + for (let attempt = 2; attempt <= 12; attempt += 1) { + queue.markFailure('k2', `duplicate-${attempt}`, now + attempt); + } + + const payload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as { + pending: Array<{ attemptCount: number; lastError: string | null }>; + deadLetter: Array; + }; + assert.equal(payload.pending[0]?.attemptCount, 1); + assert.equal(payload.pending[0]?.lastError, 'fail-1'); + assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), { + pending: 1, + ready: 1, + deadLetter: 0, + }); +}); + +test('anilist update queue does not re-enqueue dead-lettered keys', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + + const now = 1_700_000 * 1_000_000; + queue.enqueue('k4', 'Dead Letter Demo', 4); + for (let attempt = 1; attempt <= 8; attempt += 1) { + queue.markFailure('k4', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000); + } + + queue.enqueue('k4', 'Dead Letter Demo', 4); + + assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), { + pending: 0, + ready: 0, + deadLetter: 1, + }); +}); + test('anilist update queue persists and reloads from disk', () => { const queueFile = createTempQueueFile(); const loggerState = createLogger(); diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts index 776a700f..9772c626 100644 --- a/src/core/services/anilist/anilist-update-queue.ts +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -108,7 +108,8 @@ export function createAnilistUpdateQueue( return { enqueue(key: string, title: string, episode: number, season: number | null = null): void { - const existing = pending.find((item) => item.key === key); + const existing = + pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key); if (existing) { return; } @@ -147,6 +148,9 @@ export function createAnilistUpdateQueue( if (!item) { return; } + if (item.attemptCount > 0 && item.nextAttemptAt > nowMs) { + return; + } item.attemptCount += 1; item.lastError = reason; if (item.attemptCount >= MAX_ATTEMPTS) {