@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/providers/durable_provider.d.ts.map +1 -1
  3. package/dist/providers/durable_provider.js +17 -0
  4. package/dist/providers/durable_provider.js.map +1 -1
  5. package/dist/src/commands/worker.d.ts.map +1 -1
  6. package/dist/src/commands/worker.js +4 -0
  7. package/dist/src/commands/worker.js.map +1 -1
  8. package/dist/src/dashboard/index.d.ts +1 -1
  9. package/dist/src/dashboard/index.js +1 -1
  10. package/dist/src/define_config.d.ts +12 -0
  11. package/dist/src/define_config.d.ts.map +1 -1
  12. package/dist/src/define_config.js.map +1 -1
  13. package/dist/src/engine.d.ts +79 -0
  14. package/dist/src/engine.d.ts.map +1 -1
  15. package/dist/src/engine.js +187 -28
  16. package/dist/src/engine.js.map +1 -1
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.js +1 -1
  19. package/dist/src/interfaces.d.ts +12 -0
  20. package/dist/src/interfaces.d.ts.map +1 -1
  21. package/dist/src/services/booted_app.d.ts +22 -0
  22. package/dist/src/services/booted_app.d.ts.map +1 -0
  23. package/dist/src/services/booted_app.js +67 -0
  24. package/dist/src/services/booted_app.js.map +1 -0
  25. package/dist/src/services/main.d.ts.map +1 -1
  26. package/dist/src/services/main.js +6 -1
  27. package/dist/src/services/main.js.map +1 -1
  28. package/dist/src/transport-pool.d.ts +3 -0
  29. package/dist/src/transport-pool.d.ts.map +1 -1
  30. package/dist/src/transport-pool.js +7 -0
  31. package/dist/src/transport-pool.js.map +1 -1
  32. package/dist/src/transports/queue.d.ts +22 -0
  33. package/dist/src/transports/queue.d.ts.map +1 -1
  34. package/dist/src/transports/queue.js +54 -0
  35. package/dist/src/transports/queue.js.map +1 -1
  36. package/package.json +1 -1
@@ -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
- async start(workflow, input, runId, opts) {
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
- await this.runDispatcher.dispatch(runId);
524
- // Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
525
- // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
526
- if (this.controlPlane) {
527
- void this.controlPlane
528
- .publishControl({ kind: 'enqueued', runId, from: this.instanceId })
529
- .catch(() => undefined);
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
- await Promise.race([Promise.allSettled([...this.inflight]), timer]);
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
- void this.signal(`child:${runId}`, completion).catch(() => undefined);
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
- void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => undefined);
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
- queueMicrotask(() => void this.start(workflow, input, childId, opts).catch((err) => {
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
- queueMicrotask(() => {
1210
- void this.resume(runId)
1211
- .then(() => this.notifyCancelled(runId))
1212
- .catch(() => undefined);
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
- void this.singletons.wakeNext(run).catch(() => undefined);
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
- void this.singletons.wakeNext(run).catch(() => undefined);
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
- void this.notifyParent(run.id, { ok: true, value: outcome.output });
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
- void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
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
- void this.notifyParent(run.id, { ok: true, value: undefined });
2097
+ this.notifyParent(run.id, { ok: true, value: undefined });
1952
2098
  const nextId = nextContinuationId(run.id);
1953
- queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
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
- queueMicrotask(() => void this.cancel(childId).catch(() => undefined));
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
- queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => undefined));
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
  };