@adonis-agora/durable 1.0.0-rc.1 → 1.0.0-rc.3

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/CHANGELOG.md +23 -0
  2. package/dist/assets/spa/assets/index-DSdVNM0u.js +16 -0
  3. package/dist/assets/spa/assets/index-DSdVNM0u.js.map +1 -0
  4. package/dist/assets/spa/index.html +1 -1
  5. package/dist/commands/runs.d.ts.map +1 -1
  6. package/dist/commands/runs.js +4 -2
  7. package/dist/commands/runs.js.map +1 -1
  8. package/dist/src/commands/runs.js +1 -1
  9. package/dist/src/commands/runs.js.map +1 -1
  10. package/dist/src/concurrency.d.ts.map +1 -1
  11. package/dist/src/concurrency.js +1 -0
  12. package/dist/src/concurrency.js.map +1 -1
  13. package/dist/src/control-flow-signal.d.ts +5 -4
  14. package/dist/src/control-flow-signal.d.ts.map +1 -1
  15. package/dist/src/control-flow-signal.js +5 -4
  16. package/dist/src/control-flow-signal.js.map +1 -1
  17. package/dist/src/dashboard/index.d.ts +1 -1
  18. package/dist/src/dashboard/index.js +1 -1
  19. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  20. package/dist/src/dashboard/openapi.js +11 -1
  21. package/dist/src/dashboard/openapi.js.map +1 -1
  22. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  23. package/dist/src/dashboard/run-filter.js +1 -0
  24. package/dist/src/dashboard/run-filter.js.map +1 -1
  25. package/dist/src/engine.d.ts +1 -0
  26. package/dist/src/engine.d.ts.map +1 -1
  27. package/dist/src/engine.js +93 -16
  28. package/dist/src/engine.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -1
  30. package/dist/src/index.js +1 -1
  31. package/dist/src/interfaces.d.ts +17 -3
  32. package/dist/src/interfaces.d.ts.map +1 -1
  33. package/dist/src/interfaces.js +8 -0
  34. package/dist/src/interfaces.js.map +1 -1
  35. package/dist/src/scheduler.d.ts.map +1 -1
  36. package/dist/src/scheduler.js +2 -1
  37. package/dist/src/scheduler.js.map +1 -1
  38. package/dist/src/singleton-admission.d.ts.map +1 -1
  39. package/dist/src/singleton-admission.js +2 -0
  40. package/dist/src/singleton-admission.js.map +1 -1
  41. package/dist/src/singleton-gate.d.ts +1 -1
  42. package/dist/src/singleton-gate.d.ts.map +1 -1
  43. package/dist/src/singleton-gate.js +4 -3
  44. package/dist/src/singleton-gate.js.map +1 -1
  45. package/dist/src/stores/lucid.d.ts.map +1 -1
  46. package/dist/src/stores/lucid.js +5 -3
  47. package/dist/src/stores/lucid.js.map +1 -1
  48. package/dist/src/testing/in-memory-state-store.js +3 -3
  49. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  50. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  51. package/dist/src/testing-kit/state-store-conformance.js +13 -0
  52. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  53. package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
  54. package/dist/src/testing-kit/transport-conformance.js +5 -1
  55. package/dist/src/testing-kit/transport-conformance.js.map +1 -1
  56. package/dist/src/workflow-turn.d.ts.map +1 -1
  57. package/dist/src/workflow-turn.js +18 -1
  58. package/dist/src/workflow-turn.js.map +1 -1
  59. package/package.json +2 -2
  60. package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
  61. package/dist/assets/spa/assets/index-CJjMAUau.js.map +0 -1
@@ -72,6 +72,13 @@ const isBreakpoint = (cp) => cp.status === 'pending' && cp.name.startsWith(BREAK
72
72
  * through some other path (an operator's cancel, a timeout sweep, a dead-letter) while this turn's
73
73
  * outcome was being computed from a possibly-stale snapshot.
74
74
  */
75
+ /** Statuses a wake refused by a held lease may stamp a request for another turn on. */
76
+ const WAKEABLE_RUN_STATUSES = ['running', 'suspended', 'blocked', 'cancelling'];
77
+ /**
78
+ * Statuses a wake that finds the lease free re-drives: parked runs, and a `cancelling` run whose
79
+ * undo is not executing (its turn unwinds the saga).
80
+ */
81
+ const PARKED_RUN_STATUSES = ['suspended', 'blocked', 'cancelling'];
75
82
  const SETTLE_ELIGIBLE_STATUSES = [
76
83
  'pending',
77
84
  'running',
@@ -247,7 +254,13 @@ function isNewerVersion(a, b) {
247
254
  return a.localeCompare(b, undefined, { numeric: true }) > 0;
248
255
  }
249
256
  /** The statuses a run can be skipped in for an unregistered workflow — every non-terminal one. */
250
- const UNREGISTERED_DEAD_LETTER_FROM = ['pending', 'running', 'suspended', 'blocked'];
257
+ const UNREGISTERED_DEAD_LETTER_FROM = [
258
+ 'pending',
259
+ 'running',
260
+ 'suspended',
261
+ 'blocked',
262
+ 'cancelling',
263
+ ];
251
264
  /** Thrown by {@link WorkflowEngine.resume} when the run belongs to a different namespace. */
252
265
  export class NamespaceMismatch extends Error {
253
266
  constructor() {
@@ -3225,7 +3238,7 @@ export class WorkflowEngine {
3225
3238
  return [];
3226
3239
  this.#lastStalledSweepAt = now;
3227
3240
  const candidates = await this.store.listRuns({
3228
- statuses: ['suspended', 'running'],
3241
+ statuses: ['suspended', 'running', 'cancelling'],
3229
3242
  namespace: this.namespace,
3230
3243
  updatedBefore: now - this.stalledAfterMs,
3231
3244
  size: 100,
@@ -4069,6 +4082,27 @@ export class WorkflowEngine {
4069
4082
  // finished run) and re-enqueues it for a second execution. Only a run that still reads
4070
4083
  // `running` under the lease is an orphan; release the lease taken just to look at anything else.
4071
4084
  const run = await this.store.getRun(listed.id);
4085
+ // A compensating cancel whose undo died with its pod (or never started: the pod that took the
4086
+ // cancel crashed before its resume ran). Re-drive it rather than re-pend it, which would drop
4087
+ // the cancel: any turn of a `cancelling` run unwinds the saga and settles it `cancelled`.
4088
+ if (run?.status === 'cancelling') {
4089
+ // Only a pod that has the workflow can run its compensations; past the grace, one that is
4090
+ // registered nowhere is dead-lettered like any orphan.
4091
+ if (!this.isRegisteredHere(run) && !(await this.canResolveWorkflow(run))) {
4092
+ const settled = await this.settleUnregistered(run, nowMs);
4093
+ if (settled)
4094
+ results.push(settled);
4095
+ else if (settled === undefined)
4096
+ this.logUnregisteredSkip(run);
4097
+ continue;
4098
+ }
4099
+ await this.store.releaseRunLock(listed.id, this.instanceId);
4100
+ this.trackEffect(Promise.resolve(this.wake(run.id)).catch((error) => {
4101
+ console.error(`[adonis-durable] could not re-drive cancelling run ${run.id}; retrying on a later tick:`, error);
4102
+ }));
4103
+ results.push({ runId: run.id, status: 'cancelling' });
4104
+ continue;
4105
+ }
4072
4106
  if (run?.status !== 'running') {
4073
4107
  await this.store.releaseRunLock(listed.id, this.instanceId);
4074
4108
  continue;
@@ -4551,6 +4585,10 @@ export class WorkflowEngine {
4551
4585
  const run = await this.store.getRun(runId);
4552
4586
  if (!run)
4553
4587
  return null;
4588
+ // A run undoing its saga for a compensating cancel is not retried over: re-pending it would drop
4589
+ // the cancel. It ends `cancelled`, and can be requeued from there.
4590
+ if (run.status === 'cancelling')
4591
+ return { runId, status: 'cancelling' };
4554
4592
  // A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
4555
4593
  // failed checkpoint rethrows, and an awaited child's failure completion rethrows through
4556
4594
  // unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
@@ -4821,8 +4859,10 @@ export class WorkflowEngine {
4821
4859
  // (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
4822
4860
  // marker, not a sleep/signal the body asked for. Keep waiting until the decision lands. A `blocked`
4823
4861
  // run is parked on the fleet, not on anything the body asked for, and is re-driven on its own.
4824
- const isSettled = (run) => isTerminal(run.status) ||
4825
- (run.status === 'suspended' && run.awaitingDecisionTaskId === undefined);
4862
+ // A `cancelling` run is still undoing its saga: it settles once it is `cancelled`.
4863
+ const isSettled = (run) => run.status !== 'cancelling' &&
4864
+ (isTerminal(run.status) ||
4865
+ (run.status === 'suspended' && run.awaitingDecisionTaskId === undefined));
4826
4866
  const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
4827
4867
  const toResult = (run) => ({
4828
4868
  runId,
@@ -5311,6 +5351,9 @@ export class WorkflowEngine {
5311
5351
  * cancelled. Without it, cancellation is immediate (no undo).
5312
5352
  */
5313
5353
  async cancel(runId, opts) {
5354
+ return this.cancelOnce(runId, opts, true);
5355
+ }
5356
+ async cancelOnce(runId, opts, mayRetry) {
5314
5357
  const run = await this.store.getRun(runId);
5315
5358
  if (!run)
5316
5359
  return null;
@@ -5323,18 +5366,39 @@ export class WorkflowEngine {
5323
5366
  if (TERMINAL_RUN_STATUSES.includes(run.status)) {
5324
5367
  return { runId, status: run.status, output: run.output, error: run.error };
5325
5368
  }
5369
+ // A compensating cancel is already undoing the saga: a repeat cancel, with or without
5370
+ // `compensate`, echoes it. Taking the instant path below would mark it `cancelled` and skip the
5371
+ // undo still in flight. Recovery re-drives a `cancelling` run whose undo died.
5372
+ if (run.status === 'cancelling')
5373
+ return { runId, status: 'cancelling' };
5326
5374
  // Compensating cancel: resume the run with a cancellation pending — replay re-registers the saga,
5327
5375
  // and at the suspension point execute() runs the undo and marks it cancelled. Run that resume in
5328
5376
  // the BACKGROUND so the caller (e.g. an HTTP request) never blocks on replaying the workflow +
5329
5377
  // compensations. `execute` holds the run's lease, so this can't double-run one a live worker owns
5330
5378
  // (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
5331
5379
  if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
5380
+ // The buffered `cancel:<id>` marker tells a turn already executing on ANOTHER pod (its exits
5381
+ // re-read it) and pods of an older version to unwind. Written first and NOT swallowed: a failed
5382
+ // write fails the cancel before anything changed, so the caller can retry.
5383
+ await this.store.bufferSignal(`cancel:${runId}`, true);
5384
+ // DURABLE cancel intent: the persisted `cancelling` status. It survives a crash or a pod
5385
+ // handoff (any turn of a `cancelling` run unwinds it, and crash recovery re-drives one whose
5386
+ // undo died), and shows the dashboard/API that an undo is in progress. Conditional: a run that
5387
+ // moved meanwhile (settled, parked) is re-read and cancelled for what it is now.
5388
+ const marked = await this.store.updateRunIf(runId, ['suspended', 'running'], {
5389
+ status: 'cancelling',
5390
+ updatedAt: new Date(),
5391
+ });
5392
+ if (!marked) {
5393
+ await this.store.takeBufferedSignal(`cancel:${runId}`).catch(() => null);
5394
+ if (mayRetry)
5395
+ return this.cancelOnce(runId, opts, false);
5396
+ const current = await this.store.getRun(runId);
5397
+ return current
5398
+ ? { runId, status: current.status, output: current.output, error: current.error }
5399
+ : null;
5400
+ }
5332
5401
  this.cancelRequested.add(runId);
5333
- // DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
5334
- // buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
5335
- // picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
5336
- // the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
5337
- await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
5338
5402
  if (this.controlPlane) {
5339
5403
  void this.controlPlane
5340
5404
  .publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
@@ -5352,7 +5416,7 @@ export class WorkflowEngine {
5352
5416
  // needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
5353
5417
  this.trackEffect(Promise.resolve().then(() => this.resumeImplicit(runId).then(() => this.notifyCancelled(runId))));
5354
5418
  await this.cancelChildren(runId, opts);
5355
- return { runId, status: run.status };
5419
+ return { runId, status: 'cancelling' };
5356
5420
  }
5357
5421
  const error = { message: 'cancelled' };
5358
5422
  // Clears a remote turn's decision marker too: a cancelled run awaits no decision.
@@ -5689,7 +5753,7 @@ export class WorkflowEngine {
5689
5753
  publishedAt: this.clock(),
5690
5754
  })
5691
5755
  .catch(() => undefined);
5692
- const marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
5756
+ const marked = await this.store.updateRunIf(runId, WAKEABLE_RUN_STATUSES, {
5693
5757
  wakeRequestedAt: this.clock(),
5694
5758
  });
5695
5759
  return marked ? 'stamped' : 'gone';
@@ -5728,7 +5792,7 @@ export class WorkflowEngine {
5728
5792
  if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
5729
5793
  return;
5730
5794
  const fresh = await this.store.getRun(runId);
5731
- if (fresh?.status !== 'suspended' && fresh?.status !== 'blocked') {
5795
+ if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
5732
5796
  await this.store.releaseRunLock(runId, this.instanceId);
5733
5797
  return;
5734
5798
  }
@@ -5828,7 +5892,7 @@ export class WorkflowEngine {
5828
5892
  }
5829
5893
  // Free, and `pending` (its dispatch or the pending-run poll runs it) or `running` with no lease
5830
5894
  // (a holder that died mid-turn: crash recovery takes it): nothing for a wake to do.
5831
- if (run.status !== 'suspended' && run.status !== 'blocked')
5895
+ if (!PARKED_RUN_STATUSES.includes(run.status))
5832
5896
  return;
5833
5897
  if (this.runQueue) {
5834
5898
  this.enqueueRun({
@@ -5841,7 +5905,7 @@ export class WorkflowEngine {
5841
5905
  if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
5842
5906
  return;
5843
5907
  const fresh = await this.store.getRun(runId);
5844
- if (fresh?.status !== 'suspended' && fresh?.status !== 'blocked') {
5908
+ if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
5845
5909
  await this.store.releaseRunLock(runId, this.instanceId);
5846
5910
  return;
5847
5911
  }
@@ -5916,6 +5980,11 @@ export class WorkflowEngine {
5916
5980
  // A remote (e.g. Python) workflow is advanced by dispatching workflow tasks, not by running an
5917
5981
  // in-process body — but everything around it (lease, recovery, timers, the resume that lands us
5918
5982
  // here on a step result) is identical, so it branches here under the same lease.
5983
+ // A `cancelling` run is a compensating cancel in flight, maybe re-driven by crash recovery or
5984
+ // by another pod that never saw the in-memory flag: re-derive the intent from the status, so
5985
+ // this turn unwinds the saga and settles `cancelled` at whichever exit it reaches.
5986
+ if (run.status === 'cancelling')
5987
+ this.cancelRequested.add(run.id);
5919
5988
  const result = registered?.remote
5920
5989
  ? await this.runRemoteExecution(run, registered)
5921
5990
  : await this.runExecution(run, fn, registered);
@@ -6399,13 +6468,17 @@ export class WorkflowEngine {
6399
6468
  // with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
6400
6469
  // `maxRecoveryAttempts`).
6401
6470
  if (err instanceof RemoteWorkflowTimeout) {
6402
- await this.store
6471
+ const reset = await this.store
6403
6472
  .updateRunIf(run.id, ['running', 'suspended'], {
6404
6473
  status: 'running',
6405
6474
  updatedAt: new Date(),
6406
6475
  })
6407
6476
  .catch(() => false);
6408
6477
  await this.releaseTurnLease(run.id);
6478
+ // Not reset: the run moved on meanwhile (a compensating cancel made it `cancelling`, or it
6479
+ // settled). Report what it is now, not a `running` it no longer reads.
6480
+ if (!reset)
6481
+ return this.echoCurrentStatus(run.id);
6409
6482
  return { runId: run.id, status: 'running' };
6410
6483
  }
6411
6484
  const error = {
@@ -7408,7 +7481,11 @@ export class WorkflowEngine {
7408
7481
  }
7409
7482
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
7410
7483
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
7411
- if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
7484
+ // A `cancelling` run skips it: it is tearing down, not competing for a slot, and parking it here
7485
+ // would overwrite `cancelling` with `suspended`.
7486
+ if (run.status !== 'cancelling' &&
7487
+ registered?.singleton &&
7488
+ !(await this.singletons.admit(run, registered.singleton))) {
7412
7489
  return this.singletonWaitResult(run);
7413
7490
  }
7414
7491
  // A body this instance parked on dispatched steps in an earlier turn continues from there (see