fix(mobile-queue): arm retries at the earliest deadline and stop losing or replaying queued audio
This commit is contained in:
parent
ace946d5c7
commit
2576f4af7b
5 changed files with 614 additions and 57 deletions
118
apps/mobile-rn/src/features/recording/retry-scheduler.ts
Normal file
118
apps/mobile-rn/src/features/recording/retry-scheduler.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue