@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.
- package/CHANGELOG.md +6 -0
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +3 -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/define_config.d.ts +5 -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 +49 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +131 -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 +2 -1
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/package.json +1 -1
package/dist/src/engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
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);
|