bunqueue 2.8.59 → 2.8.60

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.
Files changed (61) hide show
  1. package/README.md +2 -2
  2. package/dist/application/dlqManager.d.ts +1 -1
  3. package/dist/application/dlqManager.js +1 -1
  4. package/dist/application/lockManager.js +1 -1
  5. package/dist/application/queue-manager/ack.js +1 -1
  6. package/dist/application/queue-manager/delivery.js +1 -1
  7. package/dist/application/statsManager.d.ts +1 -1
  8. package/dist/application/statsManager.js +1 -1
  9. package/dist/client/events.js +2 -2
  10. package/dist/client/flow.js +0 -1
  11. package/dist/client/queue/operations/add/single.js +1 -1
  12. package/dist/client/sandboxed/runtime/state.js +2 -2
  13. package/dist/client/tcp/connection.d.ts +1 -1
  14. package/dist/client/tcp/connection.js +1 -1
  15. package/dist/client/tcp/runtime/state.js +2 -2
  16. package/dist/client/types/metrics.d.ts +1 -1
  17. package/dist/client/worker/runtime/control.js +0 -1
  18. package/dist/client/worker/runtime/lifecycle.js +0 -1
  19. package/dist/client/worker/runtime/state.js +3 -3
  20. package/dist/client/workflow/compensationChild.d.ts +2 -0
  21. package/dist/client/workflow/compensationChild.js +3 -1
  22. package/dist/client/workflow/compensationPass.d.ts +1 -0
  23. package/dist/client/workflow/compensationPass.js +18 -2
  24. package/dist/client/workflow/compensator.d.ts +2 -1
  25. package/dist/client/workflow/compensator.js +11 -3
  26. package/dist/client/workflow/engine.js +1 -1
  27. package/dist/client/workflow/executionFence.d.ts +15 -0
  28. package/dist/client/workflow/executionFence.js +29 -0
  29. package/dist/client/workflow/executor.d.ts +5 -11
  30. package/dist/client/workflow/executor.js +48 -47
  31. package/dist/client/workflow/executorLifecycle.d.ts +1 -0
  32. package/dist/client/workflow/executorLifecycle.js +41 -10
  33. package/dist/client/workflow/executorNodes.d.ts +1 -0
  34. package/dist/client/workflow/executorNodes.js +45 -16
  35. package/dist/client/workflow/executorQueue.d.ts +9 -0
  36. package/dist/client/workflow/executorQueue.js +15 -0
  37. package/dist/client/workflow/forEachRunner.d.ts +4 -0
  38. package/dist/client/workflow/forEachRunner.js +70 -0
  39. package/dist/client/workflow/loops.d.ts +4 -5
  40. package/dist/client/workflow/loops.js +27 -93
  41. package/dist/client/workflow/mapRunner.d.ts +1 -1
  42. package/dist/client/workflow/mapRunner.js +11 -2
  43. package/dist/client/workflow/recovery.d.ts +1 -0
  44. package/dist/client/workflow/recovery.js +24 -18
  45. package/dist/client/workflow/rollbackControl.d.ts +1 -0
  46. package/dist/client/workflow/rollbackControl.js +5 -2
  47. package/dist/client/workflow/runner.d.ts +3 -1
  48. package/dist/client/workflow/runner.js +26 -3
  49. package/dist/client/workflow/stepTypes.js +1 -1
  50. package/dist/client/workflow/subWorkflowRunner.d.ts +1 -0
  51. package/dist/client/workflow/subWorkflowRunner.js +7 -0
  52. package/dist/client/workflow/waitFor.d.ts +4 -0
  53. package/dist/client/workflow/waitFor.js +23 -11
  54. package/dist/client/workflow/workflowDecisions.d.ts +1 -1
  55. package/dist/client/workflow/workflowDecisions.js +3 -1
  56. package/dist/infrastructure/persistence/sqlite.d.ts +1 -1
  57. package/dist/infrastructure/server/handler-routes/jobs.js +0 -3
  58. package/dist/infrastructure/server/handlerRoutes.d.ts +2 -2
  59. package/dist/infrastructure/server/handlerRoutes.js +2 -2
  60. package/dist/infrastructure/server/httpEndpoints.js +2 -0
  61. package/package.json +12 -10
@@ -8,19 +8,23 @@ import { claimKey, decideAdmission } from './admission';
8
8
  import { signalExecution, startExecution } from './executorLifecycle';
