@adonis-agora/durable 0.16.0 → 0.16.1
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 +66 -0
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +4 -0
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +71 -0
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +177 -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/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/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
|
};
|
|
@@ -474,7 +494,20 @@ export class WorkflowEngine {
|
|
|
474
494
|
}
|
|
475
495
|
}
|
|
476
496
|
}
|
|
477
|
-
|
|
497
|
+
start(workflow, input, runId, opts) {
|
|
498
|
+
// Return startCore's promise directly (no async wrapper): the extra microtask an `async`
|
|
499
|
+
// delegator inserts would shift `start`'s resolution one tick later, which is observable in the
|
|
500
|
+
// dispatch timing (a freshly-dispatched run reaching `running` before `start` resolves).
|
|
501
|
+
return this.startCore(workflow, input, runId, opts, true);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The shared body of {@link start}. `dispatch` gates the fire-and-forget hand-off to the run
|
|
505
|
+
* dispatcher (+ the control-plane `enqueued` nudge): the public `start` always dispatches, while an
|
|
506
|
+
* internal in-process handoff ({@link handoffRun}) passes `false` to persist the run and then OWN
|
|
507
|
+
* its pickup with a tracked {@link runOne} — so no detached dispatcher run races it and {@link drain}
|
|
508
|
+
* spans the handoff from the parent's settle until the new run has entered `inflight` and settled.
|
|
509
|
+
*/
|
|
510
|
+
async startCore(workflow, input, runId, opts, dispatch) {
|
|
478
511
|
const name = workflowName(workflow);
|
|
479
512
|
let registered = this.latest.get(name);
|
|
480
513
|
// Convention dispatch: an unregistered workflow whose name matches a LIVE worker group is routed
|
|
@@ -520,16 +553,68 @@ export class WorkflowEngine {
|
|
|
520
553
|
await this.store.createRun(run);
|
|
521
554
|
// The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
|
|
522
555
|
// NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
.
|
|
556
|
+
// An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
|
|
557
|
+
if (dispatch) {
|
|
558
|
+
await this.runDispatcher.dispatch(runId);
|
|
559
|
+
// Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
|
|
560
|
+
// plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
|
|
561
|
+
if (this.controlPlane) {
|
|
562
|
+
void this.controlPlane
|
|
563
|
+
.publishControl({ kind: 'enqueued', runId, from: this.instanceId })
|
|
564
|
+
.catch(() => undefined);
|
|
565
|
+
}
|
|
530
566
|
}
|
|
531
567
|
return { runId, status: 'pending' };
|
|
532
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Bridge an INTERNAL run handoff — the next execution of a `continue-as-new`, or a deferred child
|
|
571
|
+
* start — into the {@link postSettle} registry so {@link drain} cannot slip through the microtask +
|
|
572
|
+
* store-I/O gap between the parent settling and the new run entering `inflight`. The bare
|
|
573
|
+
* `queueMicrotask(() => void this.start(...))` these replaced left exactly that window open: the
|
|
574
|
+
* parent's `execute()` promise had already left `inflight` and its post-settle effects could have
|
|
575
|
+
* drained before the deferred `start` even ran — so `drain()` could observe both sets empty and
|
|
576
|
+
* return early, letting the continuation's/child's store writes escape onto a torn-down connection
|
|
577
|
+
* (the rolled-back Lucid test transaction → "Transaction query already complete").
|
|
578
|
+
*
|
|
579
|
+
* The tracked promise is registered SYNCHRONOUSLY (at the parent's settle), then defers ONE microtask
|
|
580
|
+
* — preserving the original reentrancy guard so a fast child can't resume a still-suspending parent —
|
|
581
|
+
* before starting. With the default in-process dispatcher it then OWNS the pickup via {@link runOne}
|
|
582
|
+
* (persisting with `dispatch: false` so no detached dispatcher run races it), so the promise stays in
|
|
583
|
+
* `postSettle` until the run has entered `inflight` and settled here; the run is briefly in BOTH sets,
|
|
584
|
+
* which is harmless because {@link drain}'s loop re-snapshots both each iteration. With a custom
|
|
585
|
+
* dispatcher the run executes off-instance, so only the durable persist is guaranteed. A start that
|
|
586
|
+
* throws is handed to `onStartError` (a child delivers it to its waiting parent) instead of vanishing.
|
|
587
|
+
*/
|
|
588
|
+
handoffRun(workflow, input, runId, opts, onStartError) {
|
|
589
|
+
this.trackEffect((async () => {
|
|
590
|
+
// Defer one microtask: never reentrantly resume the parent that is still suspending/settling.
|
|
591
|
+
await Promise.resolve();
|
|
592
|
+
try {
|
|
593
|
+
if (this.usesInProcessDispatch) {
|
|
594
|
+
// Persist WITHOUT the fire-and-forget dispatch, then drive the pickup ourselves so this
|
|
595
|
+
// promise is the SOLE executor of the run (no race with a detached dispatcher `runOne`) and
|
|
596
|
+
// stays tracked until the run has entered `inflight` and settled. `runOne` no-ops (returns
|
|
597
|
+
// null) on replay (the run already exists) or while draining (the run is left `pending` for
|
|
598
|
+
// recovery) — the durable persist above is what matters on those paths.
|
|
599
|
+
const result = await this.startCore(workflow, input, runId, opts, false);
|
|
600
|
+
// Guard-free pickup: the continuation/child is in-flight work `drain()` is waiting on, so it
|
|
601
|
+
// runs even while draining (a `runOne` here would no-op on `this.draining`, stranding it
|
|
602
|
+
// `pending` after its persist — persisted-but-not-processed). Mirrors a signal-driven resume.
|
|
603
|
+
if (result.status === 'pending')
|
|
604
|
+
await this.leaseAndResume(runId);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
// Custom dispatcher owns execution (possibly on another pod); guarantee the persist + its
|
|
608
|
+
// normal dispatch/broadcast. The in-process rolled-back-transaction hazard doesn't apply.
|
|
609
|
+
await this.startCore(workflow, input, runId, opts, true);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
if (onStartError)
|
|
614
|
+
onStartError(err);
|
|
615
|
+
}
|
|
616
|
+
})());
|
|
617
|
+
}
|
|
533
618
|
/** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
|
|
534
619
|
getRun(runId) {
|
|
535
620
|
return this.store.getRun(runId);
|
|
@@ -603,20 +688,50 @@ export class WorkflowEngine {
|
|
|
603
688
|
void p.finally(() => this.inflight.delete(p));
|
|
604
689
|
return p;
|
|
605
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Track a post-settle side effect (parent notify, singleton wake) so {@link drain} waits for it,
|
|
693
|
+
* WITHOUT the `execute()`/`settleRun` caller ever blocking on it — the run's result is returned
|
|
694
|
+
* to its caller the moment its status is persisted; this only holds the effect for `drain()`.
|
|
695
|
+
* Errors are swallowed (these are best-effort wakeups; the durable timer is the real fallback),
|
|
696
|
+
* exactly as the original `void ....catch(() => undefined)` fire-and-forget did.
|
|
697
|
+
*/
|
|
698
|
+
trackEffect(p) {
|
|
699
|
+
const tracked = p.catch(() => undefined);
|
|
700
|
+
this.postSettle.add(tracked);
|
|
701
|
+
void tracked.finally(() => this.postSettle.delete(tracked));
|
|
702
|
+
}
|
|
606
703
|
/**
|
|
607
704
|
* Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
|
|
608
705
|
* in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
|
|
609
706
|
* deploy hands off cleanly instead of leaving runs to the lease timeout.
|
|
707
|
+
*
|
|
708
|
+
* Because an internal handoff ({@link handoffRun}) is finishing work already in flight, `drain()`
|
|
709
|
+
* follows a `continue-as-new` chain across its links (and a deferred child through its parent's
|
|
710
|
+
* resume). A HOT continue-as-new loop — one that hands off to a fresh link faster than it settles —
|
|
711
|
+
* therefore keeps `postSettle`/`inflight` non-empty and consumes the ENTIRE `timeoutMs` before this
|
|
712
|
+
* returns. That is by design (tearing down mid-continuation would orphan the continuation's writes);
|
|
713
|
+
* on timeout the frontier link is left persisted and leased, so the next boot's recovery re-drives it
|
|
714
|
+
* (no loss) — the cost is spending the full timeout on shutdown. Size `timeoutMs` accordingly.
|
|
610
715
|
*/
|
|
611
716
|
async drain(timeoutMs = 10_000) {
|
|
612
717
|
this.draining = true;
|
|
613
|
-
if (this.inflight.size === 0)
|
|
718
|
+
if (this.inflight.size === 0 && this.postSettle.size === 0)
|
|
614
719
|
return;
|
|
615
720
|
const timer = new Promise((resolve) => {
|
|
616
|
-
const t = setTimeout(resolve, timeoutMs);
|
|
721
|
+
const t = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
617
722
|
t.unref?.();
|
|
618
723
|
});
|
|
619
|
-
|
|
724
|
+
// Loop, don't snapshot-once: settling an in-flight run fires post-settle effects (parent notify,
|
|
725
|
+
// singleton wake) that can resume — and thus re-inflight — another run, which settles and fires
|
|
726
|
+
// more effects. Wait until BOTH sets reach steady-state empty, or the shared timer wins. The
|
|
727
|
+
// `Promise<'timeout'>` sentinel is distinguishable from `allSettled`'s array so the race is
|
|
728
|
+
// unambiguous even when everything resolves in the same tick.
|
|
729
|
+
while (this.inflight.size > 0 || this.postSettle.size > 0) {
|
|
730
|
+
const pending = Promise.allSettled([...this.inflight, ...this.postSettle]);
|
|
731
|
+
const outcome = await Promise.race([pending, timer]);
|
|
732
|
+
if (outcome === 'timeout')
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
620
735
|
}
|
|
621
736
|
/**
|
|
622
737
|
* Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
|
|
@@ -733,6 +848,16 @@ export class WorkflowEngine {
|
|
|
733
848
|
async runOne(runId) {
|
|
734
849
|
if (this.draining)
|
|
735
850
|
return null;
|
|
851
|
+
return this.leaseAndResume(runId);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Lease and resume a run WITHOUT the {@link runOne} draining guard. The guard's job is to stop
|
|
855
|
+
* picking up NEW work once shutdown began; but an internal handoff ({@link handoffRun}) completing a
|
|
856
|
+
* continue-as-new continuation or a deferred child is finishing work already in flight — the very
|
|
857
|
+
* work {@link drain} is waiting on — so it must run even while draining, exactly as a signal-driven
|
|
858
|
+
* resume ({@link deliverSignal}) already does. Returns null if another instance holds the lease.
|
|
859
|
+
*/
|
|
860
|
+
async leaseAndResume(runId) {
|
|
736
861
|
const nowMs = this.clock();
|
|
737
862
|
const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
738
863
|
if (!acquired)
|
|
@@ -1147,7 +1272,9 @@ export class WorkflowEngine {
|
|
|
1147
1272
|
* knowing about the child feature.
|
|
1148
1273
|
*/
|
|
1149
1274
|
notifyParent(runId, completion) {
|
|
1150
|
-
|
|
1275
|
+
// Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
|
|
1276
|
+
// store writes that must be awaited by `drain()` even though the settling child returned already.
|
|
1277
|
+
this.trackEffect(this.signal(`child:${runId}`, completion));
|
|
1151
1278
|
// A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
|
|
1152
1279
|
// origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
|
|
1153
1280
|
// parent that failed on that child and is retried later consumes this success (buffered or
|
|
@@ -1155,7 +1282,7 @@ export class WorkflowEngine {
|
|
|
1155
1282
|
// fix attempt must not poison the origin's token.
|
|
1156
1283
|
const at = runId.lastIndexOf('~retry~');
|
|
1157
1284
|
if (at !== -1 && completion.ok) {
|
|
1158
|
-
|
|
1285
|
+
this.trackEffect(this.signal(`child:${runId.slice(0, at)}`, completion));
|
|
1159
1286
|
}
|
|
1160
1287
|
}
|
|
1161
1288
|
/**
|
|
@@ -1171,13 +1298,16 @@ export class WorkflowEngine {
|
|
|
1171
1298
|
* correctly observes the failed start.
|
|
1172
1299
|
*/
|
|
1173
1300
|
startChildDeferred(workflow, input, childId, opts) {
|
|
1174
|
-
|
|
1301
|
+
// Tracked handoff (see {@link handoffRun}): the deferral + reentrancy guard are preserved, but the
|
|
1302
|
+
// start is now held in `postSettle` so `drain()` waits for the child to enter `inflight` and settle
|
|
1303
|
+
// rather than slipping through the gap before the deferred start ran.
|
|
1304
|
+
this.handoffRun(workflow, input, childId, opts, (err) => {
|
|
1175
1305
|
const message = err instanceof Error ? err.message : String(err);
|
|
1176
1306
|
this.notifyParent(childId, {
|
|
1177
1307
|
ok: false,
|
|
1178
1308
|
error: `child workflow "${workflow}" failed to start: ${message}`,
|
|
1179
1309
|
});
|
|
1180
|
-
})
|
|
1310
|
+
});
|
|
1181
1311
|
}
|
|
1182
1312
|
/**
|
|
1183
1313
|
* Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
|
|
@@ -1206,11 +1336,17 @@ export class WorkflowEngine {
|
|
|
1206
1336
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
1207
1337
|
.catch(() => undefined);
|
|
1208
1338
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1339
|
+
// Tracked (see {@link trackEffect}): the background resume replays the run and drives its saga
|
|
1340
|
+
// compensations to the terminal `cancelled` write. As a bare `queueMicrotask` that whole sequence
|
|
1341
|
+
// ran untracked — and because `resume()` only enters `inflight` once it is actually called (a
|
|
1342
|
+
// microtask later), `drain()` could observe both registries empty in the gap and return BEFORE the
|
|
1343
|
+
// compensation writes, letting them land on a torn-down connection ("Transaction query already
|
|
1344
|
+
// complete"). Holding the deferred resume in `postSettle` closes that gap; `resume()` then also
|
|
1345
|
+
// tracks its own execution in `inflight`, so the run is briefly in both sets (harmless — the drain
|
|
1346
|
+
// loop re-snapshots each iteration). NOT a `handoffRun`: that STARTS a new run via `startCore`;
|
|
1347
|
+
// here an EXISTING run is resumed, whose execution already self-tracks — only the pre-call gap
|
|
1348
|
+
// needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
|
|
1349
|
+
this.trackEffect(Promise.resolve().then(() => this.resume(runId).then(() => this.notifyCancelled(runId))));
|
|
1214
1350
|
await this.cancelChildren(runId, opts);
|
|
1215
1351
|
return { runId, status: run.status };
|
|
1216
1352
|
}
|
|
@@ -1234,7 +1370,7 @@ export class WorkflowEngine {
|
|
|
1234
1370
|
.catch(() => undefined);
|
|
1235
1371
|
}
|
|
1236
1372
|
// A cancelled singleton run frees its slot — wake the next gated waiter now (notify-on-release).
|
|
1237
|
-
|
|
1373
|
+
this.trackEffect(this.singletons.wakeNext(run));
|
|
1238
1374
|
return { runId, status: 'cancelled', error };
|
|
1239
1375
|
}
|
|
1240
1376
|
/**
|
|
@@ -1506,7 +1642,7 @@ export class WorkflowEngine {
|
|
|
1506
1642
|
result.status === 'failed' ||
|
|
1507
1643
|
result.status === 'cancelled' ||
|
|
1508
1644
|
result.status === 'dead')) {
|
|
1509
|
-
|
|
1645
|
+
this.trackEffect(this.singletons.wakeNext(run));
|
|
1510
1646
|
}
|
|
1511
1647
|
return result;
|
|
1512
1648
|
}
|
|
@@ -1544,7 +1680,7 @@ export class WorkflowEngine {
|
|
|
1544
1680
|
namespace: run.namespace,
|
|
1545
1681
|
output: outcome.output,
|
|
1546
1682
|
});
|
|
1547
|
-
|
|
1683
|
+
this.notifyParent(run.id, { ok: true, value: outcome.output });
|
|
1548
1684
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
1549
1685
|
}
|
|
1550
1686
|
if (outcome.kind === 'failed') {
|
|
@@ -1556,7 +1692,7 @@ export class WorkflowEngine {
|
|
|
1556
1692
|
namespace: run.namespace,
|
|
1557
1693
|
error: outcome.error,
|
|
1558
1694
|
});
|
|
1559
|
-
|
|
1695
|
+
this.notifyParent(run.id, { ok: false, error: outcome.error.message });
|
|
1560
1696
|
return { runId: run.id, status: 'failed', error: outcome.error };
|
|
1561
1697
|
}
|
|
1562
1698
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
|
|
@@ -1948,9 +2084,12 @@ export class WorkflowEngine {
|
|
|
1948
2084
|
workflow: run.workflow,
|
|
1949
2085
|
namespace: run.namespace,
|
|
1950
2086
|
});
|
|
1951
|
-
|
|
2087
|
+
this.notifyParent(run.id, { ok: true, value: undefined });
|
|
1952
2088
|
const nextId = nextContinuationId(run.id);
|
|
1953
|
-
|
|
2089
|
+
// Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
|
|
2090
|
+
// continuation has entered `inflight` and settled, so `drain()` can't return before the next
|
|
2091
|
+
// run's store writes land. Still deferred + idempotent by the continuation id.
|
|
2092
|
+
this.handoffRun(run.workflow, err.input, nextId);
|
|
1954
2093
|
return { runId: run.id, status: 'completed' };
|
|
1955
2094
|
}
|
|
1956
2095
|
if (err instanceof WorkflowBlocked) {
|
|
@@ -2104,8 +2243,13 @@ export class WorkflowEngine {
|
|
|
2104
2243
|
// Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
|
|
2105
2244
|
// idempotent on a terminal/cancelled run (returns its existing status without side effects), so
|
|
2106
2245
|
// no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
|
|
2246
|
+
// Tracked (see {@link trackEffect}): `cancel()` writes the child — and its whole cancel cascade —
|
|
2247
|
+
// to the store, so this fire-and-forget is held in `postSettle`. Fired from a settling parent
|
|
2248
|
+
// (e.g. failFast cancelling surviving siblings), an untracked cancel's writes could otherwise
|
|
2249
|
+
// escape `drain()` onto a torn-down connection ("Transaction query already complete"). The
|
|
2250
|
+
// one-microtask defer is preserved; the promise is registered synchronously.
|
|
2107
2251
|
cancelChild: (childId) => {
|
|
2108
|
-
|
|
2252
|
+
this.trackEffect(Promise.resolve().then(() => this.cancel(childId)));
|
|
2109
2253
|
},
|
|
2110
2254
|
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
2111
2255
|
upsertSearchAttributes: async (runId, attrs) => {
|
|
@@ -2115,8 +2259,13 @@ export class WorkflowEngine {
|
|
|
2115
2259
|
updatedAt: new Date(),
|
|
2116
2260
|
});
|
|
2117
2261
|
},
|
|
2262
|
+
// Tracked (see {@link trackEffect}): `entities.dispatch` persists via `signalWithStart`
|
|
2263
|
+
// (createRun/signal on the entity's run), so this fire-and-forget is held in `postSettle` — else
|
|
2264
|
+
// an entity op signalled from a settling workflow could write after `drain()` returned (torn-down
|
|
2265
|
+
// connection → "Transaction query already complete"). The one-microtask defer is preserved; the
|
|
2266
|
+
// promise is registered synchronously.
|
|
2118
2267
|
signalEntity: (name, key, op, arg, reply) => {
|
|
2119
|
-
|
|
2268
|
+
this.trackEffect(Promise.resolve().then(() => this.entities.dispatch(name, key, op, arg, reply)));
|
|
2120
2269
|
},
|
|
2121
2270
|
interceptStep: (invocation, body) => this.interceptStep(invocation, body),
|
|
2122
2271
|
};
|