d3ro-voice/apps/mobile-rn/src/features/recording/retry-scheduler.ts

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