@adonis-agora/durable 0.54.1 → 0.54.3
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 +88 -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/codec-state-store.d.ts +12 -1
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +38 -0
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +59 -3
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +232 -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 +60 -2
- 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 +6 -1
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +163 -37
- 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 +39 -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.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +220 -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 +22 -7
- 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
|
@@ -407,6 +407,14 @@ export class WorkflowEngine {
|
|
|
407
407
|
turnSettledFans = new Map();
|
|
408
408
|
/** The kept wake reasons each executing turn on this instance serves (see {@link execute}). */
|
|
409
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();
|
|
410
418
|
/** {@link sweepBufferedSignals}: the id the pass in progress resumes after, `undefined` between passes. */
|
|
411
419
|
bufferedSweepCursor;
|
|
412
420
|
bufferedSweepActive = false;
|
|
@@ -544,6 +552,7 @@ export class WorkflowEngine {
|
|
|
544
552
|
this.latest.get(run.workflow))?.singleton,
|
|
545
553
|
});
|
|
546
554
|
this.pool.bind(async (result) => {
|
|
555
|
+
this.heartbeatWrites.delete(result.stepId);
|
|
547
556
|
// In-memory path (a `timeoutMs` step awaiting on THIS instance): resolve its pending promise.
|
|
548
557
|
const waiter = this.pending.get(result.stepId);
|
|
549
558
|
if (waiter) {
|
|
@@ -1158,9 +1167,35 @@ export class WorkflowEngine {
|
|
|
1158
1167
|
if (this.heartbeatPersists.size > 1024)
|
|
1159
1168
|
this.heartbeatPersists.clear();
|
|
1160
1169
|
this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
.
|
|
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
|
+
}
|
|
1164
1199
|
}
|
|
1165
1200
|
/**
|
|
1166
1201
|
* Declare this process a worker: flush consumer subscriptions parked by a transport whose
|
|
@@ -2127,13 +2162,19 @@ export class WorkflowEngine {
|
|
|
2127
2162
|
* delivery's own resume no-oped against this turn's still-held lease (see the WorkflowSuspended
|
|
2128
2163
|
* catch in runExecution). Deferred one macrotask so the caller's `finally` releases the lease
|
|
2129
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`.
|
|
2130
2170
|
*/
|
|
2131
|
-
recheckWaitSeqs(runId, seqs) {
|
|
2171
|
+
recheckWaitSeqs(runId, seqs, settledHereResumes = false) {
|
|
2132
2172
|
this.trackEffect((async () => {
|
|
2133
2173
|
await new Promise((r) => setTimeout(r, 0));
|
|
2134
2174
|
for (const seq of seqs) {
|
|
2135
|
-
|
|
2136
|
-
|
|
2175
|
+
if (await this.checkpointSettled(runId, seq).catch(() => false)) {
|
|
2176
|
+
if (settledHereResumes && this.settledHere.has(`${runId}:${seq}`))
|
|
2177
|
+
return;
|
|
2137
2178
|
await this.resumeImplicit(runId).catch(() => undefined);
|
|
2138
2179
|
return;
|
|
2139
2180
|
}
|
|
@@ -2150,20 +2191,31 @@ export class WorkflowEngine {
|
|
|
2150
2191
|
this.trackEffect((async () => {
|
|
2151
2192
|
await new Promise((r) => setTimeout(r, 0));
|
|
2152
2193
|
for (const fan of fans) {
|
|
2153
|
-
const pending = await this.
|
|
2154
|
-
if (pending
|
|
2194
|
+
const pending = await this.stepFanHasPending(runId, fan.from, fan.to).catch(() => true);
|
|
2195
|
+
if (!pending) {
|
|
2155
2196
|
await this.resumeImplicit(runId).catch(() => undefined);
|
|
2156
2197
|
return;
|
|
2157
2198
|
}
|
|
2158
2199
|
}
|
|
2159
2200
|
})());
|
|
2160
2201
|
}
|
|
2161
|
-
/**
|
|
2162
|
-
async
|
|
2202
|
+
/** Whether the checkpoint at `seq` is settled: a `COUNT` when the store has one, else the row. */
|
|
2203
|
+
async checkpointSettled(runId, seq) {
|
|
2204
|
+
if (this.store.countCheckpoints) {
|
|
2205
|
+
return (await this.store.countCheckpoints(runId, seq, seq, ['completed', 'failed'])) > 0;
|
|
2206
|
+
}
|
|
2207
|
+
const cp = await this.store.getCheckpoint(runId, seq);
|
|
2208
|
+
return cp?.status === 'completed' || cp?.status === 'failed';
|
|
2209
|
+
}
|
|
2210
|
+
/** Whether any step of a step fan's seq range is still `pending`. */
|
|
2211
|
+
async stepFanHasPending(runId, from, to) {
|
|
2212
|
+
if (this.store.hasCheckpoints) {
|
|
2213
|
+
return this.store.hasCheckpoints(runId, from, to, ['pending']);
|
|
2214
|
+
}
|
|
2163
2215
|
if (this.store.countCheckpoints) {
|
|
2164
|
-
return this.store.countCheckpoints(runId, from, to, ['pending']);
|
|
2216
|
+
return (await this.store.countCheckpoints(runId, from, to, ['pending'])) > 0;
|
|
2165
2217
|
}
|
|
2166
|
-
return (await this.store.listCheckpoints(runId)).
|
|
2218
|
+
return (await this.store.listCheckpoints(runId)).some((c) => c.seq >= from && c.seq <= to && c.status === 'pending');
|
|
2167
2219
|
}
|
|
2168
2220
|
/**
|
|
2169
2221
|
* Track an in-flight execution so {@link drain} can wait for it.
|
|
@@ -4379,7 +4431,7 @@ export class WorkflowEngine {
|
|
|
4379
4431
|
const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
|
|
4380
4432
|
if (routePlan?.status === 'blocked')
|
|
4381
4433
|
return this.parkBlocked(run, routePlan);
|
|
4382
|
-
const checkpoints = await this.
|
|
4434
|
+
const checkpoints = await this.replayCheckpoints(run.id);
|
|
4383
4435
|
const handoff = liveHandoff(run.id, checkpoints);
|
|
4384
4436
|
if (handoff) {
|
|
4385
4437
|
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
@@ -4943,6 +4995,42 @@ export class WorkflowEngine {
|
|
|
4943
4995
|
/** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
|
|
4944
4996
|
* throttle for {@link rearmStepLease}, the sibling of {@link heartbeatPersists}. */
|
|
4945
4997
|
stepLeaseRearms = new Map();
|
|
4998
|
+
/**
|
|
4999
|
+
* {@link stepLeaseRearms} for the steps of a fan this instance sent, by run: one entry per sent
|
|
5000
|
+
* block of consecutive seqs instead of one per step, since they share the lease stamped at send.
|
|
5001
|
+
*/
|
|
5002
|
+
fanLeaseRearms = new Map();
|
|
5003
|
+
noteFanLease(runId, seqs, lease) {
|
|
5004
|
+
if (this.remoteRedispatchMs == null || seqs.length === 0)
|
|
5005
|
+
return;
|
|
5006
|
+
let from = seqs[0];
|
|
5007
|
+
let to = from;
|
|
5008
|
+
for (const seq of seqs) {
|
|
5009
|
+
if (seq < from)
|
|
5010
|
+
from = seq;
|
|
5011
|
+
if (seq > to)
|
|
5012
|
+
to = seq;
|
|
5013
|
+
}
|
|
5014
|
+
// A gap would cover steps sent earlier, under an older lease.
|
|
5015
|
+
if (to - from + 1 !== seqs.length)
|
|
5016
|
+
return;
|
|
5017
|
+
if (this.fanLeaseRearms.size > 1024)
|
|
5018
|
+
this.fanLeaseRearms.clear();
|
|
5019
|
+
const blocks = this.fanLeaseRearms.get(runId) ?? [];
|
|
5020
|
+
blocks.push({ from, to, lookAgainAt: lease - Math.ceil(this.remoteRedispatchMs / 2) });
|
|
5021
|
+
this.fanLeaseRearms.set(runId, blocks);
|
|
5022
|
+
}
|
|
5023
|
+
fanLeaseLookAgainAt(runId, seq, now) {
|
|
5024
|
+
const blocks = this.fanLeaseRearms.get(runId);
|
|
5025
|
+
if (!blocks)
|
|
5026
|
+
return undefined;
|
|
5027
|
+
const live = blocks.filter((b) => now < b.lookAgainAt);
|
|
5028
|
+
if (live.length === 0)
|
|
5029
|
+
this.fanLeaseRearms.delete(runId);
|
|
5030
|
+
else if (live.length < blocks.length)
|
|
5031
|
+
this.fanLeaseRearms.set(runId, live);
|
|
5032
|
+
return live.find((b) => seq >= b.from && seq <= b.to)?.lookAgainAt;
|
|
5033
|
+
}
|
|
4946
5034
|
/**
|
|
4947
5035
|
* A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
|
|
4948
5036
|
* that is still working on it is never presumed dead and double-dispatched. The durable counterpart
|
|
@@ -4989,7 +5077,7 @@ export class WorkflowEngine {
|
|
|
4989
5077
|
return;
|
|
4990
5078
|
const id = stepId(runId, seq);
|
|
4991
5079
|
const now = this.clock();
|
|
4992
|
-
const lookAgainAt = this.stepLeaseRearms.get(id);
|
|
5080
|
+
const lookAgainAt = this.stepLeaseRearms.get(id) ?? this.fanLeaseLookAgainAt(runId, seq, now);
|
|
4993
5081
|
if (lookAgainAt !== undefined && now < lookAgainAt)
|
|
4994
5082
|
return;
|
|
4995
5083
|
const cp = await this.store.getCheckpoint(runId, seq);
|
|
@@ -5397,7 +5485,7 @@ export class WorkflowEngine {
|
|
|
5397
5485
|
// Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
|
|
5398
5486
|
// this resume wakes on, or one written later in this same execution) is absent from the map, and
|
|
5399
5487
|
// the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
|
|
5400
|
-
const snapshot = await this.
|
|
5488
|
+
const snapshot = await this.replayCheckpoints(run.id);
|
|
5401
5489
|
const replay = new Map();
|
|
5402
5490
|
for (const cp of snapshot)
|
|
5403
5491
|
replay.set(cp.seq, cp);
|
|
@@ -5490,7 +5578,7 @@ export class WorkflowEngine {
|
|
|
5490
5578
|
// all with reconcileMs: 0). Re-check the seqs this suspension waits on and re-drive if one
|
|
5491
5579
|
// already settled.
|
|
5492
5580
|
if (settled.status === 'suspended' && err.waitSeqs?.length) {
|
|
5493
|
-
this.recheckWaitSeqs(run.id, err.waitSeqs);
|
|
5581
|
+
this.recheckWaitSeqs(run.id, err.waitSeqs, true);
|
|
5494
5582
|
}
|
|
5495
5583
|
if (settled.status === 'suspended' && err.stepFans?.length) {
|
|
5496
5584
|
this.recheckStepFans(run.id, err.stepFans);
|
|
@@ -6311,7 +6399,11 @@ export class WorkflowEngine {
|
|
|
6311
6399
|
? unsent
|
|
6312
6400
|
: unsent.slice(0, opts.concurrency);
|
|
6313
6401
|
if (batch.length > 0) {
|
|
6314
|
-
await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
|
|
6402
|
+
const lease = await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
|
|
6403
|
+
if (lease !== undefined) {
|
|
6404
|
+
const at = this.stepLeaseWakeAt(lease);
|
|
6405
|
+
wakeAt = Math.min(wakeAt ?? at, at);
|
|
6406
|
+
}
|
|
6315
6407
|
}
|
|
6316
6408
|
if (inFlight + batch.length > 0) {
|
|
6317
6409
|
throw new WorkflowSuspended(wakeAt, undefined, undefined, [{ from: first, to: last }]);
|
|
@@ -6343,7 +6435,8 @@ export class WorkflowEngine {
|
|
|
6343
6435
|
/**
|
|
6344
6436
|
* Write the `pending` checkpoints of a step fan's new steps, then dispatch them: one
|
|
6345
6437
|
* {@link StateStore.saveCheckpoints} call and one {@link TransportPool.dispatchMany} for the batch,
|
|
6346
|
-
* where the store and transport have them.
|
|
6438
|
+
* where the store and transport have them. With the lost-dispatch self-heal on, each step is
|
|
6439
|
+
* written with its lease already stamped, and the lease is returned for the run to park on.
|
|
6347
6440
|
*/
|
|
6348
6441
|
async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
|
|
6349
6442
|
const token = stepGroup(step, runNamespace);
|
|
@@ -6355,6 +6448,7 @@ export class WorkflowEngine {
|
|
|
6355
6448
|
for (const i of batch)
|
|
6356
6449
|
validInputs.set(i, await input(i));
|
|
6357
6450
|
const enqueuedAt = new Date();
|
|
6451
|
+
const lease = this.remoteRedispatchMs == null ? undefined : this.clock() + this.remoteRedispatchMs;
|
|
6358
6452
|
const checkpoints = batch.map((i) => {
|
|
6359
6453
|
const seq = positions[i];
|
|
6360
6454
|
return {
|
|
@@ -6371,6 +6465,7 @@ export class WorkflowEngine {
|
|
|
6371
6465
|
enqueuedAt,
|
|
6372
6466
|
startedAt: enqueuedAt,
|
|
6373
6467
|
finishedAt: enqueuedAt,
|
|
6468
|
+
...(lease !== undefined ? { wakeAt: lease } : {}),
|
|
6374
6469
|
};
|
|
6375
6470
|
});
|
|
6376
6471
|
if (this.store.saveCheckpoints) {
|
|
@@ -6398,6 +6493,10 @@ export class WorkflowEngine {
|
|
|
6398
6493
|
for (const cp of checkpoints) {
|
|
6399
6494
|
this.emit({ type: 'step.started', runId, seq: cp.seq, name: step.name, kind: 'remote' });
|
|
6400
6495
|
}
|
|
6496
|
+
if (lease !== undefined) {
|
|
6497
|
+
this.noteFanLease(runId, checkpoints.map((cp) => cp.seq), lease);
|
|
6498
|
+
}
|
|
6499
|
+
return lease;
|
|
6401
6500
|
}
|
|
6402
6501
|
/**
|
|
6403
6502
|
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
@@ -6418,6 +6517,13 @@ export class WorkflowEngine {
|
|
|
6418
6517
|
attempt: task.attempt,
|
|
6419
6518
|
}, task.transport);
|
|
6420
6519
|
}
|
|
6520
|
+
/** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
|
|
6521
|
+
* column through {@link StateStore.listCheckpoints} on a store without it. */
|
|
6522
|
+
replayCheckpoints(runId) {
|
|
6523
|
+
return this.store.listCheckpointsForReplay
|
|
6524
|
+
? this.store.listCheckpointsForReplay(runId)
|
|
6525
|
+
: this.store.listCheckpoints(runId);
|
|
6526
|
+
}
|
|
6421
6527
|
/**
|
|
6422
6528
|
* Complete a durable remote step from its worker result and resume the run — runs on whichever
|
|
6423
6529
|
* instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
|
|
@@ -6500,8 +6606,28 @@ export class WorkflowEngine {
|
|
|
6500
6606
|
return;
|
|
6501
6607
|
if (await this.stepFanStillRunning(cp, result, queuedIn))
|
|
6502
6608
|
return;
|
|
6609
|
+
if (!parseStepFanGroup(cp.parallelGroup))
|
|
6610
|
+
this.noteSettledHere(result.runId, [cp.seq]);
|
|
6503
6611
|
this.resumeInBackground(result.runId, fanWakeOf(cp, result));
|
|
6504
6612
|
}
|
|
6613
|
+
/**
|
|
6614
|
+
* Record `seqs` of `runId` in {@link settledHere}, dropping the entries older than a lease. A key is
|
|
6615
|
+
* re-inserted at the end, so the map stays in the order its keys were noted and pruning stops at
|
|
6616
|
+
* the first fresh entry.
|
|
6617
|
+
*/
|
|
6618
|
+
noteSettledHere(runId, seqs) {
|
|
6619
|
+
const now = this.clock();
|
|
6620
|
+
for (const seq of seqs) {
|
|
6621
|
+
const key = `${runId}:${seq}`;
|
|
6622
|
+
this.settledHere.delete(key);
|
|
6623
|
+
this.settledHere.set(key, now);
|
|
6624
|
+
}
|
|
6625
|
+
for (const [key, at] of this.settledHere) {
|
|
6626
|
+
if (now - at <= this.leaseMs)
|
|
6627
|
+
break;
|
|
6628
|
+
this.settledHere.delete(key);
|
|
6629
|
+
}
|
|
6630
|
+
}
|
|
6505
6631
|
resumeInBackground(runId, fan) {
|
|
6506
6632
|
this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
|
|
6507
6633
|
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)}`);
|
|
@@ -6565,20 +6691,11 @@ export class WorkflowEngine {
|
|
|
6565
6691
|
resumable.push({ cp: read.value, result });
|
|
6566
6692
|
});
|
|
6567
6693
|
const finishedAt = new Date();
|
|
6568
|
-
const writes = pending.map(({ index, cp }) => {
|
|
6569
|
-
const result = results[index];
|
|
6570
|
-
return {
|
|
6571
|
-
...cp,
|
|
6572
|
-
status: result.status,
|
|
6573
|
-
output: result.status === 'completed' ? result.output : cp.output,
|
|
6574
|
-
error: result.error,
|
|
6575
|
-
events: result.events ?? cp.events,
|
|
6576
|
-
startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
|
|
6577
|
-
finishedAt,
|
|
6578
|
-
};
|
|
6579
|
-
});
|
|
6580
6694
|
// Claimed, not blindly written: a duplicate delivery may have settled a step first.
|
|
6581
|
-
const claims = await this.
|
|
6695
|
+
const claims = await this.settlePendingCheckpoints(pending.map(({ index, cp }) => ({
|
|
6696
|
+
cp,
|
|
6697
|
+
settlement: settlementOf(cp, results[index], finishedAt),
|
|
6698
|
+
})));
|
|
6582
6699
|
for (const [k, claim] of claims.entries()) {
|
|
6583
6700
|
const { index, cp } = pending[k];
|
|
6584
6701
|
const result = results[index];
|
|
@@ -6624,9 +6741,14 @@ export class WorkflowEngine {
|
|
|
6624
6741
|
if (fan)
|
|
6625
6742
|
highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
|
|
6626
6743
|
}
|
|
6744
|
+
// The one resume below replays every step of the batch.
|
|
6745
|
+
const singles = resumable
|
|
6746
|
+
.filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
|
|
6747
|
+
.map(({ cp }) => cp.seq);
|
|
6627
6748
|
for (const { cp, result } of resumable) {
|
|
6628
6749
|
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
6629
6750
|
if (!fan || result.status === 'failed') {
|
|
6751
|
+
this.noteSettledHere(runId, singles);
|
|
6630
6752
|
this.resumeInBackground(runId);
|
|
6631
6753
|
return outcomes;
|
|
6632
6754
|
}
|
|
@@ -6635,10 +6757,11 @@ export class WorkflowEngine {
|
|
|
6635
6757
|
const lastSeq = fan.firstSeq + fan.size - 1;
|
|
6636
6758
|
closed = queuedIn(fan.firstSeq, lastSeq)
|
|
6637
6759
|
? Promise.resolve(true)
|
|
6638
|
-
: this.
|
|
6760
|
+
: this.stepFanHasPending(runId, fan.firstSeq, lastSeq).catch(() => false);
|
|
6639
6761
|
barriers.set(fan.firstSeq, closed);
|
|
6640
6762
|
}
|
|
6641
6763
|
if (!(await closed)) {
|
|
6764
|
+
this.noteSettledHere(runId, singles);
|
|
6642
6765
|
this.resumeInBackground(runId, {
|
|
6643
6766
|
first: fan.firstSeq,
|
|
6644
6767
|
seq: highest.get(fan.firstSeq) ?? cp.seq,
|
|
@@ -6648,6 +6771,29 @@ export class WorkflowEngine {
|
|
|
6648
6771
|
}
|
|
6649
6772
|
return outcomes;
|
|
6650
6773
|
}
|
|
6774
|
+
/**
|
|
6775
|
+
* Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
|
|
6776
|
+
* or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}
|
|
6777
|
+
* when the store has it: one call, or one per entry when that call rejects (it then wrote
|
|
6778
|
+
* nothing). Else each step's whole row is written through {@link savePendingCheckpoints}.
|
|
6779
|
+
*/
|
|
6780
|
+
async settlePendingCheckpoints(entries) {
|
|
6781
|
+
const settle = this.store.settleCheckpointsIfPending?.bind(this.store);
|
|
6782
|
+
if (!settle) {
|
|
6783
|
+
return this.savePendingCheckpoints(entries.map(({ cp, settlement }) => ({ ...cp, ...settlement })));
|
|
6784
|
+
}
|
|
6785
|
+
const settlements = entries.map((e) => e.settlement);
|
|
6786
|
+
try {
|
|
6787
|
+
const wrote = await settle(settlements);
|
|
6788
|
+
return settlements.map((_, i) => wrote[i] === true);
|
|
6789
|
+
}
|
|
6790
|
+
catch (error) {
|
|
6791
|
+
if (settlements.length === 1)
|
|
6792
|
+
return [{ error }];
|
|
6793
|
+
}
|
|
6794
|
+
const settled = await Promise.allSettled(settlements.map((s) => settle([s])));
|
|
6795
|
+
return settled.map((r) => r.status === 'fulfilled' ? r.value[0] === true : { error: r.reason });
|
|
6796
|
+
}
|
|
6651
6797
|
/**
|
|
6652
6798
|
* {@link savePendingCheckpoint} for checkpoints at distinct seqs: one
|
|
6653
6799
|
* {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
|
|
@@ -6682,7 +6828,17 @@ export class WorkflowEngine {
|
|
|
6682
6828
|
// Left to the result of this fan queued behind this one (see `completeRemoteResults`).
|
|
6683
6829
|
if (queuedIn(fan.firstSeq, lastSeq))
|
|
6684
6830
|
return true;
|
|
6685
|
-
return
|
|
6831
|
+
return this.stepFanHasPending(result.runId, fan.firstSeq, lastSeq);
|
|
6832
|
+
}
|
|
6833
|
+
/**
|
|
6834
|
+
* {@link StateStore.getCheckpointsForSettle}, when the store can use what it leaves out: only
|
|
6835
|
+
* with {@link StateStore.settleCheckpointsIfPending}, since a settle without it (and the salvage
|
|
6836
|
+
* of a late success onto a failed run) writes back the whole row it read.
|
|
6837
|
+
*/
|
|
6838
|
+
settleReader() {
|
|
6839
|
+
return this.store.settleCheckpointsIfPending
|
|
6840
|
+
? this.store.getCheckpointsForSettle?.bind(this.store)
|
|
6841
|
+
: undefined;
|
|
6686
6842
|
}
|
|
6687
6843
|
/**
|
|
6688
6844
|
* The checkpoints of one run at `seqs`, each as its own outcome: one
|
|
@@ -6690,6 +6846,19 @@ export class WorkflowEngine {
|
|
|
6690
6846
|
* call rejecting falls back to one read each, so a failing read fails only its own entry.
|
|
6691
6847
|
*/
|
|
6692
6848
|
async readCheckpoints(runId, seqs) {
|
|
6849
|
+
const forSettle = this.settleReader();
|
|
6850
|
+
if (forSettle) {
|
|
6851
|
+
if (seqs.length > 1) {
|
|
6852
|
+
try {
|
|
6853
|
+
const bySeq = new Map((await forSettle(runId, seqs)).map((cp) => [cp.seq, cp]));
|
|
6854
|
+
return seqs.map((seq) => ({ status: 'fulfilled', value: bySeq.get(seq) ?? null }));
|
|
6855
|
+
}
|
|
6856
|
+
catch {
|
|
6857
|
+
// Fall through: one by one.
|
|
6858
|
+
}
|
|
6859
|
+
}
|
|
6860
|
+
return Promise.allSettled(seqs.map(async (seq) => (await forSettle(runId, [seq]))[0] ?? null));
|
|
6861
|
+
}
|
|
6693
6862
|
if (seqs.length > 1 && this.store.getCheckpoints) {
|
|
6694
6863
|
try {
|
|
6695
6864
|
const bySeq = new Map((await this.store.getCheckpoints(runId, seqs)).map((cp) => [cp.seq, cp]));
|
|
@@ -6708,7 +6877,10 @@ export class WorkflowEngine {
|
|
|
6708
6877
|
* late result for a terminal run — terminal is terminal).
|
|
6709
6878
|
*/
|
|
6710
6879
|
async settleRemoteCheckpoint(result) {
|
|
6711
|
-
const
|
|
6880
|
+
const forSettle = this.settleReader();
|
|
6881
|
+
const cp = forSettle
|
|
6882
|
+
? ((await forSettle(result.runId, [result.seq]))[0] ?? null)
|
|
6883
|
+
: await this.store.getCheckpoint(result.runId, result.seq);
|
|
6712
6884
|
if (!cp)
|
|
6713
6885
|
return undefined;
|
|
6714
6886
|
const run = await this.store.getRun(result.runId);
|
|
@@ -6751,31 +6923,25 @@ export class WorkflowEngine {
|
|
|
6751
6923
|
if (run && (run.status === 'failed' || run.status === 'dead')) {
|
|
6752
6924
|
await this.releaseQueueSlot(cp);
|
|
6753
6925
|
if (result.status === 'completed') {
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
});
|
|
6926
|
+
const salvage = { ...settlementOf(cp, result, new Date()), error: cp.error };
|
|
6927
|
+
if (this.store.settleCheckpointsIfPending) {
|
|
6928
|
+
await this.store.settleCheckpointsIfPending([salvage]);
|
|
6929
|
+
}
|
|
6930
|
+
else {
|
|
6931
|
+
await this.store.saveCheckpoint({ ...cp, ...salvage });
|
|
6932
|
+
}
|
|
6762
6933
|
}
|
|
6763
6934
|
return undefined;
|
|
6764
6935
|
}
|
|
6765
6936
|
const finishedAt = new Date();
|
|
6766
|
-
const
|
|
6937
|
+
const settlement = settlementOf(cp, result, finishedAt);
|
|
6938
|
+
const startedAt = settlement.startedAt;
|
|
6767
6939
|
// Claimed, not blindly written: another delivery settled it first, which is the redelivery case
|
|
6768
6940
|
// above — re-drive the resume rather than settle twice.
|
|
6769
|
-
const
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
error: result.error,
|
|
6774
|
-
events: result.events ?? cp.events,
|
|
6775
|
-
startedAt,
|
|
6776
|
-
finishedAt,
|
|
6777
|
-
});
|
|
6778
|
-
if (!claimed)
|
|
6941
|
+
const [claim] = await this.settlePendingCheckpoints([{ cp, settlement }]);
|
|
6942
|
+
if (typeof claim === 'object')
|
|
6943
|
+
throw claim.error;
|
|
6944
|
+
if (!claim)
|
|
6779
6945
|
return terminal ? undefined : cp;
|
|
6780
6946
|
await this.releaseQueueSlot(cp);
|
|
6781
6947
|
this.emit({
|
|
@@ -7060,6 +7226,19 @@ export class WorkflowEngine {
|
|
|
7060
7226
|
function conventionGroup(name, namespace) {
|
|
7061
7227
|
return tenantGroup(sanitizeQueueToken(name), namespace);
|
|
7062
7228
|
}
|
|
7229
|
+
/** What `result` settles onto its step's pending checkpoint `cp`. */
|
|
7230
|
+
function settlementOf(cp, result, finishedAt) {
|
|
7231
|
+
return {
|
|
7232
|
+
runId: cp.runId,
|
|
7233
|
+
seq: cp.seq,
|
|
7234
|
+
status: result.status,
|
|
7235
|
+
output: result.status === 'completed' ? result.output : cp.output,
|
|
7236
|
+
error: result.error,
|
|
7237
|
+
events: result.events ?? cp.events,
|
|
7238
|
+
startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
|
|
7239
|
+
finishedAt,
|
|
7240
|
+
};
|
|
7241
|
+
}
|
|
7063
7242
|
/** Key of an inline remote turn's liveness reset in `heartbeatResets` (step keys are `runId:seq`). */
|
|
7064
7243
|
function turnBeatKey(runId) {
|
|
7065
7244
|
return `turn:${runId}`;
|