@adonis-agora/durable 0.56.7 → 0.57.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.
@@ -230,6 +230,8 @@ function isNewerVersion(a, b) {
230
230
  return na > nb;
231
231
  return a.localeCompare(b, undefined, { numeric: true }) > 0;
232
232
  }
233
+ /** The statuses a run can be skipped in for an unregistered workflow — every non-terminal one. */
234
+ const UNREGISTERED_DEAD_LETTER_FROM = ['pending', 'running', 'suspended', 'blocked'];
233
235
  /** Thrown by {@link WorkflowEngine.resume} when the run belongs to a different namespace. */
234
236
  export class NamespaceMismatch extends Error {
235
237
  constructor() {
@@ -237,6 +239,16 @@ export class NamespaceMismatch extends Error {
237
239
  this.name = 'NamespaceMismatch';
238
240
  }
239
241
  }
242
+ /**
243
+ * Thrown by {@link WorkflowEngine.resume} when no workflow registered here (nor a live worker group)
244
+ * can run the run's pinned `workflow@version`.
245
+ */
246
+ export class WorkflowNotRegistered extends Error {
247
+ constructor(workflow, version) {
248
+ super(`workflow ${workflow}@${version} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
249
+ this.name = 'WorkflowNotRegistered';
250
+ }
251
+ }
240
252
  /**
241
253
  * The orchestrator. Owns workflow state and replays runs deterministically: each step's
242
254
  * result is checkpointed, so on resume a completed step returns its saved output instead of
@@ -273,6 +285,7 @@ export class WorkflowEngine {
273
285
  */
274
286
  bootedAt;
275
287
  maxRecoveryAttempts;
288
+ unregisteredWorkflowGraceMs;
276
289
  reconcileMs;
277
290
  heartbeatPersistThrottleMs;
278
291
  timerBatchSize;
@@ -514,6 +527,9 @@ export class WorkflowEngine {
514
527
  this.leaseMs = deps.leaseMs ?? 30_000;
515
528
  this.bootedAt = this.clock();
516
529
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
530
+ const grace = deps.unregisteredWorkflowGraceMs;
531
+ this.unregisteredWorkflowGraceMs =
532
+ grace !== undefined && Number.isFinite(grace) && grace >= 0 ? grace : 24 * 60 * 60 * 1000;
517
533
  // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event). A
518
534
  // non-finite or negative value falls back to the default rather than silently disabling the net
519
535
  // (NaN) or stamping a wake in the past that re-drives the run every tick (negative).
@@ -1336,7 +1352,8 @@ export class WorkflowEngine {
1336
1352
  }
1337
1353
  /**
1338
1354
  * Be notified when a run is **dead-lettered** — moved to `dead` after exceeding
1339
- * `maxRecoveryAttempts`. The listener receives the dead run (status `dead`, with its error), so a
1355
+ * `maxRecoveryAttempts`, or because its workflow version is registered nowhere (see
1356
+ * `unregisteredWorkflowGraceMs`). The listener receives the dead run (status `dead`, with its error), so a
1340
1357
  * DLQ handler can do something other than just leaving it parked: alert, push to a real queue, or
1341
1358
  * start a dead-letter workflow (e.g. `engine.onDead((run) => engine.start('pipeline-dlq', run, ...))`).
1342
1359
  * Returns an unsubscribe function.
@@ -2267,9 +2284,8 @@ export class WorkflowEngine {
2267
2284
  this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
2268
2285
  this.latest.get(run.workflow);
2269
2286
  }
2270
- if (!registered) {
2271
- throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
2272
- }
2287
+ if (!registered)
2288
+ throw new WorkflowNotRegistered(run.workflow, run.workflowVersion);
2273
2289
  return this.track(runId, this.execute(run, registered.fn));
2274
2290
  }
2275
2291
  /**
@@ -2657,6 +2673,16 @@ export class WorkflowEngine {
2657
2673
  results.push({ runId: run.id, status: 'suspended' });
2658
2674
  continue;
2659
2675
  }
2676
+ // An orphan of a workflow registered nowhere must not be counted or re-pended: both write
2677
+ // `updatedAt`, so it would never age past the grace (see settleUnregistered).
2678
+ if (!(await this.canResolveWorkflow(run))) {
2679
+ const settled = await this.settleUnregistered(run, nowMs);
2680
+ if (settled)
2681
+ results.push(settled);
2682
+ else if (settled === undefined)
2683
+ this.logUnregisteredSkip(run);
2684
+ continue;
2685
+ }
2660
2686
  // Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
2661
2687
  const settled = await this.countRecovery(run);
2662
2688
  // `null`: the run left `running` between the re-read above and the dead-letter write (a
@@ -2821,38 +2847,93 @@ export class WorkflowEngine {
2821
2847
  async countRecovery(run) {
2822
2848
  const attempts = (run.recoveryAttempts ?? 0) + 1;
2823
2849
  if (this.maxRecoveryAttempts != null && attempts > this.maxRecoveryAttempts) {
2824
- const error = {
2825
- message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
2826
- code: 'max_recovery_attempts',
2827
- };
2828
2850
  // Conditional on still-`running`, like the `pending` flip in recoverIncomplete: the lease does
2829
2851
  // not fence `cancel()`, which writes `cancelled` without it, so a cancel landing after the
2830
2852
  // caller's re-read must not be overwritten with `dead`.
2831
- const applied = await this.store.updateRunIf(run.id, ['running'], {
2832
- status: 'dead',
2833
- error,
2834
- updatedAt: new Date(),
2835
- });
2836
- await this.store.releaseRunLock(run.id, this.instanceId);
2837
- if (!applied)
2838
- return null;
2839
- this.emit({
2840
- type: 'run.failed',
2841
- runId: run.id,
2842
- workflow: run.workflow,
2843
- namespace: run.namespace,
2844
- error,
2853
+ return this.deadLetterLeased({ ...run, recoveryAttempts: attempts }, ['running'], {
2854
+ message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
2855
+ code: 'max_recovery_attempts',
2845
2856
  });
2846
- this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
2847
- // `dead` is terminal like `failed`: a parent awaiting this run as a child (ctx.child, a ctx.all /
2848
- // allSettled fan) must learn it now, as a failed child, not on its next reconcile wake.
2849
- this.notifyParent(run.id, failedCompletion(error));
2850
- return { runId: run.id, status: 'dead', error };
2851
2857
  }
2852
2858
  // Count BEFORE resuming, so a crash mid-resume still advances the counter.
2853
2859
  await this.store.updateRun(run.id, { recoveryAttempts: attempts, updatedAt: new Date() });
2854
2860
  return undefined;
2855
2861
  }
2862
+ /**
2863
+ * Move a run whose lease this instance holds to `dead`, if it is still in one of `from`, and
2864
+ * release the lease. Returns the terminal result, or `null` when the run left `from` meanwhile
2865
+ * (e.g. a lease-free `cancel()`), so it is not ours to touch.
2866
+ */
2867
+ async deadLetterLeased(run, from, error) {
2868
+ const applied = await this.store.updateRunIf(run.id, from, {
2869
+ status: 'dead',
2870
+ error,
2871
+ updatedAt: new Date(),
2872
+ });
2873
+ await this.store.releaseRunLock(run.id, this.instanceId);
2874
+ if (!applied)
2875
+ return null;
2876
+ this.emit({
2877
+ type: 'run.failed',
2878
+ runId: run.id,
2879
+ workflow: run.workflow,
2880
+ namespace: run.namespace,
2881
+ error,
2882
+ });
2883
+ this.notifyDead({ ...run, status: 'dead', error });
2884
+ // `dead` is terminal like `failed`: a parent awaiting this run as a child (ctx.child, a ctx.all /
2885
+ // allSettled fan) must learn it now, as a failed child, not on its next reconcile wake.
2886
+ this.notifyParent(run.id, failedCompletion(error));
2887
+ return { runId: run.id, status: 'dead', error };
2888
+ }
2889
+ /**
2890
+ * Whether this instance can run `run`'s pinned `workflow@version`: registered here, or routed by
2891
+ * convention to a live worker group of the same name (the lookup {@link resumeRunNow} does).
2892
+ */
2893
+ async canResolveWorkflow(run) {
2894
+ if (this.workflows.has(versionKey(run.workflow, run.workflowVersion)))
2895
+ return true;
2896
+ return this.ensureConventionWorkflow(run.workflow, run.namespace);
2897
+ }
2898
+ /**
2899
+ * A run whose lease this instance holds but whose `workflow@version` nothing here can run (see
2900
+ * {@link WorkflowNotRegistered}). Within {@link unregisteredWorkflowGraceMs} of becoming due it is a
2901
+ * rolling deploy's skew: the lease is released at once so the pod that still has the version takes
2902
+ * the run now, not after the lease expires. Past it, the workflow is gone from the whole fleet (a
2903
+ * capable pod would have taken the run long ago): dead-letter it.
2904
+ *
2905
+ * The age is read off `updatedAt` / `wakeAt`, so every path that would touch either (a recovery
2906
+ * count, a re-pend, a hand-off's lease-long `wakeAt`) must call this BEFORE writing — otherwise an
2907
+ * orphan never ages.
2908
+ *
2909
+ * Returns the `dead` result, `null` when the run left its status meanwhile (a lease-free
2910
+ * `cancel()`: settled, not ours to touch), or `undefined` when it was left for later.
2911
+ */
2912
+ async settleUnregistered(listed, nowMs) {
2913
+ // Re-read under the lease: the listing is a snapshot (see recoverIncomplete).
2914
+ const run = await this.store.getRun(listed.id);
2915
+ if (!run || !UNREGISTERED_DEAD_LETTER_FROM.includes(run.status)) {
2916
+ await this.store.releaseRunLock(listed.id, this.instanceId);
2917
+ return null;
2918
+ }
2919
+ const dueSince = Math.max(run.updatedAt.getTime(), run.wakeAt ?? 0);
2920
+ if (this.unregisteredWorkflowGraceMs === 0 ||
2921
+ nowMs - dueSince < this.unregisteredWorkflowGraceMs) {
2922
+ await this.store.releaseRunLock(run.id, this.instanceId);
2923
+ return undefined;
2924
+ }
2925
+ const dead = await this.deadLetterLeased(run, [run.status], {
2926
+ message: `workflow ${run.workflow}@${run.workflowVersion} is not registered on any live instance (unresumable for over ${this.unregisteredWorkflowGraceMs}ms) — moved to dead-letter`,
2927
+ code: 'workflow_not_registered',
2928
+ });
2929
+ if (dead)
2930
+ console.warn(`[adonis-durable] run ${run.id}: ${dead.error?.message}`);
2931
+ return dead;
2932
+ }
2933
+ /** Log a run skipped for an unregistered workflow — still there, still not progressing. */
2934
+ logUnregisteredSkip(run) {
2935
+ console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, new WorkflowNotRegistered(run.workflow, run.workflowVersion));
2936
+ }
2856
2937
  /**
2857
2938
  * Resume every suspended run whose durable timer is due — AND every run parked `blocked` (no
2858
2939
  * capable/compatible worker, design §7.5) whose recovery poll is due. Call periodically (a poller)
@@ -2891,6 +2972,15 @@ export class WorkflowEngine {
2891
2972
  if (!(await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs))) {
2892
2973
  continue;
2893
2974
  }
2975
+ // Checked before the lease-long `wakeAt` below, which would keep the orphan from aging.
2976
+ if (!(await this.canResolveWorkflow(run))) {
2977
+ const settled = await this.settleUnregistered(run, nowMs);
2978
+ if (settled)
2979
+ results.push(settled);
2980
+ else if (settled === undefined)
2981
+ this.logUnregisteredSkip(run);
2982
+ continue;
2983
+ }
2894
2984
  try {
2895
2985
  // The lease-long `wakeAt` keeps the run due if the wake is lost on the way, so a refused
2896
2986
  // wake's request it answers is not needed any more (and would keep it due every poll).
@@ -2949,9 +3039,15 @@ export class WorkflowEngine {
2949
3039
  }
2950
3040
  // resume() checks the namespace and throws NamespaceMismatch when it doesn't match (releasing the
2951
3041
  // lease) — a foreign run leased here is simply skipped rather than run.
2952
- return this.resumeImplicit(runId).catch((err) => {
3042
+ return this.resumeImplicit(runId).catch(async (err) => {
2953
3043
  if (err instanceof NamespaceMismatch)
2954
3044
  return null;
3045
+ if (err instanceof WorkflowNotRegistered) {
3046
+ const run = await this.store.getRun(runId);
3047
+ const settled = run ? await this.settleUnregistered(run, nowMs) : null;
3048
+ if (settled !== undefined)
3049
+ return settled;
3050
+ }
2955
3051
  throw err;
2956
3052
  });
2957
3053
  }
@@ -3283,6 +3379,13 @@ export class WorkflowEngine {
3283
3379
  // A foreign-namespace run is simply not ours (resume released its lease) — skip quietly.
3284
3380
  if (error instanceof NamespaceMismatch)
3285
3381
  continue;
3382
+ if (error instanceof WorkflowNotRegistered) {
3383
+ const settled = await this.settleUnregistered(run, nowMs).catch(() => undefined);
3384
+ if (settled)
3385
+ results.push(settled);
3386
+ if (settled !== undefined)
3387
+ continue;
3388
+ }
3286
3389
  // Loud: a skip is not nothing — the run is still there and not progressing, and the reason
3287
3390
  // (a version skew, a store outage) is what tells an operator which.
3288
3391
  console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);