@adonis-agora/durable 0.59.0 → 0.60.0
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 +24 -0
- package/dist/assets/spa/assets/index-70hGzOnT.css +1 -0
- package/dist/assets/spa/assets/index-BI1EANJO.js +16 -0
- package/dist/assets/spa/assets/index-BI1EANJO.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +3 -0
- package/dist/providers/durable_provider.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 +25 -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/define_config.d.ts +13 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/engine.d.ts +75 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +510 -23
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +4 -0
- package/dist/src/errors.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 +94 -1
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js +11 -0
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/sticky-continuation.d.ts +141 -0
- package/dist/src/sticky-continuation.d.ts.map +1 -0
- package/dist/src/sticky-continuation.js +231 -0
- package/dist/src/sticky-continuation.js.map +1 -0
- package/dist/src/stores/factory.d.ts +6 -0
- package/dist/src/stores/factory.d.ts.map +1 -1
- package/dist/src/stores/factory.js +3 -0
- package/dist/src/stores/factory.js.map +1 -1
- package/dist/src/stores/lucid-pg-hot-path.d.ts +30 -0
- package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -0
- package/dist/src/stores/lucid-pg-hot-path.js +67 -0
- package/dist/src/stores/lucid-pg-hot-path.js.map +1 -0
- package/dist/src/stores/lucid.d.ts +30 -1
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +456 -17
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +12 -1
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +101 -1
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +199 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transport-pool.d.ts +10 -0
- package/dist/src/transport-pool.d.ts.map +1 -1
- package/dist/src/transport-pool.js +23 -2
- package/dist/src/transport-pool.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +41 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +283 -5
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts +12 -1
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
- package/dist/src/transports/bullmq/deps.js.map +1 -1
- package/dist/src/transports/bullmq/naming.d.ts +10 -0
- package/dist/src/transports/bullmq/naming.d.ts.map +1 -1
- package/dist/src/transports/bullmq/naming.js +16 -0
- package/dist/src/transports/bullmq/naming.js.map +1 -1
- package/dist/src/transports/db.d.ts +3 -1
- package/dist/src/transports/db.d.ts.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 +3 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +2 -2
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/transports/worker-keys.d.ts +2 -1
- package/dist/src/transports/worker-keys.d.ts.map +1 -1
- package/dist/src/transports/worker-keys.js +3 -2
- package/dist/src/transports/worker-keys.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts +7 -0
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +28 -1
- package/dist/src/workflow-ctx.js.map +1 -1
- package/package.json +3 -3
- package/dist/assets/spa/assets/index-Co0ef3-q.css +0 -1
- package/dist/assets/spa/assets/index-DmgfShPp.js +0 -16
- package/dist/assets/spa/assets/index-DmgfShPp.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -35,6 +35,7 @@ import { ScheduleClient } from './schedules.js';
|
|
|
35
35
|
import { SINGLETON_RESERVED_TAGS, withoutAdmission, withSingletonTracked, } from './singleton-admission.js';
|
|
36
36
|
import { SingletonGate } from './singleton-gate.js';
|
|
37
37
|
import { validateStandard, validateStandardSync, } from './standard-schema.js';
|
|
38
|
+
import { StickyCache, StickyContinuation } from './sticky-continuation.js';
|
|
38
39
|
import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
|
|
39
40
|
import { TransportPool } from './transport-pool.js';
|
|
40
41
|
import { workflowAls } from './workflow-als.js';
|
|
@@ -49,6 +50,10 @@ function mergeTags(staticTags, runTags) {
|
|
|
49
50
|
/** A breakpoint checkpoint's `name` is `breakpoint` (or `breakpoint:<label>`). This name — not the
|
|
50
51
|
* reused `signal` kind — is the explicit marker the dashboard and `continue()` detect it by. */
|
|
51
52
|
const BREAKPOINT = 'breakpoint';
|
|
53
|
+
/** Default size of the sticky continuation cache (see {@link WorkflowEngineDeps.stickyRuns}). */
|
|
54
|
+
const DEFAULT_STICKY_RUNS = 2000;
|
|
55
|
+
/** Default idle limit of a parked body in the sticky cache. */
|
|
56
|
+
const DEFAULT_STICKY_IDLE_MS = 300_000;
|
|
52
57
|
const isBreakpoint = (cp) => cp.status === 'pending' && cp.name.startsWith(BREAKPOINT);
|
|
53
58
|
/**
|
|
54
59
|
* Every `RunStatus` from which a turn's outcome (`completed` / `failed` / `suspended` / `blocked`) is a
|
|
@@ -162,6 +167,8 @@ const leaseRefused = new WeakMap();
|
|
|
162
167
|
* parent's next turn.
|
|
163
168
|
*/
|
|
164
169
|
const LOCAL_RUN_CONCURRENCY = 256;
|
|
170
|
+
/** Pending runs one {@link WorkflowEngine.runPending} poll takes at most. */
|
|
171
|
+
const PENDING_POLL_BATCH = 100;
|
|
165
172
|
/** How many dispatched runs this instance takes from the transport's run queue at once. */
|
|
166
173
|
const RUN_CONSUMER_CONCURRENCY = 64;
|
|
167
174
|
/** The most runs handed to {@link Transport.dispatchRuns} in one call. */
|
|
@@ -285,6 +292,16 @@ export class WorkflowEngine {
|
|
|
285
292
|
maxRecoveryAttempts;
|
|
286
293
|
unregisteredWorkflowGraceMs;
|
|
287
294
|
reconcileMs;
|
|
295
|
+
/** Parked workflow bodies kept between turns (see {@link WorkflowEngineDeps.stickyRuns}). */
|
|
296
|
+
sticky;
|
|
297
|
+
/** Pending runs claimed by {@link runPending} for the turn about to run them, as read by the claim. */
|
|
298
|
+
claimedRuns = new Map();
|
|
299
|
+
/** The continuation each turn executing here runs in, by run id, while the turn runs. */
|
|
300
|
+
turnContinuations = new Map();
|
|
301
|
+
/** Whether a transport routes this instance's step results back to it (see `routeReplies`). */
|
|
302
|
+
repliesRouted = false;
|
|
303
|
+
/** The run row stamp (`updatedAt`, epoch ms) a turn here wrote when it settled `suspended`. */
|
|
304
|
+
suspendStamps = new Map();
|
|
288
305
|
heartbeatPersistThrottleMs;
|
|
289
306
|
timerBatchSize;
|
|
290
307
|
eventBufferTtlMs;
|
|
@@ -544,6 +561,9 @@ export class WorkflowEngine {
|
|
|
544
561
|
reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
|
|
545
562
|
? 300_000
|
|
546
563
|
: reconcile || undefined;
|
|
564
|
+
const stickyRuns = deps.stickyRuns ?? DEFAULT_STICKY_RUNS;
|
|
565
|
+
const stickyIdle = deps.stickyIdleMs ?? DEFAULT_STICKY_IDLE_MS;
|
|
566
|
+
this.sticky = new StickyCache(Number.isFinite(stickyRuns) ? Math.max(0, Math.floor(stickyRuns)) : DEFAULT_STICKY_RUNS, Number.isFinite(stickyIdle) && stickyIdle >= 0 ? stickyIdle : DEFAULT_STICKY_IDLE_MS);
|
|
547
567
|
// Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
|
|
548
568
|
// cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
|
|
549
569
|
const throttle = deps.heartbeatPersistThrottleMs;
|
|
@@ -673,6 +693,11 @@ export class WorkflowEngine {
|
|
|
673
693
|
async (event) => {
|
|
674
694
|
await this.persistStepEvent(event);
|
|
675
695
|
});
|
|
696
|
+
// Step results come back to the instance holding the body parked on the step, where a transport
|
|
697
|
+
// can route them (see WorkflowEngineDeps.routeReplies).
|
|
698
|
+
if ((deps.routeReplies ?? true) && this.sticky.enabled) {
|
|
699
|
+
this.repliesRouted = this.pool.routeReplies(this.instanceId);
|
|
700
|
+
}
|
|
676
701
|
// Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
|
|
677
702
|
// subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
|
|
678
703
|
// echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
|
|
@@ -3609,7 +3634,38 @@ export class WorkflowEngine {
|
|
|
3609
3634
|
});
|
|
3610
3635
|
}
|
|
3611
3636
|
async resumeRunNow(runId, allowFailed) {
|
|
3612
|
-
|
|
3637
|
+
// A run just claimed for this turn (runPending) is held, and was read by the claim.
|
|
3638
|
+
const claimed = this.claimedRuns.get(runId);
|
|
3639
|
+
if (claimed) {
|
|
3640
|
+
this.claimedRuns.delete(runId);
|
|
3641
|
+
return this.resumeLoadedRun(claimed, runId, allowFailed);
|
|
3642
|
+
}
|
|
3643
|
+
// Take the lease and read the run in one round trip when the store can: `execute` then finds
|
|
3644
|
+
// the lease held for this turn. Not when a caller already holds it for this turn (a leased
|
|
3645
|
+
// sweep) or a turn of the run is in flight here; a failed take reads the run as before.
|
|
3646
|
+
const lockAndGet = this.lockedForTurn.has(runId) || this.executionLeases.has(runId)
|
|
3647
|
+
? undefined
|
|
3648
|
+
: this.store.tryLockRunAndGet;
|
|
3649
|
+
let run = null;
|
|
3650
|
+
if (lockAndGet) {
|
|
3651
|
+
const nowMs = this.clock();
|
|
3652
|
+
run = await lockAndGet.call(this.store, runId, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
3653
|
+
}
|
|
3654
|
+
if (!run)
|
|
3655
|
+
return this.resumeLoadedRun(await this.store.getRun(runId), runId, allowFailed);
|
|
3656
|
+
this.lockedForTurn.add(runId);
|
|
3657
|
+
try {
|
|
3658
|
+
return await this.resumeLoadedRun(run, runId, allowFailed);
|
|
3659
|
+
}
|
|
3660
|
+
finally {
|
|
3661
|
+
// Still marked: the turn never started (a finished run, another namespace, a registration
|
|
3662
|
+
// that failed), so the lease taken above goes back.
|
|
3663
|
+
if (this.lockedForTurn.delete(runId)) {
|
|
3664
|
+
await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
async resumeLoadedRun(run, runId, allowFailed) {
|
|
3613
3669
|
if (!run)
|
|
3614
3670
|
throw new Error(`run ${runId} not found`);
|
|
3615
3671
|
// A definitively-finished run must not be re-executed (e.g. a worker result landing after the
|
|
@@ -3750,6 +3806,8 @@ export class WorkflowEngine {
|
|
|
3750
3806
|
*/
|
|
3751
3807
|
beginDrain(spentMs = 0) {
|
|
3752
3808
|
this.draining = true;
|
|
3809
|
+
// Parked bodies are only an optimization: the runs replay wherever they resume next.
|
|
3810
|
+
this.sticky.clear();
|
|
3753
3811
|
// Tell the local steps still running: a long one can stop between units of work instead of
|
|
3754
3812
|
// being cut off when the drain budget runs out.
|
|
3755
3813
|
if (!this.drainAbort.signal.aborted)
|
|
@@ -4661,9 +4719,58 @@ export class WorkflowEngine {
|
|
|
4661
4719
|
async runPending(nowMs = this.clock()) {
|
|
4662
4720
|
// Oldest-first (FIFO), capped per call so a backlog drains over several polls without one sweep
|
|
4663
4721
|
// fetching unboundedly. A run not picked up this tick is picked up the next.
|
|
4664
|
-
|
|
4722
|
+
// A store that can claims a batch in one statement (rows another instance is claiming are
|
|
4723
|
+
// skipped, not raced for), and the batch runs concurrently, within this engine's run budget.
|
|
4724
|
+
const claim = this.store.claimPendingRuns;
|
|
4725
|
+
if (claim) {
|
|
4726
|
+
if (this.draining)
|
|
4727
|
+
return [];
|
|
4728
|
+
const room = Math.min(PENDING_POLL_BATCH, LOCAL_RUN_CONCURRENCY - this.inflight.size);
|
|
4729
|
+
if (room <= 0)
|
|
4730
|
+
return [];
|
|
4731
|
+
const claimed = await asHeartbeat(() => claim.call(this.store, room, this.instanceId, nowMs + this.leaseMs, nowMs, this.namespace));
|
|
4732
|
+
return this.runClaimed(claimed, nowMs);
|
|
4733
|
+
}
|
|
4734
|
+
const pending = await asHeartbeat(() => this.store.listPendingRuns(PENDING_POLL_BATCH, this.namespace));
|
|
4665
4735
|
return this.resumeLeased(pending, nowMs);
|
|
4666
4736
|
}
|
|
4737
|
+
/**
|
|
4738
|
+
* Run `claimed` (pending runs this instance just leased, read as locked): every turn starts at
|
|
4739
|
+
* once, and each re-enters the lease taken for it without reading the run again. A run this
|
|
4740
|
+
* instance cannot resume is skipped and logged, as {@link resumeLeased} does; its lease lapses.
|
|
4741
|
+
*/
|
|
4742
|
+
async runClaimed(claimed, nowMs) {
|
|
4743
|
+
const turns = claimed.map(async (run) => {
|
|
4744
|
+
if (this.draining) {
|
|
4745
|
+
// Drain began after the claim: give the lease back; the run stays for another instance.
|
|
4746
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
4747
|
+
return undefined;
|
|
4748
|
+
}
|
|
4749
|
+
this.lockedForTurn.add(run.id);
|
|
4750
|
+
this.claimedRuns.set(run.id, run);
|
|
4751
|
+
try {
|
|
4752
|
+
return await this.resumeImplicit(run.id);
|
|
4753
|
+
}
|
|
4754
|
+
catch (error) {
|
|
4755
|
+
if (error instanceof NamespaceMismatch)
|
|
4756
|
+
return undefined;
|
|
4757
|
+
if (error instanceof WorkflowNotRegistered) {
|
|
4758
|
+
const settled = await this.settleUnregistered(run, nowMs).catch(() => undefined);
|
|
4759
|
+
if (settled)
|
|
4760
|
+
return settled;
|
|
4761
|
+
if (settled !== undefined)
|
|
4762
|
+
return undefined;
|
|
4763
|
+
}
|
|
4764
|
+
console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
|
|
4765
|
+
return undefined;
|
|
4766
|
+
}
|
|
4767
|
+
finally {
|
|
4768
|
+
this.lockedForTurn.delete(run.id);
|
|
4769
|
+
this.claimedRuns.delete(run.id);
|
|
4770
|
+
}
|
|
4771
|
+
});
|
|
4772
|
+
return (await Promise.all(turns)).filter((r) => r !== undefined);
|
|
4773
|
+
}
|
|
4667
4774
|
/**
|
|
4668
4775
|
* Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
|
|
4669
4776
|
* suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
|
|
@@ -6033,13 +6140,20 @@ export class WorkflowEngine {
|
|
|
6033
6140
|
// conditional write IS the guard — it subsumes the read-then-write this branch used to do (that
|
|
6034
6141
|
// was itself a narrower, TOCTOU-prone version of the same check, and only looked for `cancelled`;
|
|
6035
6142
|
// this now also catches `completed`/`dead` reached via any other path mid-turn).
|
|
6143
|
+
// Strictly later than the row this turn read: a parked body is recognized by the exact stamp its
|
|
6144
|
+
// suspend wrote (see takeContinuation), so no later settle, by any instance with any clock, may
|
|
6145
|
+
// write that same stamp again, even within the same millisecond.
|
|
6146
|
+
const stamp = new Date(Math.max(updatedAt.getTime(), run.updatedAt.getTime() + 1));
|
|
6036
6147
|
const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, await this.withRunTrace(run, {
|
|
6037
6148
|
status: 'suspended',
|
|
6038
6149
|
wakeAt: this.reconcileWakeAt(outcome.wakeAt),
|
|
6039
|
-
updatedAt,
|
|
6150
|
+
updatedAt: stamp,
|
|
6040
6151
|
}));
|
|
6041
6152
|
if (!applied)
|
|
6042
6153
|
return this.echoCurrentStatus(run.id);
|
|
6154
|
+
run.updatedAt = stamp;
|
|
6155
|
+
if (this.turnContinuations.has(run.id))
|
|
6156
|
+
this.suspendStamps.set(run.id, stamp.getTime());
|
|
6043
6157
|
this.emit({
|
|
6044
6158
|
type: 'run.suspended',
|
|
6045
6159
|
runId: run.id,
|
|
@@ -6084,7 +6198,8 @@ export class WorkflowEngine {
|
|
|
6084
6198
|
* settled it), so report what is persisted rather than writing over it.
|
|
6085
6199
|
*/
|
|
6086
6200
|
/**
|
|
6087
|
-
* A run's first execution: `pending` -> `running
|
|
6201
|
+
* A run's first execution: `pending` -> `running`, resolving whether it moved it (a run cancelled
|
|
6202
|
+
* or deleted since it was read did not move). A singleton run not yet admitted is stamped
|
|
6088
6203
|
* {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
|
|
6089
6204
|
* replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
|
|
6090
6205
|
* 0.51 (see `legacySingletonHolders`).
|
|
@@ -6092,14 +6207,19 @@ export class WorkflowEngine {
|
|
|
6092
6207
|
async markStarted(run, singleton) {
|
|
6093
6208
|
const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
|
|
6094
6209
|
const retag = tags !== run.tags;
|
|
6095
|
-
|
|
6210
|
+
// A compare-and-set on `pending`: the run was read before this turn took it, and a cancel (or a
|
|
6211
|
+
// delete) landing in between must not be overwritten with `running`, which would resurrect it.
|
|
6212
|
+
const started = await this.store.updateRunIf(run.id, ['pending'], {
|
|
6096
6213
|
status: 'running',
|
|
6097
6214
|
...(retag ? { tags } : {}),
|
|
6098
6215
|
updatedAt: new Date(),
|
|
6099
6216
|
});
|
|
6217
|
+
if (!started)
|
|
6218
|
+
return false;
|
|
6100
6219
|
run.status = 'running';
|
|
6101
6220
|
if (retag)
|
|
6102
6221
|
run.tags = tags;
|
|
6222
|
+
return true;
|
|
6103
6223
|
}
|
|
6104
6224
|
async singletonWaitResult(run) {
|
|
6105
6225
|
const current = await this.store.getRun(run.id);
|
|
@@ -6143,7 +6263,11 @@ export class WorkflowEngine {
|
|
|
6143
6263
|
return { runId: run.id, status: 'suspended' };
|
|
6144
6264
|
}
|
|
6145
6265
|
if (run.status === 'pending') {
|
|
6146
|
-
await this.markStarted(run, registered?.singleton !== undefined)
|
|
6266
|
+
if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
|
|
6267
|
+
// No longer `pending` (cancelled or deleted since it was read): nothing to run.
|
|
6268
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
6269
|
+
return await this.echoCurrentStatus(run.id);
|
|
6270
|
+
}
|
|
6147
6271
|
this.emit({
|
|
6148
6272
|
type: 'run.started',
|
|
6149
6273
|
runId: run.id,
|
|
@@ -6158,7 +6282,14 @@ export class WorkflowEngine {
|
|
|
6158
6282
|
// Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
|
|
6159
6283
|
// its group — if no live worker on that group can run it, park `blocked` before advancing rather
|
|
6160
6284
|
// than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
|
|
6161
|
-
const
|
|
6285
|
+
const routeToken = registered.convention
|
|
6286
|
+
? conventionGroup(run.workflow, run.namespace)
|
|
6287
|
+
: remote.group;
|
|
6288
|
+
let routePlan = await this.planRoute(routeToken, registered.requires);
|
|
6289
|
+
// As in ensureRoutable: re-read before parking, never park on reused descriptors.
|
|
6290
|
+
if (routePlan?.status === 'blocked') {
|
|
6291
|
+
routePlan = await this.planRoute(routeToken, registered.requires, true);
|
|
6292
|
+
}
|
|
6162
6293
|
if (routePlan?.status === 'blocked')
|
|
6163
6294
|
return this.parkBlocked(run, routePlan);
|
|
6164
6295
|
const checkpoints = await this.replayCheckpoints(run.id);
|
|
@@ -7204,7 +7335,11 @@ export class WorkflowEngine {
|
|
|
7204
7335
|
// gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
|
|
7205
7336
|
// already past `pending`, so this fires exactly once.
|
|
7206
7337
|
if (run.status === 'pending') {
|
|
7207
|
-
await this.markStarted(run, registered?.singleton !== undefined)
|
|
7338
|
+
if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
|
|
7339
|
+
// No longer `pending` (cancelled or deleted since it was read): nothing to run.
|
|
7340
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
7341
|
+
return await this.echoCurrentStatus(run.id);
|
|
7342
|
+
}
|
|
7208
7343
|
this.emit({
|
|
7209
7344
|
type: 'run.started',
|
|
7210
7345
|
runId: run.id,
|
|
@@ -7218,20 +7353,37 @@ export class WorkflowEngine {
|
|
|
7218
7353
|
if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
7219
7354
|
return this.singletonWaitResult(run);
|
|
7220
7355
|
}
|
|
7356
|
+
// A body this instance parked on dispatched steps in an earlier turn continues from there (see
|
|
7357
|
+
// `sticky-continuation.ts`), when nothing but this instance's own turn has settled the run since.
|
|
7358
|
+
const stickyOk = this.stickyEligible(run);
|
|
7359
|
+
let cont = stickyOk ? this.takeContinuation(run, fn) : undefined;
|
|
7360
|
+
const warm = cont !== undefined;
|
|
7361
|
+
// The parked steps' checkpoints, read before the body resumes: a failing read fails the turn
|
|
7362
|
+
// like a failing history read does, never the run.
|
|
7363
|
+
const parkedFresh = cont ? await this.readParked(run.id, cont) : undefined;
|
|
7221
7364
|
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
7222
|
-
const compensations = [];
|
|
7365
|
+
const compensations = cont ? cont.compensations : [];
|
|
7223
7366
|
// Load this run's checkpoints ONCE and key them by seq, so replaying the completed prefix reads
|
|
7224
7367
|
// from memory instead of one `getCheckpoint` SELECT per primitive (the O(N²) replay-reads fix).
|
|
7225
7368
|
// Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
|
|
7226
7369
|
// this resume wakes on, or one written later in this same execution) is absent from the map, and
|
|
7227
7370
|
// the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
|
|
7228
|
-
|
|
7229
|
-
const
|
|
7371
|
+
// A warm continuation already replayed its prefix: it reads only what lies past its park point.
|
|
7372
|
+
const snapshot = cont ? [] : await this.replayCheckpoints(run.id);
|
|
7373
|
+
const replay = cont ? cont.replay : new Map();
|
|
7230
7374
|
for (const cp of snapshot)
|
|
7231
7375
|
replay.set(cp.seq, cp);
|
|
7376
|
+
if (!cont && stickyOk) {
|
|
7377
|
+
cont = new StickyContinuation(run.id, fn, run.workflowVersion, compensations, replay, (c) => this.sticky.evict(c.runId));
|
|
7378
|
+
}
|
|
7379
|
+
if (cont)
|
|
7380
|
+
this.turnContinuations.set(run.id, cont);
|
|
7381
|
+
let parkedTurn = false;
|
|
7232
7382
|
let suspendedOnFan;
|
|
7233
7383
|
let settledSuspended = false;
|
|
7234
|
-
const ctx =
|
|
7384
|
+
const ctx = warm
|
|
7385
|
+
? undefined
|
|
7386
|
+
: createWorkflowCtx(this.ctxHostFor(replay, run.namespace, cont), run.id, compensations, run.workflow);
|
|
7235
7387
|
// Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
|
|
7236
7388
|
// `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
|
|
7237
7389
|
// return expression is evaluated, while the settle's own write is still in flight. A first turn
|
|
@@ -7248,8 +7400,33 @@ export class WorkflowEngine {
|
|
|
7248
7400
|
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
7249
7401
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
7250
7402
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
7251
|
-
|
|
7252
|
-
|
|
7403
|
+
let output;
|
|
7404
|
+
if (cont) {
|
|
7405
|
+
if (parkedFresh) {
|
|
7406
|
+
cont.attached = true;
|
|
7407
|
+
await cont.resumeParked(parkedFresh);
|
|
7408
|
+
}
|
|
7409
|
+
else {
|
|
7410
|
+
const input = await this.turnInput(run, registered);
|
|
7411
|
+
const body = ctx;
|
|
7412
|
+
cont.begin(() => workflowAls.run(body, () => fn(body, input)));
|
|
7413
|
+
}
|
|
7414
|
+
const outcome = await cont.next();
|
|
7415
|
+
if (outcome.kind === 'parked') {
|
|
7416
|
+
// Parked on dispatched steps with nothing else in flight: suspend exactly as a thrown
|
|
7417
|
+
// suspension would, and keep the body for the run's next turn here.
|
|
7418
|
+
parkedTurn = true;
|
|
7419
|
+
throw new WorkflowSuspended(outcome.wakeAt, outcome.waitSeqs);
|
|
7420
|
+
}
|
|
7421
|
+
if (outcome.kind === 'threw')
|
|
7422
|
+
throw outcome.error;
|
|
7423
|
+
output = outcome.value;
|
|
7424
|
+
}
|
|
7425
|
+
else {
|
|
7426
|
+
const input = await this.turnInput(run, registered);
|
|
7427
|
+
const body = ctx;
|
|
7428
|
+
output = await workflowAls.run(body, () => fn(body, input));
|
|
7429
|
+
}
|
|
7253
7430
|
// A compensating cancel requested while this turn ran (or one whose resume replays straight to
|
|
7254
7431
|
// `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
|
|
7255
7432
|
if (await this.takeCancelIntent(run.id)) {
|
|
@@ -7312,6 +7489,11 @@ export class WorkflowEngine {
|
|
|
7312
7489
|
return await this.compensateAndCancel(run, compensations);
|
|
7313
7490
|
}
|
|
7314
7491
|
const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
7492
|
+
const stamp = this.suspendStamps.get(run.id);
|
|
7493
|
+
this.suspendStamps.delete(run.id);
|
|
7494
|
+
if (parkedTurn && cont && settled.status === 'suspended' && stamp !== undefined) {
|
|
7495
|
+
this.keepContinuation(cont, stamp);
|
|
7496
|
+
}
|
|
7315
7497
|
// Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
|
|
7316
7498
|
// executing wrote its checkpoint durably, but its resume no-oped against our held lease —
|
|
7317
7499
|
// and this settle just parked the run on nothing but the reconcile fallback (or nothing at
|
|
@@ -7370,6 +7552,9 @@ export class WorkflowEngine {
|
|
|
7370
7552
|
//
|
|
7371
7553
|
// A suspended turn keeps the lease instead when a wake it refused asked for another turn, and
|
|
7372
7554
|
// runs that turn (see `requestWake`).
|
|
7555
|
+
if (cont && this.turnContinuations.get(run.id) === cont) {
|
|
7556
|
+
this.turnContinuations.delete(run.id);
|
|
7557
|
+
}
|
|
7373
7558
|
let released = true;
|
|
7374
7559
|
try {
|
|
7375
7560
|
if (settledSuspended)
|
|
@@ -7620,10 +7805,11 @@ export class WorkflowEngine {
|
|
|
7620
7805
|
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
7621
7806
|
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
7622
7807
|
* module and the engine stays the orchestrator. */
|
|
7623
|
-
ctxHostFor(replay, runNamespace) {
|
|
7808
|
+
ctxHostFor(replay, runNamespace, sticky) {
|
|
7624
7809
|
return {
|
|
7625
7810
|
store: this.store,
|
|
7626
7811
|
replay,
|
|
7812
|
+
sticky,
|
|
7627
7813
|
clock: this.clock,
|
|
7628
7814
|
stepSignal: this.drainAbort.signal,
|
|
7629
7815
|
eventBufferTtlMs: this.eventBufferTtlMs,
|
|
@@ -7631,7 +7817,18 @@ export class WorkflowEngine {
|
|
|
7631
7817
|
startStep: (s) => this.startStep(s),
|
|
7632
7818
|
completeStep: (s) => this.completeStep(s),
|
|
7633
7819
|
failStep: (s) => this.failStep(s),
|
|
7634
|
-
callRemote: (runId, seq, step, input, queue, transport, admission) =>
|
|
7820
|
+
callRemote: (runId, seq, step, input, queue, transport, admission) => {
|
|
7821
|
+
const call = this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace);
|
|
7822
|
+
if (!sticky)
|
|
7823
|
+
return call;
|
|
7824
|
+
// In a sticky continuation a call that would suspend parks instead: a later turn
|
|
7825
|
+
// re-evaluates it (the same logic, against its freshly read checkpoint).
|
|
7826
|
+
return call.catch((err) => {
|
|
7827
|
+
if (!(err instanceof WorkflowSuspended) || !sticky.attached)
|
|
7828
|
+
throw err;
|
|
7829
|
+
return sticky.park(seq, err.wakeAt, (fresh) => this.callRemote(runId, seq, step, input, queue, transport, fresh ? new Map([[seq, fresh]]) : undefined, admission, runNamespace, undefined, true));
|
|
7830
|
+
});
|
|
7831
|
+
},
|
|
7635
7832
|
callRemoteAll: (runId, positions, step, inputs, opts) => this.callRemoteAll(runId, positions, step, inputs, opts, runNamespace),
|
|
7636
7833
|
// Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
|
|
7637
7834
|
// PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
|
|
@@ -7762,12 +7959,12 @@ export class WorkflowEngine {
|
|
|
7762
7959
|
* descriptor keyspace. Empty when no transport can introspect it (in-process / pre-handshake broker)
|
|
7763
7960
|
* OR nobody is publishing — either way the guard is skipped and dispatch proceeds legacy
|
|
7764
7961
|
* assume-compatible (design §7.7). Never throws (the transport degrades to `[]`). */
|
|
7765
|
-
async liveDescriptorsFor(token) {
|
|
7962
|
+
async liveDescriptorsFor(token, fresh = false) {
|
|
7766
7963
|
const read = this.primaryTransport?.listWorkerDescriptors;
|
|
7767
7964
|
if (!read)
|
|
7768
7965
|
return [];
|
|
7769
7966
|
try {
|
|
7770
|
-
return await read.call(this.primaryTransport, token);
|
|
7967
|
+
return await read.call(this.primaryTransport, token, fresh ? { fresh } : undefined);
|
|
7771
7968
|
}
|
|
7772
7969
|
catch {
|
|
7773
7970
|
return [];
|
|
@@ -7780,8 +7977,8 @@ export class WorkflowEngine {
|
|
|
7780
7977
|
* - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
|
|
7781
7978
|
* - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
|
|
7782
7979
|
*/
|
|
7783
|
-
async planRoute(token, requires) {
|
|
7784
|
-
const descriptors = await this.liveDescriptorsFor(token);
|
|
7980
|
+
async planRoute(token, requires, fresh = false) {
|
|
7981
|
+
const descriptors = await this.liveDescriptorsFor(token, fresh);
|
|
7785
7982
|
if (descriptors.length === 0)
|
|
7786
7983
|
return undefined; // legacy/no-runtime path — skip the guard
|
|
7787
7984
|
return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
|
|
@@ -7791,7 +7988,10 @@ export class WorkflowEngine {
|
|
|
7791
7988
|
* checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
|
|
7792
7989
|
* not engage (no descriptors) or a capable+compatible worker exists. */
|
|
7793
7990
|
async ensureRoutable(token, requires) {
|
|
7794
|
-
|
|
7991
|
+
let plan = await this.planRoute(token, requires);
|
|
7992
|
+
// Never park a run on reused descriptors: a capable worker may have joined since they were read.
|
|
7993
|
+
if (plan?.status === 'blocked')
|
|
7994
|
+
plan = await this.planRoute(token, requires, true);
|
|
7795
7995
|
if (plan?.status === 'blocked')
|
|
7796
7996
|
throw new WorkflowBlocked(plan);
|
|
7797
7997
|
}
|
|
@@ -7847,7 +8047,21 @@ export class WorkflowEngine {
|
|
|
7847
8047
|
}));
|
|
7848
8048
|
return blocked;
|
|
7849
8049
|
}
|
|
7850
|
-
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup
|
|
8050
|
+
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup,
|
|
8051
|
+
/** `replay` holds this step's checkpoint as just read from the store: no re-read needed. */
|
|
8052
|
+
fresh) {
|
|
8053
|
+
// A position the turn's history does not hold is normally a first dispatch: write its `pending`
|
|
8054
|
+
// row only if none is there, instead of reading the position first. A row that is there (a turn
|
|
8055
|
+
// this one did not see wrote it) is then read and handled below like any other.
|
|
8056
|
+
if (replay !== undefined &&
|
|
8057
|
+
!replay.has(seq) &&
|
|
8058
|
+
this.store.insertCheckpointIfAbsent &&
|
|
8059
|
+
!step.timeoutMs &&
|
|
8060
|
+
this.pool.size > 0 &&
|
|
8061
|
+
!(queue && this.admission.handles(queue)) &&
|
|
8062
|
+
(await this.dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup))) {
|
|
8063
|
+
throw new WorkflowSuspended(undefined, [seq]);
|
|
8064
|
+
}
|
|
7851
8065
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
7852
8066
|
// from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
|
|
7853
8067
|
let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
|
|
@@ -7857,7 +8071,7 @@ export class WorkflowEngine {
|
|
|
7857
8071
|
// the lost-dispatch lease, or a re-dispatch) — written from a stale row, that overwrites the landed
|
|
7858
8072
|
// `completed` result with `pending` and parks the run on a lease an hour out. So a pending step is
|
|
7859
8073
|
// re-read from the store before anything acts on it (one read, only at a suspension point).
|
|
7860
|
-
if (existing?.status === 'pending' && replay?.has(seq)) {
|
|
8074
|
+
if (existing?.status === 'pending' && replay?.has(seq) && !fresh) {
|
|
7861
8075
|
existing = (await this.store.getCheckpoint(runId, seq)) ?? existing;
|
|
7862
8076
|
}
|
|
7863
8077
|
if (existing && existing.name !== step.name) {
|
|
@@ -7938,6 +8152,8 @@ export class WorkflowEngine {
|
|
|
7938
8152
|
priority: admission?.priority,
|
|
7939
8153
|
attempt: reAttempt,
|
|
7940
8154
|
transport,
|
|
8155
|
+
// A fan's steps are joined by its barrier, not a parked body: their results stay shared.
|
|
8156
|
+
replyHere: parallelGroup === undefined,
|
|
7941
8157
|
});
|
|
7942
8158
|
this.emit({
|
|
7943
8159
|
type: 'step.started',
|
|
@@ -8042,10 +8258,66 @@ export class WorkflowEngine {
|
|
|
8042
8258
|
attempt,
|
|
8043
8259
|
transport,
|
|
8044
8260
|
enqueuedAt,
|
|
8261
|
+
replyHere: parallelGroup === undefined,
|
|
8045
8262
|
});
|
|
8046
8263
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
8047
8264
|
throw new WorkflowSuspended(undefined, [seq]);
|
|
8048
8265
|
}
|
|
8266
|
+
/**
|
|
8267
|
+
* {@link callRemote}'s first dispatch of a step, written with
|
|
8268
|
+
* {@link StateStore.insertCheckpointIfAbsent}: resolves whether it wrote the `pending` row and
|
|
8269
|
+
* dispatched. `false` when the position already has a row, or when no live worker can take the
|
|
8270
|
+
* step (the read-first path then decides, so a step that is in fact done is never blocked).
|
|
8271
|
+
*/
|
|
8272
|
+
async dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup) {
|
|
8273
|
+
const validInput = step.input
|
|
8274
|
+
? await validateStandard(step.input, input, `step "${step.name}" input`, 'invalid_input')
|
|
8275
|
+
: input;
|
|
8276
|
+
const token = stepGroup(step, runNamespace);
|
|
8277
|
+
try {
|
|
8278
|
+
await this.ensureRoutable(token, step.requires);
|
|
8279
|
+
}
|
|
8280
|
+
catch (err) {
|
|
8281
|
+
if (err instanceof WorkflowBlocked)
|
|
8282
|
+
return false;
|
|
8283
|
+
throw err;
|
|
8284
|
+
}
|
|
8285
|
+
const id = stepId(runId, seq);
|
|
8286
|
+
const enqueuedAt = new Date();
|
|
8287
|
+
const insert = this.store.insertCheckpointIfAbsent;
|
|
8288
|
+
const wrote = await insert.call(this.store, {
|
|
8289
|
+
runId,
|
|
8290
|
+
seq,
|
|
8291
|
+
name: step.name,
|
|
8292
|
+
kind: 'remote',
|
|
8293
|
+
stepId: id,
|
|
8294
|
+
status: 'pending',
|
|
8295
|
+
input: validInput,
|
|
8296
|
+
attempts: 1,
|
|
8297
|
+
workerGroup: token,
|
|
8298
|
+
...(parallelGroup !== undefined ? { parallelGroup } : {}),
|
|
8299
|
+
enqueuedAt,
|
|
8300
|
+
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
8301
|
+
finishedAt: enqueuedAt,
|
|
8302
|
+
});
|
|
8303
|
+
if (!wrote)
|
|
8304
|
+
return false;
|
|
8305
|
+
await this.dispatchRemoteTask({
|
|
8306
|
+
runId,
|
|
8307
|
+
seq,
|
|
8308
|
+
name: step.name,
|
|
8309
|
+
stepId: id,
|
|
8310
|
+
group: token,
|
|
8311
|
+
input: validInput,
|
|
8312
|
+
priority: admission?.priority,
|
|
8313
|
+
attempt: 1,
|
|
8314
|
+
transport,
|
|
8315
|
+
enqueuedAt,
|
|
8316
|
+
replyHere: parallelGroup === undefined,
|
|
8317
|
+
});
|
|
8318
|
+
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
8319
|
+
return true;
|
|
8320
|
+
}
|
|
8049
8321
|
/**
|
|
8050
8322
|
* `ctx.all` over a `@Step`: one durable step per input, dispatched together, joined once. The ctx
|
|
8051
8323
|
* reserves the whole position block up front, so replay re-derives the same seqs.
|
|
@@ -8283,11 +8555,85 @@ export class WorkflowEngine {
|
|
|
8283
8555
|
context: this.context?.(),
|
|
8284
8556
|
priority: task.priority,
|
|
8285
8557
|
attempt: task.attempt,
|
|
8558
|
+
...(task.replyHere && this.repliesRouted && !this.draining
|
|
8559
|
+
? { replyTo: this.instanceId }
|
|
8560
|
+
: {}),
|
|
8286
8561
|
};
|
|
8287
8562
|
if (task.enqueuedAt)
|
|
8288
8563
|
remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
|
|
8289
8564
|
return this.pool.dispatch(remote, task.transport);
|
|
8290
8565
|
}
|
|
8566
|
+
/**
|
|
8567
|
+
* Whether this turn of `run` may run in a sticky continuation (see `sticky-continuation.ts`). Not
|
|
8568
|
+
* while the engine drains, nor with a compensating cancel requested for the run.
|
|
8569
|
+
*/
|
|
8570
|
+
stickyEligible(run) {
|
|
8571
|
+
return this.sticky.enabled && !this.draining && !this.cancelRequested.has(run.id);
|
|
8572
|
+
}
|
|
8573
|
+
/**
|
|
8574
|
+
* The continuation this instance parked for `run`, if it is still the run's latest turn: the run
|
|
8575
|
+
* is `suspended` with the very stamp this instance's suspend wrote, on the same body and version.
|
|
8576
|
+
* Any other settle in between (another instance's turn, a retry, a cancel) changed the stamp, and
|
|
8577
|
+
* the run replays instead. Taking it removes it from the cache.
|
|
8578
|
+
*/
|
|
8579
|
+
takeContinuation(run, fn) {
|
|
8580
|
+
const cont = this.sticky.take(run.id);
|
|
8581
|
+
if (!cont)
|
|
8582
|
+
return undefined;
|
|
8583
|
+
const valid = cont.fn === fn &&
|
|
8584
|
+
cont.workflowVersion === run.workflowVersion &&
|
|
8585
|
+
run.status === 'suspended' &&
|
|
8586
|
+
cont.stamp !== undefined &&
|
|
8587
|
+
run.updatedAt.getTime() === cont.stamp;
|
|
8588
|
+
if (!valid) {
|
|
8589
|
+
this.sticky.misses += 1;
|
|
8590
|
+
return undefined;
|
|
8591
|
+
}
|
|
8592
|
+
this.sticky.hits += 1;
|
|
8593
|
+
return cont;
|
|
8594
|
+
}
|
|
8595
|
+
/** Keep `cont` (parked, detached) for the run's next turn here, stamped with its suspend write. */
|
|
8596
|
+
keepContinuation(cont, stamp) {
|
|
8597
|
+
if (cont.unusable)
|
|
8598
|
+
return;
|
|
8599
|
+
cont.stamp = stamp;
|
|
8600
|
+
// Everything up to the park point is consumed; later positions read the store.
|
|
8601
|
+
cont.replay.clear();
|
|
8602
|
+
this.sticky.put(cont);
|
|
8603
|
+
}
|
|
8604
|
+
/**
|
|
8605
|
+
* The checkpoints of the steps `cont` is parked on, as a replay would see them: in one read when
|
|
8606
|
+
* the store batches, and without the input of a step that completed (what
|
|
8607
|
+
* {@link StateStore.getCheckpointsForSettle} leaves out). A step still open or failed is read
|
|
8608
|
+
* whole, since a re-dispatch or a retry sends its input again.
|
|
8609
|
+
*/
|
|
8610
|
+
async readParked(runId, cont) {
|
|
8611
|
+
const fresh = new Map();
|
|
8612
|
+
for (const seq of cont.parked.keys())
|
|
8613
|
+
fresh.set(seq, cont.hints.get(seq) ?? null);
|
|
8614
|
+
cont.hints.clear();
|
|
8615
|
+
const seqs = [...fresh].flatMap(([seq, cp]) => (cp ? [] : [seq]));
|
|
8616
|
+
if (seqs.length === 0)
|
|
8617
|
+
return fresh;
|
|
8618
|
+
const lean = this.store.getCheckpointsForSettle ?? this.store.getCheckpoints;
|
|
8619
|
+
if (lean) {
|
|
8620
|
+
for (const cp of await lean.call(this.store, runId, seqs))
|
|
8621
|
+
fresh.set(cp.seq, cp);
|
|
8622
|
+
}
|
|
8623
|
+
else {
|
|
8624
|
+
for (const seq of seqs)
|
|
8625
|
+
fresh.set(seq, await this.store.getCheckpoint(runId, seq));
|
|
8626
|
+
}
|
|
8627
|
+
if (lean === this.store.getCheckpointsForSettle) {
|
|
8628
|
+
for (const seq of seqs) {
|
|
8629
|
+
const cp = fresh.get(seq);
|
|
8630
|
+
if (cp && cp.status !== 'completed') {
|
|
8631
|
+
fresh.set(seq, await this.store.getCheckpoint(runId, seq));
|
|
8632
|
+
}
|
|
8633
|
+
}
|
|
8634
|
+
}
|
|
8635
|
+
return fresh;
|
|
8636
|
+
}
|
|
8291
8637
|
/** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
|
|
8292
8638
|
* column through {@link StateStore.listCheckpoints} on a store without it. */
|
|
8293
8639
|
replayCheckpoints(runId) {
|
|
@@ -8558,6 +8904,9 @@ export class WorkflowEngine {
|
|
|
8558
8904
|
* replays every write of the batch, so a second would only replay it again.
|
|
8559
8905
|
*/
|
|
8560
8906
|
async completeRemoteResults(runId, results, queuedIn = () => false) {
|
|
8907
|
+
if (this.store.settleResultsIfPending) {
|
|
8908
|
+
return this.settleResultsReturning(runId, results, queuedIn);
|
|
8909
|
+
}
|
|
8561
8910
|
const outcomes = results.map(() => true);
|
|
8562
8911
|
const settleOneByOne = async (indexes) => {
|
|
8563
8912
|
for (const i of indexes) {
|
|
@@ -8689,6 +9038,144 @@ export class WorkflowEngine {
|
|
|
8689
9038
|
}
|
|
8690
9039
|
return outcomes;
|
|
8691
9040
|
}
|
|
9041
|
+
/**
|
|
9042
|
+
* {@link completeRemoteResults} on a store with {@link StateStore.settleResultsIfPending}: the
|
|
9043
|
+
* results settle their `pending` rows and read them back, with their run's status, in one call,
|
|
9044
|
+
* instead of reading the rows and the run and then writing. An entry that settled nothing (a
|
|
9045
|
+
* redelivery, a step gone, a late result for a finished run) takes the single-result path, which
|
|
9046
|
+
* reads first and re-drives, drops or salvages. A settled `completed` row is also handed to the
|
|
9047
|
+
* run's parked body, if this instance holds one, so its next turn does not read it again.
|
|
9048
|
+
*/
|
|
9049
|
+
async settleResultsReturning(runId, results, queuedIn) {
|
|
9050
|
+
const outcomes = results.map(() => true);
|
|
9051
|
+
const settle = this.store.settleResultsIfPending.bind(this.store);
|
|
9052
|
+
// A second copy of a result in the same batch is a duplicate delivery: the first copy settles
|
|
9053
|
+
// the step (or re-drives the resume), so the copy is dropped like a late one.
|
|
9054
|
+
const seen = new Set();
|
|
9055
|
+
const live = results.flatMap((r, i) => {
|
|
9056
|
+
if (seen.has(r.seq))
|
|
9057
|
+
return [];
|
|
9058
|
+
seen.add(r.seq);
|
|
9059
|
+
return [i];
|
|
9060
|
+
});
|
|
9061
|
+
const finishedAt = new Date();
|
|
9062
|
+
const entries = live.map((i) => {
|
|
9063
|
+
const result = results[i];
|
|
9064
|
+
return {
|
|
9065
|
+
runId,
|
|
9066
|
+
seq: result.seq,
|
|
9067
|
+
status: result.status,
|
|
9068
|
+
output: result.status === 'completed' ? result.output : undefined,
|
|
9069
|
+
error: result.error,
|
|
9070
|
+
events: result.events,
|
|
9071
|
+
startedAt: result.startedAt ? new Date(result.startedAt) : undefined,
|
|
9072
|
+
finishedAt,
|
|
9073
|
+
};
|
|
9074
|
+
});
|
|
9075
|
+
// Never rejects as a whole: the batch call rejecting wrote nothing, so the entries are then
|
|
9076
|
+
// settled one by one and a failing one fails only its own result.
|
|
9077
|
+
let rows;
|
|
9078
|
+
try {
|
|
9079
|
+
rows = await settle(entries);
|
|
9080
|
+
}
|
|
9081
|
+
catch (err) {
|
|
9082
|
+
if (entries.length === 1) {
|
|
9083
|
+
rows = [{ error: err }];
|
|
9084
|
+
}
|
|
9085
|
+
else {
|
|
9086
|
+
const one = await Promise.allSettled(entries.map((e) => settle([e])));
|
|
9087
|
+
rows = one.map((o) => o.status === 'fulfilled' ? (o.value[0] ?? null) : { error: o.reason });
|
|
9088
|
+
}
|
|
9089
|
+
}
|
|
9090
|
+
const resumable = [];
|
|
9091
|
+
const unsettled = [];
|
|
9092
|
+
let run;
|
|
9093
|
+
// The run's parked body: in the cache, or in a turn that is parking it right now.
|
|
9094
|
+
const parked = this.sticky.peek(runId) ?? this.turnContinuations.get(runId);
|
|
9095
|
+
for (const [k, row] of rows.entries()) {
|
|
9096
|
+
const index = live[k];
|
|
9097
|
+
const result = results[index];
|
|
9098
|
+
if (row === null) {
|
|
9099
|
+
unsettled.push(index);
|
|
9100
|
+
continue;
|
|
9101
|
+
}
|
|
9102
|
+
if ('error' in row) {
|
|
9103
|
+
outcomes[index] = { error: row.error };
|
|
9104
|
+
continue;
|
|
9105
|
+
}
|
|
9106
|
+
const cp = row.checkpoint;
|
|
9107
|
+
// The run as it read when this result settled: what the resume owes it (see `resumeOwed`).
|
|
9108
|
+
run ??= { id: runId, status: row.run.status, wakeAt: row.run.wakeAt };
|
|
9109
|
+
try {
|
|
9110
|
+
await this.releaseQueueSlot(cp);
|
|
9111
|
+
}
|
|
9112
|
+
catch (error) {
|
|
9113
|
+
// Written but not acknowledged: the redelivery finds the step settled and re-drives.
|
|
9114
|
+
outcomes[index] = { error };
|
|
9115
|
+
}
|
|
9116
|
+
if (cp.status === 'completed')
|
|
9117
|
+
parked?.hint(cp);
|
|
9118
|
+
this.emit({
|
|
9119
|
+
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
9120
|
+
runId,
|
|
9121
|
+
seq: result.seq,
|
|
9122
|
+
name: cp.name,
|
|
9123
|
+
kind: cp.kind,
|
|
9124
|
+
output: result.output,
|
|
9125
|
+
error: result.error,
|
|
9126
|
+
queueMs: cp.startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
9127
|
+
durationMs: finishedAt.getTime() - cp.startedAt.getTime(),
|
|
9128
|
+
});
|
|
9129
|
+
resumable.push({ cp, result });
|
|
9130
|
+
}
|
|
9131
|
+
// Nothing written for these: read first, then re-drive, drop or salvage, one at a time.
|
|
9132
|
+
for (const index of unsettled) {
|
|
9133
|
+
try {
|
|
9134
|
+
await this.completeOneRemoteResult(results[index], queuedIn);
|
|
9135
|
+
}
|
|
9136
|
+
catch (error) {
|
|
9137
|
+
outcomes[index] = { error };
|
|
9138
|
+
}
|
|
9139
|
+
}
|
|
9140
|
+
if (resumable.length === 0)
|
|
9141
|
+
return outcomes;
|
|
9142
|
+
// The barrier logic of the read-first batch path (see completeRemoteResults): one resume.
|
|
9143
|
+
const barriers = new Map();
|
|
9144
|
+
const highest = new Map();
|
|
9145
|
+
for (const { cp } of resumable) {
|
|
9146
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
9147
|
+
if (fan)
|
|
9148
|
+
highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
|
|
9149
|
+
}
|
|
9150
|
+
const singles = resumable
|
|
9151
|
+
.filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
|
|
9152
|
+
.map(({ cp }) => cp.seq);
|
|
9153
|
+
for (const { cp, result } of resumable) {
|
|
9154
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
9155
|
+
if (!fan || result.status === 'failed') {
|
|
9156
|
+
this.noteSettledHere(runId, singles);
|
|
9157
|
+
await this.resumeOwed(run, runId);
|
|
9158
|
+
return outcomes;
|
|
9159
|
+
}
|
|
9160
|
+
let closed = barriers.get(fan.firstSeq);
|
|
9161
|
+
if (!closed) {
|
|
9162
|
+
const lastSeq = fan.firstSeq + fan.size - 1;
|
|
9163
|
+
closed = queuedIn(fan.firstSeq, lastSeq)
|
|
9164
|
+
? Promise.resolve(true)
|
|
9165
|
+
: this.stepFanHasPending(runId, fan.firstSeq, lastSeq).catch(() => false);
|
|
9166
|
+
barriers.set(fan.firstSeq, closed);
|
|
9167
|
+
}
|
|
9168
|
+
if (!(await closed)) {
|
|
9169
|
+
this.noteSettledHere(runId, singles);
|
|
9170
|
+
await this.resumeOwed(run, runId, {
|
|
9171
|
+
first: fan.firstSeq,
|
|
9172
|
+
seq: highest.get(fan.firstSeq) ?? cp.seq,
|
|
9173
|
+
});
|
|
9174
|
+
return outcomes;
|
|
9175
|
+
}
|
|
9176
|
+
}
|
|
9177
|
+
return outcomes;
|
|
9178
|
+
}
|
|
8692
9179
|
/**
|
|
8693
9180
|
* Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
|
|
8694
9181
|
* or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}
|