@adonis-agora/durable 0.53.0 → 0.54.1
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 +25 -0
- package/dist/src/buffered-child-signals.d.ts +46 -0
- package/dist/src/buffered-child-signals.d.ts.map +1 -0
- package/dist/src/buffered-child-signals.js +293 -0
- package/dist/src/buffered-child-signals.js.map +1 -0
- package/dist/src/codec-state-store.d.ts +9 -1
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +26 -0
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/commands/worker.d.ts +2 -1
- package/dist/src/commands/worker.d.ts.map +1 -1
- package/dist/src/commands/worker.js +44 -0
- package/dist/src/commands/worker.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 +63 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +326 -56
- 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 +38 -0
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +5 -1
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +111 -5
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +5 -1
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +42 -3
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/run-dispatch-contract.d.ts.map +1 -1
- package/dist/src/testing-kit/run-dispatch-contract.js +98 -0
- package/dist/src/testing-kit/run-dispatch-contract.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts +6 -0
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +189 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transport-pool.d.ts +7 -0
- package/dist/src/transport-pool.d.ts.map +1 -1
- package/dist/src/transport-pool.js +28 -0
- package/dist/src/transport-pool.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +10 -4
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +44 -1
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/db.d.ts +9 -1
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +106 -36
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts +9 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +28 -1
- package/dist/src/transports/queue.js.map +1 -1
- package/package.json +3 -3
- package/skills/durable-transports-stores/SKILL.md +11 -10
package/dist/src/engine.js
CHANGED
|
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
2
2
|
import { InMemoryAdmissionBackend } from './admission.js';
|
|
3
3
|
import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
|
|
4
4
|
import { BoundedRunner } from './bounded-runner.js';
|
|
5
|
+
import { CHILD_TOKEN_PREFIX, childCompletionNeedsBuffer, unneededChildSignals, } from './buffered-child-signals.js';
|
|
5
6
|
import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
|
|
6
7
|
import { CHILD_DELIVERED_TAG, markChildDelivered } from './child-delivery.js';
|
|
7
8
|
import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
|
|
@@ -79,6 +80,31 @@ const versionKey = (name, version) => `${name}@${version}`;
|
|
|
79
80
|
* state, so it must never match the key of the run's earlier start, even within the same millisecond.
|
|
80
81
|
*/
|
|
81
82
|
const pendingKey = (at) => `${at.getTime()}-${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
83
|
+
/** The buffered-event name under which the reasons of a run's refused wakes are kept (see `requestWake`). */
|
|
84
|
+
const wakeRequestName = (runId) => `durable:wake-request:${runId}`;
|
|
85
|
+
/** The most kept wake reasons of one run read at once. */
|
|
86
|
+
const WAKE_REQUEST_SCAN = 100;
|
|
87
|
+
/** The fan wake a settled step result brings: none for a step outside a fan, or a failure. */
|
|
88
|
+
function fanWakeOf(cp, result) {
|
|
89
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
90
|
+
if (!fan || result.status === 'failed')
|
|
91
|
+
return undefined;
|
|
92
|
+
return { first: fan.firstSeq, seq: cp.seq };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a kept wake reason (`payload` of a wake-request event) was already seen by a turn that read
|
|
96
|
+
* the fans in `settledFans` (first seq -> the highest seq it read with nothing in flight): a fan
|
|
97
|
+
* wake the turn read settled at least that far. A generic reason (`null`) is never seen.
|
|
98
|
+
*/
|
|
99
|
+
function wakeReasonSeen(payload, settledFans) {
|
|
100
|
+
const fans = payload?.fans;
|
|
101
|
+
if (!Array.isArray(fans) || fans.length === 0)
|
|
102
|
+
return false;
|
|
103
|
+
return fans.every((pair) => Array.isArray(pair) &&
|
|
104
|
+
typeof pair[0] === 'number' &&
|
|
105
|
+
typeof pair[1] === 'number' &&
|
|
106
|
+
(settledFans.get(pair[0]) ?? -1) >= pair[1]);
|
|
107
|
+
}
|
|
82
108
|
/** Whether `status` is one a run never leaves on its own. */
|
|
83
109
|
const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status);
|
|
84
110
|
/**
|
|
@@ -92,6 +118,17 @@ const COMPENSATION_SEQ_BASE = -2;
|
|
|
92
118
|
const STEP_FAN_CHUNK = 200;
|
|
93
119
|
/** The default {@link WorkflowEngineDeps.timerBatchSize}. */
|
|
94
120
|
const DEFAULT_TIMER_BATCH = 500;
|
|
121
|
+
/** {@link WorkflowEngine.sweepBufferedSignals}: rows read per page. */
|
|
122
|
+
const BUFFERED_SWEEP_PAGE = 500;
|
|
123
|
+
/** {@link WorkflowEngine.sweepBufferedSignals}: pages per call, so one tick stays short. */
|
|
124
|
+
const BUFFERED_SWEEP_PAGES_PER_CALL = 4;
|
|
125
|
+
/**
|
|
126
|
+
* {@link WorkflowEngine.sweepBufferedSignals}: wall-clock budget of one call. Past it the sweep
|
|
127
|
+
* starts no further page or parent read, and the next call resumes where this one stopped.
|
|
128
|
+
*/
|
|
129
|
+
const BUFFERED_SWEEP_BUDGET_MS = 250;
|
|
130
|
+
/** {@link WorkflowEngine.sweepBufferedSignals}: rest between two full passes over the table. */
|
|
131
|
+
const BUFFERED_SWEEP_REST_MS = 10 * 60_000;
|
|
95
132
|
/** The most results of one run settled by one batched write (see `completeRemoteResult`). */
|
|
96
133
|
const RESULT_BATCH_MAX = 256;
|
|
97
134
|
/**
|
|
@@ -361,6 +398,19 @@ export class WorkflowEngine {
|
|
|
361
398
|
runEnqueueFailures = 0;
|
|
362
399
|
/** Re-checks scheduled for runs whose wake found another instance's lease (see {@link recheckAfterLease}). */
|
|
363
400
|
leaseRetries = new Map();
|
|
401
|
+
/** Why each run is being woken on this instance, until the turn that answers it starts. */
|
|
402
|
+
wakeReasons = new Map();
|
|
403
|
+
/**
|
|
404
|
+
* The step fans each executing turn on this instance read with nothing in flight: first seq -> the
|
|
405
|
+
* highest seq read. Written by {@link callRemoteAll}, read when the turn lets go of the lease.
|
|
406
|
+
*/
|
|
407
|
+
turnSettledFans = new Map();
|
|
408
|
+
/** The kept wake reasons each executing turn on this instance serves (see {@link execute}). */
|
|
409
|
+
turnServedWakes = new Map();
|
|
410
|
+
/** {@link sweepBufferedSignals}: the id the pass in progress resumes after, `undefined` between passes. */
|
|
411
|
+
bufferedSweepCursor;
|
|
412
|
+
bufferedSweepActive = false;
|
|
413
|
+
lastBufferedSweepEndedAt = Number.NEGATIVE_INFINITY;
|
|
364
414
|
draining = false;
|
|
365
415
|
/** Shutdown wait already spent before/alongside {@link drain} (see {@link beginDrain}). */
|
|
366
416
|
drainSpentMs = 0;
|
|
@@ -1529,6 +1579,68 @@ export class WorkflowEngine {
|
|
|
1529
1579
|
* deleted (the parent→children edge is read from them). Use for retention/cleanup; this is a
|
|
1530
1580
|
* destructive store operation, not a cancellation — a running run should be {@link cancel}led first.
|
|
1531
1581
|
*/
|
|
1582
|
+
/**
|
|
1583
|
+
* Delete buffered child completions no parent will ever consume (see
|
|
1584
|
+
* `childCompletionNeedsBuffer`): those of root runs, of children whose parent already settled them
|
|
1585
|
+
* or is gone, or terminal for good. Earlier versions buffered every completion that found no
|
|
1586
|
+
* waiter, and nothing else ever removes such rows.
|
|
1587
|
+
*
|
|
1588
|
+
* Walks the buffered signals by primary key, {@link BUFFERED_SWEEP_PAGE} rows at a time and at most
|
|
1589
|
+
* {@link BUFFERED_SWEEP_PAGES_PER_CALL} pages per call, so a large backlog drains over a few worker
|
|
1590
|
+
* ticks without a long query or a big delete. Each page is decided set-based (see
|
|
1591
|
+
* `unneededChildSignals`): a handful of store calls per page and per open parent, none of them a
|
|
1592
|
+
* read by checkpoint name for an id that names its seq. A call stops once `budgetMs` of wall-clock
|
|
1593
|
+
* time is spent (default {@link BUFFERED_SWEEP_BUDGET_MS}), between pages or between two parents'
|
|
1594
|
+
* reads, and the next call resumes there. A pass that reaches the end rests
|
|
1595
|
+
* {@link BUFFERED_SWEEP_REST_MS} before the next. The worker loop starts it every tick without
|
|
1596
|
+
* waiting for it; a call made while another is running returns 0. Skipped while draining, on a
|
|
1597
|
+
* namespaced engine (a buffered signal carries no namespace, and a scoped read would report another
|
|
1598
|
+
* tenant's parent as gone), and on a store without `listBufferedSignals`/`deleteBufferedSignals`.
|
|
1599
|
+
* Returns how many rows it deleted.
|
|
1600
|
+
*/
|
|
1601
|
+
async sweepBufferedSignals(now = this.clock(), budgetMs = BUFFERED_SWEEP_BUDGET_MS) {
|
|
1602
|
+
const { listBufferedSignals, deleteBufferedSignals } = this.store;
|
|
1603
|
+
if (!listBufferedSignals || !deleteBufferedSignals)
|
|
1604
|
+
return 0;
|
|
1605
|
+
if (this.draining || this.namespace !== undefined || this.bufferedSweepActive)
|
|
1606
|
+
return 0;
|
|
1607
|
+
if (this.bufferedSweepCursor === undefined &&
|
|
1608
|
+
now - this.lastBufferedSweepEndedAt < BUFFERED_SWEEP_REST_MS) {
|
|
1609
|
+
return 0;
|
|
1610
|
+
}
|
|
1611
|
+
this.bufferedSweepActive = true;
|
|
1612
|
+
const deadline = Date.now() + budgetMs;
|
|
1613
|
+
let deleted = 0;
|
|
1614
|
+
try {
|
|
1615
|
+
for (let page = 0; page < BUFFERED_SWEEP_PAGES_PER_CALL; page += 1) {
|
|
1616
|
+
if (page > 0 && (Date.now() >= deadline || this.draining))
|
|
1617
|
+
break;
|
|
1618
|
+
const rows = await listBufferedSignals.call(this.store, CHILD_TOKEN_PREFIX, this.bufferedSweepCursor, BUFFERED_SWEEP_PAGE);
|
|
1619
|
+
const { doomed, decided } = await unneededChildSignals(this.store, rows, deadline);
|
|
1620
|
+
if (doomed.length > 0) {
|
|
1621
|
+
await deleteBufferedSignals.call(this.store, doomed.map((row) => row.id));
|
|
1622
|
+
deleted += doomed.length;
|
|
1623
|
+
}
|
|
1624
|
+
if (decided < rows.length) {
|
|
1625
|
+
// Out of time mid-page: resume after the last row decided.
|
|
1626
|
+
if (decided > 0)
|
|
1627
|
+
this.bufferedSweepCursor = rows[decided - 1].id;
|
|
1628
|
+
break;
|
|
1629
|
+
}
|
|
1630
|
+
const last = rows.at(-1);
|
|
1631
|
+
if (rows.length < BUFFERED_SWEEP_PAGE || !last) {
|
|
1632
|
+
this.bufferedSweepCursor = undefined;
|
|
1633
|
+
this.lastBufferedSweepEndedAt = now;
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
this.bufferedSweepCursor = last.id;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
finally {
|
|
1640
|
+
this.bufferedSweepActive = false;
|
|
1641
|
+
}
|
|
1642
|
+
return deleted;
|
|
1643
|
+
}
|
|
1532
1644
|
/**
|
|
1533
1645
|
* Register a pager hook for STRANDED runs — the failure `docs/reliability/failure-modes.mdx` calls
|
|
1534
1646
|
* "the stranded signature": an in-flight run untouched past `stalledAfterMs` that is either
|
|
@@ -1898,9 +2010,19 @@ export class WorkflowEngine {
|
|
|
1898
2010
|
* {@link resume}) may re-drive it. Otherwise a stale waiter or a late result left behind by a
|
|
1899
2011
|
* failed run silently re-executes its body.
|
|
1900
2012
|
*/
|
|
1901
|
-
resumeImplicit(runId) {
|
|
2013
|
+
resumeImplicit(runId, fan) {
|
|
2014
|
+
this.noteWakeReason(runId, fan);
|
|
1902
2015
|
return this.resumeRun(runId, false);
|
|
1903
2016
|
}
|
|
2017
|
+
/** Record why `runId` is being woken: a step fan's barrier opening, or (`undefined`) anything else. */
|
|
2018
|
+
noteWakeReason(runId, fan) {
|
|
2019
|
+
const reason = this.wakeReasons.get(runId) ?? { generic: false, fans: new Map() };
|
|
2020
|
+
if (fan)
|
|
2021
|
+
reason.fans.set(fan.first, Math.max(reason.fans.get(fan.first) ?? fan.seq, fan.seq));
|
|
2022
|
+
else
|
|
2023
|
+
reason.generic = true;
|
|
2024
|
+
this.wakeReasons.set(runId, reason);
|
|
2025
|
+
}
|
|
1904
2026
|
/**
|
|
1905
2027
|
* One execution of a run at a time in this process. A resume that lands while the run executes
|
|
1906
2028
|
* here neither joins that execution (the double-run) nor no-ops against its held lease (a lost
|
|
@@ -3185,6 +3307,12 @@ export class WorkflowEngine {
|
|
|
3185
3307
|
((await this.store.getRun(childId))?.tags?.includes(CHILD_DELIVERED_TAG) ?? false);
|
|
3186
3308
|
if (await delivered())
|
|
3187
3309
|
return;
|
|
3310
|
+
// Buffer it only for a parent that can still consume it (see childCompletionNeedsBuffer): a
|
|
3311
|
+
// root run, a child whose parent is gone, finished for good or already holds the outcome
|
|
3312
|
+
// would leave a row nothing ever takes.
|
|
3313
|
+
if (!(await childCompletionNeedsBuffer(this.store, token.slice(CHILD_TOKEN_PREFIX.length), completion))) {
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3188
3316
|
const resumed = await this.signal(token, completion);
|
|
3189
3317
|
// The parent may settle the child from its run row between the look above and the buffer
|
|
3190
3318
|
// write (a join that registers its waiter only now): it then stamped the child first, and the
|
|
@@ -3632,11 +3760,20 @@ export class WorkflowEngine {
|
|
|
3632
3760
|
* ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
|
|
3633
3761
|
* stops as soon as a turn served the request.
|
|
3634
3762
|
*/
|
|
3635
|
-
async requestWake(runId, lockedUntil) {
|
|
3763
|
+
async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }) {
|
|
3636
3764
|
if (this.draining)
|
|
3637
3765
|
return;
|
|
3638
3766
|
let marked;
|
|
3639
3767
|
try {
|
|
3768
|
+
// The reason first, the stamp second: a holder that finds the stamp finds the reason too.
|
|
3769
|
+
await this.store
|
|
3770
|
+
.bufferEvent({
|
|
3771
|
+
name: wakeRequestName(runId),
|
|
3772
|
+
payload: reason.generic ? null : { fans: [...reason.fans] },
|
|
3773
|
+
id: globalThis.crypto.randomUUID(),
|
|
3774
|
+
publishedAt: this.clock(),
|
|
3775
|
+
})
|
|
3776
|
+
.catch(() => undefined);
|
|
3640
3777
|
marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
|
|
3641
3778
|
wakeRequestedAt: this.clock(),
|
|
3642
3779
|
});
|
|
@@ -3664,6 +3801,41 @@ export class WorkflowEngine {
|
|
|
3664
3801
|
throw err;
|
|
3665
3802
|
});
|
|
3666
3803
|
}
|
|
3804
|
+
/**
|
|
3805
|
+
* Let go of the lease of a run this instance's turn just suspended ({@link releaseSuspendedLease}),
|
|
3806
|
+
* unless a wake refused during the turn asked for another one. A wake whose every kept reason this
|
|
3807
|
+
* turn already saw (a step fan's barrier opening for a fan the turn read settled at least that far,
|
|
3808
|
+
* or a reason the turn found kept when it started) is spent instead: its reasons are deleted, its
|
|
3809
|
+
* stamp cleared, and the lease let go of again. That is the second instance that saw a wave settle
|
|
3810
|
+
* at the same moment: the turn holding the lease already ran it. Resolves `false` when another turn
|
|
3811
|
+
* is owed.
|
|
3812
|
+
*/
|
|
3813
|
+
async letGoOfSuspendedTurn(runId) {
|
|
3814
|
+
const served = this.turnServedWakes.get(runId) ?? new Set();
|
|
3815
|
+
const settledFans = this.turnSettledFans.get(runId) ?? new Map();
|
|
3816
|
+
for (let round = 0; round < 5; round += 1) {
|
|
3817
|
+
if (await this.releaseSuspendedLease(runId)) {
|
|
3818
|
+
if (served.size > 0)
|
|
3819
|
+
this.trackEffect(this.dropWakeReasons(runId, served));
|
|
3820
|
+
return true;
|
|
3821
|
+
}
|
|
3822
|
+
const kept = await this.store.listBufferedEvents(wakeRequestName(runId), WAKE_REQUEST_SCAN);
|
|
3823
|
+
const seen = kept.filter((k) => served.has(k.id) || wakeReasonSeen(k.payload, settledFans));
|
|
3824
|
+
// No reason kept (an older engine stamped it), or one this turn has not seen: run again.
|
|
3825
|
+
if (kept.length === 0 || seen.length < kept.length)
|
|
3826
|
+
return false;
|
|
3827
|
+
await Promise.all(seen.map((k) => this.store.removeBufferedEvent(k.id).catch(() => false)));
|
|
3828
|
+
await this.store.updateRun(runId, { wakeRequestedAt: undefined });
|
|
3829
|
+
// A reason kept after the look above may have had its stamp cleared just now: look again.
|
|
3830
|
+
const spent = new Set(seen.map((k) => k.id));
|
|
3831
|
+
const later = await this.store.listBufferedEvents(wakeRequestName(runId), WAKE_REQUEST_SCAN);
|
|
3832
|
+
if (later.some((k) => !spent.has(k.id) && !wakeReasonSeen(k.payload, settledFans))) {
|
|
3833
|
+
await this.store.updateRun(runId, { wakeRequestedAt: this.clock() });
|
|
3834
|
+
return false;
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
return false;
|
|
3838
|
+
}
|
|
3667
3839
|
/**
|
|
3668
3840
|
* Let go of the lease of a run this instance's turn just suspended, unless a wake refused during
|
|
3669
3841
|
* the turn asked for another one ({@link requestWake}): then keep it and resolve `false`, and the
|
|
@@ -3750,16 +3922,28 @@ export class WorkflowEngine {
|
|
|
3750
3922
|
// the lease periodically so a long run keeps it (a crashed worker's lease still expires and is
|
|
3751
3923
|
// reclaimed by periodic recovery).
|
|
3752
3924
|
const lockNow = this.clock();
|
|
3925
|
+
// The wakes this turn answers: it reads the store after they were recorded.
|
|
3926
|
+
const reason = this.wakeReasons.get(run.id) ?? { generic: true, fans: new Map() };
|
|
3927
|
+
this.wakeReasons.delete(run.id);
|
|
3753
3928
|
if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
|
|
3754
3929
|
if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
|
|
3755
|
-
this.trackEffect(this.requestWake(run.id, run.lockedUntil));
|
|
3930
|
+
this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason));
|
|
3756
3931
|
return { runId: run.id, status: run.status };
|
|
3757
3932
|
}
|
|
3758
3933
|
}
|
|
3759
3934
|
// This turn reads the run's checkpoints after this point, so it sees whatever the wakes that
|
|
3760
|
-
// asked for it wrote: their request is served.
|
|
3935
|
+
// asked for it wrote: their request is served. Cleared before the reasons are read, so a request
|
|
3936
|
+
// whose reason this misses has its stamp land after the clear (see `requestWake`).
|
|
3937
|
+
const served = new Set();
|
|
3938
|
+
this.turnServedWakes.set(run.id, served);
|
|
3939
|
+
this.turnSettledFans.set(run.id, new Map());
|
|
3761
3940
|
if (run.wakeRequestedAt !== undefined) {
|
|
3762
3941
|
await this.store.updateRun(run.id, { wakeRequestedAt: undefined });
|
|
3942
|
+
for (const kept of await this.store
|
|
3943
|
+
.listBufferedEvents(wakeRequestName(run.id), WAKE_REQUEST_SCAN)
|
|
3944
|
+
.catch(() => [])) {
|
|
3945
|
+
served.add(kept.id);
|
|
3946
|
+
}
|
|
3763
3947
|
}
|
|
3764
3948
|
const lease = { handedOff: false };
|
|
3765
3949
|
this.executionLeases.set(run.id, lease);
|
|
@@ -3799,6 +3983,9 @@ export class WorkflowEngine {
|
|
|
3799
3983
|
result.status === 'dead')) {
|
|
3800
3984
|
this.trackEffect(this.singletons.wakeNext(run));
|
|
3801
3985
|
}
|
|
3986
|
+
// A finished run takes no more turns: the reasons kept for one are spent.
|
|
3987
|
+
if (isTerminalRun(result.status))
|
|
3988
|
+
this.trackEffect(this.dropWakeReasons(run.id));
|
|
3802
3989
|
return result;
|
|
3803
3990
|
}
|
|
3804
3991
|
finally {
|
|
@@ -3806,8 +3993,18 @@ export class WorkflowEngine {
|
|
|
3806
3993
|
this.leaseLostRuns.delete(run.id);
|
|
3807
3994
|
if (this.executionLeases.get(run.id) === lease)
|
|
3808
3995
|
this.executionLeases.delete(run.id);
|
|
3996
|
+
if (this.turnServedWakes.get(run.id) === served)
|
|
3997
|
+
this.turnServedWakes.delete(run.id);
|
|
3998
|
+
this.turnSettledFans.delete(run.id);
|
|
3809
3999
|
}
|
|
3810
4000
|
}
|
|
4001
|
+
/** Delete the kept wake reasons of `runId` (all, or the ones in `only`). */
|
|
4002
|
+
async dropWakeReasons(runId, only) {
|
|
4003
|
+
const kept = await this.store.listBufferedEvents(wakeRequestName(runId), WAKE_REQUEST_SCAN);
|
|
4004
|
+
await Promise.all(kept
|
|
4005
|
+
.filter((k) => !only || only.has(k.id))
|
|
4006
|
+
.map((k) => this.store.removeBufferedEvent(k.id).catch(() => false)));
|
|
4007
|
+
}
|
|
3811
4008
|
/**
|
|
3812
4009
|
* Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
|
|
3813
4010
|
* a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
|
|
@@ -5348,7 +5545,7 @@ export class WorkflowEngine {
|
|
|
5348
5545
|
let released = true;
|
|
5349
5546
|
try {
|
|
5350
5547
|
if (settledSuspended)
|
|
5351
|
-
released = await this.
|
|
5548
|
+
released = await this.letGoOfSuspendedTurn(run.id);
|
|
5352
5549
|
else
|
|
5353
5550
|
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
5354
5551
|
}
|
|
@@ -6051,6 +6248,7 @@ export class WorkflowEngine {
|
|
|
6051
6248
|
const failures = [];
|
|
6052
6249
|
const unsent = [];
|
|
6053
6250
|
let inFlight = 0;
|
|
6251
|
+
let highest = -1;
|
|
6054
6252
|
let wakeAt;
|
|
6055
6253
|
for (let i = 0; i < inputs.length; i += 1) {
|
|
6056
6254
|
const seq = positions[i];
|
|
@@ -6061,10 +6259,22 @@ export class WorkflowEngine {
|
|
|
6061
6259
|
}
|
|
6062
6260
|
if (cp.name !== step.name)
|
|
6063
6261
|
throw new NonDeterminismError(runId, seq, step.name, cp.name);
|
|
6262
|
+
if (seq > highest)
|
|
6263
|
+
highest = seq;
|
|
6064
6264
|
if (cp.status === 'completed') {
|
|
6065
6265
|
outputs[i] = cp.output;
|
|
6066
6266
|
continue;
|
|
6067
6267
|
}
|
|
6268
|
+
// Still out, and nothing to write for it: this turn read it a moment ago, so it waits on it
|
|
6269
|
+
// without reading it again. A result landing since then wakes the run once this turn ends.
|
|
6270
|
+
if (cp.status === 'pending' && this.stepIsWaiting(cp)) {
|
|
6271
|
+
inFlight += 1;
|
|
6272
|
+
if (cp.wakeAt != null) {
|
|
6273
|
+
const at = this.stepLeaseWakeAt(cp.wakeAt);
|
|
6274
|
+
wakeAt = Math.min(wakeAt ?? at, at);
|
|
6275
|
+
}
|
|
6276
|
+
continue;
|
|
6277
|
+
}
|
|
6068
6278
|
try {
|
|
6069
6279
|
outputs[i] = await this.callRemote(runId, seq, step, inputs[i], undefined, opts.transport, undefined, opts.admission, runNamespace, group);
|
|
6070
6280
|
}
|
|
@@ -6090,6 +6300,11 @@ export class WorkflowEngine {
|
|
|
6090
6300
|
}
|
|
6091
6301
|
}
|
|
6092
6302
|
}
|
|
6303
|
+
// Read with nothing in flight: a fan wake asking to look at it this far has been seen.
|
|
6304
|
+
if (inFlight === 0 && highest >= 0) {
|
|
6305
|
+
const settled = this.turnSettledFans.get(runId);
|
|
6306
|
+
settled?.set(first, Math.max(settled.get(first) ?? highest, highest));
|
|
6307
|
+
}
|
|
6093
6308
|
const batch = inFlight > 0
|
|
6094
6309
|
? []
|
|
6095
6310
|
: opts.concurrency === undefined
|
|
@@ -6116,7 +6331,20 @@ export class WorkflowEngine {
|
|
|
6116
6331
|
throw new GatherError(failures, inputs.length);
|
|
6117
6332
|
return outputs;
|
|
6118
6333
|
}
|
|
6119
|
-
/**
|
|
6334
|
+
/**
|
|
6335
|
+
* Whether a `pending` step a turn just read only waits for its result: {@link callRemote} would
|
|
6336
|
+
* suspend on it without writing (no redispatch deadline to stamp, the one stamped not yet due).
|
|
6337
|
+
*/
|
|
6338
|
+
stepIsWaiting(cp) {
|
|
6339
|
+
if (this.remoteRedispatchMs == null)
|
|
6340
|
+
return true;
|
|
6341
|
+
return cp.wakeAt != null && this.clock() < cp.wakeAt;
|
|
6342
|
+
}
|
|
6343
|
+
/**
|
|
6344
|
+
* Write the `pending` checkpoints of a step fan's new steps, then dispatch them: one
|
|
6345
|
+
* {@link StateStore.saveCheckpoints} call and one {@link TransportPool.dispatchMany} for the batch,
|
|
6346
|
+
* where the store and transport have them.
|
|
6347
|
+
*/
|
|
6120
6348
|
async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
|
|
6121
6349
|
const token = stepGroup(step, runNamespace);
|
|
6122
6350
|
await this.ensureRoutable(token, step.requires);
|
|
@@ -6127,42 +6355,48 @@ export class WorkflowEngine {
|
|
|
6127
6355
|
for (const i of batch)
|
|
6128
6356
|
validInputs.set(i, await input(i));
|
|
6129
6357
|
const enqueuedAt = new Date();
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6358
|
+
const checkpoints = batch.map((i) => {
|
|
6359
|
+
const seq = positions[i];
|
|
6360
|
+
return {
|
|
6361
|
+
runId,
|
|
6362
|
+
seq,
|
|
6363
|
+
name: step.name,
|
|
6364
|
+
kind: 'remote',
|
|
6365
|
+
stepId: stepId(runId, seq),
|
|
6366
|
+
status: 'pending',
|
|
6367
|
+
input: validInputs.get(i),
|
|
6368
|
+
attempts: 1,
|
|
6369
|
+
workerGroup: token,
|
|
6370
|
+
parallelGroup: group,
|
|
6371
|
+
enqueuedAt,
|
|
6372
|
+
startedAt: enqueuedAt,
|
|
6373
|
+
finishedAt: enqueuedAt,
|
|
6374
|
+
};
|
|
6375
|
+
});
|
|
6376
|
+
if (this.store.saveCheckpoints) {
|
|
6377
|
+
await this.store.saveCheckpoints(checkpoints);
|
|
6149
6378
|
}
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6379
|
+
else {
|
|
6380
|
+
for (let at = 0; at < checkpoints.length; at += STEP_FAN_CHUNK) {
|
|
6381
|
+
await Promise.all(checkpoints.slice(at, at + STEP_FAN_CHUNK).map((cp) => this.store.saveCheckpoint(cp)));
|
|
6382
|
+
}
|
|
6383
|
+
}
|
|
6384
|
+
const traceparent = this.traceparent?.();
|
|
6385
|
+
const context = this.context?.();
|
|
6386
|
+
await this.pool.dispatchMany(checkpoints.map((cp) => ({
|
|
6387
|
+
runId,
|
|
6388
|
+
seq: cp.seq,
|
|
6389
|
+
name: step.name,
|
|
6390
|
+
stepId: cp.stepId,
|
|
6391
|
+
group: token,
|
|
6392
|
+
input: cp.input,
|
|
6393
|
+
traceparent,
|
|
6394
|
+
context,
|
|
6395
|
+
priority: opts.admission?.priority,
|
|
6396
|
+
attempt: 1,
|
|
6397
|
+
})), opts.transport);
|
|
6398
|
+
for (const cp of checkpoints) {
|
|
6399
|
+
this.emit({ type: 'step.started', runId, seq: cp.seq, name: step.name, kind: 'remote' });
|
|
6166
6400
|
}
|
|
6167
6401
|
}
|
|
6168
6402
|
/**
|
|
@@ -6243,7 +6477,7 @@ export class WorkflowEngine {
|
|
|
6243
6477
|
const batch = queue.splice(0, RESULT_BATCH_MAX);
|
|
6244
6478
|
let outcomes;
|
|
6245
6479
|
try {
|
|
6246
|
-
outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
|
|
6480
|
+
outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result), (from, to) => queue.some(({ result }) => result.seq >= from && result.seq <= to));
|
|
6247
6481
|
}
|
|
6248
6482
|
catch (error) {
|
|
6249
6483
|
outcomes = batch.map(() => ({ error }));
|
|
@@ -6260,16 +6494,16 @@ export class WorkflowEngine {
|
|
|
6260
6494
|
this.resultQueues.delete(runId);
|
|
6261
6495
|
}
|
|
6262
6496
|
/** The single-result path: settle `result`, then resume its run unless its fan is still running. */
|
|
6263
|
-
async completeOneRemoteResult(result) {
|
|
6497
|
+
async completeOneRemoteResult(result, queuedIn) {
|
|
6264
6498
|
const cp = await this.settleRemoteCheckpoint(result);
|
|
6265
6499
|
if (cp === undefined)
|
|
6266
6500
|
return;
|
|
6267
|
-
if (await this.stepFanStillRunning(cp, result))
|
|
6501
|
+
if (await this.stepFanStillRunning(cp, result, queuedIn))
|
|
6268
6502
|
return;
|
|
6269
|
-
this.resumeInBackground(result.runId);
|
|
6503
|
+
this.resumeInBackground(result.runId, fanWakeOf(cp, result));
|
|
6270
6504
|
}
|
|
6271
|
-
resumeInBackground(runId) {
|
|
6272
|
-
this.trackEffect(this.resumeImplicit(runId).catch((err) => {
|
|
6505
|
+
resumeInBackground(runId, fan) {
|
|
6506
|
+
this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
|
|
6273
6507
|
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)}`);
|
|
6274
6508
|
}));
|
|
6275
6509
|
}
|
|
@@ -6279,12 +6513,12 @@ export class WorkflowEngine {
|
|
|
6279
6513
|
* {@link completeOneRemoteResult} on each in turn, with one resume for the batch: that resume
|
|
6280
6514
|
* replays every write of the batch, so a second would only replay it again.
|
|
6281
6515
|
*/
|
|
6282
|
-
async completeRemoteResults(runId, results) {
|
|
6516
|
+
async completeRemoteResults(runId, results, queuedIn = () => false) {
|
|
6283
6517
|
const outcomes = results.map(() => true);
|
|
6284
6518
|
const settleOneByOne = async (indexes) => {
|
|
6285
6519
|
for (const i of indexes) {
|
|
6286
6520
|
try {
|
|
6287
|
-
await this.completeOneRemoteResult(results[i]);
|
|
6521
|
+
await this.completeOneRemoteResult(results[i], queuedIn);
|
|
6288
6522
|
}
|
|
6289
6523
|
catch (error) {
|
|
6290
6524
|
outcomes[i] = { error };
|
|
@@ -6313,7 +6547,7 @@ export class WorkflowEngine {
|
|
|
6313
6547
|
run.status === 'dead') {
|
|
6314
6548
|
return settleOneByOne(live);
|
|
6315
6549
|
}
|
|
6316
|
-
const reads = await
|
|
6550
|
+
const reads = await this.readCheckpoints(runId, live.map((i) => results[i].seq));
|
|
6317
6551
|
// Checkpoints whose result should resume the run (barrier permitting): the ones this batch
|
|
6318
6552
|
// settled, and redeliveries of ones settled before — whose resume may never have happened.
|
|
6319
6553
|
const resumable = [];
|
|
@@ -6378,9 +6612,18 @@ export class WorkflowEngine {
|
|
|
6378
6612
|
resumable.push({ cp, result });
|
|
6379
6613
|
}
|
|
6380
6614
|
// Asked after every write of the batch committed, so the batch holding a fan's last step sees
|
|
6381
|
-
// its barrier open; once per fan
|
|
6382
|
-
//
|
|
6615
|
+
// its barrier open; once per fan, and not at all while another of the fan's results is queued
|
|
6616
|
+
// behind this batch on this instance: that result asks it after its own write (a settle, or a
|
|
6617
|
+
// redelivery of a settled step), so under a backlog the barrier is read once per drained queue,
|
|
6618
|
+
// not once per batch. A barrier that cannot be read counts as open: a spare resume replays and
|
|
6619
|
+
// re-suspends, a lost one strands the run.
|
|
6383
6620
|
const barriers = new Map();
|
|
6621
|
+
const highest = new Map();
|
|
6622
|
+
for (const { cp } of resumable) {
|
|
6623
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6624
|
+
if (fan)
|
|
6625
|
+
highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
|
|
6626
|
+
}
|
|
6384
6627
|
for (const { cp, result } of resumable) {
|
|
6385
6628
|
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6386
6629
|
if (!fan || result.status === 'failed') {
|
|
@@ -6389,11 +6632,17 @@ export class WorkflowEngine {
|
|
|
6389
6632
|
}
|
|
6390
6633
|
let closed = barriers.get(fan.firstSeq);
|
|
6391
6634
|
if (!closed) {
|
|
6392
|
-
|
|
6635
|
+
const lastSeq = fan.firstSeq + fan.size - 1;
|
|
6636
|
+
closed = queuedIn(fan.firstSeq, lastSeq)
|
|
6637
|
+
? Promise.resolve(true)
|
|
6638
|
+
: this.countPendingSteps(runId, fan.firstSeq, lastSeq).then((n) => n > 0, () => false);
|
|
6393
6639
|
barriers.set(fan.firstSeq, closed);
|
|
6394
6640
|
}
|
|
6395
6641
|
if (!(await closed)) {
|
|
6396
|
-
this.resumeInBackground(runId
|
|
6642
|
+
this.resumeInBackground(runId, {
|
|
6643
|
+
first: fan.firstSeq,
|
|
6644
|
+
seq: highest.get(fan.firstSeq) ?? cp.seq,
|
|
6645
|
+
});
|
|
6397
6646
|
return outcomes;
|
|
6398
6647
|
}
|
|
6399
6648
|
}
|
|
@@ -6425,11 +6674,32 @@ export class WorkflowEngine {
|
|
|
6425
6674
|
* which makes a fan of N steps cost O(N²) checkpoint reads. A redelivered result goes through it
|
|
6426
6675
|
* too: its re-driven resume is only owed once the fan has settled.
|
|
6427
6676
|
*/
|
|
6428
|
-
async stepFanStillRunning(cp, result) {
|
|
6677
|
+
async stepFanStillRunning(cp, result, queuedIn = () => false) {
|
|
6429
6678
|
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6430
6679
|
if (!fan || result.status === 'failed')
|
|
6431
6680
|
return false;
|
|
6432
|
-
|
|
6681
|
+
const lastSeq = fan.firstSeq + fan.size - 1;
|
|
6682
|
+
// Left to the result of this fan queued behind this one (see `completeRemoteResults`).
|
|
6683
|
+
if (queuedIn(fan.firstSeq, lastSeq))
|
|
6684
|
+
return true;
|
|
6685
|
+
return (await this.countPendingSteps(result.runId, fan.firstSeq, lastSeq)) > 0;
|
|
6686
|
+
}
|
|
6687
|
+
/**
|
|
6688
|
+
* The checkpoints of one run at `seqs`, each as its own outcome: one
|
|
6689
|
+
* {@link StateStore.getCheckpoints} call when the store has it, else one read each. The batch
|
|
6690
|
+
* call rejecting falls back to one read each, so a failing read fails only its own entry.
|
|
6691
|
+
*/
|
|
6692
|
+
async readCheckpoints(runId, seqs) {
|
|
6693
|
+
if (seqs.length > 1 && this.store.getCheckpoints) {
|
|
6694
|
+
try {
|
|
6695
|
+
const bySeq = new Map((await this.store.getCheckpoints(runId, seqs)).map((cp) => [cp.seq, cp]));
|
|
6696
|
+
return seqs.map((seq) => ({ status: 'fulfilled', value: bySeq.get(seq) ?? null }));
|
|
6697
|
+
}
|
|
6698
|
+
catch {
|
|
6699
|
+
// Fall through: one by one.
|
|
6700
|
+
}
|
|
6701
|
+
}
|
|
6702
|
+
return Promise.allSettled(seqs.map((seq) => this.store.getCheckpoint(runId, seq)));
|
|
6433
6703
|
}
|
|
6434
6704
|
/**
|
|
6435
6705
|
* The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
|