mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 00:16:53 +00:00
137 lines
4.7 KiB
TypeScript
137 lines
4.7 KiB
TypeScript
/**
|
|
* Per-key coalescing async runner: at most one task running and one pending per
|
|
* key. `submit` runs `fn` now if idle, else REPLACES the pending task (the
|
|
* displaced one never runs); the pending task starts when the running one
|
|
* settles. Collapses bursts of "save latest" to the in-flight call plus the
|
|
* most recent. Keys are independent.
|
|
*/
|
|
import { SvelteSet } from 'svelte/reactivity'
|
|
|
|
export type CoalescingTask<T = unknown> = () => T | Promise<T>
|
|
|
|
/** Rejection reason for a `submitAndWait` (or `cancel`-dropped) task discarded
|
|
* before it ran. Only awaiters see it, not fire-and-forget `submit` callers. */
|
|
export class CoalescingDisplacedError extends Error {
|
|
constructor() {
|
|
super('coalescingRunner: pending task displaced before it could run')
|
|
this.name = 'CoalescingDisplacedError'
|
|
}
|
|
}
|
|
|
|
export type CoalescingKeyedRunner = {
|
|
/** Fire-and-forget schedule per the policy above. */
|
|
submit(key: string, fn: CoalescingTask): void
|
|
/** Like `submit`, but the promise resolves/rejects with `fn`'s outcome, or
|
|
* rejects with `CoalescingDisplacedError` if dropped before running. */
|
|
submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T>
|
|
/** Drop the pending task for `key` (returns whether there was one). Can't
|
|
* abort an in-flight task. A dropped `submitAndWait` rejects with
|
|
* `CoalescingDisplacedError`. */
|
|
cancel(key: string): boolean
|
|
/** Reactively whether `key`'s chain is running (SvelteSet-backed). */
|
|
isRunning(key: string): boolean
|
|
/** Resolves once `key`'s chain has drained (nothing running, nothing
|
|
* pending), immediately if it's idle. Never rejects. */
|
|
settled(key: string): Promise<void>
|
|
}
|
|
|
|
type PendingTask = {
|
|
fn: CoalescingTask
|
|
resolve?: (value: unknown) => void
|
|
reject?: (reason: unknown) => void
|
|
}
|
|
|
|
type Entry = { pending: PendingTask | undefined }
|
|
|
|
/**
|
|
* @example
|
|
* runner.submit('k', f) // runs immediately
|
|
* runner.submit('k', g) // f running: g pending
|
|
* runner.submit('k', h) // g discarded, h pending; h runs once f settles
|
|
*/
|
|
export function createCoalescingKeyedRunner(): CoalescingKeyedRunner {
|
|
const state = new Map<string, Entry>()
|
|
// Reactive mirror of keys with a running chain, kept in lock-step with
|
|
// `state` (SvelteSet for per-key `isRunning` subscriptions).
|
|
const runningKeys = new SvelteSet<string>()
|
|
// Live chain promise per key, backing `settled`.
|
|
const chains = new Map<string, Promise<void>>()
|
|
|
|
async function chain(key: string, first: PendingTask): Promise<void> {
|
|
let current: PendingTask | undefined = first
|
|
while (current) {
|
|
try {
|
|
const result = await current.fn()
|
|
current.resolve?.(result)
|
|
} catch (e) {
|
|
// Don't kill the chain on failure — later submissions must
|
|
// still run. Awaiters get the error via their promise;
|
|
// fire-and-forget callers get a console.error.
|
|
if (current.reject) current.reject(e)
|
|
else console.error('coalescingRunner: task failed', e)
|
|
}
|
|
const entry = state.get(key)!
|
|
current = entry.pending
|
|
entry.pending = undefined
|
|
}
|
|
state.delete(key)
|
|
runningKeys.delete(key)
|
|
chains.delete(key)
|
|
}
|
|
|
|
/** Set `task` pending for `key`, displacing (and rejecting) any prior
|
|
* pending. If the key is idle, start the chain. */
|
|
function setOrDisplace(key: string, task: PendingTask): void {
|
|
const entry = state.get(key)
|
|
if (entry) {
|
|
entry.pending?.reject?.(new CoalescingDisplacedError())
|
|
entry.pending = task
|
|
return
|
|
}
|
|
state.set(key, { pending: undefined })
|
|
runningKeys.add(key)
|
|
// Register the chain promise BEFORE the first task runs. `chain` invokes
|
|
// the task synchronously, so a task that calls `settled(key)` (or that
|
|
// throws synchronously, running cleanup) would otherwise race ahead of a
|
|
// `chains.set(key, chain(...))` and leave the map wrong. A separate
|
|
// deferred sidesteps that: it's live before the task starts and resolves
|
|
// when the chain drains.
|
|
let done!: () => void
|
|
chains.set(key, new Promise<void>((resolve) => (done = resolve)))
|
|
void chain(key, task).finally(done)
|
|
}
|
|
|
|
function submit(key: string, fn: CoalescingTask): void {
|
|
setOrDisplace(key, { fn })
|
|
}
|
|
|
|
function submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
setOrDisplace(key, {
|
|
fn: fn as CoalescingTask,
|
|
resolve: resolve as (v: unknown) => void,
|
|
reject
|
|
})
|
|
})
|
|
}
|
|
|
|
function cancel(key: string): boolean {
|
|
const entry = state.get(key)
|
|
if (!entry?.pending) return false
|
|
const dropped = entry.pending
|
|
entry.pending = undefined
|
|
dropped.reject?.(new CoalescingDisplacedError())
|
|
return true
|
|
}
|
|
|
|
function isRunning(key: string): boolean {
|
|
return runningKeys.has(key)
|
|
}
|
|
|
|
function settled(key: string): Promise<void> {
|
|
return chains.get(key) ?? Promise.resolve()
|
|
}
|
|
|
|
return { submit, submitAndWait, cancel, isRunning, settled }
|
|
}
|