bunqueue 2.8.46 → 2.8.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/workflow/admission.d.ts +45 -0
- package/dist/client/workflow/admission.js +50 -0
- package/dist/client/workflow/clock.d.ts +58 -0
- package/dist/client/workflow/clock.js +104 -0
- package/dist/client/workflow/compensator.d.ts +56 -3
- package/dist/client/workflow/compensator.js +406 -27
- package/dist/client/workflow/emitter.d.ts +1 -1
- package/dist/client/workflow/emitter.js +4 -3
- package/dist/client/workflow/engine.d.ts +17 -0
- package/dist/client/workflow/engine.js +25 -0
- package/dist/client/workflow/executor.d.ts +40 -5
- package/dist/client/workflow/executor.js +227 -83
- package/dist/client/workflow/identity.d.ts +46 -0
- package/dist/client/workflow/identity.js +93 -0
- package/dist/client/workflow/index.d.ts +1 -1
- package/dist/client/workflow/loops.js +147 -14
- package/dist/client/workflow/recovery.d.ts +10 -1
- package/dist/client/workflow/recovery.js +36 -9
- package/dist/client/workflow/rollbackControl.d.ts +36 -0
- package/dist/client/workflow/rollbackControl.js +51 -0
- package/dist/client/workflow/runner.d.ts +22 -2
- package/dist/client/workflow/runner.js +125 -27
- package/dist/client/workflow/store.d.ts +64 -4
- package/dist/client/workflow/store.js +123 -30
- package/dist/client/workflow/storeCodec.d.ts +7 -0
- package/dist/client/workflow/storeCodec.js +16 -0
- package/dist/client/workflow/storeSignals.d.ts +61 -0
- package/dist/client/workflow/storeSignals.js +118 -0
- package/dist/client/workflow/types.d.ts +147 -4
- package/dist/client/workflow/unwindPlan.d.ts +87 -0
- package/dist/client/workflow/unwindPlan.js +142 -0
- package/dist/client/workflow/waitFor.d.ts +52 -0
- package/dist/client/workflow/waitFor.js +137 -0
- package/dist/client/workflow/workflow.d.ts +71 -1
- package/dist/client/workflow/workflow.js +184 -14
- package/package.json +16 -6
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a node job may run, decided as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. Job delivery is at-least-once, so the same node job arrives
|
|
5
|
+
* twice as a matter of course: a queue retry, a `recover()` over a run the live engine
|
|
6
|
+
* is already driving, a timeout re-check racing a signal. Three guards decide whether
|
|
7
|
+
* an arrival is the real one, and when one of them was missing a duplicate re-ran the
|
|
8
|
+
* node AND every node after it, giving one execution two independent advance chains,
|
|
9
|
+
* doubled side effects, and a final state of `completed` that hid the whole thing.
|
|
10
|
+
*
|
|
11
|
+
* That defect was found by the state-machine model after a long campaign, because the
|
|
12
|
+
* decision lived inside an async method that also read SQLite and dispatched work: the
|
|
13
|
+
* only way to observe it was to reproduce the race. As a function of its inputs it is
|
|
14
|
+
* a table, and a table can be enumerated.
|
|
15
|
+
*
|
|
16
|
+
* The three rejections are deliberately distinguished rather than collapsed into a
|
|
17
|
+
* boolean. `stale-cursor` and `already-in-flight` mean very different things to anyone
|
|
18
|
+
* reading a log: one is an arrival for work the run has already moved past, the other
|
|
19
|
+
* is a second arrival for work in progress right now.
|
|
20
|
+
*/
|
|
21
|
+
import type { Execution } from './types';
|
|
22
|
+
export type Admission = {
|
|
23
|
+
kind: 'run';
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'reject';
|
|
26
|
+
/**
|
|
27
|
+
* `missing` the execution is not in the store at all
|
|
28
|
+
* `not-live` it is terminal, compensating or parked, so no node may run
|
|
29
|
+
* `stale-cursor` the job names a node the run has already left
|
|
30
|
+
* `already-in-flight` this exact node is executing in this process right now
|
|
31
|
+
*/
|
|
32
|
+
reason: 'missing' | 'not-live' | 'stale-cursor' | 'already-in-flight';
|
|
33
|
+
};
|
|
34
|
+
/** States in which a node job may execute. Anything else is not a live run. */
|
|
35
|
+
export declare function isLive(state: Execution['state']): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether the job for `nodeIndex` of `exec` may run.
|
|
38
|
+
*
|
|
39
|
+
* `inFlight` is the set of `<executionId>:<nodeIndex>` claims held by this process.
|
|
40
|
+
* It is passed in rather than read from module state so the decision stays a function
|
|
41
|
+
* of its arguments.
|
|
42
|
+
*/
|
|
43
|
+
export declare function decideAdmission(exec: Execution | null, nodeIndex: number, inFlight: ReadonlySet<string>): Admission;
|
|
44
|
+
/** The claim a running node holds. One string, one place that builds it. */
|
|
45
|
+
export declare function claimKey(executionId: string, nodeIndex: number): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a node job may run, decided as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. Job delivery is at-least-once, so the same node job arrives
|
|
5
|
+
* twice as a matter of course: a queue retry, a `recover()` over a run the live engine
|
|
6
|
+
* is already driving, a timeout re-check racing a signal. Three guards decide whether
|
|
7
|
+
* an arrival is the real one, and when one of them was missing a duplicate re-ran the
|
|
8
|
+
* node AND every node after it, giving one execution two independent advance chains,
|
|
9
|
+
* doubled side effects, and a final state of `completed` that hid the whole thing.
|
|
10
|
+
*
|
|
11
|
+
* That defect was found by the state-machine model after a long campaign, because the
|
|
12
|
+
* decision lived inside an async method that also read SQLite and dispatched work: the
|
|
13
|
+
* only way to observe it was to reproduce the race. As a function of its inputs it is
|
|
14
|
+
* a table, and a table can be enumerated.
|
|
15
|
+
*
|
|
16
|
+
* The three rejections are deliberately distinguished rather than collapsed into a
|
|
17
|
+
* boolean. `stale-cursor` and `already-in-flight` mean very different things to anyone
|
|
18
|
+
* reading a log: one is an arrival for work the run has already moved past, the other
|
|
19
|
+
* is a second arrival for work in progress right now.
|
|
20
|
+
*/
|
|
21
|
+
const RUN = { kind: 'run' };
|
|
22
|
+
/** States in which a node job may execute. Anything else is not a live run. */
|
|
23
|
+
export function isLive(state) {
|
|
24
|
+
return state === 'running' || state === 'waiting';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Decide whether the job for `nodeIndex` of `exec` may run.
|
|
28
|
+
*
|
|
29
|
+
* `inFlight` is the set of `<executionId>:<nodeIndex>` claims held by this process.
|
|
30
|
+
* It is passed in rather than read from module state so the decision stays a function
|
|
31
|
+
* of its arguments.
|
|
32
|
+
*/
|
|
33
|
+
export function decideAdmission(exec, nodeIndex, inFlight) {
|
|
34
|
+
if (!exec)
|
|
35
|
+
return { kind: 'reject', reason: 'missing' };
|
|
36
|
+
if (!isLive(exec.state))
|
|
37
|
+
return { kind: 'reject', reason: 'not-live' };
|
|
38
|
+
// `currentNodeIndex` is the execution's own cursor, so a job for any other index is
|
|
39
|
+
// by definition an arrival for work this run has already moved past.
|
|
40
|
+
if (nodeIndex !== exec.currentNodeIndex)
|
|
41
|
+
return { kind: 'reject', reason: 'stale-cursor' };
|
|
42
|
+
if (inFlight.has(claimKey(exec.id, nodeIndex))) {
|
|
43
|
+
return { kind: 'reject', reason: 'already-in-flight' };
|
|
44
|
+
}
|
|
45
|
+
return RUN;
|
|
46
|
+
}
|
|
47
|
+
/** The claim a running node holds. One string, one place that builds it. */
|
|
48
|
+
export function claimKey(executionId, nodeIndex) {
|
|
49
|
+
return `${executionId}:${nodeIndex}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's only source of time and randomness.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Every defect this engine has shipped in a crash or concurrency
|
|
5
|
+
* window took a property-based campaign roughly one run in eleven to surface, and the
|
|
6
|
+
* seed that produced it did NOT reproduce it: the seed drove the sequence of commands,
|
|
7
|
+
* while the interleaving came from real timers and real process death. A failing seed
|
|
8
|
+
* you cannot replay is a bug report you cannot act on.
|
|
9
|
+
*
|
|
10
|
+
* With every `Date.now()`, `Math.random()` and `setTimeout` routed through here, a
|
|
11
|
+
* simulated clock makes a seed drive the interleaving too, so a failure replays
|
|
12
|
+
* exactly, every time, and a fix can be proven rather than hoped for.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS IS NOT. It does not make the engine deterministic on its own: SQLite, the
|
|
15
|
+
* queue's own worker loop and the OS scheduler are still outside. It removes the
|
|
16
|
+
* engine's own contribution, which is the part the engine can be held responsible for.
|
|
17
|
+
*
|
|
18
|
+
* The default is the real clock, and it is installed by default, so nothing changes
|
|
19
|
+
* for anyone who does not ask for a simulated one.
|
|
20
|
+
*/
|
|
21
|
+
export interface Clock {
|
|
22
|
+
/** Milliseconds since the epoch. */
|
|
23
|
+
now(): number;
|
|
24
|
+
/** Schedule `fn` after `ms`. The handle is whatever `clear` accepts. */
|
|
25
|
+
setTimeout(fn: () => void, ms: number): TimerHandle;
|
|
26
|
+
clearTimeout(handle: TimerHandle): void;
|
|
27
|
+
/** A number in [0, 1). Used for retry jitter and execution ids. */
|
|
28
|
+
random(): number;
|
|
29
|
+
}
|
|
30
|
+
export interface TimerHandle {
|
|
31
|
+
/** Real timers can be unref'd so a pending one does not hold the process open. */
|
|
32
|
+
unref?(): void;
|
|
33
|
+
}
|
|
34
|
+
/** The clock the engine reads. Real unless a simulation installed its own. */
|
|
35
|
+
export declare function clock(): Clock;
|
|
36
|
+
/**
|
|
37
|
+
* Install a clock, returning the previous one so a test can restore it.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately process-global rather than threaded through every call site: the
|
|
40
|
+
* alternative is a `Clock` parameter on roughly forty internal functions, which is a
|
|
41
|
+
* large diff through code whose correctness was hard-won, for no gain in a runtime
|
|
42
|
+
* that already keeps one engine per process.
|
|
43
|
+
*/
|
|
44
|
+
export declare function setClock(next: Clock): Clock;
|
|
45
|
+
/** Restore the real clock. */
|
|
46
|
+
export declare function resetClock(): void;
|
|
47
|
+
/**
|
|
48
|
+
* A clock where time only moves when the simulation says so.
|
|
49
|
+
*
|
|
50
|
+
* `advance` fires every timer due at or before the new instant, in due order, and
|
|
51
|
+
* timers scheduled BY those callbacks are picked up in the same advance, so a chain of
|
|
52
|
+
* re-armed timeouts settles the way it would in real time rather than needing one
|
|
53
|
+
* `advance` per link. Ties break by insertion order, so a seed replays identically.
|
|
54
|
+
*/
|
|
55
|
+
export declare function simulatedClock(seed: number, startAt?: number): Clock & {
|
|
56
|
+
advance(ms: number): number;
|
|
57
|
+
pending(): number;
|
|
58
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's only source of time and randomness.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Every defect this engine has shipped in a crash or concurrency
|
|
5
|
+
* window took a property-based campaign roughly one run in eleven to surface, and the
|
|
6
|
+
* seed that produced it did NOT reproduce it: the seed drove the sequence of commands,
|
|
7
|
+
* while the interleaving came from real timers and real process death. A failing seed
|
|
8
|
+
* you cannot replay is a bug report you cannot act on.
|
|
9
|
+
*
|
|
10
|
+
* With every `Date.now()`, `Math.random()` and `setTimeout` routed through here, a
|
|
11
|
+
* simulated clock makes a seed drive the interleaving too, so a failure replays
|
|
12
|
+
* exactly, every time, and a fix can be proven rather than hoped for.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS IS NOT. It does not make the engine deterministic on its own: SQLite, the
|
|
15
|
+
* queue's own worker loop and the OS scheduler are still outside. It removes the
|
|
16
|
+
* engine's own contribution, which is the part the engine can be held responsible for.
|
|
17
|
+
*
|
|
18
|
+
* The default is the real clock, and it is installed by default, so nothing changes
|
|
19
|
+
* for anyone who does not ask for a simulated one.
|
|
20
|
+
*/
|
|
21
|
+
const realClock = {
|
|
22
|
+
now: () => Date.now(),
|
|
23
|
+
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
24
|
+
clearTimeout: (h) => globalThis.clearTimeout(h),
|
|
25
|
+
random: () => Math.random(),
|
|
26
|
+
};
|
|
27
|
+
let current = realClock;
|
|
28
|
+
/** The clock the engine reads. Real unless a simulation installed its own. */
|
|
29
|
+
export function clock() {
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Install a clock, returning the previous one so a test can restore it.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately process-global rather than threaded through every call site: the
|
|
36
|
+
* alternative is a `Clock` parameter on roughly forty internal functions, which is a
|
|
37
|
+
* large diff through code whose correctness was hard-won, for no gain in a runtime
|
|
38
|
+
* that already keeps one engine per process.
|
|
39
|
+
*/
|
|
40
|
+
export function setClock(next) {
|
|
41
|
+
const previous = current;
|
|
42
|
+
current = next;
|
|
43
|
+
return previous;
|
|
44
|
+
}
|
|
45
|
+
/** Restore the real clock. */
|
|
46
|
+
export function resetClock() {
|
|
47
|
+
current = realClock;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A clock where time only moves when the simulation says so.
|
|
51
|
+
*
|
|
52
|
+
* `advance` fires every timer due at or before the new instant, in due order, and
|
|
53
|
+
* timers scheduled BY those callbacks are picked up in the same advance, so a chain of
|
|
54
|
+
* re-armed timeouts settles the way it would in real time rather than needing one
|
|
55
|
+
* `advance` per link. Ties break by insertion order, so a seed replays identically.
|
|
56
|
+
*/
|
|
57
|
+
export function simulatedClock(seed, startAt = 1_700_000_000_000) {
|
|
58
|
+
let time = startAt;
|
|
59
|
+
let sequence = 0;
|
|
60
|
+
let state = seed >>> 0 || 0x9e3779b9;
|
|
61
|
+
const timers = new Map();
|
|
62
|
+
const random = () => {
|
|
63
|
+
// xorshift32: small, seeded, and identical across runs and platforms.
|
|
64
|
+
state ^= state << 13;
|
|
65
|
+
state ^= state >>> 17;
|
|
66
|
+
state ^= state << 5;
|
|
67
|
+
return ((state >>> 0) % 1_000_000) / 1_000_000;
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
now: () => time,
|
|
71
|
+
random,
|
|
72
|
+
setTimeout(fn, ms) {
|
|
73
|
+
const id = sequence++;
|
|
74
|
+
timers.set(id, { at: time + Math.max(0, ms), order: id, fn });
|
|
75
|
+
return { unref() { }, __id: id };
|
|
76
|
+
},
|
|
77
|
+
clearTimeout(handle) {
|
|
78
|
+
const id = handle?.__id;
|
|
79
|
+
if (id !== undefined)
|
|
80
|
+
timers.delete(id);
|
|
81
|
+
},
|
|
82
|
+
pending: () => timers.size,
|
|
83
|
+
advance(ms) {
|
|
84
|
+
const target = time + ms;
|
|
85
|
+
let fired = 0;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const due = [...timers.entries()]
|
|
88
|
+
.filter(([, t]) => t.at <= target)
|
|
89
|
+
.sort((a, b) => a[1].at - b[1].at || a[1].order - b[1].order);
|
|
90
|
+
const next = due[0];
|
|
91
|
+
if (!next)
|
|
92
|
+
break;
|
|
93
|
+
timers.delete(next[0]);
|
|
94
|
+
time = Math.max(time, next[1].at);
|
|
95
|
+
next[1].fn();
|
|
96
|
+
fired++;
|
|
97
|
+
if (fired > 100_000)
|
|
98
|
+
throw new Error('simulatedClock: timer storm, over 100000 firings');
|
|
99
|
+
}
|
|
100
|
+
time = target;
|
|
101
|
+
return fired;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Saga rollback — runs compensate handlers in reverse start order.
|
|
3
|
+
*
|
|
4
|
+
* Four properties this file is responsible for, each of which used to be violated:
|
|
5
|
+
*
|
|
6
|
+
* - EXACTLY ONE OUTCOME per eligible step. Success is recorded as loudly as
|
|
7
|
+
* failure, and a step the unwind never reached is recorded as skipped rather
|
|
8
|
+
* than left blank. "Never zero, never two" is not checkable otherwise.
|
|
9
|
+
* - NEVER TWICE. A step that already carries an outcome is not re-run, so an
|
|
10
|
+
* unwind interrupted by a crash resumes where it stopped instead of replaying
|
|
11
|
+
* reversals from the top.
|
|
12
|
+
* - HALT, NOT PLOUGH ON. A compensation that fails definitively stops the chain:
|
|
13
|
+
* continuing past it would undo things whose dependencies are still standing.
|
|
14
|
+
* The remaining eligible steps are marked skipped so the gap is visible.
|
|
15
|
+
* - PIVOT. Past `.pivot()` the saga is committed; those steps are never eligible.
|
|
16
|
+
*
|
|
17
|
+
* `rollbackStatus` on the execution is a separate axis from the failure reason. The
|
|
18
|
+
* rollback is what the engine did *after* the failure, not why it failed, and an
|
|
19
|
+
* operator needs to alert on "the refund never went through" independently of "the
|
|
20
|
+
* payment failed".
|
|
3
21
|
*/
|
|
4
22
|
import type { Execution } from './types';
|
|
5
23
|
import type { Workflow } from './workflow';
|
|
@@ -10,5 +28,40 @@ export declare class WaitForSignalError extends Error {
|
|
|
10
28
|
readonly event: string;
|
|
11
29
|
constructor(event: string);
|
|
12
30
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Deliberately NOT exported, and deliberately never bulk-cleared.
|
|
33
|
+
*
|
|
34
|
+
* `Engine.close()` used to call a `releaseUnwindClaim()` that emptied this set. The
|
|
35
|
+
* set is process-global, so closing ANY engine dropped the claim protecting an unwind
|
|
36
|
+
* still running under a DIFFERENT one, and the next caller re-dispatched compensate
|
|
37
|
+
* handlers that were mid-flight: the duplicate refund this claim exists to stop.
|
|
38
|
+
*
|
|
39
|
+
* Nothing needs to clear it. `runCompensation` releases in a `finally`, so a claim
|
|
40
|
+
* outlives its unwind by nothing, bounded by the step's own `timeout` even if the
|
|
41
|
+
* handler wedges. If the process dies the module state dies with it.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Outcome of asking for an unwind. `claim-lost` means another unwind of the same
|
|
45
|
+
* execution is already in flight, so THIS call did nothing and its caller must not
|
|
46
|
+
* treat the rollback as done.
|
|
47
|
+
*/
|
|
48
|
+
export type UnwindOutcome = 'ran' | 'claim-lost';
|
|
49
|
+
/** Run compensation handlers in reverse start order for every eligible step. */
|
|
50
|
+
export declare function runCompensation(exec: Execution, wf: Workflow, store: WorkflowStore, emitter: WorkflowEmitter | null, workflows?: Map<string, Workflow>, opts?: {
|
|
51
|
+
retryFailed?: boolean;
|
|
52
|
+
}): Promise<UnwindOutcome>;
|
|
53
|
+
/**
|
|
54
|
+
* Give up on a parked unwind: every eligible step still without an outcome is
|
|
55
|
+
* recorded as skipped, and the run becomes terminal. This is where "exactly one
|
|
56
|
+
* outcome per eligible step" is finally discharged.
|
|
57
|
+
*/
|
|
58
|
+
export declare function abandonCompensation(exec: Execution, wf: Workflow, store: WorkflowStore, emitter: WorkflowEmitter | null): void;
|
|
59
|
+
/**
|
|
60
|
+
* A sub-workflow that passed its own pivot cannot be rolled back. Distinct from a
|
|
61
|
+
* handler failure so the parent's record carries the reason rather than a generic
|
|
62
|
+
* error, and so the parent parks (operator decision) instead of silently claiming
|
|
63
|
+
* success.
|
|
64
|
+
*/
|
|
65
|
+
export declare class CommittedChildError extends Error {
|
|
66
|
+
constructor(workflowName: string, executionId: string);
|
|
67
|
+
}
|