/** * Per-key retry scheduling policy for the durable audio processing queue. * * - `arm(key, atMs)` keeps ONE timer per key, always at the earliest pending * deadline. A later deadline never replaces an earlier one, so an item that * is due soon is not starved by an item that is due much later. * - `requestRun(key)` coalesces re-entrant triggers: while a pass is running, * further requests (a timer firing, a manual retry, a foreground transition) * mark the key dirty and one more pass runs after the current one. The * returned promise settles when the whole run, including that follow-up * pass, has finished. * * Clock and timer functions are injected so the policy is testable without * real time. */ export interface RetrySchedulerDeps { now: () => number; setTimer: (callback: () => void, delayMs: number) => TimerHandle; clearTimer: (handle: TimerHandle) => void; /** One processing pass for the key. */ run: (key: string) => Promise; } export interface RetryScheduler { arm(key: string, atMs: number): void; cancel(key: string): void; requestRun(key: string): Promise; /** Earliest armed deadline for the key, if any. */ nextDeadline(key: string): number | undefined; isRunning(key: string): boolean; } interface ArmedTimer { atMs: number; handle: TimerHandle; } interface ActiveRun { rerunRequested: boolean; done: Promise; } export function createRetryScheduler( deps: RetrySchedulerDeps, ): RetryScheduler { const armed = new Map>(); const active = new Map(); function arm(key: string, atMs: number): void { const existing = armed.get(key); if (existing !== undefined) { if (existing.atMs <= atMs) return; deps.clearTimer(existing.handle); } const entry: ArmedTimer = { atMs, handle: deps.setTimer(() => { if (armed.get(key) === entry) armed.delete(key); void requestRun(key).catch(() => undefined); }, Math.max(0, atMs - deps.now())), }; armed.set(key, entry); } function cancel(key: string): void { const existing = armed.get(key); if (existing !== undefined) deps.clearTimer(existing.handle); armed.delete(key); const current = active.get(key); if (current !== undefined) current.rerunRequested = false; } async function loop(key: string, state: ActiveRun): Promise { try { let failed = false; let failure: unknown = null; do { state.rerunRequested = false; try { await deps.run(key); failed = false; } catch (error) { failed = true; failure = error; } } while (state.rerunRequested); if (failed) throw failure; } finally { if (active.get(key) === state) active.delete(key); } } function requestRun(key: string): Promise { const current = active.get(key); if (current !== undefined) { current.rerunRequested = true; return current.done; } const state: ActiveRun = { rerunRequested: false, done: Promise.resolve() }; // Register before the first pass starts, and start it on a microtask, so a // re-entrant request from inside a pass joins this run (and its promise) // instead of starting a parallel one. active.set(key, state); state.done = Promise.resolve().then(() => loop(key, state)); return state.done; } function nextDeadline(key: string): number | undefined { return armed.get(key)?.atMs; } function isRunning(key: string): boolean { return active.has(key); } return { arm, cancel, requestRun, nextDeadline, isRunning }; }