@adonis-agora/durable 0.16.0 → 0.17.0
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/CHANGELOG.md +78 -0
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +17 -0
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/commands/worker.d.ts.map +1 -1
- package/dist/src/commands/worker.js +4 -0
- package/dist/src/commands/worker.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/define_config.d.ts +12 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/engine.d.ts +79 -0
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +187 -28
- package/dist/src/engine.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/interfaces.d.ts +12 -0
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/services/booted_app.d.ts +22 -0
- package/dist/src/services/booted_app.d.ts.map +1 -0
- package/dist/src/services/booted_app.js +67 -0
- package/dist/src/services/booted_app.js.map +1 -0
- package/dist/src/services/main.d.ts.map +1 -1
- package/dist/src/services/main.js +6 -1
- package/dist/src/services/main.js.map +1 -1
- package/dist/src/transport-pool.d.ts +3 -0
- package/dist/src/transport-pool.d.ts.map +1 -1
- package/dist/src/transport-pool.js +7 -0
- package/dist/src/transport-pool.js.map +1 -1
- package/dist/src/transports/queue.d.ts +22 -0
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +54 -0
- package/dist/src/transports/queue.js.map +1 -1
- package/package.json +1 -1
package/dist/src/engine.js
CHANGED
|
@@ -79,6 +79,14 @@ export class WorkflowEngine {
|
|
|
79
79
|
trackStepStart;
|
|
80
80
|
/** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
|
|
81
81
|
runDispatcher;
|
|
82
|
+
/**
|
|
83
|
+
* True when NO custom `runDispatcher` was supplied — i.e. runs execute in-process via the default
|
|
84
|
+
* fire-and-forget dispatcher. Only then does an internal handoff (continue-as-new, a deferred child)
|
|
85
|
+
* own the run's pickup itself (a tracked {@link runOne}) so {@link drain} spans it end to end; with a
|
|
86
|
+
* custom (no-op / broker) dispatcher the run executes off this instance, so the handoff guarantees
|
|
87
|
+
* only the durable persist (the in-process rolled-back-transaction hazard doesn't apply there).
|
|
88
|
+
*/
|
|
89
|
+
usesInProcessDispatch;
|
|
82
90
|
/** The control-plane's OWN handshake descriptor — negotiated against each worker's descriptor to
|
|
83
91
|
* detect protocol incompatibility before dispatch (design §7.3/§7.4). Built once at construction. */
|
|
84
92
|
cpDescriptor;
|
|
@@ -123,6 +131,17 @@ export class WorkflowEngine {
|
|
|
123
131
|
stepQueue = new Map();
|
|
124
132
|
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
125
133
|
inflight = new Set();
|
|
134
|
+
/**
|
|
135
|
+
* Post-settle side effects (parent notify, singleton wake) fired fire-and-forget AFTER a run's
|
|
136
|
+
* status was persisted. They run OFF the `execute()` path — the caller of `execute()` must never
|
|
137
|
+
* block on them — but they still issue store writes (waking a suspended parent, dispatching a
|
|
138
|
+
* gated singleton run), so `drain()` has to wait for them too. Left untracked, those writes can
|
|
139
|
+
* land after a test's global Lucid transaction rolled back ("Transaction query already complete"
|
|
140
|
+
* as an unhandled rejection) or after a graceful shutdown thought it was done. See {@link track}
|
|
141
|
+
* vs {@link trackEffect}: `inflight` is the execute() path, `postSettle` is everything that
|
|
142
|
+
* escapes it.
|
|
143
|
+
*/
|
|
144
|
+
postSettle = new Set();
|
|
126
145
|
draining = false;
|
|
127
146
|
constructor(deps) {
|
|
128
147
|
this.store = deps.store;
|
|
@@ -169,6 +188,7 @@ export class WorkflowEngine {
|
|
|
169
188
|
// Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
|
|
170
189
|
// A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
|
|
171
190
|
// run failures themselves are handled inside `execute` and surfaced as the run's `failed` state.
|
|
191
|
+
this.usesInProcessDispatch = deps.runDispatcher === undefined;
|
|
172
192
|
this.runDispatcher = deps.runDispatcher ?? {
|
|
173
193
|
dispatch: (runId) => queueMicrotask(() => void this.runOne(runId).catch(() => { })),
|
|
174
194
|
};
|
|
@@ -383,6 +403,16 @@ export class WorkflowEngine {
|
|
|
383
403
|
registerQueue(config) {
|
|
384
404
|
this.admission.register(config);
|
|
385
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Declare this process a worker: flush consumer subscriptions parked by a transport whose
|
|
408
|
+
* consumption was deferred (see {@link Transport.deferConsumers}). The `durable:work` loop calls
|
|
409
|
+
* this before its first tick; until then a deferred-transport process (an ace command, a REPL)
|
|
410
|
+
* dispatches and reads but never claims broker jobs. Idempotent; a no-op on transports that
|
|
411
|
+
* never defer (in-process transports, or a broker transport left eager).
|
|
412
|
+
*/
|
|
413
|
+
startConsumers() {
|
|
414
|
+
this.pool.startConsumers();
|
|
415
|
+
}
|
|
386
416
|
/** Subscribe to lifecycle events. Returns an unsubscribe function. */
|
|
387
417
|
subscribe(listener) {
|
|
388
418
|
this.listeners.add(listener);
|
|
@@ -474,7 +504,20 @@ export class WorkflowEngine {
|
|
|
474
504
|
}
|
|
475
505
|
}
|
|
476
506
|
}
|
|
477
|
-
|
|
507
|
+
start(workflow, input, runId, opts) {
|
|
508
|
+
// Return startCore's promise directly (no async wrapper): the extra microtask an `async`
|
|
509
|
+
// delegator inserts would shift `start`'s resolution one tick later, which is observable in the
|
|
510
|
+
// dispatch timing (a freshly-dispatched run reaching `running` before `start` resolves).
|
|
511
|
+
return this.startCore(workflow, input, runId, opts, true);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The shared body of {@link start}. `dispatch` gates the fire-and-forget hand-off to the run
|
|
515
|
+
* dispatcher (+ the control-plane `enqueued` nudge): the public `start` always dispatches, while an
|
|
516
|
+
* internal in-process handoff ({@link handoffRun}) passes `false` to persist the run and then OWN
|
|
517
|
+
* its pickup with a tracked {@link runOne} — so no detached dispatcher run races it and {@link drain}
|
|
518
|
+
* spans the handoff from the parent's settle until the new run has entered `inflight` and settled.
|
|
519
|
+
*/
|
|
520
|
+
async startCore(workflow, input, runId, opts, dispatch) {
|
|
478
521
|
const name = workflowName(workflow);
|
|
479
522
|
let registered = this.latest.get(name);
|
|
480
523
|
// Convention dispatch: an unregistered workflow whose name matches a LIVE worker group is routed
|
|
@@ -520,16 +563,68 @@ export class WorkflowEngine {
|
|
|
520
563
|
await this.store.createRun(run);
|
|
521
564
|
// The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
|
|
522
565
|
// NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
.
|
|
566
|
+
// An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
|
|
567
|
+
if (dispatch) {
|
|
568
|
+
await this.runDispatcher.dispatch(runId);
|
|
569
|
+
// Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
|
|
570
|
+
// plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
|
|
571
|
+
if (this.controlPlane) {
|
|
572
|
+
void this.controlPlane
|
|
573
|
+
.publishControl({ kind: 'enqueued', runId, from: this.instanceId })
|
|
574
|
+
.catch(() => undefined);
|
|
575
|
+
}
|
|
530
576
|
}
|
|
531
577
|
return { runId, status: 'pending' };
|
|
532
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Bridge an INTERNAL run handoff — the next execution of a `continue-as-new`, or a deferred child
|
|
581
|
+
* start — into the {@link postSettle} registry so {@link drain} cannot slip through the microtask +
|
|
582
|
+
* store-I/O gap between the parent settling and the new run entering `inflight`. The bare
|
|
583
|
+
* `queueMicrotask(() => void this.start(...))` these replaced left exactly that window open: the
|
|
584
|
+
* parent's `execute()` promise had already left `inflight` and its post-settle effects could have
|
|
585
|
+
* drained before the deferred `start` even ran — so `drain()` could observe both sets empty and
|
|
586
|
+
* return early, letting the continuation's/child's store writes escape onto a torn-down connection
|
|
587
|
+
* (the rolled-back Lucid test transaction → "Transaction query already complete").
|
|
588
|
+
*
|
|
589
|
+
* The tracked promise is registered SYNCHRONOUSLY (at the parent's settle), then defers ONE microtask
|
|
590
|
+
* — preserving the original reentrancy guard so a fast child can't resume a still-suspending parent —
|
|
591
|
+
* before starting. With the default in-process dispatcher it then OWNS the pickup via {@link runOne}
|
|
592
|
+
* (persisting with `dispatch: false` so no detached dispatcher run races it), so the promise stays in
|
|
593
|
+
* `postSettle` until the run has entered `inflight` and settled here; the run is briefly in BOTH sets,
|
|
594
|
+
* which is harmless because {@link drain}'s loop re-snapshots both each iteration. With a custom
|
|
595
|
+
* dispatcher the run executes off-instance, so only the durable persist is guaranteed. A start that
|
|
596
|
+
* throws is handed to `onStartError` (a child delivers it to its waiting parent) instead of vanishing.
|
|
597
|
+
*/
|
|
598
|
+
handoffRun(workflow, input, runId, opts, onStartError) {
|
|
599
|
+
this.trackEffect((async () => {
|
|
600
|
+
// Defer one microtask: never reentrantly resume the parent that is still suspending/settling.
|
|
601
|
+
await Promise.resolve();
|
|
602
|
+
try {
|
|
603
|
+
if (this.usesInProcessDispatch) {
|
|
604
|
+
// Persist WITHOUT the fire-and-forget dispatch, then drive the pickup ourselves so this
|
|
605
|
+
// promise is the SOLE executor of the run (no race with a detached dispatcher `runOne`) and
|
|
606
|
+
// stays tracked until the run has entered `inflight` and settled. `runOne` no-ops (returns
|
|
607
|
+
// null) on replay (the run already exists) or while draining (the run is left `pending` for
|
|
608
|
+
// recovery) — the durable persist above is what matters on those paths.
|
|
609
|
+
const result = await this.startCore(workflow, input, runId, opts, false);
|
|
610
|
+
// Guard-free pickup: the continuation/child is in-flight work `drain()` is waiting on, so it
|
|
611
|
+
// runs even while draining (a `runOne` here would no-op on `this.draining`, stranding it
|
|
612
|
+
// `pending` after its persist — persisted-but-not-processed). Mirrors a signal-driven resume.
|
|
613
|
+
if (result.status === 'pending')
|
|
614
|
+
await this.leaseAndResume(runId);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
// Custom dispatcher owns execution (possibly on another pod); guarantee the persist + its
|
|
618
|
+
// normal dispatch/broadcast. The in-process rolled-back-transaction hazard doesn't apply.
|
|
619
|
+
await this.startCore(workflow, input, runId, opts, true);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
if (onStartError)
|
|
624
|
+
onStartError(err);
|
|
625
|
+
}
|
|
626
|
+
})());
|
|
627
|
+
}
|
|
533
628
|
/** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
|
|
534
629
|
getRun(runId) {
|
|
535
630
|
return this.store.getRun(runId);
|
|
@@ -603,20 +698,50 @@ export class WorkflowEngine {
|
|
|
603
698
|
void p.finally(() => this.inflight.delete(p));
|
|
604
699
|
return p;
|
|
605
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* Track a post-settle side effect (parent notify, singleton wake) so {@link drain} waits for it,
|
|
703
|
+
* WITHOUT the `execute()`/`settleRun` caller ever blocking on it — the run's result is returned
|
|
704
|
+
* to its caller the moment its status is persisted; this only holds the effect for `drain()`.
|
|
705
|
+
* Errors are swallowed (these are best-effort wakeups; the durable timer is the real fallback),
|
|
706
|
+
* exactly as the original `void ....catch(() => undefined)` fire-and-forget did.
|
|
707
|
+
*/
|
|
708
|
+
trackEffect(p) {
|
|
709
|
+
const tracked = p.catch(() => undefined);
|
|
710
|
+
this.postSettle.add(tracked);
|
|
711
|
+
void tracked.finally(() => this.postSettle.delete(tracked));
|
|
712
|
+
}
|
|
606
713
|
/**
|
|
607
714
|
* Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
|
|
608
715
|
* in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
|
|
609
716
|
* deploy hands off cleanly instead of leaving runs to the lease timeout.
|
|
717
|
+
*
|
|
718
|
+
* Because an internal handoff ({@link handoffRun}) is finishing work already in flight, `drain()`
|
|
719
|
+
* follows a `continue-as-new` chain across its links (and a deferred child through its parent's
|
|
720
|
+
* resume). A HOT continue-as-new loop — one that hands off to a fresh link faster than it settles —
|
|
721
|
+
* therefore keeps `postSettle`/`inflight` non-empty and consumes the ENTIRE `timeoutMs` before this
|
|
722
|
+
* returns. That is by design (tearing down mid-continuation would orphan the continuation's writes);
|
|
723
|
+
* on timeout the frontier link is left persisted and leased, so the next boot's recovery re-drives it
|
|
724
|
+
* (no loss) — the cost is spending the full timeout on shutdown. Size `timeoutMs` accordingly.
|
|
610
725
|
*/
|
|
611
726
|
async drain(timeoutMs = 10_000) {
|
|
612
727
|
this.draining = true;
|
|
613
|
-
if (this.inflight.size === 0)
|
|
728
|
+
if (this.inflight.size === 0 && this.postSettle.size === 0)
|
|
614
729
|
return;
|
|
615
730
|
const timer = new Promise((resolve) => {
|
|
616
|
-
const t = setTimeout(resolve, timeoutMs);
|
|
731
|
+
const t = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
617
732
|
t.unref?.();
|
|
618
733
|
});
|
|
619
|
-
|
|
734
|
+
// Loop, don't snapshot-once: settling an in-flight run fires post-settle effects (parent notify,
|
|
735
|
+
// singleton wake) that can resume — and thus re-inflight — another run, which settles and fires
|
|
736
|
+
// more effects. Wait until BOTH sets reach steady-state empty, or the shared timer wins. The
|
|
737
|
+
// `Promise<'timeout'>` sentinel is distinguishable from `allSettled`'s array so the race is
|
|
738
|
+
// unambiguous even when everything resolves in the same tick.
|
|
739
|
+
while (this.inflight.size > 0 || this.postSettle.size > 0) {
|
|
740
|
+
const pending = Promise.allSettled([...this.inflight, ...this.postSettle]);
|
|
741
|
+
const outcome = await Promise.race([pending, timer]);
|
|
742
|
+
if (outcome === 'timeout')
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
620
745
|
}
|
|
621
746
|
/**
|
|
622
747
|
* Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
|
|
@@ -733,6 +858,16 @@ export class WorkflowEngine {
|
|
|
733
858
|
async runOne(runId) {
|
|
734
859
|
if (this.draining)
|
|
735
860
|
return null;
|
|
861
|
+
return this.leaseAndResume(runId);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Lease and resume a run WITHOUT the {@link runOne} draining guard. The guard's job is to stop
|
|
865
|
+
* picking up NEW work once shutdown began; but an internal handoff ({@link handoffRun}) completing a
|
|
866
|
+
* continue-as-new continuation or a deferred child is finishing work already in flight — the very
|
|
867
|
+
* work {@link drain} is waiting on — so it must run even while draining, exactly as a signal-driven
|
|
868
|
+
* resume ({@link deliverSignal}) already does. Returns null if another instance holds the lease.
|
|
869
|
+
*/
|
|
870
|
+
async leaseAndResume(runId) {
|
|
736
871
|
const nowMs = this.clock();
|
|
737
872
|
const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
738
873
|
if (!acquired)
|
|
@@ -1147,7 +1282,9 @@ export class WorkflowEngine {
|
|
|
1147
1282
|
* knowing about the child feature.
|
|
1148
1283
|
*/
|
|
1149
1284
|
notifyParent(runId, completion) {
|
|
1150
|
-
|
|
1285
|
+
// Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
|
|
1286
|
+
// store writes that must be awaited by `drain()` even though the settling child returned already.
|
|
1287
|
+
this.trackEffect(this.signal(`child:${runId}`, completion));
|
|
1151
1288
|
// A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
|
|
1152
1289
|
// origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
|
|
1153
1290
|
// parent that failed on that child and is retried later consumes this success (buffered or
|
|
@@ -1155,7 +1292,7 @@ export class WorkflowEngine {
|
|
|
1155
1292
|
// fix attempt must not poison the origin's token.
|
|
1156
1293
|
const at = runId.lastIndexOf('~retry~');
|
|
1157
1294
|
if (at !== -1 && completion.ok) {
|
|
1158
|
-
|
|
1295
|
+
this.trackEffect(this.signal(`child:${runId.slice(0, at)}`, completion));
|
|
1159
1296
|
}
|
|
1160
1297
|
}
|
|
1161
1298
|
/**
|
|
@@ -1171,13 +1308,16 @@ export class WorkflowEngine {
|
|
|
1171
1308
|
* correctly observes the failed start.
|
|
1172
1309
|
*/
|
|
1173
1310
|
startChildDeferred(workflow, input, childId, opts) {
|
|
1174
|
-
|
|
1311
|
+
// Tracked handoff (see {@link handoffRun}): the deferral + reentrancy guard are preserved, but the
|
|
1312
|
+
// start is now held in `postSettle` so `drain()` waits for the child to enter `inflight` and settle
|
|
1313
|
+
// rather than slipping through the gap before the deferred start ran.
|
|
1314
|
+
this.handoffRun(workflow, input, childId, opts, (err) => {
|
|
1175
1315
|
const message = err instanceof Error ? err.message : String(err);
|
|
1176
1316
|
this.notifyParent(childId, {
|
|
1177
1317
|
ok: false,
|
|
1178
1318
|
error: `child workflow "${workflow}" failed to start: ${message}`,
|
|
1179
1319
|
});
|
|
1180
|
-
})
|
|
1320
|
+
});
|
|
1181
1321
|
}
|
|
1182
1322
|
/**
|
|
1183
1323
|
* Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
|
|
@@ -1206,11 +1346,17 @@ export class WorkflowEngine {
|
|
|
1206
1346
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
1207
1347
|
.catch(() => undefined);
|
|
1208
1348
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1349
|
+
// Tracked (see {@link trackEffect}): the background resume replays the run and drives its saga
|
|
1350
|
+
// compensations to the terminal `cancelled` write. As a bare `queueMicrotask` that whole sequence
|
|
1351
|
+
// ran untracked — and because `resume()` only enters `inflight` once it is actually called (a
|
|
1352
|
+
// microtask later), `drain()` could observe both registries empty in the gap and return BEFORE the
|
|
1353
|
+
// compensation writes, letting them land on a torn-down connection ("Transaction query already
|
|
1354
|
+
// complete"). Holding the deferred resume in `postSettle` closes that gap; `resume()` then also
|
|
1355
|
+
// tracks its own execution in `inflight`, so the run is briefly in both sets (harmless — the drain
|
|
1356
|
+
// loop re-snapshots each iteration). NOT a `handoffRun`: that STARTS a new run via `startCore`;
|
|
1357
|
+
// here an EXISTING run is resumed, whose execution already self-tracks — only the pre-call gap
|
|
1358
|
+
// needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
|
|
1359
|
+
this.trackEffect(Promise.resolve().then(() => this.resume(runId).then(() => this.notifyCancelled(runId))));
|
|
1214
1360
|
await this.cancelChildren(runId, opts);
|
|
1215
1361
|
return { runId, status: run.status };
|
|
1216
1362
|
}
|
|
@@ -1234,7 +1380,7 @@ export class WorkflowEngine {
|
|
|
1234
1380
|
.catch(() => undefined);
|
|
1235
1381
|
}
|
|
1236
1382
|
// A cancelled singleton run frees its slot — wake the next gated waiter now (notify-on-release).
|
|
1237
|
-
|
|
1383
|
+
this.trackEffect(this.singletons.wakeNext(run));
|
|
1238
1384
|
return { runId, status: 'cancelled', error };
|
|
1239
1385
|
}
|
|
1240
1386
|
/**
|
|
@@ -1506,7 +1652,7 @@ export class WorkflowEngine {
|
|
|
1506
1652
|
result.status === 'failed' ||
|
|
1507
1653
|
result.status === 'cancelled' ||
|
|
1508
1654
|
result.status === 'dead')) {
|
|
1509
|
-
|
|
1655
|
+
this.trackEffect(this.singletons.wakeNext(run));
|
|
1510
1656
|
}
|
|
1511
1657
|
return result;
|
|
1512
1658
|
}
|
|
@@ -1544,7 +1690,7 @@ export class WorkflowEngine {
|
|
|
1544
1690
|
namespace: run.namespace,
|
|
1545
1691
|
output: outcome.output,
|
|
1546
1692
|
});
|
|
1547
|
-
|
|
1693
|
+
this.notifyParent(run.id, { ok: true, value: outcome.output });
|
|
1548
1694
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
1549
1695
|
}
|
|
1550
1696
|
if (outcome.kind === 'failed') {
|
|
@@ -1556,7 +1702,7 @@ export class WorkflowEngine {
|
|
|
1556
1702
|
namespace: run.namespace,
|
|
1557
1703
|
error: outcome.error,
|
|
1558
1704
|
});
|
|
1559
|
-
|
|
1705
|
+
this.notifyParent(run.id, { ok: false, error: outcome.error.message });
|
|
1560
1706
|
return { runId: run.id, status: 'failed', error: outcome.error };
|
|
1561
1707
|
}
|
|
1562
1708
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
|
|
@@ -1948,9 +2094,12 @@ export class WorkflowEngine {
|
|
|
1948
2094
|
workflow: run.workflow,
|
|
1949
2095
|
namespace: run.namespace,
|
|
1950
2096
|
});
|
|
1951
|
-
|
|
2097
|
+
this.notifyParent(run.id, { ok: true, value: undefined });
|
|
1952
2098
|
const nextId = nextContinuationId(run.id);
|
|
1953
|
-
|
|
2099
|
+
// Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
|
|
2100
|
+
// continuation has entered `inflight` and settled, so `drain()` can't return before the next
|
|
2101
|
+
// run's store writes land. Still deferred + idempotent by the continuation id.
|
|
2102
|
+
this.handoffRun(run.workflow, err.input, nextId);
|
|
1954
2103
|
return { runId: run.id, status: 'completed' };
|
|
1955
2104
|
}
|
|
1956
2105
|
if (err instanceof WorkflowBlocked) {
|
|
@@ -2104,8 +2253,13 @@ export class WorkflowEngine {
|
|
|
2104
2253
|
// Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
|
|
2105
2254
|
// idempotent on a terminal/cancelled run (returns its existing status without side effects), so
|
|
2106
2255
|
// no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
|
|
2256
|
+
// Tracked (see {@link trackEffect}): `cancel()` writes the child — and its whole cancel cascade —
|
|
2257
|
+
// to the store, so this fire-and-forget is held in `postSettle`. Fired from a settling parent
|
|
2258
|
+
// (e.g. failFast cancelling surviving siblings), an untracked cancel's writes could otherwise
|
|
2259
|
+
// escape `drain()` onto a torn-down connection ("Transaction query already complete"). The
|
|
2260
|
+
// one-microtask defer is preserved; the promise is registered synchronously.
|
|
2107
2261
|
cancelChild: (childId) => {
|
|
2108
|
-
|
|
2262
|
+
this.trackEffect(Promise.resolve().then(() => this.cancel(childId)));
|
|
2109
2263
|
},
|
|
2110
2264
|
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
2111
2265
|
upsertSearchAttributes: async (runId, attrs) => {
|
|
@@ -2115,8 +2269,13 @@ export class WorkflowEngine {
|
|
|
2115
2269
|
updatedAt: new Date(),
|
|
2116
2270
|
});
|
|
2117
2271
|
},
|
|
2272
|
+
// Tracked (see {@link trackEffect}): `entities.dispatch` persists via `signalWithStart`
|
|
2273
|
+
// (createRun/signal on the entity's run), so this fire-and-forget is held in `postSettle` — else
|
|
2274
|
+
// an entity op signalled from a settling workflow could write after `drain()` returned (torn-down
|
|
2275
|
+
// connection → "Transaction query already complete"). The one-microtask defer is preserved; the
|
|
2276
|
+
// promise is registered synchronously.
|
|
2118
2277
|
signalEntity: (name, key, op, arg, reply) => {
|
|
2119
|
-
|
|
2278
|
+
this.trackEffect(Promise.resolve().then(() => this.entities.dispatch(name, key, op, arg, reply)));
|
|
2120
2279
|
},
|
|
2121
2280
|
interceptStep: (invocation, body) => this.interceptStep(invocation, body),
|
|
2122
2281
|
};
|