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,133 @@
|
|
|
1
|
+
/** Persisted workflow execution, step outcome, and store coordination types. */
|
|
2
|
+
/** Execution state */
|
|
3
|
+
export type ExecutionState = 'running' | 'waiting' | 'completed' | 'failed' | 'compensating'
|
|
4
|
+
/**
|
|
5
|
+
* A compensation failed definitively and the unwind stopped. Deliberately NOT
|
|
6
|
+
* terminal: the run parks until an operator retries or abandons the unwind.
|
|
7
|
+
*/
|
|
8
|
+
| 'compensation-stuck';
|
|
9
|
+
/** What the engine did after a failure, independently of the failure reason. */
|
|
10
|
+
export type RollbackStatus =
|
|
11
|
+
/** Every eligible step was compensated successfully. */
|
|
12
|
+
'completed'
|
|
13
|
+
/** The unwind ran past the pivot cutoff or had nothing left to do. */
|
|
14
|
+
| 'not-applicable'
|
|
15
|
+
/** A compensation failed definitively; remaining reversals were not attempted. */
|
|
16
|
+
| 'stuck';
|
|
17
|
+
/** Step execution state */
|
|
18
|
+
export type StepState = 'pending' | 'running' | 'completed' | 'failed';
|
|
19
|
+
/** Terminal outcome of one step's rollback. */
|
|
20
|
+
export type CompensationStatus = 'compensated' | 'compensation-failed' | 'compensation-skipped';
|
|
21
|
+
export interface CompensationOutcome {
|
|
22
|
+
status: CompensationStatus;
|
|
23
|
+
at: number;
|
|
24
|
+
/** Why it failed, or why it was skipped. */
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Record of a step's execution */
|
|
28
|
+
export interface StepRecord {
|
|
29
|
+
status: StepState;
|
|
30
|
+
result?: unknown;
|
|
31
|
+
error?: string;
|
|
32
|
+
startedAt?: number;
|
|
33
|
+
completedAt?: number;
|
|
34
|
+
attempts?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Whether this step declared a compensate handler when it ran. This survives a
|
|
37
|
+
* deployment that removes or renames the definition.
|
|
38
|
+
*/
|
|
39
|
+
compensatable?: boolean;
|
|
40
|
+
/** forEach iteration item — persisted so compensation can restore __item */
|
|
41
|
+
loopItem?: unknown;
|
|
42
|
+
/** forEach iteration index — persisted so compensation can restore __index */
|
|
43
|
+
loopIndex?: number;
|
|
44
|
+
/** Exactly one terminal compensation outcome for every eligible step. */
|
|
45
|
+
compensation?: CompensationOutcome;
|
|
46
|
+
/** Idempotency key of the forward execution, persisted before the body runs. */
|
|
47
|
+
idempotencyKey?: string;
|
|
48
|
+
/** Child execution owned by a sub-workflow record. */
|
|
49
|
+
childExecutionId?: string;
|
|
50
|
+
/** Occurrence of this step name within the run — loops reuse a single name. */
|
|
51
|
+
occurrence?: number;
|
|
52
|
+
}
|
|
53
|
+
/** Full persisted execution state */
|
|
54
|
+
export interface Execution {
|
|
55
|
+
id: string;
|
|
56
|
+
workflowName: string;
|
|
57
|
+
state: ExecutionState;
|
|
58
|
+
input: unknown;
|
|
59
|
+
steps: Record<string, StepRecord>;
|
|
60
|
+
currentNodeIndex: number;
|
|
61
|
+
/** Flattened names selected by resolved branch paths. */
|
|
62
|
+
resolvedSteps?: string[];
|
|
63
|
+
/** Durable results of control-flow decisions, keyed by node/iteration identity. */
|
|
64
|
+
decisions?: Record<string, unknown>;
|
|
65
|
+
/** Structural identity of the sealed workflow definition that started this run. */
|
|
66
|
+
definitionHash?: string;
|
|
67
|
+
/** What happened to the rollback. Independent of `failureReason`. */
|
|
68
|
+
rollbackStatus?: RollbackStatus;
|
|
69
|
+
/** Why the run failed. Independent of `rollbackStatus`. */
|
|
70
|
+
failureReason?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Node index at which `.pivot()` committed. Once set, backward recovery is off for
|
|
73
|
+
* the whole saga and recovery is forward-only.
|
|
74
|
+
*/
|
|
75
|
+
committedAt?: number;
|
|
76
|
+
signals: Record<string, unknown>;
|
|
77
|
+
/** Parent execution that owns this sub-workflow child. */
|
|
78
|
+
parentExecutionId?: string;
|
|
79
|
+
createdAt: number;
|
|
80
|
+
updatedAt: number;
|
|
81
|
+
}
|
|
82
|
+
/** Handle returned from engine.start() */
|
|
83
|
+
export interface RunHandle {
|
|
84
|
+
id: string;
|
|
85
|
+
workflowName: string;
|
|
86
|
+
}
|
|
87
|
+
/** Stable, bounded pagination for execution listings. */
|
|
88
|
+
export interface ExecutionListOptions {
|
|
89
|
+
/** Page size. Defaults to 100 and is capped at 1000. */
|
|
90
|
+
limit?: number;
|
|
91
|
+
/** Number of rows to skip in the deterministic createdAt/id order. */
|
|
92
|
+
offset?: number;
|
|
93
|
+
}
|
|
94
|
+
/** Internal job data for step execution */
|
|
95
|
+
export interface StepJobData {
|
|
96
|
+
executionId: string;
|
|
97
|
+
workflowName: string;
|
|
98
|
+
nodeIndex: number;
|
|
99
|
+
}
|
|
100
|
+
/** Result of engine.recover() */
|
|
101
|
+
export interface RecoverResult {
|
|
102
|
+
/** Number of running executions re-enqueued */
|
|
103
|
+
running: number;
|
|
104
|
+
/** Number of waiting executions with re-armed timers */
|
|
105
|
+
waiting: number;
|
|
106
|
+
/** Number of compensating executions re-run */
|
|
107
|
+
compensating: number;
|
|
108
|
+
/** Total recovered */
|
|
109
|
+
total: number;
|
|
110
|
+
}
|
|
111
|
+
/** Result of WorkflowStore.recordSignal() */
|
|
112
|
+
export interface SignalOutcome {
|
|
113
|
+
/** Whether the execution row exists */
|
|
114
|
+
found: boolean;
|
|
115
|
+
/** True for the single caller that claimed the resume of a parked run */
|
|
116
|
+
resumed: boolean;
|
|
117
|
+
workflowName: string;
|
|
118
|
+
currentNodeIndex: number;
|
|
119
|
+
}
|
|
120
|
+
/** Result of WorkflowStore.parkForSignal() */
|
|
121
|
+
export interface ParkOutcome {
|
|
122
|
+
/** The awaited signal was already recorded — advance instead of parking */
|
|
123
|
+
signalPresent: boolean;
|
|
124
|
+
/** This caller transitioned the run to 'waiting' */
|
|
125
|
+
parked: boolean;
|
|
126
|
+
/** Signals as persisted, for refreshing a stale in-memory snapshot */
|
|
127
|
+
signals: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
/** Options for cleanup */
|
|
130
|
+
export interface CleanupOptions {
|
|
131
|
+
maxAge: number;
|
|
132
|
+
states?: ExecutionState[];
|
|
133
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { Queue } from '../queue/queue';
|
|
|
3
3
|
import type { Workflow } from './workflow';
|
|
4
4
|
import type { WorkflowStore } from './store';
|
|
5
5
|
import type { WorkflowEmitter } from './emitter';
|
|
6
|
-
import type { Execution, StepJobData, RunHandle, RecoverResult } from './types';
|
|
6
|
+
import type { Execution, ExecutionListOptions, StepJobData, RunHandle, RecoverResult } from './types';
|
|
7
7
|
export declare class WorkflowExecutor {
|
|
8
8
|
private readonly store;
|
|
9
9
|
private readonly queue;
|
|
@@ -39,28 +39,15 @@ export declare class WorkflowExecutor {
|
|
|
39
39
|
processStep(data: StepJobData): Promise<unknown>;
|
|
40
40
|
private runNode;
|
|
41
41
|
signal(executionId: string, event: string, payload: unknown): Promise<void>;
|
|
42
|
+
private get lifecycleDeps();
|
|
42
43
|
/** Retry the compensation that parked the run, then finish the unwind. */
|
|
43
44
|
resumeCompensation(executionId: string): Promise<void>;
|
|
44
45
|
/** Give up on a parked unwind, recording the outstanding steps as skipped. */
|
|
45
46
|
abandonCompensation(executionId: string): void;
|
|
46
47
|
private get rollbackDeps();
|
|
47
48
|
getExecution(id: string): Execution | null;
|
|
48
|
-
listExecutions(wfName?: string, state?: Execution['state']): Execution[];
|
|
49
|
-
private
|
|
50
|
-
private runStep;
|
|
51
|
-
private runBranch;
|
|
52
|
-
private runParallel;
|
|
53
|
-
private runSubWorkflow;
|
|
54
|
-
/**
|
|
55
|
-
* Commit the saga. From here the run can still fail, but it can no longer be
|
|
56
|
-
* rolled back: recovery past this point is forward-only.
|
|
57
|
-
*/
|
|
58
|
-
private runPivot;
|
|
59
|
-
/**
|
|
60
|
-
* Run a node body that manages its own step records (loops, forEach, map), then
|
|
61
|
-
* move on. These differ only in which executor they delegate to.
|
|
62
|
-
*/
|
|
63
|
-
private runBody;
|
|
49
|
+
listExecutions(wfName?: string, state?: Execution['state'], options?: ExecutionListOptions): Execution[];
|
|
50
|
+
private get nodeDeps();
|
|
64
51
|
private advance;
|
|
65
52
|
private enqueue;
|
|
66
53
|
private get waitForDeps();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { assertNoIndexCollision, assertNoDuplicateWaitFor
|
|
2
|
-
import { executeStepWithRetry, executeParallelSteps, executeSubWorkflow, buildContext, } from './runner';
|
|
3
|
-
import { executeDoUntil, executeDoWhile, executeForEach, executeMap } from './loops';
|
|
1
|
+
import { assertNoIndexCollision, assertNoDuplicateWaitFor } from './workflow';
|
|
4
2
|
import { WaitForSignalError, runCompensation } from './compensator';
|
|
5
3
|
import { abandonParkedCompensation, resumeCompensation, } from './rollbackControl';
|
|
6
4
|
import { recoverExecutions } from './recovery';
|
|
7
|
-
import { clearTimers,
|
|
8
|
-
import { clock } from './clock';
|
|
5
|
+
import { clearTimers, scheduleTimeoutCheck } from './waitFor';
|
|
9
6
|
import { describeError } from './identity';
|
|
10
7
|
import { claimKey, decideAdmission } from './admission';
|
|
8
|
+
import { signalExecution, startExecution } from './executorLifecycle';
|
|
9
|
+
import { executeWorkflowNode } from './executorNodes';
|
|
10
|
+
import { bindExecutionDefinition, WorkflowDefinitionMismatchError } from './definitionGuard';
|
|
11
11
|
export class WorkflowExecutor {
|
|
12
12
|
store;
|
|
13
13
|
queue;
|
|
@@ -33,6 +33,12 @@ export class WorkflowExecutor {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
register(workflow) {
|
|
36
|
+
if (this.workflows.has(workflow.name)) {
|
|
37
|
+
throw new Error(`Workflow "${workflow.name}" is already registered`);
|
|
38
|
+
}
|
|
39
|
+
if (workflow.nodes.length === 0) {
|
|
40
|
+
throw new Error(`Workflow "${workflow.name}" has no steps`);
|
|
41
|
+
}
|
|
36
42
|
const names = workflow.getStepNames();
|
|
37
43
|
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
38
44
|
if (dupes.length > 0) {
|
|
@@ -40,32 +46,19 @@ export class WorkflowExecutor {
|
|
|
40
46
|
}
|
|
41
47
|
assertNoIndexCollision(workflow);
|
|
42
48
|
assertNoDuplicateWaitFor(workflow);
|
|
49
|
+
const definitionHash = workflow.seal();
|
|
50
|
+
const conflict = this.store
|
|
51
|
+
.listActive(workflow.name)
|
|
52
|
+
.find((exec) => exec.state !== 'compensation-stuck' &&
|
|
53
|
+
exec.definitionHash !== undefined &&
|
|
54
|
+
exec.definitionHash !== definitionHash);
|
|
55
|
+
if (conflict) {
|
|
56
|
+
throw new WorkflowDefinitionMismatchError(conflict.id, workflow.name);
|
|
57
|
+
}
|
|
43
58
|
this.workflows.set(workflow.name, workflow);
|
|
44
59
|
}
|
|
45
60
|
async start(workflowName, input, parentExecutionId) {
|
|
46
|
-
|
|
47
|
-
if (!wf)
|
|
48
|
-
throw new Error(`Workflow "${workflowName}" not registered`);
|
|
49
|
-
if (wf.nodes.length === 0)
|
|
50
|
-
throw new Error(`Workflow "${workflowName}" has no steps`);
|
|
51
|
-
const now = clock().now();
|
|
52
|
-
const id = `wf_${now}_${clock().random().toString(36).slice(2, 10)}`;
|
|
53
|
-
const exec = {
|
|
54
|
-
id,
|
|
55
|
-
workflowName,
|
|
56
|
-
state: 'running',
|
|
57
|
-
input,
|
|
58
|
-
steps: {},
|
|
59
|
-
currentNodeIndex: 0,
|
|
60
|
-
signals: {},
|
|
61
|
-
...(parentExecutionId ? { parentExecutionId } : {}),
|
|
62
|
-
createdAt: now,
|
|
63
|
-
updatedAt: now,
|
|
64
|
-
};
|
|
65
|
-
this.store.save(exec);
|
|
66
|
-
this.emitter?.emitWorkflow('workflow:started', id, workflowName, 'running', { input });
|
|
67
|
-
await this.enqueue(exec);
|
|
68
|
-
return { id, workflowName };
|
|
61
|
+
return await startExecution(this.lifecycleDeps, workflowName, input, parentExecutionId);
|
|
69
62
|
}
|
|
70
63
|
/**
|
|
71
64
|
* Nodes this process is currently executing, keyed `<execution>:<nodeIndex>`.
|
|
@@ -104,12 +97,13 @@ export class WorkflowExecutor {
|
|
|
104
97
|
}
|
|
105
98
|
}
|
|
106
99
|
async runNode(data, exec) {
|
|
107
|
-
// If waiting, set back to running for timeout re-check
|
|
108
|
-
if (exec.state === 'waiting')
|
|
109
|
-
exec.state = 'running';
|
|
110
100
|
const wf = this.workflows.get(exec.workflowName);
|
|
111
101
|
if (!wf)
|
|
112
102
|
throw new Error(`Workflow "${exec.workflowName}" not registered`);
|
|
103
|
+
bindExecutionDefinition(exec, wf, this.updateFn);
|
|
104
|
+
// If waiting, set back to running for timeout re-check
|
|
105
|
+
if (exec.state === 'waiting')
|
|
106
|
+
exec.state = 'running';
|
|
113
107
|
const node = wf.nodes[data.nodeIndex];
|
|
114
108
|
if (!node) {
|
|
115
109
|
exec.state = 'completed';
|
|
@@ -118,7 +112,7 @@ export class WorkflowExecutor {
|
|
|
118
112
|
return null;
|
|
119
113
|
}
|
|
120
114
|
try {
|
|
121
|
-
await this.
|
|
115
|
+
await executeWorkflowNode(this.nodeDeps, exec, node, data.nodeIndex, wf);
|
|
122
116
|
}
|
|
123
117
|
catch (err) {
|
|
124
118
|
if (err instanceof WaitForSignalError)
|
|
@@ -141,48 +135,17 @@ export class WorkflowExecutor {
|
|
|
141
135
|
return null;
|
|
142
136
|
}
|
|
143
137
|
async signal(executionId, event, payload) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// out (`test/repro-workflow-operator-signal.test.ts`).
|
|
156
|
-
const current = this.store.get(executionId);
|
|
157
|
-
if (current && current.state !== 'running' && current.state !== 'waiting') {
|
|
158
|
-
throw new Error(`Execution "${executionId}" is "${current.state}" and cannot receive the signal "${event}"`);
|
|
159
|
-
}
|
|
160
|
-
const timer = this.timeoutTimers.get(executionId);
|
|
161
|
-
if (timer) {
|
|
162
|
-
clock().clearTimeout(timer);
|
|
163
|
-
this.timeoutTimers.delete(executionId);
|
|
164
|
-
}
|
|
165
|
-
// Record the payload and claim the resume in a single transaction that touches
|
|
166
|
-
// only the `signals` and `state` columns. Writing through the store — rather than
|
|
167
|
-
// mutating a snapshot and calling update() — is what keeps a concurrently
|
|
168
|
-
// executing step from overwriting the payload with its own stale `signals`
|
|
169
|
-
// (test/repro-workflow-signal-lost-update.test.ts).
|
|
170
|
-
//
|
|
171
|
-
// The claim is a conditional `state = 'waiting'` UPDATE, so duplicate or
|
|
172
|
-
// concurrent signals collapse to exactly one resume and every step after the
|
|
173
|
-
// waitFor runs exactly once. A signal that lands before the run parks records
|
|
174
|
-
// its payload and returns; runWaitFor then consumes it via parkForSignal().
|
|
175
|
-
const outcome = this.store.recordSignal(executionId, event, payload);
|
|
176
|
-
if (!outcome.found)
|
|
177
|
-
throw new Error(`Execution "${executionId}" not found`);
|
|
178
|
-
this.emitter?.emitSignal('signal:received', executionId, outcome.workflowName, event, payload);
|
|
179
|
-
if (!outcome.resumed)
|
|
180
|
-
return;
|
|
181
|
-
await this.queue.add('wf:step', {
|
|
182
|
-
executionId,
|
|
183
|
-
workflowName: outcome.workflowName,
|
|
184
|
-
nodeIndex: outcome.currentNodeIndex,
|
|
185
|
-
});
|
|
138
|
+
await signalExecution(this.lifecycleDeps, executionId, event, payload);
|
|
139
|
+
}
|
|
140
|
+
get lifecycleDeps() {
|
|
141
|
+
return {
|
|
142
|
+
store: this.store,
|
|
143
|
+
queue: this.queue,
|
|
144
|
+
workflows: this.workflows,
|
|
145
|
+
emitter: this.emitter,
|
|
146
|
+
timers: this.timeoutTimers,
|
|
147
|
+
enqueue: (exec) => this.enqueue(exec),
|
|
148
|
+
};
|
|
186
149
|
}
|
|
187
150
|
/** Retry the compensation that parked the run, then finish the unwind. */
|
|
188
151
|
async resumeCompensation(executionId) {
|
|
@@ -202,140 +165,19 @@ export class WorkflowExecutor {
|
|
|
202
165
|
getExecution(id) {
|
|
203
166
|
return this.store.get(id);
|
|
204
167
|
}
|
|
205
|
-
listExecutions(wfName, state) {
|
|
206
|
-
return this.store.list(wfName, state);
|
|
207
|
-
}
|
|
208
|
-
async executeNode(exec, node, idx, wf) {
|
|
209
|
-
if (node.type === 'step')
|
|
210
|
-
await this.runStep(exec, node.def, idx, wf);
|
|
211
|
-
else if (node.type === 'branch')
|
|
212
|
-
await this.runBranch(exec, node, idx, wf);
|
|
213
|
-
else if (node.type === 'parallel')
|
|
214
|
-
await this.runParallel(exec, node, idx, wf);
|
|
215
|
-
else if (node.type === 'subWorkflow')
|
|
216
|
-
await this.runSubWorkflow(exec, node, idx, wf);
|
|
217
|
-
else if (node.type === 'doUntil')
|
|
218
|
-
await this.runBody(exec, idx, wf, executeDoUntil, node.def);
|
|
219
|
-
else if (node.type === 'doWhile')
|
|
220
|
-
await this.runBody(exec, idx, wf, executeDoWhile, node.def);
|
|
221
|
-
else if (node.type === 'forEach')
|
|
222
|
-
await this.runBody(exec, idx, wf, executeForEach, node.def);
|
|
223
|
-
else if (node.type === 'map')
|
|
224
|
-
await this.runBody(exec, idx, wf, executeMap, node.def);
|
|
225
|
-
else if (node.type === 'pivot')
|
|
226
|
-
await this.runPivot(exec, idx, wf);
|
|
227
|
-
else
|
|
228
|
-
await runWaitFor(this.waitForDeps, exec, node, idx, wf);
|
|
229
|
-
}
|
|
230
|
-
async runStep(exec, def, idx, wf) {
|
|
231
|
-
const ctx = buildContext(exec);
|
|
232
|
-
await executeStepWithRetry(def, ctx, exec, { emitter: this.emitter, updateFn: this.updateFn });
|
|
233
|
-
await this.advance(exec, idx + 1, wf);
|
|
234
|
-
}
|
|
235
|
-
async runBranch(exec, node, idx, wf) {
|
|
236
|
-
const pathName = node.def.condition(buildContext(exec));
|
|
237
|
-
const pathSteps = node.def.paths.get(pathName);
|
|
238
|
-
if (pathSteps && pathSteps.length > 0) {
|
|
239
|
-
for (const step of pathSteps) {
|
|
240
|
-
await executeStepWithRetry(step, buildContext(exec), exec, {
|
|
241
|
-
emitter: this.emitter,
|
|
242
|
-
updateFn: this.updateFn,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
await this.advance(exec, idx + 1, wf);
|
|
247
|
-
}
|
|
248
|
-
async runParallel(exec, node, idx, wf) {
|
|
249
|
-
await executeParallelSteps(node.def.steps, buildContext(exec), exec, this.emitter, this.updateFn);
|
|
250
|
-
await this.advance(exec, idx + 1, wf);
|
|
251
|
-
}
|
|
252
|
-
async runSubWorkflow(exec, node, idx, wf) {
|
|
253
|
-
const subInput = node.inputMapper(buildContext(exec));
|
|
254
|
-
const recordKey = `sub:${node.name}`;
|
|
255
|
-
try {
|
|
256
|
-
const { results, executionId } = await executeSubWorkflow(node.name, subInput,
|
|
257
|
-
// The child must record who owns it: without this, recovery treats it as a
|
|
258
|
-
// top-level run and drives it behind this parent's back.
|
|
259
|
-
async (name, input) => {
|
|
260
|
-
const handle = await this.start(name, input, exec.id);
|
|
261
|
-
// Claim it in the parent's record BEFORE waiting on it. The record used to be
|
|
262
|
-
// written only on completion, so a re-entry after a restart found nothing to
|
|
263
|
-
// resume and started a second child.
|
|
264
|
-
//
|
|
265
|
-
// Awaited rather than done on a side branch: a detached `.then()` on a start
|
|
266
|
-
// that REJECTS, which is what an unregistered child does, leaves an unhandled
|
|
267
|
-
// rejection even though the caller handles the same rejection properly.
|
|
268
|
-
exec.steps[recordKey] = { status: 'running', childExecutionId: handle.id };
|
|
269
|
-
this.store.update(exec);
|
|
270
|
-
return handle;
|
|
271
|
-
}, (id) => this.store.get(id), undefined, exec.steps[recordKey]?.childExecutionId);
|
|
272
|
-
exec.steps[recordKey] = {
|
|
273
|
-
status: 'completed',
|
|
274
|
-
result: results,
|
|
275
|
-
completedAt: clock().now(),
|
|
276
|
-
childExecutionId: executionId,
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
catch (error) {
|
|
280
|
-
// Settle the record before letting the failure through.
|
|
281
|
-
//
|
|
282
|
-
// Only the success write existed, so every interesting outcome — the child failed,
|
|
283
|
-
// parked in `compensation-stuck`, or timed out — left the record `running`.
|
|
284
|
-
// `unwindSet` drops anything that is neither `completed` nor `failed` one line
|
|
285
|
-
// before the `sub:` branch can admit it, so `unwindChild` was never called: a
|
|
286
|
-
// parent whose child was parked with stock still reserved reversed its own steps,
|
|
287
|
-
// reached the end of the pass and reported `rollbackStatus: 'completed'`
|
|
288
|
-
// (`test/repro-workflow-child-park-inherit.test.ts`).
|
|
289
|
-
//
|
|
290
|
-
// It is also the truthful record on its own terms. A `sub:` step left `running`
|
|
291
|
-
// after the parent has failed reads on a dashboard as a child still in flight.
|
|
292
|
-
const claimed = exec.steps[recordKey];
|
|
293
|
-
if (claimed) {
|
|
294
|
-
exec.steps[recordKey] = {
|
|
295
|
-
...claimed,
|
|
296
|
-
status: 'failed',
|
|
297
|
-
error: describeError(error),
|
|
298
|
-
completedAt: clock().now(),
|
|
299
|
-
};
|
|
300
|
-
// Guarded for the same reason as the failure write in `runner.ts`: `error` below
|
|
301
|
-
// carries the real cause, and letting a write failure propagate instead replaced
|
|
302
|
-
// it. The child's own diagnostic is what the rollback guide's field table points
|
|
303
|
-
// an operator at, `... timed out` or `... is parked mid-rollback; resolve it with
|
|
304
|
-
// resumeCompensation or abandonCompensation`, so destroying it takes away the
|
|
305
|
-
// pointer to the child.
|
|
306
|
-
//
|
|
307
|
-
// This one does not self-heal, which is what made it worth guarding rather than
|
|
308
|
-
// commenting: the next write succeeds, persists the wrong diagnostic, and the run
|
|
309
|
-
// goes terminal carrying it
|
|
310
|
-
// (`test/repro-workflow-step-error-masking.test.ts`).
|
|
311
|
-
try {
|
|
312
|
-
this.store.update(exec);
|
|
313
|
-
}
|
|
314
|
-
catch {
|
|
315
|
-
// Deliberately swallowed; the in-memory record still settles the `sub:` step, and
|
|
316
|
-
// `runNode` re-persists the whole execution one frame up.
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
throw error;
|
|
320
|
-
}
|
|
321
|
-
await this.advance(exec, idx + 1, wf);
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Commit the saga. From here the run can still fail, but it can no longer be
|
|
325
|
-
* rolled back: recovery past this point is forward-only.
|
|
326
|
-
*/
|
|
327
|
-
async runPivot(exec, idx, wf) {
|
|
328
|
-
exec.committedAt = idx;
|
|
329
|
-
this.store.update(exec);
|
|
330
|
-
await this.advance(exec, idx + 1, wf);
|
|
168
|
+
listExecutions(wfName, state, options) {
|
|
169
|
+
return this.store.list(wfName, state, options);
|
|
331
170
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
171
|
+
get nodeDeps() {
|
|
172
|
+
return {
|
|
173
|
+
store: this.store,
|
|
174
|
+
emitter: this.emitter,
|
|
175
|
+
updateFn: this.updateFn,
|
|
176
|
+
advance: (exec, nextIdx, wf) => this.advance(exec, nextIdx, wf),
|
|
177
|
+
start: (name, input, parentId) => this.start(name, input, parentId),
|
|
178
|
+
enqueue: (exec) => this.enqueue(exec),
|
|
179
|
+
waitFor: this.waitForDeps,
|
|
180
|
+
};
|
|
339
181
|
}
|
|
340
182
|
async advance(exec, nextIdx, wf) {
|
|
341
183
|
exec.currentNodeIndex = nextIdx;
|
|
@@ -386,7 +228,7 @@ export class WorkflowExecutor {
|
|
|
386
228
|
}
|
|
387
229
|
/** Recover orphaned executions after a crash/restart */
|
|
388
230
|
async recover() {
|
|
389
|
-
return recoverExecutions({
|
|
231
|
+
return await recoverExecutions({
|
|
390
232
|
store: this.store,
|
|
391
233
|
queue: this.queue,
|
|
392
234
|
workflows: this.workflows,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Durable start and signal publication for WorkflowExecutor. */
|
|
2
|
+
import type { Queue } from '../queue/queue';
|
|
3
|
+
import { type TimerHandle } from './clock';
|
|
4
|
+
import type { WorkflowEmitter } from './emitter';
|
|
5
|
+
import type { WorkflowStore } from './store';
|
|
6
|
+
import type { Execution, RunHandle } from './types';
|
|
7
|
+
import type { Workflow } from './workflow';
|
|
8
|
+
interface LifecycleDeps {
|
|
9
|
+
store: WorkflowStore;
|
|
10
|
+
queue: Queue;
|
|
11
|
+
workflows: ReadonlyMap<string, Workflow>;
|
|
12
|
+
emitter: WorkflowEmitter | null;
|
|
13
|
+
timers: Map<string, TimerHandle>;
|
|
14
|
+
enqueue: (exec: Execution) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function startExecution(deps: LifecycleDeps, workflowName: string, input: unknown, parentExecutionId?: string): Promise<RunHandle>;
|
|
17
|
+
export declare function signalExecution(deps: LifecycleDeps, executionId: string, event: string, payload: unknown): Promise<void>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Durable start and signal publication for WorkflowExecutor. */
|
|
2
|
+
import { clock } from './clock';
|
|
3
|
+
import { unusableEventName } from './workflow';
|
|
4
|
+
import { newExecutionId } from './identity';
|
|
5
|
+
export async function startExecution(deps, workflowName, input, parentExecutionId) {
|
|
6
|
+
const wf = deps.workflows.get(workflowName);
|
|
7
|
+
if (!wf)
|
|
8
|
+
throw new Error(`Workflow "${workflowName}" not registered`);
|
|
9
|
+
if (wf.nodes.length === 0)
|
|
10
|
+
throw new Error(`Workflow "${workflowName}" has no steps`);
|
|
11
|
+
const now = clock().now();
|
|
12
|
+
const id = newExecutionId();
|
|
13
|
+
const exec = {
|
|
14
|
+
id,
|
|
15
|
+
workflowName,
|
|
16
|
+
state: 'running',
|
|
17
|
+
input,
|
|
18
|
+
steps: {},
|
|
19
|
+
currentNodeIndex: 0,
|
|
20
|
+
signals: {},
|
|
21
|
+
definitionHash: wf.seal(),
|
|
22
|
+
...(parentExecutionId ? { parentExecutionId } : {}),
|
|
23
|
+
createdAt: now,
|
|
24
|
+
updatedAt: now,
|
|
25
|
+
};
|
|
26
|
+
deps.store.save(exec);
|
|
27
|
+
deps.emitter?.emitWorkflow('workflow:started', id, workflowName, 'running', { input });
|
|
28
|
+
try {
|
|
29
|
+
await deps.enqueue(exec);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// start() cannot return an id when publication fails. Removing the row keeps both
|
|
33
|
+
// top-level starts and sub-workflow starts from creating an unreachable orphan.
|
|
34
|
+
deps.store.remove(id);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return { id, workflowName };
|
|
38
|
+
}
|
|
39
|
+
export async function signalExecution(deps, executionId, event, payload) {
|
|
40
|
+
const bad = unusableEventName(event);
|
|
41
|
+
if (bad)
|
|
42
|
+
throw new Error(`Cannot signal an event ${bad}`);
|
|
43
|
+
// recordSignal owns the read/check/write transaction. In particular it rejects a
|
|
44
|
+
// duplicate key before writing, so concurrent deliveries are first-writer-wins.
|
|
45
|
+
const outcome = deps.store.recordSignal(executionId, event, payload);
|
|
46
|
+
if (!outcome.found)
|
|
47
|
+
throw new Error(`Execution "${executionId}" not found`);
|
|
48
|
+
deps.emitter?.emitSignal('signal:received', executionId, outcome.workflowName, event, payload);
|
|
49
|
+
if (!outcome.resumed)
|
|
50
|
+
return;
|
|
51
|
+
const timer = deps.timers.get(executionId);
|
|
52
|
+
if (timer) {
|
|
53
|
+
clock().clearTimeout(timer);
|
|
54
|
+
deps.timers.delete(executionId);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await deps.queue.add('wf:step', {
|
|
58
|
+
executionId,
|
|
59
|
+
workflowName: outcome.workflowName,
|
|
60
|
+
nodeIndex: outcome.currentNodeIndex,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Preserve the accepted payload but release the publication claim. Recovery sees
|
|
65
|
+
// a waiting execution with its signal present and republishes the same node.
|
|
66
|
+
deps.store.restoreSignalWait(executionId, event, outcome.currentNodeIndex);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Workflow node dispatch, split from the executor's lifecycle orchestration. */
|
|
2
|
+
import type { WorkflowEmitter } from './emitter';
|
|
3
|
+
import type { WorkflowStore } from './store';
|
|
4
|
+
import type { Execution, RunHandle, WorkflowNode } from './types';
|
|
5
|
+
import { type WaitForDeps } from './waitFor';
|
|
6
|
+
import type { Workflow } from './workflow';
|
|
7
|
+
interface NodeExecutionDeps {
|
|
8
|
+
store: WorkflowStore;
|
|
9
|
+
emitter: WorkflowEmitter | null;
|
|
10
|
+
updateFn: (exec: Execution) => void;
|
|
11
|
+
advance: (exec: Execution, nextIdx: number, workflow: Workflow) => Promise<void>;
|
|
12
|
+
start: (name: string, input: unknown, parentId: string) => Promise<RunHandle>;
|
|
13
|
+
enqueue: (exec: Execution) => Promise<void>;
|
|
14
|
+
waitFor: WaitForDeps;
|
|
15
|
+
}
|
|
16
|
+
export declare function executeWorkflowNode(deps: NodeExecutionDeps, exec: Execution, node: WorkflowNode, idx: number, wf: Workflow): Promise<void>;
|
|
17
|
+
export {};
|