diff --git a/.changeset/fix-scheduler-sleep-overflow.md b/.changeset/fix-scheduler-sleep-overflow.md new file mode 100644 index 0000000000..bf51ade22d --- /dev/null +++ b/.changeset/fix-scheduler-sleep-overflow.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Fixed scheduler `sleep` firing immediately for durations longer than ~24.8 days, caused by Node.js `setTimeout` overflowing its 32-bit millisecond limit. diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts index cc8aac5f51..c91466f667 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts @@ -150,13 +150,13 @@ describe('PluginTaskManagerImpl', () => { id: 'task1', timeout: Duration.fromMillis(5000), frequency: Duration.fromObject({ years: 1 }), - initialDelay: Duration.fromObject({ years: 1 }), + initialDelay: Duration.fromObject({ seconds: 60 }), fn, scope: 'global', }); await manager.triggerTask('task1'); - jest.advanceTimersByTime(5000); + await jest.advanceTimersByTimeAsync(65_000); await promise; expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts index e2536abb88..c4126588fb 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts @@ -56,6 +56,29 @@ describe('util', () => { await promise; expect(true).toBe(true); }, 1_000); + + it('handles durations longer than 2^31 ms by chunking setTimeout calls', async () => { + jest.useFakeTimers(); + try { + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + let resolved = false; + const promise = sleep(Duration.fromMillis(thirtyDaysMs)).then(() => { + resolved = true; + }); + + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(2 ** 30); + expect(resolved).toBe(false); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(thirtyDaysMs - 2 ** 30); + await promise; + expect(resolved).toBe(true); + } finally { + jest.useRealTimers(); + } + }); }); describe('delegateAbortController', () => { diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts index 3e42d198df..06d6750ba0 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts @@ -54,6 +54,11 @@ export function nowPlus(duration: Duration | undefined, knex: Knex) { return knex.raw(`now() + interval '${seconds} seconds'`); } +// Node.js setTimeout uses a 32-bit signed integer internally, so timeouts +// longer than 2^31-1 ms (~24.8 days) fire immediately. We cap each individual +// wait at 2^30 ms (~12.4 days) and loop until the full duration has elapsed. +const MAX_TIMEOUT_MS = 2 ** 30; + /** * Sleep for the given duration, but return sooner if the abort signal * triggers. @@ -69,19 +74,34 @@ export async function sleep( return; } - await new Promise(resolve => { - let timeoutHandle: NodeJS.Timeout | undefined = undefined; + let remaining = duration.as('milliseconds'); + if (!Number.isFinite(remaining) || remaining <= 0) { + return; + } - const done = () => { + await new Promise(resolve => { + let timeoutHandle: NodeJS.Timeout | undefined; + + const finish = () => { if (timeoutHandle) { clearTimeout(timeoutHandle); } - abortSignal?.removeEventListener('abort', done); + abortSignal?.removeEventListener('abort', finish); resolve(); }; - timeoutHandle = setTimeout(done, duration.as('milliseconds')); - abortSignal?.addEventListener('abort', done); + const tick = () => { + if (remaining <= 0) { + finish(); + return; + } + const chunk = Math.min(remaining, MAX_TIMEOUT_MS); + remaining -= chunk; + timeoutHandle = setTimeout(tick, chunk); + }; + + abortSignal?.addEventListener('abort', finish); + tick(); }); }