@adonis-agora/durable 0.54.0 → 0.54.2
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 +90 -0
- package/dist/assets/spa/assets/index-9kO8tWjg.js +16 -0
- package/dist/assets/spa/assets/index-9kO8tWjg.js.map +1 -0
- package/dist/assets/spa/index.html +1 -1
- package/dist/src/buffered-child-signals.d.ts +20 -4
- package/dist/src/buffered-child-signals.d.ts.map +1 -1
- package/dist/src/buffered-child-signals.js +207 -63
- package/dist/src/buffered-child-signals.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +10 -1
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +32 -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 -3
- 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 +69 -8
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +248 -53
- 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 +47 -0
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/stores/lucid-mappers.d.ts +6 -1
- package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
- package/dist/src/stores/lucid-mappers.js +41 -0
- package/dist/src/stores/lucid-mappers.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +4 -1
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +148 -37
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +4 -1
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +29 -0
- 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 +12 -3
- 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 +230 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transports/adaptive-concurrency.d.ts +2 -0
- package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -1
- package/dist/src/transports/adaptive-concurrency.js +39 -9
- package/dist/src/transports/adaptive-concurrency.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +8 -4
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
- package/dist/src/transports/bullmq/deps.js +18 -1
- package/dist/src/transports/bullmq/deps.js.map +1 -1
- package/dist/src/transports/db.js +4 -4
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +8 -4
- package/dist/src/transports/queue.js.map +1 -1
- package/package.json +2 -2
- package/skills/durable-reliability/SKILL.md +2 -1
- package/skills/durable-transports-stores/SKILL.md +15 -2
- package/dist/assets/spa/assets/index-BEk3-95l.js +0 -16
- package/dist/assets/spa/assets/index-BEk3-95l.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -122,6 +122,11 @@ const DEFAULT_TIMER_BATCH = 500;
|
|
|
122
122
|
const BUFFERED_SWEEP_PAGE = 500;
|
|
123
123
|
/** {@link WorkflowEngine.sweepBufferedSignals}: pages per call, so one tick stays short. */
|
|
124
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;
|
|
125
130
|
/** {@link WorkflowEngine.sweepBufferedSignals}: rest between two full passes over the table. */
|
|
126
131
|
const BUFFERED_SWEEP_REST_MS = 10 * 60_000;
|
|
127
132
|
/** The most results of one run settled by one batched write (see `completeRemoteResult`). */
|
|
@@ -402,6 +407,14 @@ export class WorkflowEngine {
|
|
|
402
407
|
turnSettledFans = new Map();
|
|
403
408
|
/** The kept wake reasons each executing turn on this instance serves (see {@link execute}). */
|
|
404
409
|
turnServedWakes = new Map();
|
|
410
|
+
/**
|
|
411
|
+
* `<runId>:<seq>` of the steps (not of a step fan) whose result this instance settled and whose
|
|
412
|
+
* run it then resumed, with when, kept for a lease. That resume runs a turn after any this instance
|
|
413
|
+
* is executing, or asks the lease holder for one, so the re-check after an in-process turn
|
|
414
|
+
* ({@link recheckWaitSeqs}) has no wake to recover for such a step: resuming again would only
|
|
415
|
+
* replay the run once more.
|
|
416
|
+
*/
|
|
417
|
+
settledHere = new Map();
|
|
405
418
|
/** {@link sweepBufferedSignals}: the id the pass in progress resumes after, `undefined` between passes. */
|
|
406
419
|
bufferedSweepCursor;
|
|
407
420
|
bufferedSweepActive = false;
|
|
@@ -539,6 +552,7 @@ export class WorkflowEngine {
|
|
|
539
552
|
this.latest.get(run.workflow))?.singleton,
|
|
540
553
|
});
|
|
541
554
|
this.pool.bind(async (result) => {
|
|
555
|
+
this.heartbeatWrites.delete(result.stepId);
|
|
542
556
|
// In-memory path (a `timeoutMs` step awaiting on THIS instance): resolve its pending promise.
|
|
543
557
|
const waiter = this.pending.get(result.stepId);
|
|
544
558
|
if (waiter) {
|
|
@@ -1153,9 +1167,35 @@ export class WorkflowEngine {
|
|
|
1153
1167
|
if (this.heartbeatPersists.size > 1024)
|
|
1154
1168
|
this.heartbeatPersists.clear();
|
|
1155
1169
|
this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
.
|
|
1170
|
+
this.heartbeatWrites.set(id, {
|
|
1171
|
+
runId: beat.runId,
|
|
1172
|
+
seq: beat.seq,
|
|
1173
|
+
at: new Date(beat.at ?? now),
|
|
1174
|
+
progress: beat.progress,
|
|
1175
|
+
});
|
|
1176
|
+
if (!this.heartbeatWriter) {
|
|
1177
|
+
this.heartbeatWriter = this.writeHeartbeats().finally(() => {
|
|
1178
|
+
this.heartbeatWriter = undefined;
|
|
1179
|
+
});
|
|
1180
|
+
this.trackEffect(this.heartbeatWriter);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/** Beats {@link persistHeartbeat} let through and {@link writeHeartbeats} has not written yet, by
|
|
1184
|
+
* stepId: a newer beat of the same step replaces the queued one, a step's result drops it. */
|
|
1185
|
+
heartbeatWrites = new Map();
|
|
1186
|
+
heartbeatWriter;
|
|
1187
|
+
/**
|
|
1188
|
+
* Write the queued beats one at a time, oldest first, until none is left. One write in flight keeps
|
|
1189
|
+
* a burst of beats (every step of a wide fan sends one when a worker picks it up) from taking the
|
|
1190
|
+
* store's connections away from the turns and results, and a step that finishes while its beat
|
|
1191
|
+
* waits never has it written: nothing reads a settled step's liveness.
|
|
1192
|
+
*/
|
|
1193
|
+
async writeHeartbeats() {
|
|
1194
|
+
const record = this.store.recordStepHeartbeat?.bind(this.store);
|
|
1195
|
+
for (const [id, beat] of this.heartbeatWrites) {
|
|
1196
|
+
this.heartbeatWrites.delete(id);
|
|
1197
|
+
await record?.(beat.runId, beat.seq, beat.at, beat.progress).catch(() => undefined);
|
|
1198
|
+
}
|
|
1159
1199
|
}
|
|
1160
1200
|
/**
|
|
1161
1201
|
* Declare this process a worker: flush consumer subscriptions parked by a transport whose
|
|
@@ -1582,13 +1622,18 @@ export class WorkflowEngine {
|
|
|
1582
1622
|
*
|
|
1583
1623
|
* Walks the buffered signals by primary key, {@link BUFFERED_SWEEP_PAGE} rows at a time and at most
|
|
1584
1624
|
* {@link BUFFERED_SWEEP_PAGES_PER_CALL} pages per call, so a large backlog drains over a few worker
|
|
1585
|
-
* ticks without a long query or a big delete.
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
|
|
1591
|
-
|
|
1625
|
+
* ticks without a long query or a big delete. Each page is decided set-based (see
|
|
1626
|
+
* `unneededChildSignals`): a handful of store calls per page and per open parent, none of them a
|
|
1627
|
+
* read by checkpoint name for an id that names its seq. A call stops once `budgetMs` of wall-clock
|
|
1628
|
+
* time is spent (default {@link BUFFERED_SWEEP_BUDGET_MS}), between pages or between two parents'
|
|
1629
|
+
* reads, and the next call resumes there. A pass that reaches the end rests
|
|
1630
|
+
* {@link BUFFERED_SWEEP_REST_MS} before the next. The worker loop starts it every tick without
|
|
1631
|
+
* waiting for it; a call made while another is running returns 0. Skipped while draining, on a
|
|
1632
|
+
* namespaced engine (a buffered signal carries no namespace, and a scoped read would report another
|
|
1633
|
+
* tenant's parent as gone), and on a store without `listBufferedSignals`/`deleteBufferedSignals`.
|
|
1634
|
+
* Returns how many rows it deleted.
|
|
1635
|
+
*/
|
|
1636
|
+
async sweepBufferedSignals(now = this.clock(), budgetMs = BUFFERED_SWEEP_BUDGET_MS) {
|
|
1592
1637
|
const { listBufferedSignals, deleteBufferedSignals } = this.store;
|
|
1593
1638
|
if (!listBufferedSignals || !deleteBufferedSignals)
|
|
1594
1639
|
return 0;
|
|
@@ -1599,15 +1644,24 @@ export class WorkflowEngine {
|
|
|
1599
1644
|
return 0;
|
|
1600
1645
|
}
|
|
1601
1646
|
this.bufferedSweepActive = true;
|
|
1647
|
+
const deadline = Date.now() + budgetMs;
|
|
1602
1648
|
let deleted = 0;
|
|
1603
1649
|
try {
|
|
1604
1650
|
for (let page = 0; page < BUFFERED_SWEEP_PAGES_PER_CALL; page += 1) {
|
|
1651
|
+
if (page > 0 && (Date.now() >= deadline || this.draining))
|
|
1652
|
+
break;
|
|
1605
1653
|
const rows = await listBufferedSignals.call(this.store, CHILD_TOKEN_PREFIX, this.bufferedSweepCursor, BUFFERED_SWEEP_PAGE);
|
|
1606
|
-
const doomed = await unneededChildSignals(this.store, rows);
|
|
1654
|
+
const { doomed, decided } = await unneededChildSignals(this.store, rows, deadline);
|
|
1607
1655
|
if (doomed.length > 0) {
|
|
1608
1656
|
await deleteBufferedSignals.call(this.store, doomed.map((row) => row.id));
|
|
1609
1657
|
deleted += doomed.length;
|
|
1610
1658
|
}
|
|
1659
|
+
if (decided < rows.length) {
|
|
1660
|
+
// Out of time mid-page: resume after the last row decided.
|
|
1661
|
+
if (decided > 0)
|
|
1662
|
+
this.bufferedSweepCursor = rows[decided - 1].id;
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
1611
1665
|
const last = rows.at(-1);
|
|
1612
1666
|
if (rows.length < BUFFERED_SWEEP_PAGE || !last) {
|
|
1613
1667
|
this.bufferedSweepCursor = undefined;
|
|
@@ -2108,13 +2162,19 @@ export class WorkflowEngine {
|
|
|
2108
2162
|
* delivery's own resume no-oped against this turn's still-held lease (see the WorkflowSuspended
|
|
2109
2163
|
* catch in runExecution). Deferred one macrotask so the caller's `finally` releases the lease
|
|
2110
2164
|
* first; tracked so `drain()` waits for the re-driven turn's writes.
|
|
2165
|
+
*
|
|
2166
|
+
* With `settledHereResumes`, a step in {@link settledHere} needs no re-drive: the turn that
|
|
2167
|
+
* suspended on it read its snapshot before the settle, so the settle's resume queued behind that
|
|
2168
|
+
* turn or asked the lease holder for one. A remote workflow's decision is applied after the turn
|
|
2169
|
+
* that computed it ended, so the settle's resume may already have run, and it passes `false`.
|
|
2111
2170
|
*/
|
|
2112
|
-
recheckWaitSeqs(runId, seqs) {
|
|
2171
|
+
recheckWaitSeqs(runId, seqs, settledHereResumes = false) {
|
|
2113
2172
|
this.trackEffect((async () => {
|
|
2114
2173
|
await new Promise((r) => setTimeout(r, 0));
|
|
2115
2174
|
for (const seq of seqs) {
|
|
2116
|
-
|
|
2117
|
-
|
|
2175
|
+
if (await this.checkpointSettled(runId, seq).catch(() => false)) {
|
|
2176
|
+
if (settledHereResumes && this.settledHere.has(`${runId}:${seq}`))
|
|
2177
|
+
return;
|
|
2118
2178
|
await this.resumeImplicit(runId).catch(() => undefined);
|
|
2119
2179
|
return;
|
|
2120
2180
|
}
|
|
@@ -2140,6 +2200,14 @@ export class WorkflowEngine {
|
|
|
2140
2200
|
})());
|
|
2141
2201
|
}
|
|
2142
2202
|
/** {@link StateStore.countCheckpoints} of `pending` steps, or a JS count on a store without it. */
|
|
2203
|
+
/** Whether the checkpoint at `seq` is settled: a `COUNT` when the store has one, else the row. */
|
|
2204
|
+
async checkpointSettled(runId, seq) {
|
|
2205
|
+
if (this.store.countCheckpoints) {
|
|
2206
|
+
return (await this.store.countCheckpoints(runId, seq, seq, ['completed', 'failed'])) > 0;
|
|
2207
|
+
}
|
|
2208
|
+
const cp = await this.store.getCheckpoint(runId, seq);
|
|
2209
|
+
return cp?.status === 'completed' || cp?.status === 'failed';
|
|
2210
|
+
}
|
|
2143
2211
|
async countPendingSteps(runId, from, to) {
|
|
2144
2212
|
if (this.store.countCheckpoints) {
|
|
2145
2213
|
return this.store.countCheckpoints(runId, from, to, ['pending']);
|
|
@@ -4360,7 +4428,7 @@ export class WorkflowEngine {
|
|
|
4360
4428
|
const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
|
|
4361
4429
|
if (routePlan?.status === 'blocked')
|
|
4362
4430
|
return this.parkBlocked(run, routePlan);
|
|
4363
|
-
const checkpoints = await this.
|
|
4431
|
+
const checkpoints = await this.replayCheckpoints(run.id);
|
|
4364
4432
|
const handoff = liveHandoff(run.id, checkpoints);
|
|
4365
4433
|
if (handoff) {
|
|
4366
4434
|
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
@@ -4924,6 +4992,42 @@ export class WorkflowEngine {
|
|
|
4924
4992
|
/** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
|
|
4925
4993
|
* throttle for {@link rearmStepLease}, the sibling of {@link heartbeatPersists}. */
|
|
4926
4994
|
stepLeaseRearms = new Map();
|
|
4995
|
+
/**
|
|
4996
|
+
* {@link stepLeaseRearms} for the steps of a fan this instance sent, by run: one entry per sent
|
|
4997
|
+
* block of consecutive seqs instead of one per step, since they share the lease stamped at send.
|
|
4998
|
+
*/
|
|
4999
|
+
fanLeaseRearms = new Map();
|
|
5000
|
+
noteFanLease(runId, seqs, lease) {
|
|
5001
|
+
if (this.remoteRedispatchMs == null || seqs.length === 0)
|
|
5002
|
+
return;
|
|
5003
|
+
let from = seqs[0];
|
|
5004
|
+
let to = from;
|
|
5005
|
+
for (const seq of seqs) {
|
|
5006
|
+
if (seq < from)
|
|
5007
|
+
from = seq;
|
|
5008
|
+
if (seq > to)
|
|
5009
|
+
to = seq;
|
|
5010
|
+
}
|
|
5011
|
+
// A gap would cover steps sent earlier, under an older lease.
|
|
5012
|
+
if (to - from + 1 !== seqs.length)
|
|
5013
|
+
return;
|
|
5014
|
+
if (this.fanLeaseRearms.size > 1024)
|
|
5015
|
+
this.fanLeaseRearms.clear();
|
|
5016
|
+
const blocks = this.fanLeaseRearms.get(runId) ?? [];
|
|
5017
|
+
blocks.push({ from, to, lookAgainAt: lease - Math.ceil(this.remoteRedispatchMs / 2) });
|
|
5018
|
+
this.fanLeaseRearms.set(runId, blocks);
|
|
5019
|
+
}
|
|
5020
|
+
fanLeaseLookAgainAt(runId, seq, now) {
|
|
5021
|
+
const blocks = this.fanLeaseRearms.get(runId);
|
|
5022
|
+
if (!blocks)
|
|
5023
|
+
return undefined;
|
|
5024
|
+
const live = blocks.filter((b) => now < b.lookAgainAt);
|
|
5025
|
+
if (live.length === 0)
|
|
5026
|
+
this.fanLeaseRearms.delete(runId);
|
|
5027
|
+
else if (live.length < blocks.length)
|
|
5028
|
+
this.fanLeaseRearms.set(runId, live);
|
|
5029
|
+
return live.find((b) => seq >= b.from && seq <= b.to)?.lookAgainAt;
|
|
5030
|
+
}
|
|
4927
5031
|
/**
|
|
4928
5032
|
* A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
|
|
4929
5033
|
* that is still working on it is never presumed dead and double-dispatched. The durable counterpart
|
|
@@ -4970,7 +5074,7 @@ export class WorkflowEngine {
|
|
|
4970
5074
|
return;
|
|
4971
5075
|
const id = stepId(runId, seq);
|
|
4972
5076
|
const now = this.clock();
|
|
4973
|
-
const lookAgainAt = this.stepLeaseRearms.get(id);
|
|
5077
|
+
const lookAgainAt = this.stepLeaseRearms.get(id) ?? this.fanLeaseLookAgainAt(runId, seq, now);
|
|
4974
5078
|
if (lookAgainAt !== undefined && now < lookAgainAt)
|
|
4975
5079
|
return;
|
|
4976
5080
|
const cp = await this.store.getCheckpoint(runId, seq);
|
|
@@ -5378,7 +5482,7 @@ export class WorkflowEngine {
|
|
|
5378
5482
|
// Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
|
|
5379
5483
|
// this resume wakes on, or one written later in this same execution) is absent from the map, and
|
|
5380
5484
|
// the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
|
|
5381
|
-
const snapshot = await this.
|
|
5485
|
+
const snapshot = await this.replayCheckpoints(run.id);
|
|
5382
5486
|
const replay = new Map();
|
|
5383
5487
|
for (const cp of snapshot)
|
|
5384
5488
|
replay.set(cp.seq, cp);
|
|
@@ -5471,7 +5575,7 @@ export class WorkflowEngine {
|
|
|
5471
5575
|
// all with reconcileMs: 0). Re-check the seqs this suspension waits on and re-drive if one
|
|
5472
5576
|
// already settled.
|
|
5473
5577
|
if (settled.status === 'suspended' && err.waitSeqs?.length) {
|
|
5474
|
-
this.recheckWaitSeqs(run.id, err.waitSeqs);
|
|
5578
|
+
this.recheckWaitSeqs(run.id, err.waitSeqs, true);
|
|
5475
5579
|
}
|
|
5476
5580
|
if (settled.status === 'suspended' && err.stepFans?.length) {
|
|
5477
5581
|
this.recheckStepFans(run.id, err.stepFans);
|
|
@@ -6292,7 +6396,11 @@ export class WorkflowEngine {
|
|
|
6292
6396
|
? unsent
|
|
6293
6397
|
: unsent.slice(0, opts.concurrency);
|
|
6294
6398
|
if (batch.length > 0) {
|
|
6295
|
-
await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
|
|
6399
|
+
const lease = await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
|
|
6400
|
+
if (lease !== undefined) {
|
|
6401
|
+
const at = this.stepLeaseWakeAt(lease);
|
|
6402
|
+
wakeAt = Math.min(wakeAt ?? at, at);
|
|
6403
|
+
}
|
|
6296
6404
|
}
|
|
6297
6405
|
if (inFlight + batch.length > 0) {
|
|
6298
6406
|
throw new WorkflowSuspended(wakeAt, undefined, undefined, [{ from: first, to: last }]);
|
|
@@ -6324,7 +6432,8 @@ export class WorkflowEngine {
|
|
|
6324
6432
|
/**
|
|
6325
6433
|
* Write the `pending` checkpoints of a step fan's new steps, then dispatch them: one
|
|
6326
6434
|
* {@link StateStore.saveCheckpoints} call and one {@link TransportPool.dispatchMany} for the batch,
|
|
6327
|
-
* where the store and transport have them.
|
|
6435
|
+
* where the store and transport have them. With the lost-dispatch self-heal on, each step is
|
|
6436
|
+
* written with its lease already stamped, and the lease is returned for the run to park on.
|
|
6328
6437
|
*/
|
|
6329
6438
|
async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
|
|
6330
6439
|
const token = stepGroup(step, runNamespace);
|
|
@@ -6336,6 +6445,7 @@ export class WorkflowEngine {
|
|
|
6336
6445
|
for (const i of batch)
|
|
6337
6446
|
validInputs.set(i, await input(i));
|
|
6338
6447
|
const enqueuedAt = new Date();
|
|
6448
|
+
const lease = this.remoteRedispatchMs == null ? undefined : this.clock() + this.remoteRedispatchMs;
|
|
6339
6449
|
const checkpoints = batch.map((i) => {
|
|
6340
6450
|
const seq = positions[i];
|
|
6341
6451
|
return {
|
|
@@ -6352,6 +6462,7 @@ export class WorkflowEngine {
|
|
|
6352
6462
|
enqueuedAt,
|
|
6353
6463
|
startedAt: enqueuedAt,
|
|
6354
6464
|
finishedAt: enqueuedAt,
|
|
6465
|
+
...(lease !== undefined ? { wakeAt: lease } : {}),
|
|
6355
6466
|
};
|
|
6356
6467
|
});
|
|
6357
6468
|
if (this.store.saveCheckpoints) {
|
|
@@ -6379,6 +6490,10 @@ export class WorkflowEngine {
|
|
|
6379
6490
|
for (const cp of checkpoints) {
|
|
6380
6491
|
this.emit({ type: 'step.started', runId, seq: cp.seq, name: step.name, kind: 'remote' });
|
|
6381
6492
|
}
|
|
6493
|
+
if (lease !== undefined) {
|
|
6494
|
+
this.noteFanLease(runId, checkpoints.map((cp) => cp.seq), lease);
|
|
6495
|
+
}
|
|
6496
|
+
return lease;
|
|
6382
6497
|
}
|
|
6383
6498
|
/**
|
|
6384
6499
|
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
@@ -6399,6 +6514,13 @@ export class WorkflowEngine {
|
|
|
6399
6514
|
attempt: task.attempt,
|
|
6400
6515
|
}, task.transport);
|
|
6401
6516
|
}
|
|
6517
|
+
/** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
|
|
6518
|
+
* column through {@link StateStore.listCheckpoints} on a store without it. */
|
|
6519
|
+
replayCheckpoints(runId) {
|
|
6520
|
+
return this.store.listCheckpointsForReplay
|
|
6521
|
+
? this.store.listCheckpointsForReplay(runId)
|
|
6522
|
+
: this.store.listCheckpoints(runId);
|
|
6523
|
+
}
|
|
6402
6524
|
/**
|
|
6403
6525
|
* Complete a durable remote step from its worker result and resume the run — runs on whichever
|
|
6404
6526
|
* instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
|
|
@@ -6481,8 +6603,28 @@ export class WorkflowEngine {
|
|
|
6481
6603
|
return;
|
|
6482
6604
|
if (await this.stepFanStillRunning(cp, result, queuedIn))
|
|
6483
6605
|
return;
|
|
6606
|
+
if (!parseStepFanGroup(cp.parallelGroup))
|
|
6607
|
+
this.noteSettledHere(result.runId, [cp.seq]);
|
|
6484
6608
|
this.resumeInBackground(result.runId, fanWakeOf(cp, result));
|
|
6485
6609
|
}
|
|
6610
|
+
/**
|
|
6611
|
+
* Record `seqs` of `runId` in {@link settledHere}, dropping the entries older than a lease. A key is
|
|
6612
|
+
* re-inserted at the end, so the map stays in the order its keys were noted and pruning stops at
|
|
6613
|
+
* the first fresh entry.
|
|
6614
|
+
*/
|
|
6615
|
+
noteSettledHere(runId, seqs) {
|
|
6616
|
+
const now = this.clock();
|
|
6617
|
+
for (const seq of seqs) {
|
|
6618
|
+
const key = `${runId}:${seq}`;
|
|
6619
|
+
this.settledHere.delete(key);
|
|
6620
|
+
this.settledHere.set(key, now);
|
|
6621
|
+
}
|
|
6622
|
+
for (const [key, at] of this.settledHere) {
|
|
6623
|
+
if (now - at <= this.leaseMs)
|
|
6624
|
+
break;
|
|
6625
|
+
this.settledHere.delete(key);
|
|
6626
|
+
}
|
|
6627
|
+
}
|
|
6486
6628
|
resumeInBackground(runId, fan) {
|
|
6487
6629
|
this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
|
|
6488
6630
|
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)}`);
|
|
@@ -6546,20 +6688,11 @@ export class WorkflowEngine {
|
|
|
6546
6688
|
resumable.push({ cp: read.value, result });
|
|
6547
6689
|
});
|
|
6548
6690
|
const finishedAt = new Date();
|
|
6549
|
-
const writes = pending.map(({ index, cp }) => {
|
|
6550
|
-
const result = results[index];
|
|
6551
|
-
return {
|
|
6552
|
-
...cp,
|
|
6553
|
-
status: result.status,
|
|
6554
|
-
output: result.status === 'completed' ? result.output : cp.output,
|
|
6555
|
-
error: result.error,
|
|
6556
|
-
events: result.events ?? cp.events,
|
|
6557
|
-
startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
|
|
6558
|
-
finishedAt,
|
|
6559
|
-
};
|
|
6560
|
-
});
|
|
6561
6691
|
// Claimed, not blindly written: a duplicate delivery may have settled a step first.
|
|
6562
|
-
const claims = await this.
|
|
6692
|
+
const claims = await this.settlePendingCheckpoints(pending.map(({ index, cp }) => ({
|
|
6693
|
+
cp,
|
|
6694
|
+
settlement: settlementOf(cp, results[index], finishedAt),
|
|
6695
|
+
})));
|
|
6563
6696
|
for (const [k, claim] of claims.entries()) {
|
|
6564
6697
|
const { index, cp } = pending[k];
|
|
6565
6698
|
const result = results[index];
|
|
@@ -6605,9 +6738,14 @@ export class WorkflowEngine {
|
|
|
6605
6738
|
if (fan)
|
|
6606
6739
|
highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
|
|
6607
6740
|
}
|
|
6741
|
+
// The one resume below replays every step of the batch.
|
|
6742
|
+
const singles = resumable
|
|
6743
|
+
.filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
|
|
6744
|
+
.map(({ cp }) => cp.seq);
|
|
6608
6745
|
for (const { cp, result } of resumable) {
|
|
6609
6746
|
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6610
6747
|
if (!fan || result.status === 'failed') {
|
|
6748
|
+
this.noteSettledHere(runId, singles);
|
|
6611
6749
|
this.resumeInBackground(runId);
|
|
6612
6750
|
return outcomes;
|
|
6613
6751
|
}
|
|
@@ -6620,6 +6758,7 @@ export class WorkflowEngine {
|
|
|
6620
6758
|
barriers.set(fan.firstSeq, closed);
|
|
6621
6759
|
}
|
|
6622
6760
|
if (!(await closed)) {
|
|
6761
|
+
this.noteSettledHere(runId, singles);
|
|
6623
6762
|
this.resumeInBackground(runId, {
|
|
6624
6763
|
first: fan.firstSeq,
|
|
6625
6764
|
seq: highest.get(fan.firstSeq) ?? cp.seq,
|
|
@@ -6629,6 +6768,29 @@ export class WorkflowEngine {
|
|
|
6629
6768
|
}
|
|
6630
6769
|
return outcomes;
|
|
6631
6770
|
}
|
|
6771
|
+
/**
|
|
6772
|
+
* Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
|
|
6773
|
+
* or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}
|
|
6774
|
+
* when the store has it: one call, or one per entry when that call rejects (it then wrote
|
|
6775
|
+
* nothing). Else each step's whole row is written through {@link savePendingCheckpoints}.
|
|
6776
|
+
*/
|
|
6777
|
+
async settlePendingCheckpoints(entries) {
|
|
6778
|
+
const settle = this.store.settleCheckpointsIfPending?.bind(this.store);
|
|
6779
|
+
if (!settle) {
|
|
6780
|
+
return this.savePendingCheckpoints(entries.map(({ cp, settlement }) => ({ ...cp, ...settlement })));
|
|
6781
|
+
}
|
|
6782
|
+
const settlements = entries.map((e) => e.settlement);
|
|
6783
|
+
try {
|
|
6784
|
+
const wrote = await settle(settlements);
|
|
6785
|
+
return settlements.map((_, i) => wrote[i] === true);
|
|
6786
|
+
}
|
|
6787
|
+
catch (error) {
|
|
6788
|
+
if (settlements.length === 1)
|
|
6789
|
+
return [{ error }];
|
|
6790
|
+
}
|
|
6791
|
+
const settled = await Promise.allSettled(settlements.map((s) => settle([s])));
|
|
6792
|
+
return settled.map((r) => r.status === 'fulfilled' ? r.value[0] === true : { error: r.reason });
|
|
6793
|
+
}
|
|
6632
6794
|
/**
|
|
6633
6795
|
* {@link savePendingCheckpoint} for checkpoints at distinct seqs: one
|
|
6634
6796
|
* {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
|
|
@@ -6665,12 +6827,35 @@ export class WorkflowEngine {
|
|
|
6665
6827
|
return true;
|
|
6666
6828
|
return (await this.countPendingSteps(result.runId, fan.firstSeq, lastSeq)) > 0;
|
|
6667
6829
|
}
|
|
6830
|
+
/**
|
|
6831
|
+
* {@link StateStore.getCheckpointsForSettle}, when the store can use what it leaves out: only
|
|
6832
|
+
* with {@link StateStore.settleCheckpointsIfPending}, since a settle without it (and the salvage
|
|
6833
|
+
* of a late success onto a failed run) writes back the whole row it read.
|
|
6834
|
+
*/
|
|
6835
|
+
settleReader() {
|
|
6836
|
+
return this.store.settleCheckpointsIfPending
|
|
6837
|
+
? this.store.getCheckpointsForSettle?.bind(this.store)
|
|
6838
|
+
: undefined;
|
|
6839
|
+
}
|
|
6668
6840
|
/**
|
|
6669
6841
|
* The checkpoints of one run at `seqs`, each as its own outcome: one
|
|
6670
6842
|
* {@link StateStore.getCheckpoints} call when the store has it, else one read each. The batch
|
|
6671
6843
|
* call rejecting falls back to one read each, so a failing read fails only its own entry.
|
|
6672
6844
|
*/
|
|
6673
6845
|
async readCheckpoints(runId, seqs) {
|
|
6846
|
+
const forSettle = this.settleReader();
|
|
6847
|
+
if (forSettle) {
|
|
6848
|
+
if (seqs.length > 1) {
|
|
6849
|
+
try {
|
|
6850
|
+
const bySeq = new Map((await forSettle(runId, seqs)).map((cp) => [cp.seq, cp]));
|
|
6851
|
+
return seqs.map((seq) => ({ status: 'fulfilled', value: bySeq.get(seq) ?? null }));
|
|
6852
|
+
}
|
|
6853
|
+
catch {
|
|
6854
|
+
// Fall through: one by one.
|
|
6855
|
+
}
|
|
6856
|
+
}
|
|
6857
|
+
return Promise.allSettled(seqs.map(async (seq) => (await forSettle(runId, [seq]))[0] ?? null));
|
|
6858
|
+
}
|
|
6674
6859
|
if (seqs.length > 1 && this.store.getCheckpoints) {
|
|
6675
6860
|
try {
|
|
6676
6861
|
const bySeq = new Map((await this.store.getCheckpoints(runId, seqs)).map((cp) => [cp.seq, cp]));
|
|
@@ -6689,7 +6874,10 @@ export class WorkflowEngine {
|
|
|
6689
6874
|
* late result for a terminal run — terminal is terminal).
|
|
6690
6875
|
*/
|
|
6691
6876
|
async settleRemoteCheckpoint(result) {
|
|
6692
|
-
const
|
|
6877
|
+
const forSettle = this.settleReader();
|
|
6878
|
+
const cp = forSettle
|
|
6879
|
+
? ((await forSettle(result.runId, [result.seq]))[0] ?? null)
|
|
6880
|
+
: await this.store.getCheckpoint(result.runId, result.seq);
|
|
6693
6881
|
if (!cp)
|
|
6694
6882
|
return undefined;
|
|
6695
6883
|
const run = await this.store.getRun(result.runId);
|
|
@@ -6732,31 +6920,25 @@ export class WorkflowEngine {
|
|
|
6732
6920
|
if (run && (run.status === 'failed' || run.status === 'dead')) {
|
|
6733
6921
|
await this.releaseQueueSlot(cp);
|
|
6734
6922
|
if (result.status === 'completed') {
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
});
|
|
6923
|
+
const salvage = { ...settlementOf(cp, result, new Date()), error: cp.error };
|
|
6924
|
+
if (this.store.settleCheckpointsIfPending) {
|
|
6925
|
+
await this.store.settleCheckpointsIfPending([salvage]);
|
|
6926
|
+
}
|
|
6927
|
+
else {
|
|
6928
|
+
await this.store.saveCheckpoint({ ...cp, ...salvage });
|
|
6929
|
+
}
|
|
6743
6930
|
}
|
|
6744
6931
|
return undefined;
|
|
6745
6932
|
}
|
|
6746
6933
|
const finishedAt = new Date();
|
|
6747
|
-
const
|
|
6934
|
+
const settlement = settlementOf(cp, result, finishedAt);
|
|
6935
|
+
const startedAt = settlement.startedAt;
|
|
6748
6936
|
// Claimed, not blindly written: another delivery settled it first, which is the redelivery case
|
|
6749
6937
|
// above — re-drive the resume rather than settle twice.
|
|
6750
|
-
const
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
error: result.error,
|
|
6755
|
-
events: result.events ?? cp.events,
|
|
6756
|
-
startedAt,
|
|
6757
|
-
finishedAt,
|
|
6758
|
-
});
|
|
6759
|
-
if (!claimed)
|
|
6938
|
+
const [claim] = await this.settlePendingCheckpoints([{ cp, settlement }]);
|
|
6939
|
+
if (typeof claim === 'object')
|
|
6940
|
+
throw claim.error;
|
|
6941
|
+
if (!claim)
|
|
6760
6942
|
return terminal ? undefined : cp;
|
|
6761
6943
|
await this.releaseQueueSlot(cp);
|
|
6762
6944
|
this.emit({
|
|
@@ -7041,6 +7223,19 @@ export class WorkflowEngine {
|
|
|
7041
7223
|
function conventionGroup(name, namespace) {
|
|
7042
7224
|
return tenantGroup(sanitizeQueueToken(name), namespace);
|
|
7043
7225
|
}
|
|
7226
|
+
/** What `result` settles onto its step's pending checkpoint `cp`. */
|
|
7227
|
+
function settlementOf(cp, result, finishedAt) {
|
|
7228
|
+
return {
|
|
7229
|
+
runId: cp.runId,
|
|
7230
|
+
seq: cp.seq,
|
|
7231
|
+
status: result.status,
|
|
7232
|
+
output: result.status === 'completed' ? result.output : cp.output,
|
|
7233
|
+
error: result.error,
|
|
7234
|
+
events: result.events ?? cp.events,
|
|
7235
|
+
startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
|
|
7236
|
+
finishedAt,
|
|
7237
|
+
};
|
|
7238
|
+
}
|
|
7044
7239
|
/** Key of an inline remote turn's liveness reset in `heartbeatResets` (step keys are `runId:seq`). */
|
|
7045
7240
|
function turnBeatKey(runId) {
|
|
7046
7241
|
return `turn:${runId}`;
|