9
9
  import { executeWorkflowNode } from './executorNodes';
10
10
  import { bindExecutionDefinition, WorkflowDefinitionMismatchError } from './definitionGuard';
11
+ import { isWorkflowExecutionClosed, WorkflowExecutionFence } from './executionFence';
12
+ import { enqueueWorkflowStep } from './executorQueue';
11
13
  export class WorkflowExecutor {
12
14
  store;
13
15
  queue;
14
16
  emitter;
15
17
  workflows = new Map();
16
18
  timeoutTimers = new Map();
19
+ fence = new WorkflowExecutionFence();
17
20
  /**
18
21
  * Release every armed waitFor timer. The engine owns the executor's lifetime, so
19
22
  * `Engine.close()` must call this: an armed timer would otherwise fire into a
20
23
  * closing queue, and a caller that keeps the process alive after closing one engine
21
24
  * has no other handle on them.
22
25
  */
23
- close() {
26
+ close(force = false) {
27
+ this.fence.close(force);
24
28
  clearTimers(this.timeoutTimers);
25
29
  }
26
30
  updateFn;
@@ -29,10 +33,12 @@ export class WorkflowExecutor {
29
33
  this.queue = queue;
30
34
  this.emitter = emitter;
31
35
  this.updateFn = (e) => {
36
+ this.fence.assertActive();
32
37
  this.store.update(e);
33
38
  };
34
39
  }
35
40
  register(workflow) {
41
+ this.fence.assertActive();
36
42
  if (this.workflows.has(workflow.name)) {
37
43
  throw new Error(`Workflow "${workflow.name}" is already registered`);
38
44
  }
@@ -58,31 +64,21 @@ export class WorkflowExecutor {
58
64
  this.workflows.set(workflow.name, workflow);
59
65
  }
60
66
  async start(workflowName, input, parentExecutionId) {
67
+ this.fence.assertActive();
61
68
  return await startExecution(this.lifecycleDeps, workflowName, input, parentExecutionId);
62
69
  }
63
70
  /**
64
71
  * Nodes this process is currently executing, keyed `<execution>:<nodeIndex>`.
65
72
  *
66
- * The cursor guard below rejects a job for a node the run has already left, but not
67
- * a SECOND job for the node it is on right now: both carry the same index. That is
68
- * the reachable duplicate, because `recover()` re-enqueues the current node of every
69
- * `running` execution and is documented as callable on a live engine. Without this
70
- * claim the node runs twice and each copy advances the run independently, doubling
71
- * every side effect after it while the run still ends `completed`.
72
- *
73
- * A claim, not a queue-level dedup: a deterministic `jobId` was tried and could
74
- * swallow a LEGITIMATE later re-enqueue of the same node, wedging the run forever.
75
- * This drops only a duplicate that overlaps in time.
73
+ * The cursor rejects stale jobs but not two overlapping deliveries for the current
74
+ * node. This process-local claim drops that overlap. Queue-level dedup is unsuitable:
75
+ * a retained ID can swallow a legitimate recovery enqueue and wedge the run.
76
76
  */
77
77
  nodesInFlight = new Set();
78
78
  async processStep(data) {
79
- // The admission DECISION lives in `admission.ts` as a pure function; this method
80
- // only carries it out. Delivery is at-least-once, so the same node job arrives
81
- // twice routinely, and when one of the three guards was missing a duplicate re-ran
82
- // the node and every node after it: two advance chains on one execution, doubled
83
- // side effects, and a final `completed` that hid it. That took a long model
84
- // campaign to find because the reasoning was buried in a method that also read
85
- // SQLite and dispatched work.
79
+ if (!this.fence.isActive())
80
+ return null;
81
+ // Admission is pure; this method only owns the in-flight claim and dispatch.
86
82
  const exec = this.store.get(data.executionId);
87
83
  const admission = decideAdmission(exec, data.nodeIndex, this.nodesInFlight);
88
84
  if (admission.kind === 'reject' || !exec)
@@ -106,8 +102,9 @@ export class WorkflowExecutor {
106
102
  exec.state = 'running';
107
103
  const node = wf.nodes[data.nodeIndex];
108
104
  if (!node) {
105
+ this.fence.assertActive();
109
106
  exec.state = 'completed';
110
- this.store.update(exec);
107
+ this.updateFn(exec);
111
108
  this.emitter?.emitWorkflow('workflow:completed', exec.id, exec.workflowName, 'completed');
112
109
  return null;
113
110
  }
@@ -115,26 +112,27 @@ export class WorkflowExecutor {
115
112
  await executeWorkflowNode(this.nodeDeps, exec, node, data.nodeIndex, wf);
116
113
  }
117
114
  catch (err) {
115
+ if (!this.fence.isActive() || isWorkflowExecutionClosed(err))
116
+ return null;
118
117
  if (err instanceof WaitForSignalError)
119
118
  return null;
119
+ this.fence.assertActive();
120
120
  exec.state = 'failed';
121
121
  // Why the run failed — kept distinct from what the rollback then did.
122
122
  exec.failureReason = describeError(err);
123
- // Deliberately UNGUARDED, unlike the writes on the throwing paths in `runner.ts` and
124
- // `runSubWorkflow`. A throw here skips `compensate()` below, which sounds worse and
125
- // is not: disk still says `running`, `listRecoverable()` covers `running`, so the
126
- // next `recover()` re-drives this node and the unwind happens then. Swallowing it
127
- // would instead leave a run that looks failed and was never rolled back, with
128
- // nothing scheduled to notice. Guard this only alongside a durable signal that the
129
- // rollback is still owed.
130
- this.store.update(exec);
123
+ // A failed transition must persist before compensation starts. If the guarded
124
+ // write fails, recovery sees the old running row and re-drives this node.
125
+ this.updateFn(exec);
131
126
  this.emitter?.emitWorkflow('workflow:failed', exec.id, exec.workflowName, 'failed');
127
+ if (!this.fence.isActive())
128
+ return null;
132
129
  await this.compensate(exec, wf);
133
130
  throw err;
134
131
  }
135
132
  return null;
136
133
  }
137
134
  async signal(executionId, event, payload) {
135
+ this.fence.assertActive();
138
136
  await signalExecution(this.lifecycleDeps, executionId, event, payload);
139
137
  }
140
138
  get lifecycleDeps() {
@@ -145,14 +143,17 @@ export class WorkflowExecutor {
145
143
  emitter: this.emitter,
146
144
  timers: this.timeoutTimers,
147
145
  enqueue: (exec) => this.enqueue(exec),
146
+ assertActive: this.fence.assertActive,
148
147
  };
149
148
  }
150
149
  /** Retry the compensation that parked the run, then finish the unwind. */
151
150
  async resumeCompensation(executionId) {
151
+ this.fence.assertActive();
152
152
  await resumeCompensation(this.rollbackDeps, executionId);
153
153
  }
154
154
  /** Give up on a parked unwind, recording the outstanding steps as skipped. */
155
155
  abandonCompensation(executionId) {
156
+ this.fence.assertActive();
156
157
  abandonParkedCompensation(this.rollbackDeps, executionId);
157
158
  }
158
159
  get rollbackDeps() {
@@ -160,6 +161,7 @@ export class WorkflowExecutor {
160
161
  store: this.store,
161
162
  emitter: this.emitter,
162
163
  workflows: this.workflows,
164
+ assertActive: this.fence.assertActive,
163
165
  };
164
166
  }
165
167
  getExecution(id) {
@@ -177,14 +179,16 @@ export class WorkflowExecutor {
177
179
  start: (name, input, parentId) => this.start(name, input, parentId),
178
180
  enqueue: (exec) => this.enqueue(exec),
179
181
  waitFor: this.waitForDeps,
182
+ assertActive: this.fence.assertActive,
180
183
  };
181
184
  }
182
185
  async advance(exec, nextIdx, wf) {
186
+ this.fence.assertActive();
183
187
  exec.currentNodeIndex = nextIdx;
184
- this.store.update(exec);
188
+ this.updateFn(exec);
185
189
  if (nextIdx >= wf.nodes.length) {
186
190
  exec.state = 'completed';
187
- this.store.update(exec);
191
+ this.updateFn(exec);
188
192
  this.emitter?.emitWorkflow('workflow:completed', exec.id, exec.workflowName, 'completed');
189
193
  }
190
194
  else {
@@ -192,22 +196,7 @@ export class WorkflowExecutor {
192
196
  }
193
197
  }
194
198
  async enqueue(exec) {
195
- const jobData = {
196
- executionId: exec.id,
197
- workflowName: exec.workflowName,
198
- nodeIndex: exec.currentNodeIndex,
199
- };
200
- // Deliberately NO deterministic jobId here.
201
- //
202
- // `<execution>:<nodeIndex>` was tried, to let the queue's custom-id dedup collapse
203
- // a duplicate enqueue. It buys nothing the cursor guard in processStep does not
204
- // already provide (a duplicate job is ignored there), and it introduces a liveness
205
- // risk in exchange: if the custom-id entry outlives the job it names, a legitimate
206
- // re-enqueue of the same node is swallowed and the run wedges permanently. A
207
- // generated model campaign produced exactly one unexplained `execution wedged in
208
- // "running"` with it enabled and none without. A duplicate job that is ignored is
209
- // strictly safer than a missing job that never arrives.
210
- await this.queue.add('wf:step', jobData);
199
+ await enqueueWorkflowStep(this.queue, exec, this.fence.assertActive);
211
200
  }
212
201
  get waitForDeps() {
213
202
  return {
@@ -215,19 +204,30 @@ export class WorkflowExecutor {
215
204
  emitter: this.emitter,
216
205
  advance: (e, next, w) => this.advance(e, next, w),
217
206
  compensate: (e, w) => this.compensate(e, w),
207
+ updateFn: this.updateFn,
208
+ assertActive: this.fence.assertActive,
218
209
  scheduleTimeoutCheck: (id, name, i, ms) => {
219
210
  this.scheduleTimeoutCheck(id, name, i, ms);
220
211
  },
221
212
  };
222
213
  }
223
214
  scheduleTimeoutCheck(execId, workflowName, nodeIdx, ms) {
224
- scheduleTimeoutCheck({ queue: this.queue, timers: this.timeoutTimers }, execId, workflowName, nodeIdx, ms);
215
+ scheduleTimeoutCheck({
216
+ queue: this.queue,
217
+ timers: this.timeoutTimers,
218
+ assertActive: this.fence.assertActive,
219
+ isActive: () => this.fence.isActive(),
220
+ }, execId, workflowName, nodeIdx, ms);
225
221
  }
226
222
  async compensate(exec, wf) {
227
- await runCompensation(exec, wf, this.store, this.emitter, this.workflows);
223
+ this.fence.assertActive();
224
+ await runCompensation(exec, wf, this.store, this.emitter, this.workflows, {
225
+ assertActive: this.fence.assertActive,
226
+ });
228
227
  }
229
228
  /** Recover orphaned executions after a crash/restart */
230
229
  async recover() {
230
+ this.fence.assertActive();
231
231
  return await recoverExecutions({
232
232
  store: this.store,
233
233
  queue: this.queue,
@@ -235,6 +235,7 @@ export class WorkflowExecutor {
235
235
  emitter: this.emitter,
236
236
  timeoutTimers: this.timeoutTimers,
237
237
  nodesInFlight: this.nodesInFlight,
238
+ assertActive: this.fence.assertActive,
238
239
  scheduleTimeoutCheck: (id, name, idx, ms) => {
239
240
  this.scheduleTimeoutCheck(id, name, idx, ms);
240
241
  },
@@ -12,6 +12,7 @@ interface LifecycleDeps {
12
12
  emitter: WorkflowEmitter | null;
13
13
  timers: Map<string, TimerHandle>;
14
14
  enqueue: (exec: Execution) => Promise<void>;
15
+ assertActive: () => void;
15
16
  }
16
17
  export declare function startExecution(deps: LifecycleDeps, workflowName: string, input: unknown, parentExecutionId?: string): Promise<RunHandle>;
17
18
  export declare function signalExecution(deps: LifecycleDeps, executionId: string, event: string, payload: unknown): Promise<void>;
@@ -3,6 +3,7 @@ import { clock } from './clock';
3
3
  import { unusableEventName } from './workflow';
4
4
  import { newExecutionId } from './identity';
5
5
  export async function startExecution(deps, workflowName, input, parentExecutionId) {
6
+ deps.assertActive();
6
7
  const wf = deps.workflows.get(workflowName);
7
8
  if (!wf)
8
9
  throw new Error(`Workflow "${workflowName}" not registered`);
@@ -23,20 +24,36 @@ export async function startExecution(deps, workflowName, input, parentExecutionI
23
24
  createdAt: now,
24
25
  updatedAt: now,
25
26
  };
27
+ deps.assertActive();
26
28
  deps.store.save(exec);
27
- deps.emitter?.emitWorkflow('workflow:started', id, workflowName, 'running', { input });
28
29
  try {
30
+ deps.emitter?.emitWorkflow('workflow:started', id, workflowName, 'running', { input });
31
+ deps.assertActive();
29
32
  await deps.enqueue(exec);
33
+ deps.assertActive();
30
34
  }
31
35
  catch (error) {
32
36
  // start() cannot return an id when publication fails. Removing the row keeps both
33
37
  // top-level starts and sub-workflow starts from creating an unreachable orphan.
34
- deps.store.remove(id);
35
- throw error;
38
+ let failure = error;
39
+ try {
40
+ deps.assertActive();
41
+ }
42
+ catch (closedError) {
43
+ failure = closedError;
44
+ }
45
+ try {
46
+ deps.store.remove(id);
47
+ }
48
+ catch {
49
+ // If shutdown already closed SQLite, recovery owns the still-running row.
50
+ }
51
+ throw failure;
36
52
  }
37
53
  return { id, workflowName };
38
54
  }
39
55
  export async function signalExecution(deps, executionId, event, payload) {
56
+ deps.assertActive();
40
57
  const bad = unusableEventName(event);
41
58
  if (bad)
42
59
  throw new Error(`Cannot signal an event ${bad}`);
@@ -48,22 +65,36 @@ export async function signalExecution(deps, executionId, event, payload) {
48
65
  deps.emitter?.emitSignal('signal:received', executionId, outcome.workflowName, event, payload);
49
66
  if (!outcome.resumed)
50
67
  return;
51
- const timer = deps.timers.get(executionId);
52
- if (timer) {
53
- clock().clearTimeout(timer);
54
- deps.timers.delete(executionId);
55
- }
56
68
  try {
69
+ deps.assertActive();
70
+ const timer = deps.timers.get(executionId);
71
+ if (timer) {
72
+ clock().clearTimeout(timer);
73
+ deps.timers.delete(executionId);
74
+ }
57
75
  await deps.queue.add('wf:step', {
58
76
  executionId,
59
77
  workflowName: outcome.workflowName,
60
78
  nodeIndex: outcome.currentNodeIndex,
61
79
  });
80
+ deps.assertActive();
62
81
  }
63
82
  catch (error) {
64
83
  // Preserve the accepted payload but release the publication claim. Recovery sees
65
84
  // a waiting execution with its signal present and republishes the same node.
66
- deps.store.restoreSignalWait(executionId, event, outcome.currentNodeIndex);
67
- throw error;
85
+ let failure = error;
86
+ try {
87
+ deps.assertActive();
88
+ }
89
+ catch (closedError) {
90
+ failure = closedError;
91
+ }
92
+ try {
93
+ deps.store.restoreSignalWait(executionId, event, outcome.currentNodeIndex);
94
+ }
95
+ catch {
96
+ // A closed store leaves a recoverable running row with its signal preserved.
97
+ }
98
+ throw failure;
68
99
  }
69
100
  }
@@ -12,6 +12,7 @@ interface NodeExecutionDeps {
12
12
  start: (name: string, input: unknown, parentId: string) => Promise<RunHandle>;
13
13
  enqueue: (exec: Execution) => Promise<void>;
14
14
  waitFor: WaitForDeps;
15
+ assertActive: () => void;
15
16
  }
16
17
  export declare function executeWorkflowNode(deps: NodeExecutionDeps, exec: Execution, node: WorkflowNode, idx: number, wf: Workflow): Promise<void>;
17
18
  export {};
@@ -1,51 +1,61 @@
1
1
  /** Workflow node dispatch, split from the executor's lifecycle orchestration. */
2
2
  import { clock } from './clock';
3
+ import { isWorkflowExecutionClosed } from './executionFence';
3
4
  import { describeError } from './identity';
4
5
  import { executeDoUntil, executeDoWhile, executeForEach, executeMap } from './loops';
5
6
  import { buildContext, executeParallelSteps, executeStepWithRetry, executeSubWorkflow, } from './runner';
6
7
  import { runWaitFor } from './waitFor';
7
8
  import { branchDecisionKey, resolveDecision, subWorkflowInputDecisionKey, } from './workflowDecisions';
8
9
  export async function executeWorkflowNode(deps, exec, node, idx, wf) {
10
+ deps.assertActive();
9
11
  if (node.type === 'step') {
10
12
  await executeStepWithRetry(node.def, buildContext(exec), exec, {
11
13
  emitter: deps.emitter,
12
14
  updateFn: deps.updateFn,
15
+ assertActive: deps.assertActive,
13
16
  });
14
17
  }
15
18
  else if (node.type === 'branch') {
16
19
  await runBranch(deps, exec, node, idx);
17
20
  }
18
21
  else if (node.type === 'parallel') {
19
- await executeParallelSteps(node.def.steps, buildContext(exec), exec, deps.emitter, deps.updateFn);
22
+ await executeParallelSteps(node.def.steps, buildContext(exec), exec, {
23
+ emitter: deps.emitter,
24
+ updateFn: deps.updateFn,
25
+ assertActive: deps.assertActive,
26
+ });
20
27
  }
21
28
  else if (node.type === 'subWorkflow') {
22
29
  await runSubWorkflow(deps, exec, node, idx);
23
30
  }
24
31
  else if (node.type === 'doUntil') {
25
- await executeDoUntil(node.def, exec, deps.emitter, deps.updateFn);
32
+ await executeDoUntil(node.def, exec, deps.emitter, deps.updateFn, deps.assertActive);
26
33
  }
27
34
  else if (node.type === 'doWhile') {
28
- await executeDoWhile(node.def, exec, deps.emitter, deps.updateFn);
35
+ await executeDoWhile(node.def, exec, deps.emitter, deps.updateFn, deps.assertActive);
29
36
  }
30
37
  else if (node.type === 'forEach') {
31
- await executeForEach(node.def, exec, deps.emitter, deps.updateFn);
38
+ await executeForEach(node.def, exec, deps.emitter, deps.updateFn, deps.assertActive);
32
39
  }
33
40
  else if (node.type === 'map') {
34
- await executeMap(node.def, exec, deps.emitter, deps.updateFn);
41
+ await executeMap(node.def, exec, deps.emitter, deps.updateFn, deps.assertActive);
35
42
  }
36
43
  else if (node.type === 'pivot') {
37
44
  // A pivot commits the saga before the cursor moves beyond it.
38
45
  exec.committedAt = idx;
39
- deps.store.update(exec);
46
+ deps.updateFn(exec);
40
47
  }
41
48
  else {
42
49
  await runWaitFor(deps.waitFor, exec, node, idx, wf);
43
50
  return;
44
51
  }
52
+ deps.assertActive();
45
53
  await deps.advance(exec, idx + 1, wf);
46
54
  }
47
55
  async function runBranch(deps, exec, node, idx) {
48
- const pathName = await resolveDecision(exec, branchDecisionKey(idx), () => node.def.condition(buildContext(exec)), deps.updateFn);
56
+ deps.assertActive();
57
+ const pathName = await resolveDecision(exec, branchDecisionKey(idx), () => node.def.condition(buildContext(exec)), deps.updateFn, deps.assertActive);
58
+ deps.assertActive();
49
59
  if (typeof pathName !== 'string') {
50
60
  throw new Error(`Branch at node ${idx} returned a non-string path`);
51
61
  }
@@ -66,26 +76,37 @@ async function runBranch(deps, exec, node, idx) {
66
76
  await executeStepWithRetry(step, buildContext(exec), exec, {
67
77
  emitter: deps.emitter,
68
78
  updateFn: deps.updateFn,
79
+ assertActive: deps.assertActive,
69
80
  });
81
+ deps.assertActive();
70
82
  }
71
83
  }
72
84
  async function runSubWorkflow(deps, exec, node, idx) {
73
- const subInput = await resolveDecision(exec, subWorkflowInputDecisionKey(idx), () => structuredClone(node.inputMapper(buildContext(exec))), deps.updateFn);
85
+ deps.assertActive();
86
+ const subInput = await resolveDecision(exec, subWorkflowInputDecisionKey(idx), () => structuredClone(node.inputMapper(buildContext(exec))), deps.updateFn, deps.assertActive);
87
+ deps.assertActive();
74
88
  const recordKey = `sub:${node.name}`;
75
89
  const childExecutionId = await adoptExistingChild(deps, exec, node.name, recordKey);
90
+ deps.assertActive();
76
91
  try {
77
92
  const { results, executionId } = await executeSubWorkflow(node.name, subInput, async (name, input) => {
78
93
  const handle = await deps.start(name, input, exec.id);
94
+ deps.assertActive();
79
95
  // Claim the child before polling it. A restart can then resume the existing
80
96
  // child instead of starting another one.
81
97
  exec.steps[recordKey] = { status: 'running', childExecutionId: handle.id };
82
- deps.store.update(exec);
98
+ deps.updateFn(exec);
83
99
  return handle;
84
- }, (id) => deps.store.get(id), {
100
+ }, (id) => {
101
+ deps.assertActive();
102
+ return deps.store.get(id);
103
+ }, {
85
104
  pollIntervalMs: node.pollInterval,
86
105
  maxWaitMs: node.timeout,
87
106
  existingChildId: childExecutionId,
107
+ assertActive: deps.assertActive,
88
108
  });
109
+ deps.assertActive();
89
110
  exec.steps[recordKey] = {
90
111
  status: 'completed',
91
112
  result: results,
@@ -94,11 +115,15 @@ async function runSubWorkflow(deps, exec, node, idx) {
94
115
  };
95
116
  }
96
117
  catch (error) {
97
- settleFailedChild(deps.store, exec, recordKey, error);
118
+ if (isWorkflowExecutionClosed(error))
119
+ throw error;
120
+ deps.assertActive();
121
+ settleFailedChild(deps.updateFn, exec, recordKey, error);
98
122
  throw error;
99
123
  }
100
124
  }
101
125
  async function adoptExistingChild(deps, exec, workflowName, recordKey) {
126
+ deps.assertActive();
102
127
  const claimedId = exec.steps[recordKey]?.childExecutionId;
103
128
  const child = claimedId ? deps.store.get(claimedId) : deps.store.findChild(exec.id, workflowName);
104
129
  if (!child)
@@ -109,16 +134,18 @@ async function adoptExistingChild(deps, exec, workflowName, recordKey) {
109
134
  status: 'running',
110
135
  childExecutionId: child.id,
111
136
  };
112
- deps.store.update(exec);
137
+ deps.updateFn(exec);
113
138
  }
114
139
  // The child row is durable before its initial queue publication. A crash in that
115
140
  // gap leaves it running but undelivered; duplicate publication is safe because node
116
141
  // admission and persisted outcomes make it idempotent.
117
- if (child.state === 'running')
142
+ if (child.state === 'running') {
118
143
  await deps.enqueue(child);
144
+ deps.assertActive();
145
+ }
119
146
  return child.id;
120
147
  }
121
- function settleFailedChild(store, exec, recordKey, error) {
148
+ function settleFailedChild(updateFn, exec, recordKey, error) {
122
149
  const claimed = exec.steps[recordKey];
123
150
  if (!claimed)
124
151
  return;
@@ -129,9 +156,11 @@ function settleFailedChild(store, exec, recordKey, error) {
129
156
  completedAt: clock().now(),
130
157
  };
131
158
  try {
132
- store.update(exec);
159
+ updateFn(exec);
133
160
  }
134
- catch {
161
+ catch (writeError) {
162
+ if (isWorkflowExecutionClosed(writeError))
163
+ throw writeError;
135
164
  // The generic failure path persists this in-memory record. Do not replace the
136
165
  // child's diagnostic with an incidental write error.
137
166
  }
@@ -0,0 +1,9 @@
1
+ /** Durable workflow-node publication shared by execution and recovery. */
2
+ import type { Queue } from '../queue/queue';
3
+ import type { Execution } from './types';
4
+ /**
5
+ * Publish one node without a deterministic job ID. The cursor and in-flight guards
6
+ * discard overlapping duplicates, while queue-level dedup could suppress a legitimate
7
+ * re-enqueue after recovery and wedge the execution permanently.
8
+ */
9
+ export declare function enqueueWorkflowStep(queue: Queue, exec: Execution, assertActive: () => void): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /** Durable workflow-node publication shared by execution and recovery. */
2
+ /**
3
+ * Publish one node without a deterministic job ID. The cursor and in-flight guards
4
+ * discard overlapping duplicates, while queue-level dedup could suppress a legitimate
5
+ * re-enqueue after recovery and wedge the execution permanently.
6
+ */
7
+ export async function enqueueWorkflowStep(queue, exec, assertActive) {
8
+ assertActive();
9
+ const jobData = {
10
+ executionId: exec.id,
11
+ workflowName: exec.workflowName,
12
+ nodeIndex: exec.currentNodeIndex,
13
+ };
14
+ await queue.add('wf:step', jobData);
15
+ }
@@ -0,0 +1,4 @@
1
+ /** Durable forEach execution, split from conditional loop runners. */
2
+ import type { Execution, ForEachDefinition } from './types';
3
+ import type { WorkflowEmitter } from './emitter';
4
+ export declare function executeForEach(def: ForEachDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void, assertActive?: () => void): Promise<void>;
@@ -0,0 +1,70 @@
1
+ /** Durable forEach execution, split from conditional loop runners. */
2
+ import { assertWorkflowActive, isWorkflowExecutionClosed } from './executionFence';
3
+ import { buildContext, executeStepWithRetry } from './runner';
4
+ import { forEachItemsDecisionKey, resolveDecision } from './workflowDecisions';
5
+ export async function executeForEach(def, exec, emitter, updateFn, assertActive = assertWorkflowActive) {
6
+ assertActive();
7
+ const items = await resolveDecision(exec, forEachItemsDecisionKey(def.step.name), () => structuredClone(def.items(buildContext(exec))), updateFn, assertActive);
8
+ assertActive();
9
+ // Array-likes used to report success while doing the wrong amount of work: a number
10
+ // ran zero items and a string ran one item per character.
11
+ if (!Array.isArray(items)) {
12
+ throw new Error(`forEach items must be an array, got ${items === null ? 'null' : typeof items}`);
13
+ }
14
+ if (items.length > def.maxIterations) {
15
+ throw new Error(`forEach items (${items.length}) exceeds maxIterations (${def.maxIterations})`);
16
+ }
17
+ for (let i = 0; i < items.length; i++) {
18
+ assertActive();
19
+ const item = structuredClone(items[i]);
20
+ const indexedName = `${def.step.name}:${i}`;
21
+ const indexedStep = {
22
+ ...def.step,
23
+ name: indexedName,
24
+ handler: (stepCtx) => def.step.handler({
25
+ ...stepCtx,
26
+ steps: { ...stepCtx.steps, __item: item, __index: i },
27
+ }),
28
+ };
29
+ // A completed item is memoised; the bare name remains the aggregate view of the
30
+ // last iteration for loop conditions and downstream steps.
31
+ const completed = exec.steps[indexedName];
32
+ if (completed?.status === 'completed') {
33
+ exec.steps[def.step.name] = { ...completed };
34
+ updateFn(exec);
35
+ continue;
36
+ }
37
+ let thrown;
38
+ let threw = false;
39
+ try {
40
+ await executeStepWithRetry(indexedStep, buildContext(exec), exec, { emitter, updateFn, assertActive }, i);
41
+ assertActive();
42
+ }
43
+ catch (error) {
44
+ if (isWorkflowExecutionClosed(error))
45
+ throw error;
46
+ assertActive();
47
+ thrown = error;
48
+ threw = true;
49
+ }
50
+ // Persist the item identity on both success and failure. A failed provider call
51
+ // can still need compensation, and its reversal needs the original item/index.
52
+ const record = exec.steps[indexedName];
53
+ if (record) {
54
+ record.loopItem = item;
55
+ record.loopIndex = i;
56
+ exec.steps[def.step.name] = { ...record };
57
+ try {
58
+ updateFn(exec);
59
+ }
60
+ catch (writeError) {
61
+ if (isWorkflowExecutionClosed(writeError))
62
+ throw writeError;
63
+ if (!threw)
64
+ throw writeError;
65
+ }
66
+ }
67
+ if (threw)
68
+ throw thrown;
69
+ }
70
+ }
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * Loop execution logic for the Workflow Engine.
3
3
  */
4
- import type { Execution, LoopDefinition, ForEachDefinition } from './types';
4
+ import type { Execution, LoopDefinition } from './types';
5
5
  import type { WorkflowEmitter } from './emitter';
6
6
  export { executeMap } from './mapRunner';
7
+ export { executeForEach } from './forEachRunner';
7
8
  /** Execute a doUntil loop: run steps, then check condition. Repeat until condition returns true. */
8
- export declare function executeDoUntil(def: LoopDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
9
+ export declare function executeDoUntil(def: LoopDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void, assertActive?: () => void): Promise<void>;
9
10
  /** Execute a doWhile loop: check condition first, then run steps. Repeat while condition is true. */
10
- export declare function executeDoWhile(def: LoopDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
11
- /** Execute a forEach loop: iterate over items, executing the step for each */
12
- export declare function executeForEach(def: ForEachDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
11
+ export declare function executeDoWhile(def: LoopDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void, assertActive?: () => void): Promise<void>;