118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
/**
|
|
* 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<TimerHandle> {
|
|
now: () => number;
|
|
setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
|
clearTimer: (handle: TimerHandle) => void;
|
|
/** One processing pass for the key. */
|
|
run: (key: string) => Promise<void>;
|
|
}
|
|
|
|
export interface RetryScheduler {
|
|
arm(key: string, atMs: number): void;
|
|
cancel(key: string): void;
|
|
requestRun(key: string): Promise<void>;
|
|
/** Earliest armed deadline for the key, if any. */
|
|
nextDeadline(key: string): number | undefined;
|
|
isRunning(key: string): boolean;
|
|
}
|
|
|
|
interface ArmedTimer<TimerHandle> {
|
|
atMs: number;
|
|
handle: TimerHandle;
|
|
}
|
|
|
|
interface ActiveRun {
|
|
rerunRequested: boolean;
|
|
done: Promise<void>;
|
|
}
|
|
|
|
export function createRetryScheduler<TimerHandle>(
|
|
deps: RetrySchedulerDeps<TimerHandle>,
|
|
): RetryScheduler {
|
|
const armed = new Map<string, ArmedTimer<TimerHandle>>();
|
|
const active = new Map<string, ActiveRun>();
|
|
|
|
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<TimerHandle> = {
|
|
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<void> {
|
|
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<void> {
|
|
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 };
|
|
}
|