diff --git a/frontend/src/lib/coalescingRunner.ts b/frontend/src/lib/coalescingRunner.ts new file mode 100644 index 0000000000..02353b5dd9 --- /dev/null +++ b/frontend/src/lib/coalescingRunner.ts @@ -0,0 +1,55 @@ +/** + * Per-key coalescing async runner. + * + * For each key, at most one task is running and at most one is pending. + * `submit(key, fn)` runs `fn` immediately when the key is idle; otherwise + * `fn` REPLACES any previously-pending task for that key (the displaced + * one is dropped and never runs). When the running task settles, the + * pending task — if any — starts next. Different keys are independent. + * + * Use to collapse bursts of "save the latest version" calls down to + * exactly two runs: the one in flight plus the most recent. + */ +export type CoalescingTask = () => unknown | Promise + +export type CoalescingKeyedRunner = { + /** Schedule `fn` under `key` per the policy above. Synchronous; `fn` + * is invoked on the current tick when the key is idle. */ + submit(key: string, fn: CoalescingTask): void +} + +type Entry = { pending: CoalescingTask | undefined } + +export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { + const state = new Map() + + async function chain(key: string, first: CoalescingTask): Promise { + let current: CoalescingTask | undefined = first + while (current) { + try { + await current() + } catch (e) { + // Don't kill the chain on a task failure — bursty callers + // rely on later submissions still running. + console.error('coalescingRunner: task failed', e) + } + const entry = state.get(key)! + current = entry.pending + entry.pending = undefined + } + state.delete(key) + } + + function submit(key: string, fn: CoalescingTask): void { + const entry = state.get(key) + if (entry) { + // Drop whatever was queued — only the latest submission matters. + entry.pending = fn + return + } + state.set(key, { pending: undefined }) + void chain(key, fn) + } + + return { submit } +}