@adonis-agora/durable 0.56.4 → 0.56.6
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 +57 -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/dispatch-key.d.ts +27 -0
- package/dist/src/dispatch-key.d.ts.map +1 -0
- package/dist/src/dispatch-key.js +27 -0
- package/dist/src/dispatch-key.js.map +1 -0
- package/dist/src/engine.d.ts +97 -6
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +322 -41
- 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 +12 -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 +10 -0
- package/dist/src/testing/in-memory-state-store.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 +140 -58
- 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
|
@@ -7,6 +7,7 @@ import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.
|
|
|
7
7
|
import { CHILD_DELIVERED_TAG, markChildDelivered } from './child-delivery.js';
|
|
8
8
|
import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
|
|
9
9
|
import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
|
|
10
|
+
import { DISPATCH_KEY, dispatchKey } from './dispatch-key.js';
|
|
10
11
|
import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
|
|
11
12
|
import { parseDuration } from './duration.js';
|
|
12
13
|
import { Entities } from './entities.js';
|
|
@@ -140,6 +141,19 @@ const BUFFERED_SWEEP_BUDGET_MS = 250;
|
|
|
140
141
|
const BUFFERED_SWEEP_REST_MS = 10 * 60_000;
|
|
141
142
|
/** The most results of one run settled by one batched write (see `completeRemoteResult`). */
|
|
142
143
|
const RESULT_BATCH_MAX = 256;
|
|
144
|
+
/** The most runs one owed-resume write marks (see `WorkflowEngine.markResumeOwed`). */
|
|
145
|
+
const OWED_RESUME_BATCH_MAX = 500;
|
|
146
|
+
/**
|
|
147
|
+
* How long a settled step result's acknowledgement waits for the run's resume before it marks the
|
|
148
|
+
* run as owed one instead (see `WorkflowEngine.resumeOwed`). A turn that ends within it needs no mark.
|
|
149
|
+
*/
|
|
150
|
+
const OWED_RESUME_GRACE_MS = 3_000;
|
|
151
|
+
/**
|
|
152
|
+
* The results `execute` returns when another instance holds the run's lease — no turn ran here, so the
|
|
153
|
+
* wake that asked for it is not served yet — each with the durable request that wake made instead
|
|
154
|
+
* (see `WorkflowEngine.resumeOwed`). By identity, so the result keeps the shape every caller sees.
|
|
155
|
+
*/
|
|
156
|
+
const leaseRefused = new WeakMap();
|
|
143
157
|
/**
|
|
144
158
|
* How many runs this instance executes in-process at once when it runs them itself (the default
|
|
145
159
|
* dispatcher without a run queue, deferred child starts): past it, run ids wait in memory in arrival
|
|
@@ -320,6 +334,10 @@ export class WorkflowEngine {
|
|
|
320
334
|
pending = new Map();
|
|
321
335
|
/** Each run's step results waiting to be settled on this instance — see {@link completeRemoteResult}. */
|
|
322
336
|
resultQueues = new Map();
|
|
337
|
+
/** Runs waiting to be marked as owed a resume by the next batched write — see {@link markResumeOwed}. */
|
|
338
|
+
owedResumes = [];
|
|
339
|
+
/** Whether an owed-resume write is in flight (the runs marked meanwhile go out in the next). */
|
|
340
|
+
markingOwedResumes = false;
|
|
323
341
|
/** Per-step "reset the liveness timer" callbacks, called with the beat's worker timestamp when a
|
|
324
342
|
* heartbeat arrives. */
|
|
325
343
|
heartbeatResets = new Map();
|
|
@@ -2196,8 +2214,13 @@ export class WorkflowEngine {
|
|
|
2196
2214
|
}
|
|
2197
2215
|
// A resume issued from INSIDE this run's own execution (its ambient ctx is this run) can only be
|
|
2198
2216
|
// satisfied once that execution ends — awaiting it there would deadlock. Queue it, answer now.
|
|
2199
|
-
if (workflowAls.getStore()?.runId === runId)
|
|
2200
|
-
|
|
2217
|
+
if (workflowAls.getStore()?.runId === runId) {
|
|
2218
|
+
// Queued, not run: the wake is not served yet (see `resumeOwed`).
|
|
2219
|
+
return this.echoCurrentStatus(runId).then((echoed) => {
|
|
2220
|
+
leaseRefused.set(echoed, Promise.resolve('failed'));
|
|
2221
|
+
return echoed;
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2201
2224
|
return queued.result;
|
|
2202
2225
|
}
|
|
2203
2226
|
/** Drop `gate` once `tail` settles, unless another execution queued behind it meanwhile. */
|
|
@@ -2597,8 +2620,22 @@ export class WorkflowEngine {
|
|
|
2597
2620
|
// the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
|
|
2598
2621
|
// worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
|
|
2599
2622
|
// of `remoteRedispatchMs` / `redispatchPending`.
|
|
2600
|
-
|
|
2623
|
+
// The durable marker of work on the transport: a `pending` remote checkpoint is written before
|
|
2624
|
+
// the dispatch and settled by the result.
|
|
2625
|
+
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
2626
|
+
const dispatched = checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
|
|
2601
2627
|
if (dispatched) {
|
|
2628
|
+
// `running` with a lapsed lease: the turn that wrote these checkpoints died before settling
|
|
2629
|
+
// the run, possibly before its dispatch reached the broker. Send what it may have lost.
|
|
2630
|
+
try {
|
|
2631
|
+
await this.redispatchCrashedTurnSteps(run, checkpoints, true);
|
|
2632
|
+
}
|
|
2633
|
+
catch (error) {
|
|
2634
|
+
// Not sent: leave the run `running` for the next pass rather than park it on a lost step.
|
|
2635
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
2636
|
+
console.error(`[adonis-durable] could not re-dispatch the steps of crashed run ${run.id}; retrying on a later tick:`, error);
|
|
2637
|
+
continue;
|
|
2638
|
+
}
|
|
2602
2639
|
// A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
|
|
2603
2640
|
// lease here means that turn was lost — the call in flight is not what the run waits on next.
|
|
2604
2641
|
// Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
|
|
@@ -2693,13 +2730,87 @@ export class WorkflowEngine {
|
|
|
2693
2730
|
return released;
|
|
2694
2731
|
}
|
|
2695
2732
|
/**
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
2733
|
+
* {@link redispatchCrashedTurnSteps} for a `suspended` run a timer pass just leased, when the run as
|
|
2734
|
+
* listed shows the previous lease holder died holding it: a lease is released (both columns
|
|
2735
|
+
* cleared) by every path that ends normally, so one still set but lapsed is a holder that never
|
|
2736
|
+
* reached its release — a turn SIGKILLed mid-way, most likely. A resumed turn keeps the run
|
|
2737
|
+
* `suspended` while it executes, so this is the only trace such a crash leaves. Called with the
|
|
2738
|
+
* lease held; a failure is reported and does not stop the resume.
|
|
2699
2739
|
*/
|
|
2700
|
-
async
|
|
2701
|
-
|
|
2702
|
-
|
|
2740
|
+
async redispatchIfHolderCrashed(listed, nowMs) {
|
|
2741
|
+
if (listed.status !== 'suspended')
|
|
2742
|
+
return;
|
|
2743
|
+
if (listed.lockedBy === undefined || listed.lockedUntil === undefined)
|
|
2744
|
+
return;
|
|
2745
|
+
if (listed.lockedUntil > nowMs)
|
|
2746
|
+
return;
|
|
2747
|
+
try {
|
|
2748
|
+
await this.redispatchCrashedTurnSteps(listed, await this.store.listCheckpoints(listed.id));
|
|
2749
|
+
}
|
|
2750
|
+
catch (error) {
|
|
2751
|
+
console.error(`[adonis-durable] could not re-dispatch the steps of run ${listed.id} after its lease holder died; its turn proceeds:`, error);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
/**
|
|
2755
|
+
* Re-dispatch the remote steps a CRASHED turn of `run` wrote `pending` and may never have sent.
|
|
2756
|
+
*
|
|
2757
|
+
* A turn persists a step's `pending` checkpoint BEFORE handing the job to the transport (so a fast
|
|
2758
|
+
* result always finds it), then settles the run. A process that dies between the two leaves a
|
|
2759
|
+
* `pending` step no worker ever received — and every later turn replays straight back to "pending,
|
|
2760
|
+
* keep waiting" (re-dispatching on replay is the opt-in `remoteRedispatchMs`, because a step may
|
|
2761
|
+
* legitimately run longer than any window). Observed under a SIGKILLed pod: runs stuck forever.
|
|
2762
|
+
*
|
|
2763
|
+
* The caller holds the run's lease and has evidence the turn died; this narrows it to the steps
|
|
2764
|
+
* THAT turn wrote: the ones enqueued at or after the run's last settle (`updatedAt` — a turn that
|
|
2765
|
+
* ends normally settles after its dispatches, so a step it sent is always older than its settle).
|
|
2766
|
+
* `sinceInclusive` is for a run still `running`: its `updatedAt` is that same turn's start.
|
|
2767
|
+
*
|
|
2768
|
+
* Each step goes out again under its own `stepId` and attempt, keyed like its original dispatch
|
|
2769
|
+
* (see `dispatch-key.ts`): a broker that deduplicates by key (BullMQ) adds nothing while the original
|
|
2770
|
+
* job still waits or runs, or has just finished. Elsewhere a step whose original dispatch did land
|
|
2771
|
+
* runs twice (at-least-once, as documented), and the second result finds the checkpoint settled and
|
|
2772
|
+
* is dropped. Only on a crash — a healthy run never gets here. Remote (polyglot) workflows are left to
|
|
2773
|
+
* their own turn re-drive. Returns how many steps were sent.
|
|
2774
|
+
*/
|
|
2775
|
+
async redispatchCrashedTurnSteps(run, checkpoints, sinceInclusive = false) {
|
|
2776
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
2777
|
+
this.latest.get(run.workflow);
|
|
2778
|
+
// No transport: nothing could have been dispatched, nothing can be sent again.
|
|
2779
|
+
if (registered?.remote || this.pool.size === 0)
|
|
2780
|
+
return 0;
|
|
2781
|
+
const since = run.updatedAt.getTime();
|
|
2782
|
+
const lost = checkpoints.filter((cp) => {
|
|
2783
|
+
if (cp.kind !== 'remote' || cp.status !== 'pending' || !cp.stepId)
|
|
2784
|
+
return false;
|
|
2785
|
+
const at = cp.enqueuedAt.getTime();
|
|
2786
|
+
return sinceInclusive ? at >= since : at > since;
|
|
2787
|
+
});
|
|
2788
|
+
for (const cp of lost) {
|
|
2789
|
+
await this.dispatchRemoteTask({
|
|
2790
|
+
runId: run.id,
|
|
2791
|
+
seq: cp.seq,
|
|
2792
|
+
name: cp.name,
|
|
2793
|
+
stepId: cp.stepId,
|
|
2794
|
+
group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
|
|
2795
|
+
input: cp.input,
|
|
2796
|
+
attempt: cp.attempts,
|
|
2797
|
+
priority: run.priority,
|
|
2798
|
+
// The original dispatch's key: a broker that still holds that job adds nothing.
|
|
2799
|
+
enqueuedAt: cp.enqueuedAt,
|
|
2800
|
+
});
|
|
2801
|
+
this.emit({
|
|
2802
|
+
type: 'step.started',
|
|
2803
|
+
runId: run.id,
|
|
2804
|
+
seq: cp.seq,
|
|
2805
|
+
name: cp.name,
|
|
2806
|
+
kind: 'remote',
|
|
2807
|
+
redispatched: true,
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
if (lost.length > 0) {
|
|
2811
|
+
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)`);
|
|
2812
|
+
}
|
|
2813
|
+
return lost.length;
|
|
2703
2814
|
}
|
|
2704
2815
|
/**
|
|
2705
2816
|
* Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
|
|
@@ -2789,6 +2900,7 @@ export class WorkflowEngine {
|
|
|
2789
2900
|
});
|
|
2790
2901
|
if (!claimed)
|
|
2791
2902
|
continue;
|
|
2903
|
+
await this.redispatchIfHolderCrashed(run, nowMs);
|
|
2792
2904
|
}
|
|
2793
2905
|
finally {
|
|
2794
2906
|
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
@@ -3162,6 +3274,7 @@ export class WorkflowEngine {
|
|
|
3162
3274
|
return;
|
|
3163
3275
|
}
|
|
3164
3276
|
try {
|
|
3277
|
+
await this.redispatchIfHolderCrashed(run, nowMs);
|
|
3165
3278
|
// A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
|
|
3166
3279
|
const settled = onLocked ? await onLocked(run) : undefined;
|
|
3167
3280
|
results.push(settled ?? (await this.resumeImplicit(run.id)));
|
|
@@ -3896,21 +4009,12 @@ export class WorkflowEngine {
|
|
|
3896
4009
|
});
|
|
3897
4010
|
}
|
|
3898
4011
|
/**
|
|
3899
|
-
*
|
|
3900
|
-
*
|
|
3901
|
-
*
|
|
3902
|
-
*
|
|
3903
|
-
* the request survives both this instance and the holder going down.
|
|
3904
|
-
*
|
|
3905
|
-
* The holder may have let go between the refused lease and the stamp: nobody but the timer poll
|
|
3906
|
-
* would see it then, so the lease is tried once more here. The in-memory re-check
|
|
3907
|
-
* ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
|
|
3908
|
-
* stops as soon as a turn served the request.
|
|
4012
|
+
* The durable half of {@link requestWake}: keep the wake's reason, then stamp
|
|
4013
|
+
* {@link WorkflowRun.wakeRequestedAt} on the run. `stamped`; `gone` when the run is no longer live
|
|
4014
|
+
* (nothing to wake); `failed` when the stamp could not be kept (a schema without the column, a store
|
|
4015
|
+
* error). Never rejects.
|
|
3909
4016
|
*/
|
|
3910
|
-
async
|
|
3911
|
-
if (this.draining)
|
|
3912
|
-
return;
|
|
3913
|
-
let marked;
|
|
4017
|
+
async stampWakeRequest(runId, reason) {
|
|
3914
4018
|
try {
|
|
3915
4019
|
// The reason first, the stamp second: a holder that finds the stamp finds the reason too.
|
|
3916
4020
|
await this.store
|
|
@@ -3921,16 +4025,37 @@ export class WorkflowEngine {
|
|
|
3921
4025
|
publishedAt: this.clock(),
|
|
3922
4026
|
})
|
|
3923
4027
|
.catch(() => undefined);
|
|
3924
|
-
marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
|
|
4028
|
+
const marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
|
|
3925
4029
|
wakeRequestedAt: this.clock(),
|
|
3926
4030
|
});
|
|
4031
|
+
return marked ? 'stamped' : 'gone';
|
|
3927
4032
|
}
|
|
3928
4033
|
catch {
|
|
4034
|
+
return 'failed';
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
/**
|
|
4038
|
+
* A wake that found the run leased by another instance (its turn is executing) asks for another
|
|
4039
|
+
* turn, durably: it stamps {@link WorkflowRun.wakeRequestedAt} on the run. The holder does not let
|
|
4040
|
+
* go of the lease while the stamp is there ({@link releaseSuspendedLease}) and runs another turn,
|
|
4041
|
+
* which sees what the wake wrote; and a `suspended` run carrying it is due for the timer poll, so
|
|
4042
|
+
* the request survives both this instance and the holder going down.
|
|
4043
|
+
*
|
|
4044
|
+
* The holder may have let go between the refused lease and the stamp: nobody but the timer poll
|
|
4045
|
+
* would see it then, so the lease is tried once more here. The in-memory re-check
|
|
4046
|
+
* ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
|
|
4047
|
+
* stops as soon as a turn served the request.
|
|
4048
|
+
*/
|
|
4049
|
+
async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }, stamping) {
|
|
4050
|
+
if (!stamping && this.draining)
|
|
4051
|
+
return;
|
|
4052
|
+
const stamp = await (stamping ?? this.stampWakeRequest(runId, reason));
|
|
4053
|
+
if (stamp === 'failed') {
|
|
3929
4054
|
// Not kept (a schema without the column, a store error): the in-memory re-check is all there is.
|
|
3930
4055
|
this.recheckAfterLease(runId, lockedUntil);
|
|
3931
4056
|
return;
|
|
3932
4057
|
}
|
|
3933
|
-
if (
|
|
4058
|
+
if (stamp === 'gone')
|
|
3934
4059
|
return;
|
|
3935
4060
|
// The fast path for a holder that went down mid-turn: look again in memory too (it stops once a
|
|
3936
4061
|
// turn served the request).
|
|
@@ -4074,8 +4199,11 @@ export class WorkflowEngine {
|
|
|
4074
4199
|
this.wakeReasons.delete(run.id);
|
|
4075
4200
|
if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
|
|
4076
4201
|
if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
|
|
4077
|
-
this.
|
|
4078
|
-
|
|
4202
|
+
const stamping = this.draining ? undefined : this.stampWakeRequest(run.id, reason);
|
|
4203
|
+
this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason, stamping));
|
|
4204
|
+
const refused = { runId: run.id, status: run.status };
|
|
4205
|
+
leaseRefused.set(refused, stamping ?? Promise.resolve('failed'));
|
|
4206
|
+
return refused;
|
|
4079
4207
|
}
|
|
4080
4208
|
}
|
|
4081
4209
|
// This turn reads the run's checkpoints after this point, so it sees whatever the wakes that
|
|
@@ -6404,6 +6532,7 @@ export class WorkflowEngine {
|
|
|
6404
6532
|
priority: admission?.priority,
|
|
6405
6533
|
attempt,
|
|
6406
6534
|
transport,
|
|
6535
|
+
enqueuedAt,
|
|
6407
6536
|
});
|
|
6408
6537
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
6409
6538
|
throw new WorkflowSuspended(undefined, [seq]);
|
|
@@ -6607,7 +6736,9 @@ export class WorkflowEngine {
|
|
|
6607
6736
|
traceparent,
|
|
6608
6737
|
context,
|
|
6609
6738
|
priority: opts.admission?.priority,
|
|
6739
|
+
lazyPickupBeat: true,
|
|
6610
6740
|
attempt: 1,
|
|
6741
|
+
[DISPATCH_KEY]: dispatchKey(cp.stepId, 1, enqueuedAt),
|
|
6611
6742
|
})), opts.transport);
|
|
6612
6743
|
// Awaited below; a rejection is not left unhandled while the next round is written.
|
|
6613
6744
|
dispatch.catch(() => { });
|
|
@@ -6632,7 +6763,7 @@ export class WorkflowEngine {
|
|
|
6632
6763
|
* {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
|
|
6633
6764
|
*/
|
|
6634
6765
|
dispatchRemoteTask(task) {
|
|
6635
|
-
|
|
6766
|
+
const remote = {
|
|
6636
6767
|
runId: task.runId,
|
|
6637
6768
|
seq: task.seq,
|
|
6638
6769
|
name: task.name,
|
|
@@ -6643,7 +6774,10 @@ export class WorkflowEngine {
|
|
|
6643
6774
|
context: this.context?.(),
|
|
6644
6775
|
priority: task.priority,
|
|
6645
6776
|
attempt: task.attempt,
|
|
6646
|
-
}
|
|
6777
|
+
};
|
|
6778
|
+
if (task.enqueuedAt)
|
|
6779
|
+
remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
|
|
6780
|
+
return this.pool.dispatch(remote, task.transport);
|
|
6647
6781
|
}
|
|
6648
6782
|
/** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
|
|
6649
6783
|
* column through {@link StateStore.listCheckpoints} on a store without it. */
|
|
@@ -6729,14 +6863,15 @@ export class WorkflowEngine {
|
|
|
6729
6863
|
}
|
|
6730
6864
|
/** The single-result path: settle `result`, then resume its run unless its fan is still running. */
|
|
6731
6865
|
async completeOneRemoteResult(result, queuedIn) {
|
|
6732
|
-
const
|
|
6733
|
-
if (
|
|
6866
|
+
const settled = await this.settleRemoteCheckpoint(result);
|
|
6867
|
+
if (settled === undefined)
|
|
6734
6868
|
return;
|
|
6869
|
+
const { cp, run } = settled;
|
|
6735
6870
|
if (await this.stepFanStillRunning(cp, result, queuedIn))
|
|
6736
6871
|
return;
|
|
6737
6872
|
if (!parseStepFanGroup(cp.parallelGroup))
|
|
6738
6873
|
this.noteSettledHere(result.runId, [cp.seq]);
|
|
6739
|
-
this.
|
|
6874
|
+
await this.resumeOwed(run, result.runId, fanWakeOf(cp, result));
|
|
6740
6875
|
}
|
|
6741
6876
|
/**
|
|
6742
6877
|
* Record `seqs` of `runId` in {@link settledHere}, dropping the entries older than a lease. A key is
|
|
@@ -6756,10 +6891,156 @@ export class WorkflowEngine {
|
|
|
6756
6891
|
this.settledHere.delete(key);
|
|
6757
6892
|
}
|
|
6758
6893
|
}
|
|
6759
|
-
|
|
6760
|
-
|
|
6894
|
+
/**
|
|
6895
|
+
* Resume the run a settled step result owes a turn, and make that turn DURABLE before the result
|
|
6896
|
+
* is acknowledged (the caller's return is the acknowledgement).
|
|
6897
|
+
*
|
|
6898
|
+
* Settling a result does two things — settle the checkpoint, then resume the run — and only the
|
|
6899
|
+
* first is durable on its own: the resume runs in the background, so the transport is never held
|
|
6900
|
+
* by a turn that awaits another result (the self-deadlock the background resume exists for). A pod
|
|
6901
|
+
* SIGKILLed after the acknowledgement and before (or mid-way through) the resumed turn, which keeps
|
|
6902
|
+
* the run `suspended` and so is invisible to the orphan sweep, left the run parked on whatever
|
|
6903
|
+
* `wakeAt` its last turn wrote — for a step that is the reconcile fallback, minutes out — with
|
|
6904
|
+
* nothing else to wake it: the result was already acknowledged, so no redelivery re-drives it.
|
|
6905
|
+
*
|
|
6906
|
+
* So the acknowledgement waits for one of two things, at most {@link OWED_RESUME_GRACE_MS}:
|
|
6907
|
+
* - the resumed turn ending here: it settled or re-parked the run itself, so nothing is owed any
|
|
6908
|
+
* more — the common case, and it costs no write;
|
|
6909
|
+
* - else ({@link OWED_RESUME_GRACE_MS} passed, or the resume failed) a durable mark: for a
|
|
6910
|
+
* `suspended` run {@link markResumeOwed} pulls its timer in to one lease from now, so every
|
|
6911
|
+
* instance's due-timer poll resumes it once the lease a dying turn may hold has lapsed; for a run
|
|
6912
|
+
* whose turn is still in flight, a {@link requestWake} stamp (see {@link owedDurably}). A resume
|
|
6913
|
+
* refused by another instance's lease already made that stamp, which is then enough.
|
|
6914
|
+
* A pod dying while the acknowledgement waits never acknowledged: the transport redelivers the
|
|
6915
|
+
* result, whose settled checkpoint re-drives the resume on a live instance.
|
|
6916
|
+
*
|
|
6917
|
+
* The wait is bounded, so a turn that awaits another result in memory holds this one for at most
|
|
6918
|
+
* the grace before it is marked and acknowledged — never a deadlock.
|
|
6919
|
+
*/
|
|
6920
|
+
async resumeOwed(run, runId, fan) {
|
|
6921
|
+
let served = false;
|
|
6922
|
+
let refusal;
|
|
6923
|
+
const resumed = this.resumeImplicit(runId, fan).then((result) => {
|
|
6924
|
+
refusal = leaseRefused.get(result);
|
|
6925
|
+
served = refusal === undefined;
|
|
6926
|
+
}, (err) => {
|
|
6761
6927
|
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)}`);
|
|
6762
|
-
})
|
|
6928
|
+
});
|
|
6929
|
+
this.trackEffect(resumed);
|
|
6930
|
+
const owed = this.owedDurably(run);
|
|
6931
|
+
if (owed === undefined)
|
|
6932
|
+
return;
|
|
6933
|
+
let timer;
|
|
6934
|
+
await Promise.race([
|
|
6935
|
+
resumed,
|
|
6936
|
+
new Promise((resolve) => {
|
|
6937
|
+
timer = setTimeout(resolve, OWED_RESUME_GRACE_MS);
|
|
6938
|
+
}),
|
|
6939
|
+
]);
|
|
6940
|
+
clearTimeout(timer);
|
|
6941
|
+
if (served)
|
|
6942
|
+
return;
|
|
6943
|
+
// Refused: the wake already asked the lease holder for a turn, durably, unless that failed.
|
|
6944
|
+
if (refusal !== undefined && (await refusal) !== 'failed')
|
|
6945
|
+
return;
|
|
6946
|
+
if (owed === 'mark') {
|
|
6947
|
+
await this.markResumeOwed(run, () => served);
|
|
6948
|
+
return;
|
|
6949
|
+
}
|
|
6950
|
+
if ((await this.stampWakeRequest(runId, { generic: true, fans: new Map() })) === 'failed') {
|
|
6951
|
+
console.warn(`[adonis-durable] could not mark run ${runId} as owed a turn (it recovers on its next reconcile wake)`);
|
|
6952
|
+
}
|
|
6953
|
+
}
|
|
6954
|
+
/**
|
|
6955
|
+
* What makes the turn a result settled for `run` (as read when it settled) owes durable, if the
|
|
6956
|
+
* resume does not end in time: `mark` ({@link markResumeOwed}) for a `suspended` run whose timer is
|
|
6957
|
+
* not already due within a lease; `stamp` (a {@link requestWake} stamp) for a run whose turn is in
|
|
6958
|
+
* flight (`running`, or `blocked`) — its turn may park it after this, overwriting any timer, and a
|
|
6959
|
+
* holder that dies after parking leaves a `suspended` run the orphan sweep never sees, so only the
|
|
6960
|
+
* stamp (kept across the park, and due for the timer poll at once) survives both. Else nothing.
|
|
6961
|
+
*/
|
|
6962
|
+
owedDurably(run) {
|
|
6963
|
+
if (run?.status === 'suspended')
|
|
6964
|
+
return this.owesMark(run) ? 'mark' : undefined;
|
|
6965
|
+
if (run?.status === 'running' || run?.status === 'blocked')
|
|
6966
|
+
return 'stamp';
|
|
6967
|
+
return undefined;
|
|
6968
|
+
}
|
|
6969
|
+
/**
|
|
6970
|
+
* Whether a result settled for `run` (as read when it settled) would need {@link markResumeOwed}: only
|
|
6971
|
+
* a `suspended` run whose timer is not already due within a lease (a run whose turn is in flight
|
|
6972
|
+
* gets a wake request instead, see {@link owedDurably}).
|
|
6973
|
+
*/
|
|
6974
|
+
owesMark(run) {
|
|
6975
|
+
if (run?.status !== 'suspended')
|
|
6976
|
+
return false;
|
|
6977
|
+
return run.wakeAt === undefined || run.wakeAt > this.clock() + this.leaseMs;
|
|
6978
|
+
}
|
|
6979
|
+
/**
|
|
6980
|
+
* Mark `run` as owed a resume: pull its timer in to one lease from now (only IN — a timer already
|
|
6981
|
+
* due sooner is kept, and only while the run is still `suspended`). Leased like any due run, so a
|
|
6982
|
+
* live turn is never doubled — at worst a resume that lands after the mark replays and re-parks.
|
|
6983
|
+
* `served` reports a resume that ended meanwhile, whose mark is then dropped if not yet written.
|
|
6984
|
+
*
|
|
6985
|
+
* Batched across runs when the store has {@link StateStore.pullRunWakesIn}. Best-effort: a store that
|
|
6986
|
+
* refuses the write leaves the run exactly where it was before.
|
|
6987
|
+
*/
|
|
6988
|
+
async markResumeOwed(run, served = () => false) {
|
|
6989
|
+
if (!this.owesMark(run))
|
|
6990
|
+
return;
|
|
6991
|
+
if (!this.store.pullRunWakesIn) {
|
|
6992
|
+
await this.markRunResumeOwed(run.id, this.clock() + this.leaseMs);
|
|
6993
|
+
return;
|
|
6994
|
+
}
|
|
6995
|
+
// Batched across runs: the results of a busy instance arrive many runs at a time (a results batch
|
|
6996
|
+
// settles each of its runs concurrently), and each would otherwise cost its own UPDATE. The first
|
|
6997
|
+
// mark goes out at once; the ones arriving while it is in flight go out together in the next.
|
|
6998
|
+
await new Promise((done) => {
|
|
6999
|
+
this.owedResumes.push({ runId: run.id, served, done });
|
|
7000
|
+
if (!this.markingOwedResumes)
|
|
7001
|
+
void this.markOwedResumes();
|
|
7002
|
+
});
|
|
7003
|
+
}
|
|
7004
|
+
/** Write the queued owed-resume marks, a batch at a time, until none is left (see {@link markResumeOwed}). */
|
|
7005
|
+
async markOwedResumes() {
|
|
7006
|
+
this.markingOwedResumes = true;
|
|
7007
|
+
try {
|
|
7008
|
+
while (this.owedResumes.length > 0) {
|
|
7009
|
+
const batch = this.owedResumes.splice(0, OWED_RESUME_BATCH_MAX);
|
|
7010
|
+
// One lease from the write, not from each mark's arrival: never later than a single write.
|
|
7011
|
+
const owedBy = this.clock() + this.leaseMs;
|
|
7012
|
+
// A resume that ended while its mark queued owes nothing any more.
|
|
7013
|
+
const runIds = batch.filter((entry) => !entry.served()).map((entry) => entry.runId);
|
|
7014
|
+
if (runIds.length === 0) {
|
|
7015
|
+
for (const entry of batch)
|
|
7016
|
+
entry.done();
|
|
7017
|
+
continue;
|
|
7018
|
+
}
|
|
7019
|
+
try {
|
|
7020
|
+
await this.store.pullRunWakesIn?.(runIds, owedBy);
|
|
7021
|
+
}
|
|
7022
|
+
catch {
|
|
7023
|
+
// A batch the store refused (a deadlock victim, a dropped connection) is retried one run at
|
|
7024
|
+
// a time, so one bad row costs no other run its mark.
|
|
7025
|
+
for (const runId of new Set(runIds))
|
|
7026
|
+
await this.markRunResumeOwed(runId, owedBy);
|
|
7027
|
+
}
|
|
7028
|
+
for (const entry of batch)
|
|
7029
|
+
entry.done();
|
|
7030
|
+
}
|
|
7031
|
+
}
|
|
7032
|
+
finally {
|
|
7033
|
+
this.markingOwedResumes = false;
|
|
7034
|
+
}
|
|
7035
|
+
}
|
|
7036
|
+
/** {@link markResumeOwed} for one run, through {@link StateStore.updateRunIf}. Best-effort. */
|
|
7037
|
+
async markRunResumeOwed(runId, owedBy) {
|
|
7038
|
+
try {
|
|
7039
|
+
await this.store.updateRunIf(runId, ['suspended'], { wakeAt: owedBy });
|
|
7040
|
+
}
|
|
7041
|
+
catch (err) {
|
|
7042
|
+
console.warn(`[adonis-durable] could not mark run ${runId} as owed a resume (it recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
|
|
7043
|
+
}
|
|
6763
7044
|
}
|
|
6764
7045
|
/**
|
|
6765
7046
|
* Settle a batch of one run's step results; per result, `true` (settled or dropped) or the error
|
|
@@ -6877,7 +7158,7 @@ export class WorkflowEngine {
|
|
|
6877
7158
|
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6878
7159
|
if (!fan || result.status === 'failed') {
|
|
6879
7160
|
this.noteSettledHere(runId, singles);
|
|
6880
|
-
this.
|
|
7161
|
+
await this.resumeOwed(run, runId);
|
|
6881
7162
|
return outcomes;
|
|
6882
7163
|
}
|
|
6883
7164
|
let closed = barriers.get(fan.firstSeq);
|
|
@@ -6890,7 +7171,7 @@ export class WorkflowEngine {
|
|
|
6890
7171
|
}
|
|
6891
7172
|
if (!(await closed)) {
|
|
6892
7173
|
this.noteSettledHere(runId, singles);
|
|
6893
|
-
this.
|
|
7174
|
+
await this.resumeOwed(run, runId, {
|
|
6894
7175
|
first: fan.firstSeq,
|
|
6895
7176
|
seq: highest.get(fan.firstSeq) ?? cp.seq,
|
|
6896
7177
|
});
|
|
@@ -7000,7 +7281,7 @@ export class WorkflowEngine {
|
|
|
7000
7281
|
}
|
|
7001
7282
|
/**
|
|
7002
7283
|
* The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
|
|
7003
|
-
* the resume when a redelivered result finds it already settled). Returns the step's checkpoint as
|
|
7284
|
+
* the resume when a redelivered result finds it already settled). Returns the step's checkpoint (and its run) as
|
|
7004
7285
|
* read when its run should be resumed, or `undefined` when no resume is wanted (no checkpoint, or a
|
|
7005
7286
|
* late result for a terminal run — terminal is terminal).
|
|
7006
7287
|
*/
|
|
@@ -7033,7 +7314,7 @@ export class WorkflowEngine {
|
|
|
7033
7314
|
// explicit `requeue`/`durable:retry`, never to a stray result.
|
|
7034
7315
|
if (cp.status !== 'pending') {
|
|
7035
7316
|
if (!terminal)
|
|
7036
|
-
return cp;
|
|
7317
|
+
return { cp, run };
|
|
7037
7318
|
return undefined;
|
|
7038
7319
|
}
|
|
7039
7320
|
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
|
|
@@ -7070,7 +7351,7 @@ export class WorkflowEngine {
|
|
|
7070
7351
|
if (typeof claim === 'object')
|
|
7071
7352
|
throw claim.error;
|
|
7072
7353
|
if (!claim)
|
|
7073
|
-
return terminal ? undefined : cp;
|
|
7354
|
+
return terminal ? undefined : { cp, run };
|
|
7074
7355
|
await this.releaseQueueSlot(cp);
|
|
7075
7356
|
this.emit({
|
|
7076
7357
|
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
@@ -7083,7 +7364,7 @@ export class WorkflowEngine {
|
|
|
7083
7364
|
queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
7084
7365
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
7085
7366
|
});
|
|
7086
|
-
return cp;
|
|
7367
|
+
return { cp, run };
|
|
7087
7368
|
}
|
|
7088
7369
|
/**
|
|
7089
7370
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|