@adonis-agora/durable 0.56.3 → 0.56.5
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 +34 -0
- package/dist/src/codec-state-store.d.ts +3 -0
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +7 -0
- package/dist/src/codec-state-store.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 +49 -5
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +148 -14
- 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 +21 -0
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/protocol.d.ts +3 -0
- package/dist/src/protocol.d.ts.map +1 -1
- package/dist/src/protocol.js +21 -3
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +1 -0
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +14 -0
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +1 -0
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +7 -0
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +35 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transport-pool.d.ts.map +1 -1
- package/dist/src/transport-pool.js +4 -13
- package/dist/src/transport-pool.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +4 -2
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +110 -56
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/worker-keys.d.ts +3 -1
- package/dist/src/transports/worker-keys.d.ts.map +1 -1
- package/dist/src/transports/worker-keys.js +23 -11
- package/dist/src/transports/worker-keys.js.map +1 -1
- package/package.json +1 -1
package/dist/src/engine.js
CHANGED
|
@@ -2597,8 +2597,22 @@ export class WorkflowEngine {
|
|
|
2597
2597
|
// the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
|
|
2598
2598
|
// worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
|
|
2599
2599
|
// of `remoteRedispatchMs` / `redispatchPending`.
|
|
2600
|
-
|
|
2600
|
+
// The durable marker of work on the transport: a `pending` remote checkpoint is written before
|
|
2601
|
+
// the dispatch and settled by the result.
|
|
2602
|
+
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
2603
|
+
const dispatched = checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
|
|
2601
2604
|
if (dispatched) {
|
|
2605
|
+
// `running` with a lapsed lease: the turn that wrote these checkpoints died before settling
|
|
2606
|
+
// the run, possibly before its dispatch reached the broker. Send what it may have lost.
|
|
2607
|
+
try {
|
|
2608
|
+
await this.redispatchCrashedTurnSteps(run, checkpoints, true);
|
|
2609
|
+
}
|
|
2610
|
+
catch (error) {
|
|
2611
|
+
// Not sent: leave the run `running` for the next pass rather than park it on a lost step.
|
|
2612
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
2613
|
+
console.error(`[adonis-durable] could not re-dispatch the steps of crashed run ${run.id}; retrying on a later tick:`, error);
|
|
2614
|
+
continue;
|
|
2615
|
+
}
|
|
2602
2616
|
// A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
|
|
2603
2617
|
// lease here means that turn was lost — the call in flight is not what the run waits on next.
|
|
2604
2618
|
// Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
|
|
@@ -2693,13 +2707,83 @@ export class WorkflowEngine {
|
|
|
2693
2707
|
return released;
|
|
2694
2708
|
}
|
|
2695
2709
|
/**
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
2710
|
+
* {@link redispatchCrashedTurnSteps} for a `suspended` run a timer pass just leased, when the run as
|
|
2711
|
+
* listed shows the previous lease holder died holding it: a lease is released (both columns
|
|
2712
|
+
* cleared) by every path that ends normally, so one still set but lapsed is a holder that never
|
|
2713
|
+
* reached its release — a turn SIGKILLed mid-way, most likely. A resumed turn keeps the run
|
|
2714
|
+
* `suspended` while it executes, so this is the only trace such a crash leaves. Called with the
|
|
2715
|
+
* lease held; a failure is reported and does not stop the resume.
|
|
2699
2716
|
*/
|
|
2700
|
-
async
|
|
2701
|
-
|
|
2702
|
-
|
|
2717
|
+
async redispatchIfHolderCrashed(listed, nowMs) {
|
|
2718
|
+
if (listed.status !== 'suspended')
|
|
2719
|
+
return;
|
|
2720
|
+
if (listed.lockedBy === undefined || listed.lockedUntil === undefined)
|
|
2721
|
+
return;
|
|
2722
|
+
if (listed.lockedUntil > nowMs)
|
|
2723
|
+
return;
|
|
2724
|
+
try {
|
|
2725
|
+
await this.redispatchCrashedTurnSteps(listed, await this.store.listCheckpoints(listed.id));
|
|
2726
|
+
}
|
|
2727
|
+
catch (error) {
|
|
2728
|
+
console.error(`[adonis-durable] could not re-dispatch the steps of run ${listed.id} after its lease holder died; its turn proceeds:`, error);
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Re-dispatch the remote steps a CRASHED turn of `run` wrote `pending` and may never have sent.
|
|
2733
|
+
*
|
|
2734
|
+
* A turn persists a step's `pending` checkpoint BEFORE handing the job to the transport (so a fast
|
|
2735
|
+
* result always finds it), then settles the run. A process that dies between the two leaves a
|
|
2736
|
+
* `pending` step no worker ever received — and every later turn replays straight back to "pending,
|
|
2737
|
+
* keep waiting" (re-dispatching on replay is the opt-in `remoteRedispatchMs`, because a step may
|
|
2738
|
+
* legitimately run longer than any window). Observed under a SIGKILLed pod: runs stuck forever.
|
|
2739
|
+
*
|
|
2740
|
+
* The caller holds the run's lease and has evidence the turn died; this narrows it to the steps
|
|
2741
|
+
* THAT turn wrote: the ones enqueued at or after the run's last settle (`updatedAt` — a turn that
|
|
2742
|
+
* ends normally settles after its dispatches, so a step it sent is always older than its settle).
|
|
2743
|
+
* `sinceInclusive` is for a run still `running`: its `updatedAt` is that same turn's start.
|
|
2744
|
+
*
|
|
2745
|
+
* Each step goes out again under its own `stepId` and attempt: a step whose original dispatch did
|
|
2746
|
+
* land runs twice (at-least-once, as documented), and the second result finds the checkpoint
|
|
2747
|
+
* settled and is dropped. Only on a crash — a healthy run never gets here. Remote (polyglot)
|
|
2748
|
+
* workflows are left to their own turn re-drive. Returns how many steps were sent.
|
|
2749
|
+
*/
|
|
2750
|
+
async redispatchCrashedTurnSteps(run, checkpoints, sinceInclusive = false) {
|
|
2751
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
2752
|
+
this.latest.get(run.workflow);
|
|
2753
|
+
// No transport: nothing could have been dispatched, nothing can be sent again.
|
|
2754
|
+
if (registered?.remote || this.pool.size === 0)
|
|
2755
|
+
return 0;
|
|
2756
|
+
const since = run.updatedAt.getTime();
|
|
2757
|
+
const lost = checkpoints.filter((cp) => {
|
|
2758
|
+
if (cp.kind !== 'remote' || cp.status !== 'pending' || !cp.stepId)
|
|
2759
|
+
return false;
|
|
2760
|
+
const at = cp.enqueuedAt.getTime();
|
|
2761
|
+
return sinceInclusive ? at >= since : at > since;
|
|
2762
|
+
});
|
|
2763
|
+
for (const cp of lost) {
|
|
2764
|
+
await this.dispatchRemoteTask({
|
|
2765
|
+
runId: run.id,
|
|
2766
|
+
seq: cp.seq,
|
|
2767
|
+
name: cp.name,
|
|
2768
|
+
stepId: cp.stepId,
|
|
2769
|
+
group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
|
|
2770
|
+
input: cp.input,
|
|
2771
|
+
attempt: cp.attempts,
|
|
2772
|
+
priority: run.priority,
|
|
2773
|
+
});
|
|
2774
|
+
this.emit({
|
|
2775
|
+
type: 'step.started',
|
|
2776
|
+
runId: run.id,
|
|
2777
|
+
seq: cp.seq,
|
|
2778
|
+
name: cp.name,
|
|
2779
|
+
kind: 'remote',
|
|
2780
|
+
redispatched: true,
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
if (lost.length > 0) {
|
|
2784
|
+
console.warn(`[adonis-durable] re-dispatched ${lost.length} step(s) of run ${run.id} written by a turn that died before settling (a step that did reach a worker may run twice)`);
|
|
2785
|
+
}
|
|
2786
|
+
return lost.length;
|
|
2703
2787
|
}
|
|
2704
2788
|
/**
|
|
2705
2789
|
* Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
|
|
@@ -2789,6 +2873,7 @@ export class WorkflowEngine {
|
|
|
2789
2873
|
});
|
|
2790
2874
|
if (!claimed)
|
|
2791
2875
|
continue;
|
|
2876
|
+
await this.redispatchIfHolderCrashed(run, nowMs);
|
|
2792
2877
|
}
|
|
2793
2878
|
finally {
|
|
2794
2879
|
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
@@ -3162,6 +3247,7 @@ export class WorkflowEngine {
|
|
|
3162
3247
|
return;
|
|
3163
3248
|
}
|
|
3164
3249
|
try {
|
|
3250
|
+
await this.redispatchIfHolderCrashed(run, nowMs);
|
|
3165
3251
|
// A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
|
|
3166
3252
|
const settled = onLocked ? await onLocked(run) : undefined;
|
|
3167
3253
|
results.push(settled ?? (await this.resumeImplicit(run.id)));
|
|
@@ -5190,7 +5276,17 @@ export class WorkflowEngine {
|
|
|
5190
5276
|
}
|
|
5191
5277
|
const renewed = now + this.remoteRedispatchMs;
|
|
5192
5278
|
const previous = cp.wakeAt;
|
|
5193
|
-
|
|
5279
|
+
// Conditional, never a blind write of the row read above: another instance may have settled the
|
|
5280
|
+
// step (its result) or re-dispatched it (new `attempts` and deadline) since, and writing `cp`
|
|
5281
|
+
// back would undo either — a `completed` step back to `pending`, the step run a second time.
|
|
5282
|
+
const wrote = this.store.renewCheckpointLease
|
|
5283
|
+
? await this.store.renewCheckpointLease(runId, seq, previous, renewed)
|
|
5284
|
+
: await this.savePendingCheckpoint({ ...cp, wakeAt: renewed });
|
|
5285
|
+
if (!wrote) {
|
|
5286
|
+
// The step moved on under us: nothing of ours to defend, and the next beat re-reads it.
|
|
5287
|
+
this.stepLeaseRearms.delete(id);
|
|
5288
|
+
return;
|
|
5289
|
+
}
|
|
5194
5290
|
setBounded(this.stepLeaseRearms, id, renewed - halfWindow);
|
|
5195
5291
|
// If the run is parked EXACTLY on this step's old lease, carry its wake forward too — otherwise a
|
|
5196
5292
|
// beating worker costs one wasted turn per renewal window. Only on an exact match: any other
|
|
@@ -6597,6 +6693,7 @@ export class WorkflowEngine {
|
|
|
6597
6693
|
traceparent,
|
|
6598
6694
|
context,
|
|
6599
6695
|
priority: opts.admission?.priority,
|
|
6696
|
+
lazyPickupBeat: true,
|
|
6600
6697
|
attempt: 1,
|
|
6601
6698
|
})), opts.transport);
|
|
6602
6699
|
// Awaited below; a rejection is not left unhandled while the next round is written.
|
|
@@ -6719,11 +6816,13 @@ export class WorkflowEngine {
|
|
|
6719
6816
|
}
|
|
6720
6817
|
/** The single-result path: settle `result`, then resume its run unless its fan is still running. */
|
|
6721
6818
|
async completeOneRemoteResult(result, queuedIn) {
|
|
6722
|
-
const
|
|
6723
|
-
if (
|
|
6819
|
+
const settled = await this.settleRemoteCheckpoint(result);
|
|
6820
|
+
if (settled === undefined)
|
|
6724
6821
|
return;
|
|
6822
|
+
const { cp, run } = settled;
|
|
6725
6823
|
if (await this.stepFanStillRunning(cp, result, queuedIn))
|
|
6726
6824
|
return;
|
|
6825
|
+
await this.markResumeOwed(run);
|
|
6727
6826
|
if (!parseStepFanGroup(cp.parallelGroup))
|
|
6728
6827
|
this.noteSettledHere(result.runId, [cp.seq]);
|
|
6729
6828
|
this.resumeInBackground(result.runId, fanWakeOf(cp, result));
|
|
@@ -6746,6 +6845,39 @@ export class WorkflowEngine {
|
|
|
6746
6845
|
this.settledHere.delete(key);
|
|
6747
6846
|
}
|
|
6748
6847
|
}
|
|
6848
|
+
/**
|
|
6849
|
+
* Make the turn a settled step result owes its run DURABLE before the result is acknowledged.
|
|
6850
|
+
*
|
|
6851
|
+
* Settling a result does two things — settle the checkpoint, then resume the run — and only the
|
|
6852
|
+
* first is durable: the resume runs in the background, after the transport has acknowledged the
|
|
6853
|
+
* result. A pod SIGKILLed in between (or mid-way through the resumed turn, which keeps the run
|
|
6854
|
+
* `suspended` and so is invisible to the orphan sweep) left the run parked on whatever `wakeAt` its
|
|
6855
|
+
* last turn wrote — for a step that is the reconcile fallback, minutes out — with nothing else to
|
|
6856
|
+
* wake it: the result was already acknowledged, so no redelivery re-drives it.
|
|
6857
|
+
*
|
|
6858
|
+
* So pull the suspended run's timer in to one lease from now first. The resume this instance is
|
|
6859
|
+
* about to run re-parks the run (or settles it) and so overwrites it; if this instance dies before
|
|
6860
|
+
* that, every instance's due-timer poll resumes the run once the lease a dying turn may hold has
|
|
6861
|
+
* lapsed. Leased like any due run, so a live turn is never doubled — at worst a resume landing after
|
|
6862
|
+
* the timer replays and re-parks. Only pulls a timer IN (one already due sooner is kept), and only
|
|
6863
|
+
* for a `suspended` run: a run still `running` has its first turn holding the lease, which re-checks
|
|
6864
|
+
* the step on settling (see `recheckWaitSeqs`) and is recovered by the orphan sweep if it dies.
|
|
6865
|
+
*
|
|
6866
|
+
* Best-effort: a store that refuses the write leaves the run exactly where it was before.
|
|
6867
|
+
*/
|
|
6868
|
+
async markResumeOwed(run) {
|
|
6869
|
+
if (run?.status !== 'suspended')
|
|
6870
|
+
return;
|
|
6871
|
+
const owedBy = this.clock() + this.leaseMs;
|
|
6872
|
+
if (run.wakeAt !== undefined && run.wakeAt <= owedBy)
|
|
6873
|
+
return;
|
|
6874
|
+
try {
|
|
6875
|
+
await this.store.updateRunIf(run.id, ['suspended'], { wakeAt: owedBy });
|
|
6876
|
+
}
|
|
6877
|
+
catch (err) {
|
|
6878
|
+
console.warn(`[adonis-durable] could not mark run ${run.id} as owed a resume (it recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
|
|
6879
|
+
}
|
|
6880
|
+
}
|
|
6749
6881
|
resumeInBackground(runId, fan) {
|
|
6750
6882
|
this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
|
|
6751
6883
|
console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -6866,6 +6998,7 @@ export class WorkflowEngine {
|
|
|
6866
6998
|
for (const { cp, result } of resumable) {
|
|
6867
6999
|
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6868
7000
|
if (!fan || result.status === 'failed') {
|
|
7001
|
+
await this.markResumeOwed(run);
|
|
6869
7002
|
this.noteSettledHere(runId, singles);
|
|
6870
7003
|
this.resumeInBackground(runId);
|
|
6871
7004
|
return outcomes;
|
|
@@ -6879,6 +7012,7 @@ export class WorkflowEngine {
|
|
|
6879
7012
|
barriers.set(fan.firstSeq, closed);
|
|
6880
7013
|
}
|
|
6881
7014
|
if (!(await closed)) {
|
|
7015
|
+
await this.markResumeOwed(run);
|
|
6882
7016
|
this.noteSettledHere(runId, singles);
|
|
6883
7017
|
this.resumeInBackground(runId, {
|
|
6884
7018
|
first: fan.firstSeq,
|
|
@@ -6990,7 +7124,7 @@ export class WorkflowEngine {
|
|
|
6990
7124
|
}
|
|
6991
7125
|
/**
|
|
6992
7126
|
* The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
|
|
6993
|
-
* the resume when a redelivered result finds it already settled). Returns the step's checkpoint as
|
|
7127
|
+
* the resume when a redelivered result finds it already settled). Returns the step's checkpoint (and its run) as
|
|
6994
7128
|
* read when its run should be resumed, or `undefined` when no resume is wanted (no checkpoint, or a
|
|
6995
7129
|
* late result for a terminal run — terminal is terminal).
|
|
6996
7130
|
*/
|
|
@@ -7023,7 +7157,7 @@ export class WorkflowEngine {
|
|
|
7023
7157
|
// explicit `requeue`/`durable:retry`, never to a stray result.
|
|
7024
7158
|
if (cp.status !== 'pending') {
|
|
7025
7159
|
if (!terminal)
|
|
7026
|
-
return cp;
|
|
7160
|
+
return { cp, run };
|
|
7027
7161
|
return undefined;
|
|
7028
7162
|
}
|
|
7029
7163
|
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
|
|
@@ -7060,7 +7194,7 @@ export class WorkflowEngine {
|
|
|
7060
7194
|
if (typeof claim === 'object')
|
|
7061
7195
|
throw claim.error;
|
|
7062
7196
|
if (!claim)
|
|
7063
|
-
return terminal ? undefined : cp;
|
|
7197
|
+
return terminal ? undefined : { cp, run };
|
|
7064
7198
|
await this.releaseQueueSlot(cp);
|
|
7065
7199
|
this.emit({
|
|
7066
7200
|
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
@@ -7073,7 +7207,7 @@ export class WorkflowEngine {
|
|
|
7073
7207
|
queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
7074
7208
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
7075
7209
|
});
|
|
7076
|
-
return cp;
|
|
7210
|
+
return { cp, run };
|
|
7077
7211
|
}
|
|
7078
7212
|
/**
|
|
7079
7213
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|