bunqueue 2.8.49 → 2.8.50
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/README.md +19 -9
- package/dist/application/contextFactory.d.ts +4 -2
- package/dist/application/contextFactory.js +3 -0
- package/dist/application/flowFailureRecovery.d.ts +17 -0
- package/dist/application/flowFailureRecovery.js +92 -0
- package/dist/application/operations/ack.d.ts +3 -2
- package/dist/application/operations/ack.js +34 -8
- package/dist/application/operations/flowPush.d.ts +9 -0
- package/dist/application/operations/flowPush.js +112 -0
- package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
- package/dist/application/operations/flowTopologyValidation.js +101 -0
- package/dist/application/operations/flowValidation.d.ts +3 -0
- package/dist/application/operations/flowValidation.js +166 -0
- package/dist/application/operations/push.d.ts +3 -0
- package/dist/application/operations/push.js +2 -2
- package/dist/application/operations/pushInsert.d.ts +7 -1
- package/dist/application/operations/pushInsert.js +18 -8
- package/dist/application/operations/pushLocks.d.ts +1 -0
- package/dist/application/operations/pushLocks.js +5 -1
- package/dist/application/operations/queryOperations.js +7 -6
- package/dist/application/queueManager.d.ts +5 -0
- package/dist/application/queueManager.js +195 -60
- package/dist/cli/commandRouter.d.ts +1 -1
- package/dist/client/flow.d.ts +3 -6
- package/dist/client/flow.js +71 -210
- package/dist/client/flowAtomic.d.ts +9 -0
- package/dist/client/flowAtomic.js +24 -0
- package/dist/client/flowJobCoreMethods.d.ts +28 -0
- package/dist/client/flowJobCoreMethods.js +151 -0
- package/dist/client/flowJobDependencies.d.ts +7 -0
- package/dist/client/flowJobDependencies.js +64 -0
- package/dist/client/flowJobFactory.d.ts +11 -24
- package/dist/client/flowJobFactory.js +91 -360
- package/dist/client/flowJobMoveMethods.d.ts +16 -0
- package/dist/client/flowJobMoveMethods.js +112 -0
- package/dist/client/flowJobTypes.d.ts +29 -0
- package/dist/client/flowJobTypes.js +6 -0
- package/dist/client/flowLegacyPlan.d.ts +16 -0
- package/dist/client/flowLegacyPlan.js +118 -0
- package/dist/client/flowOptions.d.ts +10 -0
- package/dist/client/flowOptions.js +44 -0
- package/dist/client/flowPlan.d.ts +16 -0
- package/dist/client/flowPlan.js +93 -0
- package/dist/client/flowPush.js +4 -1
- package/dist/client/flowReader.d.ts +10 -0
- package/dist/client/flowReader.js +147 -0
- package/dist/client/jobHelpers.js +2 -0
- package/dist/client/workflow/clock.d.ts +3 -1
- package/dist/client/workflow/clock.js +18 -1
- package/dist/client/workflow/compensationChild.d.ts +30 -0
- package/dist/client/workflow/compensationChild.js +65 -0
- package/dist/client/workflow/compensationPass.d.ts +16 -0
- package/dist/client/workflow/compensationPass.js +96 -0
- package/dist/client/workflow/compensationSupport.d.ts +24 -0
- package/dist/client/workflow/compensationSupport.js +57 -0
- package/dist/client/workflow/compensator.d.ts +11 -50
- package/dist/client/workflow/compensator.js +22 -374
- package/dist/client/workflow/definitionGuard.d.ts +11 -0
- package/dist/client/workflow/definitionGuard.js +22 -0
- package/dist/client/workflow/engine.d.ts +2 -2
- package/dist/client/workflow/engine.js +8 -8
- package/dist/client/workflow/eventTypes.d.ts +44 -0
- package/dist/client/workflow/eventTypes.js +2 -0
- package/dist/client/workflow/executionTypes.d.ts +133 -0
- package/dist/client/workflow/executionTypes.js +2 -0
- package/dist/client/workflow/executor.d.ts +4 -17
- package/dist/client/workflow/executor.js +50 -208
- package/dist/client/workflow/executorLifecycle.d.ts +18 -0
- package/dist/client/workflow/executorLifecycle.js +69 -0
- package/dist/client/workflow/executorNodes.d.ts +17 -0
- package/dist/client/workflow/executorNodes.js +138 -0
- package/dist/client/workflow/identity.d.ts +2 -0
- package/dist/client/workflow/identity.js +8 -0
- package/dist/client/workflow/index.d.ts +1 -1
- package/dist/client/workflow/loops.d.ts +3 -5
- package/dist/client/workflow/loops.js +25 -23
- package/dist/client/workflow/mapRunner.d.ts +4 -0
- package/dist/client/workflow/mapRunner.js +45 -0
- package/dist/client/workflow/recovery.js +10 -3
- package/dist/client/workflow/runner.d.ts +2 -21
- package/dist/client/workflow/runner.js +39 -104
- package/dist/client/workflow/runnerTiming.d.ts +14 -0
- package/dist/client/workflow/runnerTiming.js +66 -0
- package/dist/client/workflow/stepTypes.d.ts +171 -0
- package/dist/client/workflow/stepTypes.js +3 -0
- package/dist/client/workflow/store.d.ts +17 -17
- package/dist/client/workflow/store.js +65 -100
- package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
- package/dist/client/workflow/storeExecutionCodec.js +34 -0
- package/dist/client/workflow/storeListing.d.ts +11 -0
- package/dist/client/workflow/storeListing.js +45 -0
- package/dist/client/workflow/storeMaintenance.d.ts +4 -0
- package/dist/client/workflow/storeMaintenance.js +40 -0
- package/dist/client/workflow/storeSignals.d.ts +9 -0
- package/dist/client/workflow/storeSignals.js +38 -2
- package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
- package/dist/client/workflow/subWorkflowRunner.js +40 -0
- package/dist/client/workflow/types.d.ts +4 -356
- package/dist/client/workflow/types.js +1 -3
- package/dist/client/workflow/waitFor.js +39 -26
- package/dist/client/workflow/workflow.d.ts +16 -59
- package/dist/client/workflow/workflow.js +53 -179
- package/dist/client/workflow/workflowDecisions.d.ts +11 -0
- package/dist/client/workflow/workflowDecisions.js +27 -0
- package/dist/client/workflow/workflowDefinition.d.ts +16 -0
- package/dist/client/workflow/workflowDefinition.js +123 -0
- package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
- package/dist/client/workflow/workflowIntrospection.js +46 -0
- package/dist/client/workflow/workflowValidation.d.ts +44 -0
- package/dist/client/workflow/workflowValidation.js +143 -0
- package/dist/domain/types/command.d.ts +7 -1
- package/dist/domain/types/flow.d.ts +25 -0
- package/dist/domain/types/flow.js +1 -0
- package/dist/infrastructure/persistence/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +52 -3
- package/dist/infrastructure/persistence/sqlite.d.ts +16 -0
- package/dist/infrastructure/persistence/sqlite.js +129 -7
- package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
- package/dist/infrastructure/persistence/sqliteSerializer.d.ts +2 -0
- package/dist/infrastructure/persistence/sqliteSerializer.js +18 -5
- package/dist/infrastructure/persistence/statements.d.ts +4 -0
- package/dist/infrastructure/persistence/statements.js +8 -2
- package/dist/infrastructure/server/handlerRoutes.js +3 -0
- package/dist/infrastructure/server/handlers/advanced.js +2 -2
- package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
- package/dist/infrastructure/server/handlers/flow.js +11 -0
- package/dist/infrastructure/server/handlers/index.d.ts +1 -0
- package/dist/infrastructure/server/handlers/index.js +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing primitives used while running a workflow step.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from runner.ts so the runner stays focused on state transitions.
|
|
5
|
+
*/
|
|
6
|
+
import { clock } from './clock';
|
|
7
|
+
import { describeError } from './identity';
|
|
8
|
+
/** Largest delay accepted by Bun/Node timers without wrapping to 1ms. */
|
|
9
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
10
|
+
/** Exponential retry backoff with jitter. */
|
|
11
|
+
export function retryBackoffDelay(attempt, baseMs = 500, maxMs = 30_000) {
|
|
12
|
+
const delay = Math.min(baseMs * 2 ** (attempt - 1), maxMs);
|
|
13
|
+
return delay + delay * 0.5 * clock().random();
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Bound a value or PromiseLike without passing an overflowing delay to the runtime.
|
|
17
|
+
*
|
|
18
|
+
* Long deadlines are re-armed in platform-sized chunks. Promise.resolve is
|
|
19
|
+
* intentional: it assimilates userland thenables as well as native promises.
|
|
20
|
+
*/
|
|
21
|
+
export function runWithTimeout(value, timeoutMs, controller) {
|
|
22
|
+
const operation = Promise.resolve(value);
|
|
23
|
+
if (!(timeoutMs > 0) || !Number.isFinite(timeoutMs))
|
|
24
|
+
return operation;
|
|
25
|
+
const scheduler = clock();
|
|
26
|
+
const deadline = scheduler.now() + timeoutMs;
|
|
27
|
+
if (!Number.isFinite(deadline))
|
|
28
|
+
return operation;
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let timer;
|
|
31
|
+
let settled = false;
|
|
32
|
+
const clearTimer = () => {
|
|
33
|
+
if (timer)
|
|
34
|
+
scheduler.clearTimeout(timer);
|
|
35
|
+
timer = undefined;
|
|
36
|
+
};
|
|
37
|
+
const onTimeout = () => {
|
|
38
|
+
if (settled)
|
|
39
|
+
return;
|
|
40
|
+
const remaining = deadline - scheduler.now();
|
|
41
|
+
if (remaining > 0) {
|
|
42
|
+
timer = scheduler.setTimeout(onTimeout, Math.min(remaining, MAX_TIMER_DELAY_MS));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
settled = true;
|
|
46
|
+
timer = undefined;
|
|
47
|
+
const error = new Error(`Step timed out after ${timeoutMs}ms`);
|
|
48
|
+
controller?.abort(error);
|
|
49
|
+
reject(error);
|
|
50
|
+
};
|
|
51
|
+
timer = scheduler.setTimeout(onTimeout, Math.min(timeoutMs, MAX_TIMER_DELAY_MS));
|
|
52
|
+
operation.then((result) => {
|
|
53
|
+
if (settled)
|
|
54
|
+
return;
|
|
55
|
+
settled = true;
|
|
56
|
+
clearTimer();
|
|
57
|
+
resolve(result);
|
|
58
|
+
}, (reason) => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
62
|
+
clearTimer();
|
|
63
|
+
reject(reason instanceof Error ? reason : new Error(describeError(reason)));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Workflow handler, definition, and graph types. */
|
|
2
|
+
/** Context passed to step handlers */
|
|
3
|
+
export interface StepContext<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
|
|
4
|
+
/** Original workflow input */
|
|
5
|
+
readonly input: TInput;
|
|
6
|
+
/** Results from completed steps (step name → result) */
|
|
7
|
+
readonly steps: Readonly<TSteps>;
|
|
8
|
+
/** Signals received via engine.signal() */
|
|
9
|
+
readonly signals: Readonly<Record<string, unknown>>;
|
|
10
|
+
/** Current execution ID */
|
|
11
|
+
readonly executionId: string;
|
|
12
|
+
/**
|
|
13
|
+
* Aborted when this handler attempt reaches its timeout.
|
|
14
|
+
*
|
|
15
|
+
* Handlers that support cooperative cancellation should pass this signal to
|
|
16
|
+
* downstream I/O so timed-out work does not continue after the workflow moves on.
|
|
17
|
+
*/
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
/**
|
|
20
|
+
* Idempotency key for THIS execution of the step. Stable across automatic retries
|
|
21
|
+
* and across crash-resume; different for a different run. Pass it straight to the
|
|
22
|
+
* provider so a repeat lands on the same operation instead of a new one.
|
|
23
|
+
*/
|
|
24
|
+
readonly idempotencyKey?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Compensation only: the key the FORWARD step used. When the forward outcome is
|
|
27
|
+
* in doubt, this is what lets a rollback ask the provider "did this actually
|
|
28
|
+
* happen?" instead of depending on an output that may never have been persisted.
|
|
29
|
+
*/
|
|
30
|
+
readonly forwardIdempotencyKey?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Step handler function (type-erased for internal storage) */
|
|
33
|
+
export type StepHandler<TInput = unknown, TResult = unknown> = (ctx: StepContext<TInput, any>) => Promise<TResult> | TResult;
|
|
34
|
+
/** Typed step handler — preserves accumulated step types */
|
|
35
|
+
export type TypedStepHandler<TInput, TSteps extends Record<string, unknown>, TResult> = (ctx: StepContext<TInput, TSteps>) => Promise<TResult> | TResult;
|
|
36
|
+
/** Compensate handler (type-erased for internal storage) */
|
|
37
|
+
export type CompensateHandler<TInput = unknown> = (ctx: StepContext<TInput, any>) => Promise<void> | void;
|
|
38
|
+
/** Typed compensate handler — preserves accumulated step types */
|
|
39
|
+
export type TypedCompensateHandler<TInput, TSteps extends Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => Promise<void> | void;
|
|
40
|
+
/** Schema-like object — any object with a .parse() method (Zod, ArkType, Valibot, etc.) */
|
|
41
|
+
export interface SchemaLike {
|
|
42
|
+
parse(data: unknown): unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Options for a single step.
|
|
46
|
+
*
|
|
47
|
+
* `TSteps` is part of the signature for source compatibility and for symmetry with
|
|
48
|
+
* `TypedStepHandler`, even though `compensate` no longer narrows on it (see below).
|
|
49
|
+
* Removing the parameter would break every explicit `StepOptions<In, Steps>` in
|
|
50
|
+
* user code.
|
|
51
|
+
*/
|
|
52
|
+
export interface StepOptions<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
|
|
53
|
+
retry?: number;
|
|
54
|
+
timeout?: number;
|
|
55
|
+
/**
|
|
56
|
+
* A METHOD taking a permissively-typed context, and every part of that is load
|
|
57
|
+
* bearing. Three forms were measured against real handler shapes:
|
|
58
|
+
*
|
|
59
|
+
* shape union method<TSteps> method<any>
|
|
60
|
+
* compensate: async (ctx) => ... TS7006 ok ok
|
|
61
|
+
* annotated with steps it reads ok ok ok
|
|
62
|
+
* annotated with a step that does not exist ok TS2322 ok
|
|
63
|
+
* CompensateHandler<TInput> alias ok ok ok
|
|
64
|
+
*
|
|
65
|
+
* Not a union (`TypedCompensateHandler | CompensateHandler`): TypeScript cannot
|
|
66
|
+
* contextually type a parameter against a union of signatures, so the inline arrow
|
|
67
|
+
* every documented example uses was an implicit `any` and failed `noImplicitAny`.
|
|
68
|
+
*
|
|
69
|
+
* A method rather than a property so parameters stay bivariant under
|
|
70
|
+
* `strictFunctionTypes`, which is what lets an explicitly annotated handler through.
|
|
71
|
+
*
|
|
72
|
+
* `any` rather than `TSteps` for the step map, deliberately: with `TSteps` an
|
|
73
|
+
* annotation naming a step this workflow does not declare is rejected, and the
|
|
74
|
+
* published union accepted it. Keeping the looser map costs typed access to
|
|
75
|
+
* `ctx.steps` inside a rollback, which handlers already narrow with a cast in
|
|
76
|
+
* practice, and buys source compatibility with every handler written before.
|
|
77
|
+
*/
|
|
78
|
+
compensate?(ctx: StepContext<TInput, TSteps extends never ? never : any>): Promise<void> | void;
|
|
79
|
+
/** Validate step input before execution */
|
|
80
|
+
inputSchema?: SchemaLike;
|
|
81
|
+
/** Validate step output after execution */
|
|
82
|
+
outputSchema?: SchemaLike;
|
|
83
|
+
}
|
|
84
|
+
/** Internal step definition */
|
|
85
|
+
export interface StepDefinition {
|
|
86
|
+
name: string;
|
|
87
|
+
handler: StepHandler;
|
|
88
|
+
compensate?: CompensateHandler;
|
|
89
|
+
retry: number;
|
|
90
|
+
timeout: number;
|
|
91
|
+
inputSchema?: SchemaLike;
|
|
92
|
+
outputSchema?: SchemaLike;
|
|
93
|
+
}
|
|
94
|
+
/** Branch condition function */
|
|
95
|
+
export type BranchCondition<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => string;
|
|
96
|
+
/** Internal branch definition (type-erased) */
|
|
97
|
+
export interface BranchDefinition {
|
|
98
|
+
condition: BranchCondition<any, any>;
|
|
99
|
+
paths: Map<string, StepDefinition[]>;
|
|
100
|
+
}
|
|
101
|
+
/** Definition of a parallel step group */
|
|
102
|
+
export interface ParallelDefinition {
|
|
103
|
+
steps: StepDefinition[];
|
|
104
|
+
}
|
|
105
|
+
/** Input mapper for sub-workflows */
|
|
106
|
+
export type SubWorkflowInputMapper<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown;
|
|
107
|
+
/** Bounds for polling a child workflow. */
|
|
108
|
+
export interface SubWorkflowOptions {
|
|
109
|
+
/** Maximum time to wait for the child. Defaults to five minutes. */
|
|
110
|
+
timeout?: number;
|
|
111
|
+
/** Delay between durable child-state reads. Defaults to 100ms. */
|
|
112
|
+
pollInterval?: number;
|
|
113
|
+
}
|
|
114
|
+
/** Loop condition: receives context + iteration count, returns boolean */
|
|
115
|
+
export type LoopCondition<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>, iteration: number) => boolean | Promise<boolean>;
|
|
116
|
+
/** Definition of a doUntil/doWhile loop (type-erased) */
|
|
117
|
+
export interface LoopDefinition {
|
|
118
|
+
condition: LoopCondition<any, any>;
|
|
119
|
+
steps: StepDefinition[];
|
|
120
|
+
maxIterations: number;
|
|
121
|
+
}
|
|
122
|
+
/** Item extractor for forEach */
|
|
123
|
+
export type ForEachItemsExtractor<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown[];
|
|
124
|
+
/** Definition of a forEach loop (type-erased) */
|
|
125
|
+
export interface ForEachDefinition {
|
|
126
|
+
items: ForEachItemsExtractor<any, any>;
|
|
127
|
+
step: StepDefinition;
|
|
128
|
+
maxIterations: number;
|
|
129
|
+
}
|
|
130
|
+
/** Transform function for map */
|
|
131
|
+
export type MapTransformFn<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown;
|
|
132
|
+
/** Definition of a map node (type-erased) */
|
|
133
|
+
export interface MapDefinition {
|
|
134
|
+
name: string;
|
|
135
|
+
transform: MapTransformFn<any, any>;
|
|
136
|
+
}
|
|
137
|
+
/** Workflow node (discriminated union) */
|
|
138
|
+
export type WorkflowNode = {
|
|
139
|
+
type: 'step';
|
|
140
|
+
def: StepDefinition;
|
|
141
|
+
} | {
|
|
142
|
+
type: 'branch';
|
|
143
|
+
def: BranchDefinition;
|
|
144
|
+
} | {
|
|
145
|
+
type: 'waitFor';
|
|
146
|
+
event: string;
|
|
147
|
+
timeout?: number;
|
|
148
|
+
} | {
|
|
149
|
+
type: 'parallel';
|
|
150
|
+
def: ParallelDefinition;
|
|
151
|
+
} | {
|
|
152
|
+
type: 'subWorkflow';
|
|
153
|
+
name: string;
|
|
154
|
+
inputMapper: SubWorkflowInputMapper<any, any>;
|
|
155
|
+
timeout: number;
|
|
156
|
+
pollInterval: number;
|
|
157
|
+
} | {
|
|
158
|
+
type: 'doUntil';
|
|
159
|
+
def: LoopDefinition;
|
|
160
|
+
} | {
|
|
161
|
+
type: 'doWhile';
|
|
162
|
+
def: LoopDefinition;
|
|
163
|
+
} | {
|
|
164
|
+
type: 'forEach';
|
|
165
|
+
def: ForEachDefinition;
|
|
166
|
+
} | {
|
|
167
|
+
type: 'map';
|
|
168
|
+
def: MapDefinition;
|
|
169
|
+
} | {
|
|
170
|
+
type: 'pivot';
|
|
171
|
+
};
|
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WorkflowStore - SQLite persistence for workflow executions
|
|
3
3
|
*/
|
|
4
|
-
import type { Execution, ExecutionState, ParkOutcome, SignalOutcome } from './types';
|
|
4
|
+
import type { Execution, ExecutionListOptions, ExecutionState, ParkOutcome, SignalOutcome } from './types';
|
|
5
5
|
export declare class WorkflowStore {
|
|
6
6
|
private readonly db;
|
|
7
7
|
private readonly stmts;
|
|
8
8
|
/** Sole owner of the `signals` column — see storeSignals.ts */
|
|
9
9
|
private readonly signals;
|
|
10
|
+
private readonly listing;
|
|
10
11
|
constructor(dbPath?: string);
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* duplicate execution id silently OVERWROTE a live run instead of failing. Ids carry
|
|
16
|
-
* a random component, which makes that vanishingly rare in production and reachable
|
|
17
|
-
* in a simulation, where a seeded generator draws from a far smaller space. A lost
|
|
18
|
-
* execution is the worst possible presentation of a collision; a constraint error is
|
|
19
|
-
* the best.
|
|
20
|
-
*
|
|
21
|
-
* This is the ONLY writer of `signals` outside SignalCoordinator, and it is safe
|
|
22
|
-
* only because its single caller passes a fresh execution whose signals are `{}`.
|
|
23
|
-
* Calling it on a live run would write a stale snapshot over signals delivered
|
|
24
|
-
* since it was read, which is exactly the lost-update SignalCoordinator exists to
|
|
25
|
-
* prevent. Use `update()` for anything that already exists.
|
|
13
|
+
* Insert a fresh execution. A plain INSERT exposes id collisions instead of
|
|
14
|
+
* replacing live runs. This is the only signal writer outside SignalCoordinator and
|
|
15
|
+
* is safe because callers pass a new execution with no signals.
|
|
26
16
|
*/
|
|
27
17
|
save(exec: Execution): void;
|
|
28
18
|
get(id: string): Execution | null;
|
|
19
|
+
findChild(parentExecutionId: string, workflowName: string): Execution | null;
|
|
20
|
+
/** Remove an execution whose initial queue publication failed. */
|
|
21
|
+
remove(id: string): boolean;
|
|
29
22
|
/**
|
|
30
23
|
* Persist the step-level columns of an execution.
|
|
31
24
|
*
|
|
@@ -43,12 +36,20 @@ export declare class WorkflowStore {
|
|
|
43
36
|
* claim the single resume for this caller.
|
|
44
37
|
*/
|
|
45
38
|
recordSignal(id: string, event: string, payload: unknown): SignalOutcome;
|
|
39
|
+
/**
|
|
40
|
+
* Restore the wait claim if publishing its resume job failed.
|
|
41
|
+
*
|
|
42
|
+
* The signal remains durable, so recover() can publish the resume again.
|
|
43
|
+
*/
|
|
44
|
+
restoreSignalWait(id: string, event: string, nodeIndex: number): boolean;
|
|
46
45
|
/**
|
|
47
46
|
* Park a running execution at a `waitFor`, unless the awaited signal has already
|
|
48
47
|
* been recorded (in which case the caller must advance instead).
|
|
49
48
|
*/
|
|
50
49
|
parkForSignal(id: string, event: string): ParkOutcome;
|
|
51
|
-
list(workflowName?: string, state?: ExecutionState): Execution[];
|
|
50
|
+
list(workflowName?: string, state?: ExecutionState, options?: ExecutionListOptions): Execution[];
|
|
51
|
+
/** Runs whose registered definition must remain available and structurally stable. */
|
|
52
|
+
listActive(workflowName: string): Execution[];
|
|
52
53
|
/**
|
|
53
54
|
* Executions in a recoverable state that recovery may drive ON ITS OWN.
|
|
54
55
|
*
|
|
@@ -79,5 +80,4 @@ export declare class WorkflowStore {
|
|
|
79
80
|
/** Get archived execution count */
|
|
80
81
|
getArchivedCount(): number;
|
|
81
82
|
close(): void;
|
|
82
|
-
private rowToExecution;
|
|
83
83
|
}
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* WorkflowStore - SQLite persistence for workflow executions
|
|
3
3
|
*/
|
|
4
4
|
import { Database } from 'bun:sqlite';
|
|
5
|
-
import { pack
|
|
5
|
+
import { pack } from './storeCodec';
|
|
6
6
|
import { SignalCoordinator } from './storeSignals';
|
|
7
7
|
import { clock } from './clock';
|
|
8
|
+
import { decodeExecution, packExecutionMeta } from './storeExecutionCodec';
|
|
9
|
+
import { archivedExecutionCount, archiveExecutions, cleanupExecutions } from './storeMaintenance';
|
|
10
|
+
import { ExecutionListing } from './storeListing';
|
|
8
11
|
const CREATE_TABLE = `
|
|
9
12
|
CREATE TABLE IF NOT EXISTS workflow_executions (
|
|
10
13
|
id TEXT PRIMARY KEY,
|
|
@@ -34,23 +37,19 @@ CREATE TABLE IF NOT EXISTS workflow_executions_archive (
|
|
|
34
37
|
)`;
|
|
35
38
|
const CREATE_IDX_NAME = `CREATE INDEX IF NOT EXISTS idx_wf_name ON workflow_executions(workflow_name)`;
|
|
36
39
|
const CREATE_IDX_STATE = `CREATE INDEX IF NOT EXISTS idx_wf_state ON workflow_executions(state)`;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
meta.committedAt = exec.committedAt;
|
|
45
|
-
if (exec.parentExecutionId !== undefined)
|
|
46
|
-
meta.parentExecutionId = exec.parentExecutionId;
|
|
47
|
-
return Object.keys(meta).length > 0 ? pack(meta) : null;
|
|
48
|
-
}
|
|
40
|
+
const CREATE_LIST_INDEXES = [
|
|
41
|
+
`CREATE INDEX IF NOT EXISTS idx_wf_created ON workflow_executions(created_at DESC, id DESC)`,
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_wf_name_created ON workflow_executions(workflow_name, created_at DESC, id DESC)`,
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS idx_wf_state_created ON workflow_executions(state, created_at DESC, id DESC)`,
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS idx_wf_name_state_created ON workflow_executions(workflow_name, state, created_at DESC, id DESC)`,
|
|
45
|
+
`CREATE INDEX IF NOT EXISTS idx_wf_state_updated ON workflow_executions(state, updated_at ASC, id ASC)`,
|
|
46
|
+
];
|
|
49
47
|
export class WorkflowStore {
|
|
50
48
|
db;
|
|
51
49
|
stmts;
|
|
52
50
|
/** Sole owner of the `signals` column — see storeSignals.ts */
|
|
53
51
|
signals;
|
|
52
|
+
listing;
|
|
54
53
|
constructor(dbPath) {
|
|
55
54
|
this.db = new Database(dbPath ?? ':memory:', { create: true });
|
|
56
55
|
this.db.run('PRAGMA journal_mode = WAL');
|
|
@@ -64,6 +63,8 @@ export class WorkflowStore {
|
|
|
64
63
|
this.db.run(CREATE_ARCHIVE_TABLE);
|
|
65
64
|
this.db.run(CREATE_IDX_NAME);
|
|
66
65
|
this.db.run(CREATE_IDX_STATE);
|
|
66
|
+
for (const statement of CREATE_LIST_INDEXES)
|
|
67
|
+
this.db.run(statement);
|
|
67
68
|
// Rollback bookkeeping arrived after the original schema. One nullable blob
|
|
68
69
|
// rather than three columns keeps the migration to a single guarded statement
|
|
69
70
|
// per table; SQLite has no ADD COLUMN IF NOT EXISTS, so the throw IS the check.
|
|
@@ -89,39 +90,42 @@ export class WorkflowStore {
|
|
|
89
90
|
SET state = ?, steps = ?, current_node_index = ?, resolved_steps = ?, updated_at = ?, meta = ?
|
|
90
91
|
WHERE id = ?
|
|
91
92
|
`),
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
listRecoverable: this.db.prepare(`SELECT * FROM workflow_executions
|
|
93
|
+
childrenByName: this.db.prepare(`SELECT * FROM workflow_executions WHERE workflow_name = ? ORDER BY created_at ASC, id ASC`),
|
|
94
|
+
listActiveByName: this.db.prepare(`SELECT * FROM workflow_executions
|
|
95
|
+
WHERE workflow_name = ?
|
|
96
|
+
AND state IN ('running', 'waiting', 'compensating', 'compensation-stuck', 'failed')`),
|
|
97
|
+
listRecoverable: this.db.prepare(`SELECT * FROM workflow_executions
|
|
98
|
+
WHERE state IN ('running', 'waiting', 'compensating', 'failed')
|
|
99
|
+
ORDER BY updated_at ASC, id ASC`),
|
|
100
|
+
remove: this.db.prepare(`DELETE FROM workflow_executions WHERE id = ?`),
|
|
97
101
|
};
|
|
98
102
|
this.signals = new SignalCoordinator(this.db);
|
|
103
|
+
this.listing = new ExecutionListing(this.db);
|
|
99
104
|
}
|
|
100
105
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* duplicate execution id silently OVERWROTE a live run instead of failing. Ids carry
|
|
105
|
-
* a random component, which makes that vanishingly rare in production and reachable
|
|
106
|
-
* in a simulation, where a seeded generator draws from a far smaller space. A lost
|
|
107
|
-
* execution is the worst possible presentation of a collision; a constraint error is
|
|
108
|
-
* the best.
|
|
109
|
-
*
|
|
110
|
-
* This is the ONLY writer of `signals` outside SignalCoordinator, and it is safe
|
|
111
|
-
* only because its single caller passes a fresh execution whose signals are `{}`.
|
|
112
|
-
* Calling it on a live run would write a stale snapshot over signals delivered
|
|
113
|
-
* since it was read, which is exactly the lost-update SignalCoordinator exists to
|
|
114
|
-
* prevent. Use `update()` for anything that already exists.
|
|
106
|
+
* Insert a fresh execution. A plain INSERT exposes id collisions instead of
|
|
107
|
+
* replacing live runs. This is the only signal writer outside SignalCoordinator and
|
|
108
|
+
* is safe because callers pass a new execution with no signals.
|
|
115
109
|
*/
|
|
116
110
|
save(exec) {
|
|
117
111
|
if (Object.keys(exec.signals).length > 0) {
|
|
118
112
|
throw new Error('WorkflowStore.save() is for new executions only; it would overwrite delivered signals. Use update().');
|
|
119
113
|
}
|
|
120
|
-
this.stmts.upsert.run(exec.id, exec.workflowName, exec.state, pack(exec.input), pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, pack(exec.signals), exec.createdAt, exec.updatedAt,
|
|
114
|
+
this.stmts.upsert.run(exec.id, exec.workflowName, exec.state, pack(exec.input), pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, pack(exec.signals), exec.createdAt, exec.updatedAt, packExecutionMeta(exec));
|
|
121
115
|
}
|
|
122
116
|
get(id) {
|
|
123
117
|
const row = this.stmts.get.get(id);
|
|
124
|
-
return row ?
|
|
118
|
+
return row ? decodeExecution(row) : null;
|
|
119
|
+
}
|
|
120
|
+
findChild(parentExecutionId, workflowName) {
|
|
121
|
+
const rows = this.stmts.childrenByName.all(workflowName);
|
|
122
|
+
return (rows.map(decodeExecution).find((child) => child.parentExecutionId === parentExecutionId) ??
|
|
123
|
+
null);
|
|
124
|
+
}
|
|
125
|
+
/** Remove an execution whose initial queue publication failed. */
|
|
126
|
+
remove(id) {
|
|
127
|
+
const result = this.stmts.remove.run(id);
|
|
128
|
+
return result.changes === 1;
|
|
125
129
|
}
|
|
126
130
|
/**
|
|
127
131
|
* Persist the step-level columns of an execution.
|
|
@@ -136,7 +140,7 @@ export class WorkflowStore {
|
|
|
136
140
|
*/
|
|
137
141
|
update(exec) {
|
|
138
142
|
exec.updatedAt = clock().now();
|
|
139
|
-
this.stmts.updateState.run(exec.state, pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, exec.updatedAt,
|
|
143
|
+
this.stmts.updateState.run(exec.state, pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, exec.updatedAt, packExecutionMeta(exec), exec.id);
|
|
140
144
|
}
|
|
141
145
|
/**
|
|
142
146
|
* Record a signal payload and, if the run is parked at a `waitFor`, atomically
|
|
@@ -145,6 +149,14 @@ export class WorkflowStore {
|
|
|
145
149
|
recordSignal(id, event, payload) {
|
|
146
150
|
return this.signals.record(id, event, payload);
|
|
147
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Restore the wait claim if publishing its resume job failed.
|
|
154
|
+
*
|
|
155
|
+
* The signal remains durable, so recover() can publish the resume again.
|
|
156
|
+
*/
|
|
157
|
+
restoreSignalWait(id, event, nodeIndex) {
|
|
158
|
+
return this.signals.restoreWaiting(id, event, nodeIndex);
|
|
159
|
+
}
|
|
148
160
|
/**
|
|
149
161
|
* Park a running execution at a `waitFor`, unless the awaited signal has already
|
|
150
162
|
* been recorded (in which case the caller must advance instead).
|
|
@@ -152,21 +164,15 @@ export class WorkflowStore {
|
|
|
152
164
|
parkForSignal(id, event) {
|
|
153
165
|
return this.signals.park(id, event);
|
|
154
166
|
}
|
|
155
|
-
list(workflowName, state) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
rows = this.stmts.listByState.all(state);
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
rows = this.stmts.list.all();
|
|
168
|
-
}
|
|
169
|
-
return rows.map((r) => this.rowToExecution(r));
|
|
167
|
+
list(workflowName, state, options) {
|
|
168
|
+
return this.listing.list(workflowName, state, options);
|
|
169
|
+
}
|
|
170
|
+
/** Runs whose registered definition must remain available and structurally stable. */
|
|
171
|
+
listActive(workflowName) {
|
|
172
|
+
const rows = this.stmts.listActiveByName.all(workflowName);
|
|
173
|
+
return rows
|
|
174
|
+
.map(decodeExecution)
|
|
175
|
+
.filter((exec) => exec.state !== 'failed' || exec.rollbackStatus === undefined);
|
|
170
176
|
}
|
|
171
177
|
/**
|
|
172
178
|
* Executions in a recoverable state that recovery may drive ON ITS OWN.
|
|
@@ -185,8 +191,12 @@ export class WorkflowStore {
|
|
|
185
191
|
*/
|
|
186
192
|
listRecoverable() {
|
|
187
193
|
const rows = this.stmts.listRecoverable.all();
|
|
188
|
-
const all = rows.map(
|
|
189
|
-
return all.filter((
|
|
194
|
+
const all = rows.map(decodeExecution);
|
|
195
|
+
return all.filter((execution) => {
|
|
196
|
+
if (execution.state === 'failed' && execution.rollbackStatus !== undefined)
|
|
197
|
+
return false;
|
|
198
|
+
return !execution.parentExecutionId || this.get(execution.parentExecutionId) === null;
|
|
199
|
+
});
|
|
190
200
|
}
|
|
191
201
|
/**
|
|
192
202
|
* Delete executions at least `maxAgeMs` old in terminal states.
|
|
@@ -197,62 +207,17 @@ export class WorkflowStore {
|
|
|
197
207
|
* (`test/repro-workflow-archive-boundary.test.ts`).
|
|
198
208
|
*/
|
|
199
209
|
cleanup(maxAgeMs, states = ['completed', 'failed']) {
|
|
200
|
-
|
|
201
|
-
const placeholders = states.map(() => '?').join(',');
|
|
202
|
-
const stmt = this.db.prepare(`DELETE FROM workflow_executions WHERE updated_at <= ? AND state IN (${placeholders})`);
|
|
203
|
-
const result = stmt.run(cutoff, ...states);
|
|
204
|
-
return result.changes;
|
|
210
|
+
return cleanupExecutions(this.db, maxAgeMs, states);
|
|
205
211
|
}
|
|
206
212
|
/** Archive executions at least `maxAgeMs` old to the archive table. Cutoff inclusive, as in `cleanup`. */
|
|
207
213
|
archive(maxAgeMs, states = ['completed', 'failed']) {
|
|
208
|
-
|
|
209
|
-
const now = clock().now();
|
|
210
|
-
const placeholders = states.map(() => '?').join(',');
|
|
211
|
-
const rows = this.db
|
|
212
|
-
.prepare(`SELECT * FROM workflow_executions WHERE updated_at <= ? AND state IN (${placeholders}) LIMIT 1000`)
|
|
213
|
-
.all(cutoff, ...states);
|
|
214
|
-
if (rows.length === 0)
|
|
215
|
-
return 0;
|
|
216
|
-
const insertArchive = this.db.prepare(`
|
|
217
|
-
INSERT OR REPLACE INTO workflow_executions_archive
|
|
218
|
-
(id, workflow_name, state, input, steps, current_node_index, resolved_steps, signals, created_at, updated_at, archived_at, meta)
|
|
219
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
220
|
-
`);
|
|
221
|
-
const deleteOriginal = this.db.prepare(`DELETE FROM workflow_executions WHERE id = ?`);
|
|
222
|
-
const tx = this.db.transaction(() => {
|
|
223
|
-
for (const row of rows) {
|
|
224
|
-
insertArchive.run(row.id, row.workflow_name, row.state, row.input, row.steps, row.current_node_index, row.resolved_steps, row.signals, row.created_at, row.updated_at, now, row.meta ?? null);
|
|
225
|
-
deleteOriginal.run(row.id);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
tx();
|
|
229
|
-
return rows.length;
|
|
214
|
+
return archiveExecutions(this.db, maxAgeMs, states);
|
|
230
215
|
}
|
|
231
216
|
/** Get archived execution count */
|
|
232
217
|
getArchivedCount() {
|
|
233
|
-
|
|
234
|
-
.prepare(`SELECT COUNT(*) as cnt FROM workflow_executions_archive`)
|
|
235
|
-
.get();
|
|
236
|
-
return row.cnt;
|
|
218
|
+
return archivedExecutionCount(this.db);
|
|
237
219
|
}
|
|
238
220
|
close() {
|
|
239
221
|
this.db.close();
|
|
240
222
|
}
|
|
241
|
-
rowToExecution(row) {
|
|
242
|
-
return {
|
|
243
|
-
id: row.id,
|
|
244
|
-
workflowName: row.workflow_name,
|
|
245
|
-
state: row.state,
|
|
246
|
-
input: unpack(row.input),
|
|
247
|
-
steps: unpack(row.steps) ?? {},
|
|
248
|
-
currentNodeIndex: row.current_node_index,
|
|
249
|
-
resolvedSteps: row.resolved_steps
|
|
250
|
-
? unpack(row.resolved_steps)
|
|
251
|
-
: undefined,
|
|
252
|
-
signals: unpack(row.signals) ?? {},
|
|
253
|
-
createdAt: row.created_at,
|
|
254
|
-
updatedAt: row.updated_at,
|
|
255
|
-
...(unpack(row.meta) ?? {}),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
223
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Execution } from './types';
|
|
2
|
+
export interface ExecutionMeta {
|
|
3
|
+
rollbackStatus?: Execution['rollbackStatus'];
|
|
4
|
+
failureReason?: string;
|
|
5
|
+
committedAt?: number;
|
|
6
|
+
parentExecutionId?: string;
|
|
7
|
+
decisions?: Record<string, unknown>;
|
|
8
|
+
definitionHash?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function packExecutionMeta(exec: Execution): Uint8Array | null;
|
|
11
|
+
export declare function decodeExecution(row: Record<string, unknown>): Execution;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { pack, unpack } from './storeCodec';
|
|
2
|
+
export function packExecutionMeta(exec) {
|
|
3
|
+
const meta = {};
|
|
4
|
+
if (exec.rollbackStatus !== undefined)
|
|
5
|
+
meta.rollbackStatus = exec.rollbackStatus;
|
|
6
|
+
if (exec.failureReason !== undefined)
|
|
7
|
+
meta.failureReason = exec.failureReason;
|
|
8
|
+
if (exec.committedAt !== undefined)
|
|
9
|
+
meta.committedAt = exec.committedAt;
|
|
10
|
+
if (exec.parentExecutionId !== undefined)
|
|
11
|
+
meta.parentExecutionId = exec.parentExecutionId;
|
|
12
|
+
if (exec.decisions !== undefined)
|
|
13
|
+
meta.decisions = exec.decisions;
|
|
14
|
+
if (exec.definitionHash !== undefined)
|
|
15
|
+
meta.definitionHash = exec.definitionHash;
|
|
16
|
+
return Object.keys(meta).length > 0 ? pack(meta) : null;
|
|
17
|
+
}
|
|
18
|
+
export function decodeExecution(row) {
|
|
19
|
+
return {
|
|
20
|
+
id: row.id,
|
|
21
|
+
workflowName: row.workflow_name,
|
|
22
|
+
state: row.state,
|
|
23
|
+
input: unpack(row.input),
|
|
24
|
+
steps: unpack(row.steps) ?? {},
|
|
25
|
+
currentNodeIndex: row.current_node_index,
|
|
26
|
+
resolvedSteps: row.resolved_steps
|
|
27
|
+
? unpack(row.resolved_steps)
|
|
28
|
+
: undefined,
|
|
29
|
+
signals: unpack(row.signals) ?? {},
|
|
30
|
+
createdAt: row.created_at,
|
|
31
|
+
updatedAt: row.updated_at,
|
|
32
|
+
...(unpack(row.meta) ?? {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { Execution, ExecutionListOptions, ExecutionState } from './types';
|
|
3
|
+
/** Prepared, consistently ordered execution-list queries. */
|
|
4
|
+
export declare class ExecutionListing {
|
|
5
|
+
private readonly all;
|
|
6
|
+
private readonly byName;
|
|
7
|
+
private readonly byState;
|
|
8
|
+
private readonly byBoth;
|
|
9
|
+
constructor(db: Database);
|
|
10
|
+
list(workflowName?: string, state?: ExecutionState, options?: ExecutionListOptions): Execution[];
|
|
11
|
+
}
|