mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 08:00:59 +00:00
feat: add immediate-save bypass that cancels pending debouncer + runner tasks
This commit is contained in:
@@ -10,18 +10,49 @@
|
||||
* 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<unknown>
|
||||
export type CoalescingTask<T = unknown> = () => T | Promise<T>
|
||||
|
||||
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
|
||||
/** Thrown into the rejection of a `submitAndWait` promise (and a
|
||||
* `cancel`-dropped pending task) when the task is discarded before it
|
||||
* had a chance to run. Fire-and-forget `submit` callers don't see this
|
||||
* — only awaiters do. */
|
||||
export class CoalescingDisplacedError extends Error {
|
||||
constructor() {
|
||||
super('coalescingRunner: pending task displaced before it could run')
|
||||
this.name = 'CoalescingDisplacedError'
|
||||
}
|
||||
}
|
||||
|
||||
type Entry = { pending: CoalescingTask | undefined }
|
||||
export type CoalescingKeyedRunner = {
|
||||
/** Fire-and-forget. Schedule `fn` under `key` per the policy above.
|
||||
* Synchronous; `fn` is invoked on the current tick when the key is
|
||||
* idle. If a previously-submitted task is still pending for this
|
||||
* key, it is silently dropped. */
|
||||
submit(key: string, fn: CoalescingTask): void
|
||||
/** Same scheduling as `submit`, but returns a promise that resolves
|
||||
* with `fn`'s return value when `fn` actually runs, rejects with
|
||||
* `fn`'s throw if `fn` fails, or rejects with `CoalescingDisplacedError`
|
||||
* if this submission is dropped (by `cancel`, or by a later
|
||||
* `submit` / `submitAndWait` for the same key) before it runs. */
|
||||
submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T>
|
||||
/** Drop the pending (queued) task for `key` without running it.
|
||||
* Returns true if there was something to cancel. Does NOT affect
|
||||
* any task currently in flight — there's no way to abort it. If
|
||||
* the dropped task was submitted via `submitAndWait`, its promise
|
||||
* rejects with `CoalescingDisplacedError`. */
|
||||
cancel(key: string): boolean
|
||||
}
|
||||
|
||||
type PendingTask = {
|
||||
fn: CoalescingTask
|
||||
resolve?: (value: unknown) => void
|
||||
reject?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type Entry = { pending: PendingTask | undefined }
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const runner = createCoalescingKeyedRunner()
|
||||
* // f, g, h are async functions
|
||||
@@ -34,15 +65,20 @@ type Entry = { pending: CoalescingTask | undefined }
|
||||
export function createCoalescingKeyedRunner(): CoalescingKeyedRunner {
|
||||
const state = new Map<string, Entry>()
|
||||
|
||||
async function chain(key: string, first: CoalescingTask): Promise<void> {
|
||||
let current: CoalescingTask | undefined = first
|
||||
async function chain(key: string, first: PendingTask): Promise<void> {
|
||||
let current: PendingTask | undefined = first
|
||||
while (current) {
|
||||
try {
|
||||
await current()
|
||||
const result = await current.fn()
|
||||
current.resolve?.(result)
|
||||
} 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)
|
||||
// rely on later submissions still running. submitAndWait
|
||||
// callers see the error via their promise; fire-and-forget
|
||||
// submit callers get a console.error so the failure isn't
|
||||
// silently swallowed.
|
||||
if (current.reject) current.reject(e)
|
||||
else console.error('coalescingRunner: task failed', e)
|
||||
}
|
||||
const entry = state.get(key)!
|
||||
current = entry.pending
|
||||
@@ -51,16 +87,42 @@ export function createCoalescingKeyedRunner(): CoalescingKeyedRunner {
|
||||
state.delete(key)
|
||||
}
|
||||
|
||||
function submit(key: string, fn: CoalescingTask): void {
|
||||
/** Set `task` as the pending entry for `key`, displacing whatever
|
||||
* was there (and rejecting its promise if it had one). If the key
|
||||
* is idle, set up the entry and start the chain. */
|
||||
function setOrDisplace(key: string, task: PendingTask): void {
|
||||
const entry = state.get(key)
|
||||
if (entry) {
|
||||
// Drop whatever was queued — only the latest submission matters.
|
||||
entry.pending = fn
|
||||
entry.pending?.reject?.(new CoalescingDisplacedError())
|
||||
entry.pending = task
|
||||
return
|
||||
}
|
||||
state.set(key, { pending: undefined })
|
||||
void chain(key, fn)
|
||||
void chain(key, task)
|
||||
}
|
||||
|
||||
return { submit }
|
||||
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
|
||||
}
|
||||
|
||||
return { submit, submitAndWait, cancel }
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
workspace: $workspaceStore ?? '',
|
||||
itemKind: app.raw_app ? 'raw_app' : 'app',
|
||||
path,
|
||||
value: null
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
} else {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
|
||||
@@ -93,7 +93,8 @@
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'flow',
|
||||
path,
|
||||
value: null
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
} else {
|
||||
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'script',
|
||||
path,
|
||||
value: null
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
} else {
|
||||
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
|
||||
|
||||
@@ -21,6 +21,11 @@ export type DebouncerByKey = {
|
||||
/** Replace any pending task under `key` with `fn`, set/extend the
|
||||
* timer to `min(now + debounceMs, chainStart + maxDebounceMs)`. */
|
||||
schedule(key: string, fn: DebouncedTask): void
|
||||
/** Clear the timer and drop the pending task for `key` without
|
||||
* running it. Returns true if there was something to cancel. Use
|
||||
* to hand control of a key over to an imperative path (e.g. an
|
||||
* immediate save that supersedes the queued autosave). */
|
||||
cancel(key: string): boolean
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
@@ -66,5 +71,13 @@ export function createDebouncerByKey(opts: {
|
||||
entries.set(key, { timer, task: fn, chainStart })
|
||||
}
|
||||
|
||||
return { schedule }
|
||||
function cancel(key: string): boolean {
|
||||
const existing = entries.get(key)
|
||||
if (!existing) return false
|
||||
clearTimeout(existing.timer)
|
||||
entries.delete(key)
|
||||
return true
|
||||
}
|
||||
|
||||
return { schedule, cancel }
|
||||
}
|
||||
|
||||
@@ -74,6 +74,15 @@ export type UserDraftDbSyncerSaveOpts = {
|
||||
/** `null` signals a delete — the server removes the row under the same
|
||||
* conflict rules as an upsert. */
|
||||
value: unknown | null
|
||||
/** Bypass the autosave debouncer for THIS save. Cancels any pending
|
||||
* debouncer task for the same key (the queued autosave would
|
||||
* otherwise overwrite what we're about to send), routes through the
|
||||
* coalescing runner so ordering against any in-flight POST is
|
||||
* preserved, and the returned promise resolves only after the POST
|
||||
* actually lands. Use for `await save(...); then-read-the-server`
|
||||
* flows (table-row delete, etc.) where a fire-and-forget save would
|
||||
* race the next read. */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export type UserDraftLastSyncQuery = {
|
||||
@@ -157,6 +166,18 @@ export const UserDraftDbSyncer = {
|
||||
|
||||
async save(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
|
||||
if (opts.immediate) {
|
||||
// Drop the queued autosave (if any) — letting it fire after
|
||||
// our POST would re-save the pre-delete value. The runner's
|
||||
// own cancel is implicit in `submitAndWait`, which displaces
|
||||
// any pending runner task with ours, but call it explicitly
|
||||
// so a `submit` -> `cancel` -> `submitAndWait` sequence is
|
||||
// observable in the runner's internal state for debugging.
|
||||
debouncer.cancel(key)
|
||||
runner.cancel(key)
|
||||
await runner.submitAndWait(key, () => postSave(opts))
|
||||
return
|
||||
}
|
||||
debouncer.schedule(key, () => {
|
||||
runner.submit(key, () => postSave(opts))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user