@adonis-agora/durable 0.59.0 → 1.0.0-rc.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 +44 -0
- package/dist/assets/spa/assets/index-70hGzOnT.css +1 -0
- package/dist/assets/spa/assets/index-CJjMAUau.js +16 -0
- package/dist/assets/spa/assets/index-CJjMAUau.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/providers/dashboard_provider.d.ts +1 -1
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +4 -2
- package/dist/providers/dashboard_provider.js.map +1 -1
- 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/base-workflow.d.ts +12 -1
- package/dist/src/base-workflow.d.ts.map +1 -1
- package/dist/src/base-workflow.js +13 -2
- package/dist/src/base-workflow.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/control-plane-redis/redis-control-plane.d.ts +70 -7
- package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
- package/dist/src/control-plane-redis/redis-control-plane.js +108 -7
- package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
- package/dist/src/control-planes/factory.d.ts +15 -3
- package/dist/src/control-planes/factory.d.ts.map +1 -1
- package/dist/src/control-planes/factory.js +3 -1
- package/dist/src/control-planes/factory.js.map +1 -1
- package/dist/src/dashboard/gateway-adapter.d.ts +1 -1
- package/dist/src/dashboard/gateway-adapter.js +1 -1
- package/dist/src/dashboard/handlers.d.ts +1 -1
- package/dist/src/dashboard/handlers.js +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/dashboard/openapi.js +1 -1
- package/dist/src/dashboard/openapi.js.map +1 -1
- package/dist/src/define_config.d.ts +14 -1
- 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 +81 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +532 -28
- 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 +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +122 -5
- 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/run-gateway/proxy-run-gateway.d.ts.map +1 -1
- package/dist/src/run-gateway/proxy-run-gateway.js +7 -14
- package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
- package/dist/src/run-gateway/run-request-responder.d.ts +1 -2
- package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
- package/dist/src/run-gateway/run-request-responder.js +1 -2
- package/dist/src/run-gateway/run-request-responder.js.map +1 -1
- package/dist/src/scheduler.d.ts +0 -5
- package/dist/src/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler.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 +13 -3
- package/dist/src/transports/bullmq/naming.d.ts.map +1 -1
- package/dist/src/transports/bullmq/naming.js +19 -3
- package/dist/src/transports/bullmq/naming.js.map +1 -1
- package/dist/src/transports/db.d.ts +3 -8
- 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/event-emitter.d.ts +0 -5
- package/dist/src/transports/event-emitter.d.ts.map +1 -1
- package/dist/src/transports/event-emitter.js.map +1 -1
- package/dist/src/transports/factory.d.ts +0 -15
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +0 -3
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/transports/queue.d.ts +3 -8
- 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/dist/stubs/config/durable.stub +1 -1
- package/package.json +7 -7
- package/skills/durable-cluster/SKILL.md +4 -3
- package/skills/durable-setup/SKILL.md +6 -3
- package/skills/durable-transports-stores/SKILL.md +3 -22
- package/skills/durable-workflows/SKILL.md +7 -4
- 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
|
|
@@ -107,6 +112,11 @@ function wakeReasonSeen(payload, settledFans) {
|
|
|
107
112
|
typeof pair[1] === 'number' &&
|
|
108
113
|
(settledFans.get(pair[0]) ?? -1) >= pair[1]);
|
|
109
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The control-plane target of a message about a run in `namespace`: the run's namespace, so the
|
|
117
|
+
* instances serving it hear it wherever the publisher is. Unknown → the publisher's own channel.
|
|
118
|
+
*/
|
|
119
|
+
const runTarget = (namespace) => namespace === undefined ? undefined : { namespace };
|
|
110
120
|
/** Whether `status` is one a run never leaves on its own. */
|
|
111
121
|
const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status);
|
|
112
122
|
/**
|
|
@@ -162,6 +172,8 @@ const leaseRefused = new WeakMap();
|
|
|
162
172
|
* parent's next turn.
|
|
163
173
|
*/
|
|
164
174
|
const LOCAL_RUN_CONCURRENCY = 256;
|
|
175
|
+
/** Pending runs one {@link WorkflowEngine.runPending} poll takes at most. */
|
|
176
|
+
const PENDING_POLL_BATCH = 100;
|
|
165
177
|
/** How many dispatched runs this instance takes from the transport's run queue at once. */
|
|
166
178
|
const RUN_CONSUMER_CONCURRENCY = 64;
|
|
167
179
|
/** The most runs handed to {@link Transport.dispatchRuns} in one call. */
|
|
@@ -285,6 +297,16 @@ export class WorkflowEngine {
|
|
|
285
297
|
maxRecoveryAttempts;
|
|
286
298
|
unregisteredWorkflowGraceMs;
|
|
287
299
|
reconcileMs;
|
|
300
|
+
/** Parked workflow bodies kept between turns (see {@link WorkflowEngineDeps.stickyRuns}). */
|
|
301
|
+
sticky;
|
|
302
|
+
/** Pending runs claimed by {@link runPending} for the turn about to run them, as read by the claim. */
|
|
303
|
+
claimedRuns = new Map();
|
|
304
|
+
/** The continuation each turn executing here runs in, by run id, while the turn runs. */
|
|
305
|
+
turnContinuations = new Map();
|
|
306
|
+
/** Whether a transport routes this instance's step results back to it (see `routeReplies`). */
|
|
307
|
+
repliesRouted = false;
|
|
308
|
+
/** The run row stamp (`updatedAt`, epoch ms) a turn here wrote when it settled `suspended`. */
|
|
309
|
+
suspendStamps = new Map();
|
|
288
310
|
heartbeatPersistThrottleMs;
|
|
289
311
|
timerBatchSize;
|
|
290
312
|
eventBufferTtlMs;
|
|
@@ -530,6 +552,8 @@ export class WorkflowEngine {
|
|
|
530
552
|
// which is what it must stay on to reach every tenant's queues.
|
|
531
553
|
if (this.namespace !== undefined)
|
|
532
554
|
this.pool.useNamespace(this.namespace);
|
|
555
|
+
if (this.namespace !== undefined)
|
|
556
|
+
this.controlPlane?.useNamespace?.(this.namespace);
|
|
533
557
|
this.leaseMs = deps.leaseMs ?? 30_000;
|
|
534
558
|
this.bootedAt = this.clock();
|
|
535
559
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
@@ -544,6 +568,9 @@ export class WorkflowEngine {
|
|
|
544
568
|
reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
|
|
545
569
|
? 300_000
|
|
546
570
|
: reconcile || undefined;
|
|
571
|
+
const stickyRuns = deps.stickyRuns ?? DEFAULT_STICKY_RUNS;
|
|
572
|
+
const stickyIdle = deps.stickyIdleMs ?? DEFAULT_STICKY_IDLE_MS;
|
|
573
|
+
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
574
|
// Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
|
|
548
575
|
// cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
|
|
549
576
|
const throttle = deps.heartbeatPersistThrottleMs;
|
|
@@ -673,6 +700,11 @@ export class WorkflowEngine {
|
|
|
673
700
|
async (event) => {
|
|
674
701
|
await this.persistStepEvent(event);
|
|
675
702
|
});
|
|
703
|
+
// Step results come back to the instance holding the body parked on the step, where a transport
|
|
704
|
+
// can route them (see WorkflowEngineDeps.routeReplies).
|
|
705
|
+
if ((deps.routeReplies ?? true) && this.sticky.enabled) {
|
|
706
|
+
this.repliesRouted = this.pool.routeReplies(this.instanceId);
|
|
707
|
+
}
|
|
676
708
|
// Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
|
|
677
709
|
// subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
|
|
678
710
|
// echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
|
|
@@ -1353,6 +1385,14 @@ export class WorkflowEngine {
|
|
|
1353
1385
|
* worker bridge can use this for cooperative cancellation: abort the in-flight work for `runId`
|
|
1354
1386
|
* instead of finishing it just to have the result discarded. Returns an unsubscribe function.
|
|
1355
1387
|
*/
|
|
1388
|
+
/**
|
|
1389
|
+
* Observe the control-plane traffic of every namespace, not only this engine's own: the process
|
|
1390
|
+
* mounts a console (the durable dashboard) whose live tail follows runs of every namespace. The
|
|
1391
|
+
* dashboard provider calls it; a control plane without namespaced channels ignores it.
|
|
1392
|
+
*/
|
|
1393
|
+
observeAllNamespaces() {
|
|
1394
|
+
this.controlPlane?.observeAll?.();
|
|
1395
|
+
}
|
|
1356
1396
|
onCancel(listener) {
|
|
1357
1397
|
this.cancelListeners.add(listener);
|
|
1358
1398
|
return () => this.cancelListeners.delete(listener);
|
|
@@ -2660,7 +2700,9 @@ export class WorkflowEngine {
|
|
|
2660
2700
|
this.eventsToBroadcast.shift();
|
|
2661
2701
|
msg = { kind: 'event', event: first, from: this.instanceId };
|
|
2662
2702
|
}
|
|
2663
|
-
|
|
2703
|
+
// A run's own event goes to its namespace's channel; step events carry none and stay here.
|
|
2704
|
+
const target = msg.kind === 'event' ? runTarget(msg.event.namespace) : undefined;
|
|
2705
|
+
await this.controlPlane.publishControl(msg, target).catch(() => {
|
|
2664
2706
|
// control-plane delivery is best-effort observability; never break execution
|
|
2665
2707
|
});
|
|
2666
2708
|
}
|
|
@@ -2817,7 +2859,7 @@ export class WorkflowEngine {
|
|
|
2817
2859
|
// queue already delivers it to a consuming instance.
|
|
2818
2860
|
if (this.controlPlane && !this.runQueue) {
|
|
2819
2861
|
void this.controlPlane
|
|
2820
|
-
.publishControl({ kind: 'enqueued', runId, from: this.instanceId })
|
|
2862
|
+
.publishControl({ kind: 'enqueued', runId, from: this.instanceId }, runTarget(run.namespace))
|
|
2821
2863
|
.catch(() => undefined);
|
|
2822
2864
|
}
|
|
2823
2865
|
}
|
|
@@ -3609,7 +3651,38 @@ export class WorkflowEngine {
|
|
|
3609
3651
|
});
|
|
3610
3652
|
}
|
|
3611
3653
|
async resumeRunNow(runId, allowFailed) {
|
|
3612
|
-
|
|
3654
|
+
// A run just claimed for this turn (runPending) is held, and was read by the claim.
|
|
3655
|
+
const claimed = this.claimedRuns.get(runId);
|
|
3656
|
+
if (claimed) {
|
|
3657
|
+
this.claimedRuns.delete(runId);
|
|
3658
|
+
return this.resumeLoadedRun(claimed, runId, allowFailed);
|
|
3659
|
+
}
|
|
3660
|
+
// Take the lease and read the run in one round trip when the store can: `execute` then finds
|
|
3661
|
+
// the lease held for this turn. Not when a caller already holds it for this turn (a leased
|
|
3662
|
+
// sweep) or a turn of the run is in flight here; a failed take reads the run as before.
|
|
3663
|
+
const lockAndGet = this.lockedForTurn.has(runId) || this.executionLeases.has(runId)
|
|
3664
|
+
? undefined
|
|
3665
|
+
: this.store.tryLockRunAndGet;
|
|
3666
|
+
let run = null;
|
|
3667
|
+
if (lockAndGet) {
|
|
3668
|
+
const nowMs = this.clock();
|
|
3669
|
+
run = await lockAndGet.call(this.store, runId, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
3670
|
+
}
|
|
3671
|
+
if (!run)
|
|
3672
|
+
return this.resumeLoadedRun(await this.store.getRun(runId), runId, allowFailed);
|
|
3673
|
+
this.lockedForTurn.add(runId);
|
|
3674
|
+
try {
|
|
3675
|
+
return await this.resumeLoadedRun(run, runId, allowFailed);
|
|
3676
|
+
}
|
|
3677
|
+
finally {
|
|
3678
|
+
// Still marked: the turn never started (a finished run, another namespace, a registration
|
|
3679
|
+
// that failed), so the lease taken above goes back.
|
|
3680
|
+
if (this.lockedForTurn.delete(runId)) {
|
|
3681
|
+
await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
async resumeLoadedRun(run, runId, allowFailed) {
|
|
3613
3686
|
if (!run)
|
|
3614
3687
|
throw new Error(`run ${runId} not found`);
|
|
3615
3688
|
// A definitively-finished run must not be re-executed (e.g. a worker result landing after the
|
|
@@ -3750,6 +3823,8 @@ export class WorkflowEngine {
|
|
|
3750
3823
|
*/
|
|
3751
3824
|
beginDrain(spentMs = 0) {
|
|
3752
3825
|
this.draining = true;
|
|
3826
|
+
// Parked bodies are only an optimization: the runs replay wherever they resume next.
|
|
3827
|
+
this.sticky.clear();
|
|
3753
3828
|
// Tell the local steps still running: a long one can stop between units of work instead of
|
|
3754
3829
|
// being cut off when the drain budget runs out.
|
|
3755
3830
|
if (!this.drainAbort.signal.aborted)
|
|
@@ -3923,7 +3998,7 @@ export class WorkflowEngine {
|
|
|
3923
3998
|
this.notifyCancelled(run.id);
|
|
3924
3999
|
if (this.controlPlane) {
|
|
3925
4000
|
void this.controlPlane
|
|
3926
|
-
.publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId })
|
|
4001
|
+
.publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId }, runTarget(run.namespace))
|
|
3927
4002
|
.catch(() => undefined);
|
|
3928
4003
|
}
|
|
3929
4004
|
this.notifyParent(run.id, { ok: false, error: error.message });
|
|
@@ -4661,9 +4736,58 @@ export class WorkflowEngine {
|
|
|
4661
4736
|
async runPending(nowMs = this.clock()) {
|
|
4662
4737
|
// Oldest-first (FIFO), capped per call so a backlog drains over several polls without one sweep
|
|
4663
4738
|
// fetching unboundedly. A run not picked up this tick is picked up the next.
|
|
4664
|
-
|
|
4739
|
+
// A store that can claims a batch in one statement (rows another instance is claiming are
|
|
4740
|
+
// skipped, not raced for), and the batch runs concurrently, within this engine's run budget.
|
|
4741
|
+
const claim = this.store.claimPendingRuns;
|
|
4742
|
+
if (claim) {
|
|
4743
|
+
if (this.draining)
|
|
4744
|
+
return [];
|
|
4745
|
+
const room = Math.min(PENDING_POLL_BATCH, LOCAL_RUN_CONCURRENCY - this.inflight.size);
|
|
4746
|
+
if (room <= 0)
|
|
4747
|
+
return [];
|
|
4748
|
+
const claimed = await asHeartbeat(() => claim.call(this.store, room, this.instanceId, nowMs + this.leaseMs, nowMs, this.namespace));
|
|
4749
|
+
return this.runClaimed(claimed, nowMs);
|
|
4750
|
+
}
|
|
4751
|
+
const pending = await asHeartbeat(() => this.store.listPendingRuns(PENDING_POLL_BATCH, this.namespace));
|
|
4665
4752
|
return this.resumeLeased(pending, nowMs);
|
|
4666
4753
|
}
|
|
4754
|
+
/**
|
|
4755
|
+
* Run `claimed` (pending runs this instance just leased, read as locked): every turn starts at
|
|
4756
|
+
* once, and each re-enters the lease taken for it without reading the run again. A run this
|
|
4757
|
+
* instance cannot resume is skipped and logged, as {@link resumeLeased} does; its lease lapses.
|
|
4758
|
+
*/
|
|
4759
|
+
async runClaimed(claimed, nowMs) {
|
|
4760
|
+
const turns = claimed.map(async (run) => {
|
|
4761
|
+
if (this.draining) {
|
|
4762
|
+
// Drain began after the claim: give the lease back; the run stays for another instance.
|
|
4763
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
4764
|
+
return undefined;
|
|
4765
|
+
}
|
|
4766
|
+
this.lockedForTurn.add(run.id);
|
|
4767
|
+
this.claimedRuns.set(run.id, run);
|
|
4768
|
+
try {
|
|
4769
|
+
return await this.resumeImplicit(run.id);
|
|
4770
|
+
}
|
|
4771
|
+
catch (error) {
|
|
4772
|
+
if (error instanceof NamespaceMismatch)
|
|
4773
|
+
return undefined;
|
|
4774
|
+
if (error instanceof WorkflowNotRegistered) {
|
|
4775
|
+
const settled = await this.settleUnregistered(run, nowMs).catch(() => undefined);
|
|
4776
|
+
if (settled)
|
|
4777
|
+
return settled;
|
|
4778
|
+
if (settled !== undefined)
|
|
4779
|
+
return undefined;
|
|
4780
|
+
}
|
|
4781
|
+
console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
|
|
4782
|
+
return undefined;
|
|
4783
|
+
}
|
|
4784
|
+
finally {
|
|
4785
|
+
this.lockedForTurn.delete(run.id);
|
|
4786
|
+
this.claimedRuns.delete(run.id);
|
|
4787
|
+
}
|
|
4788
|
+
});
|
|
4789
|
+
return (await Promise.all(turns)).filter((r) => r !== undefined);
|
|
4790
|
+
}
|
|
4667
4791
|
/**
|
|
4668
4792
|
* Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
|
|
4669
4793
|
* suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
|
|
@@ -5172,7 +5296,7 @@ export class WorkflowEngine {
|
|
|
5172
5296
|
await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
|
|
5173
5297
|
if (this.controlPlane) {
|
|
5174
5298
|
void this.controlPlane
|
|
5175
|
-
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
5299
|
+
.publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
|
|
5176
5300
|
.catch(() => undefined);
|
|
5177
5301
|
}
|
|
5178
5302
|
// Tracked (see {@link trackEffect}): the background resume replays the run and drives its saga
|
|
@@ -5213,7 +5337,7 @@ export class WorkflowEngine {
|
|
|
5213
5337
|
this.notifyCancelled(runId);
|
|
5214
5338
|
if (this.controlPlane) {
|
|
5215
5339
|
void this.controlPlane
|
|
5216
|
-
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
5340
|
+
.publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
|
|
5217
5341
|
.catch(() => undefined);
|
|
5218
5342
|
}
|
|
5219
5343
|
// A cancelled singleton run frees its slot — wake the next gated waiter now (notify-on-release).
|
|
@@ -6033,13 +6157,20 @@ export class WorkflowEngine {
|
|
|
6033
6157
|
// conditional write IS the guard — it subsumes the read-then-write this branch used to do (that
|
|
6034
6158
|
// was itself a narrower, TOCTOU-prone version of the same check, and only looked for `cancelled`;
|
|
6035
6159
|
// this now also catches `completed`/`dead` reached via any other path mid-turn).
|
|
6160
|
+
// Strictly later than the row this turn read: a parked body is recognized by the exact stamp its
|
|
6161
|
+
// suspend wrote (see takeContinuation), so no later settle, by any instance with any clock, may
|
|
6162
|
+
// write that same stamp again, even within the same millisecond.
|
|
6163
|
+
const stamp = new Date(Math.max(updatedAt.getTime(), run.updatedAt.getTime() + 1));
|
|
6036
6164
|
const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, await this.withRunTrace(run, {
|
|
6037
6165
|
status: 'suspended',
|
|
6038
6166
|
wakeAt: this.reconcileWakeAt(outcome.wakeAt),
|
|
6039
|
-
updatedAt,
|
|
6167
|
+
updatedAt: stamp,
|
|
6040
6168
|
}));
|
|
6041
6169
|
if (!applied)
|
|
6042
6170
|
return this.echoCurrentStatus(run.id);
|
|
6171
|
+
run.updatedAt = stamp;
|
|
6172
|
+
if (this.turnContinuations.has(run.id))
|
|
6173
|
+
this.suspendStamps.set(run.id, stamp.getTime());
|
|
6043
6174
|
this.emit({
|
|
6044
6175
|
type: 'run.suspended',
|
|
6045
6176
|
runId: run.id,
|
|
@@ -6084,7 +6215,8 @@ export class WorkflowEngine {
|
|
|
6084
6215
|
* settled it), so report what is persisted rather than writing over it.
|
|
6085
6216
|
*/
|
|
6086
6217
|
/**
|
|
6087
|
-
* A run's first execution: `pending` -> `running
|
|
6218
|
+
* A run's first execution: `pending` -> `running`, resolving whether it moved it (a run cancelled
|
|
6219
|
+
* or deleted since it was read did not move). A singleton run not yet admitted is stamped
|
|
6088
6220
|
* {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
|
|
6089
6221
|
* replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
|
|
6090
6222
|
* 0.51 (see `legacySingletonHolders`).
|
|
@@ -6092,14 +6224,19 @@ export class WorkflowEngine {
|
|
|
6092
6224
|
async markStarted(run, singleton) {
|
|
6093
6225
|
const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
|
|
6094
6226
|
const retag = tags !== run.tags;
|
|
6095
|
-
|
|
6227
|
+
// A compare-and-set on `pending`: the run was read before this turn took it, and a cancel (or a
|
|
6228
|
+
// delete) landing in between must not be overwritten with `running`, which would resurrect it.
|
|
6229
|
+
const started = await this.store.updateRunIf(run.id, ['pending'], {
|
|
6096
6230
|
status: 'running',
|
|
6097
6231
|
...(retag ? { tags } : {}),
|
|
6098
6232
|
updatedAt: new Date(),
|
|
6099
6233
|
});
|
|
6234
|
+
if (!started)
|
|
6235
|
+
return false;
|
|
6100
6236
|
run.status = 'running';
|
|
6101
6237
|
if (retag)
|
|
6102
6238
|
run.tags = tags;
|
|
6239
|
+
return true;
|
|
6103
6240
|
}
|
|
6104
6241
|
async singletonWaitResult(run) {
|
|
6105
6242
|
const current = await this.store.getRun(run.id);
|
|
@@ -6143,7 +6280,11 @@ export class WorkflowEngine {
|
|
|
6143
6280
|
return { runId: run.id, status: 'suspended' };
|
|
6144
6281
|
}
|
|
6145
6282
|
if (run.status === 'pending') {
|
|
6146
|
-
await this.markStarted(run, registered?.singleton !== undefined)
|
|
6283
|
+
if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
|
|
6284
|
+
// No longer `pending` (cancelled or deleted since it was read): nothing to run.
|
|
6285
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
6286
|
+
return await this.echoCurrentStatus(run.id);
|
|
6287
|
+
}
|
|
6147
6288
|
this.emit({
|
|
6148
6289
|
type: 'run.started',
|
|
6149
6290
|
runId: run.id,
|
|
@@ -6158,7 +6299,14 @@ export class WorkflowEngine {
|
|
|
6158
6299
|
// Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
|
|
6159
6300
|
// its group — if no live worker on that group can run it, park `blocked` before advancing rather
|
|
6160
6301
|
// than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
|
|
6161
|
-
const
|
|
6302
|
+
const routeToken = registered.convention
|
|
6303
|
+
? conventionGroup(run.workflow, run.namespace)
|
|
6304
|
+
: remote.group;
|
|
6305
|
+
let routePlan = await this.planRoute(routeToken, registered.requires);
|
|
6306
|
+
// As in ensureRoutable: re-read before parking, never park on reused descriptors.
|
|
6307
|
+
if (routePlan?.status === 'blocked') {
|
|
6308
|
+
routePlan = await this.planRoute(routeToken, registered.requires, true);
|
|
6309
|
+
}
|
|
6162
6310
|
if (routePlan?.status === 'blocked')
|
|
6163
6311
|
return this.parkBlocked(run, routePlan);
|
|
6164
6312
|
const checkpoints = await this.replayCheckpoints(run.id);
|
|
@@ -7204,7 +7352,11 @@ export class WorkflowEngine {
|
|
|
7204
7352
|
// gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
|
|
7205
7353
|
// already past `pending`, so this fires exactly once.
|
|
7206
7354
|
if (run.status === 'pending') {
|
|
7207
|
-
await this.markStarted(run, registered?.singleton !== undefined)
|
|
7355
|
+
if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
|
|
7356
|
+
// No longer `pending` (cancelled or deleted since it was read): nothing to run.
|
|
7357
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
7358
|
+
return await this.echoCurrentStatus(run.id);
|
|
7359
|
+
}
|
|
7208
7360
|
this.emit({
|
|
7209
7361
|
type: 'run.started',
|
|
7210
7362
|
runId: run.id,
|
|
@@ -7218,20 +7370,37 @@ export class WorkflowEngine {
|
|
|
7218
7370
|
if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
7219
7371
|
return this.singletonWaitResult(run);
|
|
7220
7372
|
}
|
|
7373
|
+
// A body this instance parked on dispatched steps in an earlier turn continues from there (see
|
|
7374
|
+
// `sticky-continuation.ts`), when nothing but this instance's own turn has settled the run since.
|
|
7375
|
+
const stickyOk = this.stickyEligible(run);
|
|
7376
|
+
let cont = stickyOk ? this.takeContinuation(run, fn) : undefined;
|
|
7377
|
+
const warm = cont !== undefined;
|
|
7378
|
+
// The parked steps' checkpoints, read before the body resumes: a failing read fails the turn
|
|
7379
|
+
// like a failing history read does, never the run.
|
|
7380
|
+
const parkedFresh = cont ? await this.readParked(run.id, cont) : undefined;
|
|
7221
7381
|
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
7222
|
-
const compensations = [];
|
|
7382
|
+
const compensations = cont ? cont.compensations : [];
|
|
7223
7383
|
// Load this run's checkpoints ONCE and key them by seq, so replaying the completed prefix reads
|
|
7224
7384
|
// from memory instead of one `getCheckpoint` SELECT per primitive (the O(N²) replay-reads fix).
|
|
7225
7385
|
// Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
|
|
7226
7386
|
// this resume wakes on, or one written later in this same execution) is absent from the map, and
|
|
7227
7387
|
// the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
|
|
7228
|
-
|
|
7229
|
-
const
|
|
7388
|
+
// A warm continuation already replayed its prefix: it reads only what lies past its park point.
|
|
7389
|
+
const snapshot = cont ? [] : await this.replayCheckpoints(run.id);
|
|
7390
|
+
const replay = cont ? cont.replay : new Map();
|
|
7230
7391
|
for (const cp of snapshot)
|
|
7231
7392
|
replay.set(cp.seq, cp);
|
|
7393
|
+
if (!cont && stickyOk) {
|
|
7394
|
+
cont = new StickyContinuation(run.id, fn, run.workflowVersion, compensations, replay, (c) => this.sticky.evict(c.runId));
|
|
7395
|
+
}
|
|
7396
|
+
if (cont)
|
|
7397
|
+
this.turnContinuations.set(run.id, cont);
|
|
7398
|
+
let parkedTurn = false;
|
|
7232
7399
|
let suspendedOnFan;
|
|
7233
7400
|
let settledSuspended = false;
|
|
7234
|
-
const ctx =
|
|
7401
|
+
const ctx = warm
|
|
7402
|
+
? undefined
|
|
7403
|
+
: createWorkflowCtx(this.ctxHostFor(replay, run.namespace, cont), run.id, compensations, run.workflow);
|
|
7235
7404
|
// Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
|
|
7236
7405
|
// `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
|
|
7237
7406
|
// return expression is evaluated, while the settle's own write is still in flight. A first turn
|
|
@@ -7248,8 +7417,33 @@ export class WorkflowEngine {
|
|
|
7248
7417
|
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
7249
7418
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
7250
7419
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
7251
|
-
|
|
7252
|
-
|
|
7420
|
+
let output;
|
|
7421
|
+
if (cont) {
|
|
7422
|
+
if (parkedFresh) {
|
|
7423
|
+
cont.attached = true;
|
|
7424
|
+
await cont.resumeParked(parkedFresh);
|
|
7425
|
+
}
|
|
7426
|
+
else {
|
|
7427
|
+
const input = await this.turnInput(run, registered);
|
|
7428
|
+
const body = ctx;
|
|
7429
|
+
cont.begin(() => workflowAls.run(body, () => fn(body, input)));
|
|
7430
|
+
}
|
|
7431
|
+
const outcome = await cont.next();
|
|
7432
|
+
if (outcome.kind === 'parked') {
|
|
7433
|
+
// Parked on dispatched steps with nothing else in flight: suspend exactly as a thrown
|
|
7434
|
+
// suspension would, and keep the body for the run's next turn here.
|
|
7435
|
+
parkedTurn = true;
|
|
7436
|
+
throw new WorkflowSuspended(outcome.wakeAt, outcome.waitSeqs);
|
|
7437
|
+
}
|
|
7438
|
+
if (outcome.kind === 'threw')
|
|
7439
|
+
throw outcome.error;
|
|
7440
|
+
output = outcome.value;
|
|
7441
|
+
}
|
|
7442
|
+
else {
|
|
7443
|
+
const input = await this.turnInput(run, registered);
|
|
7444
|
+
const body = ctx;
|
|
7445
|
+
output = await workflowAls.run(body, () => fn(body, input));
|
|
7446
|
+
}
|
|
7253
7447
|
// A compensating cancel requested while this turn ran (or one whose resume replays straight to
|
|
7254
7448
|
// `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
|
|
7255
7449
|
if (await this.takeCancelIntent(run.id)) {
|
|
@@ -7312,6 +7506,11 @@ export class WorkflowEngine {
|
|
|
7312
7506
|
return await this.compensateAndCancel(run, compensations);
|
|
7313
7507
|
}
|
|
7314
7508
|
const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
7509
|
+
const stamp = this.suspendStamps.get(run.id);
|
|
7510
|
+
this.suspendStamps.delete(run.id);
|
|
7511
|
+
if (parkedTurn && cont && settled.status === 'suspended' && stamp !== undefined) {
|
|
7512
|
+
this.keepContinuation(cont, stamp);
|
|
7513
|
+
}
|
|
7315
7514
|
// Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
|
|
7316
7515
|
// executing wrote its checkpoint durably, but its resume no-oped against our held lease —
|
|
7317
7516
|
// and this settle just parked the run on nothing but the reconcile fallback (or nothing at
|
|
@@ -7370,6 +7569,9 @@ export class WorkflowEngine {
|
|
|
7370
7569
|
//
|
|
7371
7570
|
// A suspended turn keeps the lease instead when a wake it refused asked for another turn, and
|
|
7372
7571
|
// runs that turn (see `requestWake`).
|
|
7572
|
+
if (cont && this.turnContinuations.get(run.id) === cont) {
|
|
7573
|
+
this.turnContinuations.delete(run.id);
|
|
7574
|
+
}
|
|
7373
7575
|
let released = true;
|
|
7374
7576
|
try {
|
|
7375
7577
|
if (settledSuspended)
|
|
@@ -7620,10 +7822,11 @@ export class WorkflowEngine {
|
|
|
7620
7822
|
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
7621
7823
|
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
7622
7824
|
* module and the engine stays the orchestrator. */
|
|
7623
|
-
ctxHostFor(replay, runNamespace) {
|
|
7825
|
+
ctxHostFor(replay, runNamespace, sticky) {
|
|
7624
7826
|
return {
|
|
7625
7827
|
store: this.store,
|
|
7626
7828
|
replay,
|
|
7829
|
+
sticky,
|
|
7627
7830
|
clock: this.clock,
|
|
7628
7831
|
stepSignal: this.drainAbort.signal,
|
|
7629
7832
|
eventBufferTtlMs: this.eventBufferTtlMs,
|
|
@@ -7631,7 +7834,18 @@ export class WorkflowEngine {
|
|
|
7631
7834
|
startStep: (s) => this.startStep(s),
|
|
7632
7835
|
completeStep: (s) => this.completeStep(s),
|
|
7633
7836
|
failStep: (s) => this.failStep(s),
|
|
7634
|
-
callRemote: (runId, seq, step, input, queue, transport, admission) =>
|
|
7837
|
+
callRemote: (runId, seq, step, input, queue, transport, admission) => {
|
|
7838
|
+
const call = this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace);
|
|
7839
|
+
if (!sticky)
|
|
7840
|
+
return call;
|
|
7841
|
+
// In a sticky continuation a call that would suspend parks instead: a later turn
|
|
7842
|
+
// re-evaluates it (the same logic, against its freshly read checkpoint).
|
|
7843
|
+
return call.catch((err) => {
|
|
7844
|
+
if (!(err instanceof WorkflowSuspended) || !sticky.attached)
|
|
7845
|
+
throw err;
|
|
7846
|
+
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));
|
|
7847
|
+
});
|
|
7848
|
+
},
|
|
7635
7849
|
callRemoteAll: (runId, positions, step, inputs, opts) => this.callRemoteAll(runId, positions, step, inputs, opts, runNamespace),
|
|
7636
7850
|
// Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
|
|
7637
7851
|
// PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
|
|
@@ -7762,12 +7976,12 @@ export class WorkflowEngine {
|
|
|
7762
7976
|
* descriptor keyspace. Empty when no transport can introspect it (in-process / pre-handshake broker)
|
|
7763
7977
|
* OR nobody is publishing — either way the guard is skipped and dispatch proceeds legacy
|
|
7764
7978
|
* assume-compatible (design §7.7). Never throws (the transport degrades to `[]`). */
|
|
7765
|
-
async liveDescriptorsFor(token) {
|
|
7979
|
+
async liveDescriptorsFor(token, fresh = false) {
|
|
7766
7980
|
const read = this.primaryTransport?.listWorkerDescriptors;
|
|
7767
7981
|
if (!read)
|
|
7768
7982
|
return [];
|
|
7769
7983
|
try {
|
|
7770
|
-
return await read.call(this.primaryTransport, token);
|
|
7984
|
+
return await read.call(this.primaryTransport, token, fresh ? { fresh } : undefined);
|
|
7771
7985
|
}
|
|
7772
7986
|
catch {
|
|
7773
7987
|
return [];
|
|
@@ -7780,8 +7994,8 @@ export class WorkflowEngine {
|
|
|
7780
7994
|
* - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
|
|
7781
7995
|
* - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
|
|
7782
7996
|
*/
|
|
7783
|
-
async planRoute(token, requires) {
|
|
7784
|
-
const descriptors = await this.liveDescriptorsFor(token);
|
|
7997
|
+
async planRoute(token, requires, fresh = false) {
|
|
7998
|
+
const descriptors = await this.liveDescriptorsFor(token, fresh);
|
|
7785
7999
|
if (descriptors.length === 0)
|
|
7786
8000
|
return undefined; // legacy/no-runtime path — skip the guard
|
|
7787
8001
|
return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
|
|
@@ -7791,7 +8005,10 @@ export class WorkflowEngine {
|
|
|
7791
8005
|
* checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
|
|
7792
8006
|
* not engage (no descriptors) or a capable+compatible worker exists. */
|
|
7793
8007
|
async ensureRoutable(token, requires) {
|
|
7794
|
-
|
|
8008
|
+
let plan = await this.planRoute(token, requires);
|
|
8009
|
+
// Never park a run on reused descriptors: a capable worker may have joined since they were read.
|
|
8010
|
+
if (plan?.status === 'blocked')
|
|
8011
|
+
plan = await this.planRoute(token, requires, true);
|
|
7795
8012
|
if (plan?.status === 'blocked')
|
|
7796
8013
|
throw new WorkflowBlocked(plan);
|
|
7797
8014
|
}
|
|
@@ -7847,7 +8064,21 @@ export class WorkflowEngine {
|
|
|
7847
8064
|
}));
|
|
7848
8065
|
return blocked;
|
|
7849
8066
|
}
|
|
7850
|
-
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup
|
|
8067
|
+
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup,
|
|
8068
|
+
/** `replay` holds this step's checkpoint as just read from the store: no re-read needed. */
|
|
8069
|
+
fresh) {
|
|
8070
|
+
// A position the turn's history does not hold is normally a first dispatch: write its `pending`
|
|
8071
|
+
// row only if none is there, instead of reading the position first. A row that is there (a turn
|
|
8072
|
+
// this one did not see wrote it) is then read and handled below like any other.
|
|
8073
|
+
if (replay !== undefined &&
|
|
8074
|
+
!replay.has(seq) &&
|
|
8075
|
+
this.store.insertCheckpointIfAbsent &&
|
|
8076
|
+
!step.timeoutMs &&
|
|
8077
|
+
this.pool.size > 0 &&
|
|
8078
|
+
!(queue && this.admission.handles(queue)) &&
|
|
8079
|
+
(await this.dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup))) {
|
|
8080
|
+
throw new WorkflowSuspended(undefined, [seq]);
|
|
8081
|
+
}
|
|
7851
8082
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
7852
8083
|
// from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
|
|
7853
8084
|
let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
|
|
@@ -7857,7 +8088,7 @@ export class WorkflowEngine {
|
|
|
7857
8088
|
// the lost-dispatch lease, or a re-dispatch) — written from a stale row, that overwrites the landed
|
|
7858
8089
|
// `completed` result with `pending` and parks the run on a lease an hour out. So a pending step is
|
|
7859
8090
|
// 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)) {
|
|
8091
|
+
if (existing?.status === 'pending' && replay?.has(seq) && !fresh) {
|
|
7861
8092
|
existing = (await this.store.getCheckpoint(runId, seq)) ?? existing;
|
|
7862
8093
|
}
|
|
7863
8094
|
if (existing && existing.name !== step.name) {
|
|
@@ -7938,6 +8169,8 @@ export class WorkflowEngine {
|
|
|
7938
8169
|
priority: admission?.priority,
|
|
7939
8170
|
attempt: reAttempt,
|
|
7940
8171
|
transport,
|
|
8172
|
+
// A fan's steps are joined by its barrier, not a parked body: their results stay shared.
|
|
8173
|
+
replyHere: parallelGroup === undefined,
|
|
7941
8174
|
});
|
|
7942
8175
|
this.emit({
|
|
7943
8176
|
type: 'step.started',
|
|
@@ -8042,10 +8275,66 @@ export class WorkflowEngine {
|
|
|
8042
8275
|
attempt,
|
|
8043
8276
|
transport,
|
|
8044
8277
|
enqueuedAt,
|
|
8278
|
+
replyHere: parallelGroup === undefined,
|
|
8045
8279
|
});
|
|
8046
8280
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
8047
8281
|
throw new WorkflowSuspended(undefined, [seq]);
|
|
8048
8282
|
}
|
|
8283
|
+
/**
|
|
8284
|
+
* {@link callRemote}'s first dispatch of a step, written with
|
|
8285
|
+
* {@link StateStore.insertCheckpointIfAbsent}: resolves whether it wrote the `pending` row and
|
|
8286
|
+
* dispatched. `false` when the position already has a row, or when no live worker can take the
|
|
8287
|
+
* step (the read-first path then decides, so a step that is in fact done is never blocked).
|
|
8288
|
+
*/
|
|
8289
|
+
async dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup) {
|
|
8290
|
+
const validInput = step.input
|
|
8291
|
+
? await validateStandard(step.input, input, `step "${step.name}" input`, 'invalid_input')
|
|
8292
|
+
: input;
|
|
8293
|
+
const token = stepGroup(step, runNamespace);
|
|
8294
|
+
try {
|
|
8295
|
+
await this.ensureRoutable(token, step.requires);
|
|
8296
|
+
}
|
|
8297
|
+
catch (err) {
|
|
8298
|
+
if (err instanceof WorkflowBlocked)
|
|
8299
|
+
return false;
|
|
8300
|
+
throw err;
|
|
8301
|
+
}
|
|
8302
|
+
const id = stepId(runId, seq);
|
|
8303
|
+
const enqueuedAt = new Date();
|
|
8304
|
+
const insert = this.store.insertCheckpointIfAbsent;
|
|
8305
|
+
const wrote = await insert.call(this.store, {
|
|
8306
|
+
runId,
|
|
8307
|
+
seq,
|
|
8308
|
+
name: step.name,
|
|
8309
|
+
kind: 'remote',
|
|
8310
|
+
stepId: id,
|
|
8311
|
+
status: 'pending',
|
|
8312
|
+
input: validInput,
|
|
8313
|
+
attempts: 1,
|
|
8314
|
+
workerGroup: token,
|
|
8315
|
+
...(parallelGroup !== undefined ? { parallelGroup } : {}),
|
|
8316
|
+
enqueuedAt,
|
|
8317
|
+
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
8318
|
+
finishedAt: enqueuedAt,
|
|
8319
|
+
});
|
|
8320
|
+
if (!wrote)
|
|
8321
|
+
return false;
|
|
8322
|
+
await this.dispatchRemoteTask({
|
|
8323
|
+
runId,
|
|
8324
|
+
seq,
|
|
8325
|
+
name: step.name,
|
|
8326
|
+
stepId: id,
|
|
8327
|
+
group: token,
|
|
8328
|
+
input: validInput,
|
|
8329
|
+
priority: admission?.priority,
|
|
8330
|
+
attempt: 1,
|
|
8331
|
+
transport,
|
|
8332
|
+
enqueuedAt,
|
|
8333
|
+
replyHere: parallelGroup === undefined,
|
|
8334
|
+
});
|
|
8335
|
+
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
8336
|
+
return true;
|
|
8337
|
+
}
|
|
8049
8338
|
/**
|
|
8050
8339
|
* `ctx.all` over a `@Step`: one durable step per input, dispatched together, joined once. The ctx
|
|
8051
8340
|
* reserves the whole position block up front, so replay re-derives the same seqs.
|
|
@@ -8283,11 +8572,85 @@ export class WorkflowEngine {
|
|
|
8283
8572
|
context: this.context?.(),
|
|
8284
8573
|
priority: task.priority,
|
|
8285
8574
|
attempt: task.attempt,
|
|
8575
|
+
...(task.replyHere && this.repliesRouted && !this.draining
|
|
8576
|
+
? { replyTo: this.instanceId }
|
|
8577
|
+
: {}),
|
|
8286
8578
|
};
|
|
8287
8579
|
if (task.enqueuedAt)
|
|
8288
8580
|
remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
|
|
8289
8581
|
return this.pool.dispatch(remote, task.transport);
|
|
8290
8582
|
}
|
|
8583
|
+
/**
|
|
8584
|
+
* Whether this turn of `run` may run in a sticky continuation (see `sticky-continuation.ts`). Not
|
|
8585
|
+
* while the engine drains, nor with a compensating cancel requested for the run.
|
|
8586
|
+
*/
|
|
8587
|
+
stickyEligible(run) {
|
|
8588
|
+
return this.sticky.enabled && !this.draining && !this.cancelRequested.has(run.id);
|
|
8589
|
+
}
|
|
8590
|
+
/**
|
|
8591
|
+
* The continuation this instance parked for `run`, if it is still the run's latest turn: the run
|
|
8592
|
+
* is `suspended` with the very stamp this instance's suspend wrote, on the same body and version.
|
|
8593
|
+
* Any other settle in between (another instance's turn, a retry, a cancel) changed the stamp, and
|
|
8594
|
+
* the run replays instead. Taking it removes it from the cache.
|
|
8595
|
+
*/
|
|
8596
|
+
takeContinuation(run, fn) {
|
|
8597
|
+
const cont = this.sticky.take(run.id);
|
|
8598
|
+
if (!cont)
|
|
8599
|
+
return undefined;
|
|
8600
|
+
const valid = cont.fn === fn &&
|
|
8601
|
+
cont.workflowVersion === run.workflowVersion &&
|
|
8602
|
+
run.status === 'suspended' &&
|
|
8603
|
+
cont.stamp !== undefined &&
|
|
8604
|
+
run.updatedAt.getTime() === cont.stamp;
|
|
8605
|
+
if (!valid) {
|
|
8606
|
+
this.sticky.misses += 1;
|
|
8607
|
+
return undefined;
|
|
8608
|
+
}
|
|
8609
|
+
this.sticky.hits += 1;
|
|
8610
|
+
return cont;
|
|
8611
|
+
}
|
|
8612
|
+
/** Keep `cont` (parked, detached) for the run's next turn here, stamped with its suspend write. */
|
|
8613
|
+
keepContinuation(cont, stamp) {
|
|
8614
|
+
if (cont.unusable)
|
|
8615
|
+
return;
|
|
8616
|
+
cont.stamp = stamp;
|
|
8617
|
+
// Everything up to the park point is consumed; later positions read the store.
|
|
8618
|
+
cont.replay.clear();
|
|
8619
|
+
this.sticky.put(cont);
|
|
8620
|
+
}
|
|
8621
|
+
/**
|
|
8622
|
+
* The checkpoints of the steps `cont` is parked on, as a replay would see them: in one read when
|
|
8623
|
+
* the store batches, and without the input of a step that completed (what
|
|
8624
|
+
* {@link StateStore.getCheckpointsForSettle} leaves out). A step still open or failed is read
|
|
8625
|
+
* whole, since a re-dispatch or a retry sends its input again.
|
|
8626
|
+
*/
|
|
8627
|
+
async readParked(runId, cont) {
|
|
8628
|
+
const fresh = new Map();
|
|
8629
|
+
for (const seq of cont.parked.keys())
|
|
8630
|
+
fresh.set(seq, cont.hints.get(seq) ?? null);
|
|
8631
|
+
cont.hints.clear();
|
|
8632
|
+
const seqs = [...fresh].flatMap(([seq, cp]) => (cp ? [] : [seq]));
|
|
8633
|
+
if (seqs.length === 0)
|
|
8634
|
+
return fresh;
|
|
8635
|
+
const lean = this.store.getCheckpointsForSettle ?? this.store.getCheckpoints;
|
|
8636
|
+
if (lean) {
|
|
8637
|
+
for (const cp of await lean.call(this.store, runId, seqs))
|
|
8638
|
+
fresh.set(cp.seq, cp);
|
|
8639
|
+
}
|
|
8640
|
+
else {
|
|
8641
|
+
for (const seq of seqs)
|
|
8642
|
+
fresh.set(seq, await this.store.getCheckpoint(runId, seq));
|
|
8643
|
+
}
|
|
8644
|
+
if (lean === this.store.getCheckpointsForSettle) {
|
|
8645
|
+
for (const seq of seqs) {
|
|
8646
|
+
const cp = fresh.get(seq);
|
|
8647
|
+
if (cp && cp.status !== 'completed') {
|
|
8648
|
+
fresh.set(seq, await this.store.getCheckpoint(runId, seq));
|
|
8649
|
+
}
|
|
8650
|
+
}
|
|
8651
|
+
}
|
|
8652
|
+
return fresh;
|
|
8653
|
+
}
|
|
8291
8654
|
/** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
|
|
8292
8655
|
* column through {@link StateStore.listCheckpoints} on a store without it. */
|
|
8293
8656
|
replayCheckpoints(runId) {
|
|
@@ -8558,6 +8921,9 @@ export class WorkflowEngine {
|
|
|
8558
8921
|
* replays every write of the batch, so a second would only replay it again.
|
|
8559
8922
|
*/
|
|
8560
8923
|
async completeRemoteResults(runId, results, queuedIn = () => false) {
|
|
8924
|
+
if (this.store.settleResultsIfPending) {
|
|
8925
|
+
return this.settleResultsReturning(runId, results, queuedIn);
|
|
8926
|
+
}
|
|
8561
8927
|
const outcomes = results.map(() => true);
|
|
8562
8928
|
const settleOneByOne = async (indexes) => {
|
|
8563
8929
|
for (const i of indexes) {
|
|
@@ -8689,6 +9055,144 @@ export class WorkflowEngine {
|
|
|
8689
9055
|
}
|
|
8690
9056
|
return outcomes;
|
|
8691
9057
|
}
|
|
9058
|
+
/**
|
|
9059
|
+
* {@link completeRemoteResults} on a store with {@link StateStore.settleResultsIfPending}: the
|
|
9060
|
+
* results settle their `pending` rows and read them back, with their run's status, in one call,
|
|
9061
|
+
* instead of reading the rows and the run and then writing. An entry that settled nothing (a
|
|
9062
|
+
* redelivery, a step gone, a late result for a finished run) takes the single-result path, which
|
|
9063
|
+
* reads first and re-drives, drops or salvages. A settled `completed` row is also handed to the
|
|
9064
|
+
* run's parked body, if this instance holds one, so its next turn does not read it again.
|
|
9065
|
+
*/
|
|
9066
|
+
async settleResultsReturning(runId, results, queuedIn) {
|
|
9067
|
+
const outcomes = results.map(() => true);
|
|
9068
|
+
const settle = this.store.settleResultsIfPending.bind(this.store);
|
|
9069
|
+
// A second copy of a result in the same batch is a duplicate delivery: the first copy settles
|
|
9070
|
+
// the step (or re-drives the resume), so the copy is dropped like a late one.
|
|
9071
|
+
const seen = new Set();
|
|
9072
|
+
const live = results.flatMap((r, i) => {
|
|
9073
|
+
if (seen.has(r.seq))
|
|
9074
|
+
return [];
|
|
9075
|
+
seen.add(r.seq);
|
|
9076
|
+
return [i];
|
|
9077
|
+
});
|
|
9078
|
+
const finishedAt = new Date();
|
|
9079
|
+
const entries = live.map((i) => {
|
|
9080
|
+
const result = results[i];
|
|
9081
|
+
return {
|
|
9082
|
+
runId,
|
|
9083
|
+
seq: result.seq,
|
|
9084
|
+
status: result.status,
|
|
9085
|
+
output: result.status === 'completed' ? result.output : undefined,
|
|
9086
|
+
error: result.error,
|
|
9087
|
+
events: result.events,
|
|
9088
|
+
startedAt: result.startedAt ? new Date(result.startedAt) : undefined,
|
|
9089
|
+
finishedAt,
|
|
9090
|
+
};
|
|
9091
|
+
});
|
|
9092
|
+
// Never rejects as a whole: the batch call rejecting wrote nothing, so the entries are then
|
|
9093
|
+
// settled one by one and a failing one fails only its own result.
|
|
9094
|
+
let rows;
|
|
9095
|
+
try {
|
|
9096
|
+
rows = await settle(entries);
|
|
9097
|
+
}
|
|
9098
|
+
catch (err) {
|
|
9099
|
+
if (entries.length === 1) {
|
|
9100
|
+
rows = [{ error: err }];
|
|
9101
|
+
}
|
|
9102
|
+
else {
|
|
9103
|
+
const one = await Promise.allSettled(entries.map((e) => settle([e])));
|
|
9104
|
+
rows = one.map((o) => o.status === 'fulfilled' ? (o.value[0] ?? null) : { error: o.reason });
|
|
9105
|
+
}
|
|
9106
|
+
}
|
|
9107
|
+
const resumable = [];
|
|
9108
|
+
const unsettled = [];
|
|
9109
|
+
let run;
|
|
9110
|
+
// The run's parked body: in the cache, or in a turn that is parking it right now.
|
|
9111
|
+
const parked = this.sticky.peek(runId) ?? this.turnContinuations.get(runId);
|
|
9112
|
+
for (const [k, row] of rows.entries()) {
|
|
9113
|
+
const index = live[k];
|
|
9114
|
+
const result = results[index];
|
|
9115
|
+
if (row === null) {
|
|
9116
|
+
unsettled.push(index);
|
|
9117
|
+
continue;
|
|
9118
|
+
}
|
|
9119
|
+
if ('error' in row) {
|
|
9120
|
+
outcomes[index] = { error: row.error };
|
|
9121
|
+
continue;
|
|
9122
|
+
}
|
|
9123
|
+
const cp = row.checkpoint;
|
|
9124
|
+
// The run as it read when this result settled: what the resume owes it (see `resumeOwed`).
|
|
9125
|
+
run ??= { id: runId, status: row.run.status, wakeAt: row.run.wakeAt };
|
|
9126
|
+
try {
|
|
9127
|
+
await this.releaseQueueSlot(cp);
|
|
9128
|
+
}
|
|
9129
|
+
catch (error) {
|
|
9130
|
+
// Written but not acknowledged: the redelivery finds the step settled and re-drives.
|
|
9131
|
+
outcomes[index] = { error };
|
|
9132
|
+
}
|
|
9133
|
+
if (cp.status === 'completed')
|
|
9134
|
+
parked?.hint(cp);
|
|
9135
|
+
this.emit({
|
|
9136
|
+
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
9137
|
+
runId,
|
|
9138
|
+
seq: result.seq,
|
|
9139
|
+
name: cp.name,
|
|
9140
|
+
kind: cp.kind,
|
|
9141
|
+
output: result.output,
|
|
9142
|
+
error: result.error,
|
|
9143
|
+
queueMs: cp.startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
9144
|
+
durationMs: finishedAt.getTime() - cp.startedAt.getTime(),
|
|
9145
|
+
});
|
|
9146
|
+
resumable.push({ cp, result });
|
|
9147
|
+
}
|
|
9148
|
+
// Nothing written for these: read first, then re-drive, drop or salvage, one at a time.
|
|
9149
|
+
for (const index of unsettled) {
|
|
9150
|
+
try {
|
|
9151
|
+
await this.completeOneRemoteResult(results[index], queuedIn);
|
|
9152
|
+
}
|
|
9153
|
+
catch (error) {
|
|
9154
|
+
outcomes[index] = { error };
|
|
9155
|
+
}
|
|
9156
|
+
}
|
|
9157
|
+
if (resumable.length === 0)
|
|
9158
|
+
return outcomes;
|
|
9159
|
+
// The barrier logic of the read-first batch path (see completeRemoteResults): one resume.
|
|
9160
|
+
const barriers = new Map();
|
|
9161
|
+
const highest = new Map();
|
|
9162
|
+
for (const { cp } of resumable) {
|
|
9163
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
9164
|
+
if (fan)
|
|
9165
|
+
highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
|
|
9166
|
+
}
|
|
9167
|
+
const singles = resumable
|
|
9168
|
+
.filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
|
|
9169
|
+
.map(({ cp }) => cp.seq);
|
|
9170
|
+
for (const { cp, result } of resumable) {
|
|
9171
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
9172
|
+
if (!fan || result.status === 'failed') {
|
|
9173
|
+
this.noteSettledHere(runId, singles);
|
|
9174
|
+
await this.resumeOwed(run, runId);
|
|
9175
|
+
return outcomes;
|
|
9176
|
+
}
|
|
9177
|
+
let closed = barriers.get(fan.firstSeq);
|
|
9178
|
+
if (!closed) {
|
|
9179
|
+
const lastSeq = fan.firstSeq + fan.size - 1;
|
|
9180
|
+
closed = queuedIn(fan.firstSeq, lastSeq)
|
|
9181
|
+
? Promise.resolve(true)
|
|
9182
|
+
: this.stepFanHasPending(runId, fan.firstSeq, lastSeq).catch(() => false);
|
|
9183
|
+
barriers.set(fan.firstSeq, closed);
|
|
9184
|
+
}
|
|
9185
|
+
if (!(await closed)) {
|
|
9186
|
+
this.noteSettledHere(runId, singles);
|
|
9187
|
+
await this.resumeOwed(run, runId, {
|
|
9188
|
+
first: fan.firstSeq,
|
|
9189
|
+
seq: highest.get(fan.firstSeq) ?? cp.seq,
|
|
9190
|
+
});
|
|
9191
|
+
return outcomes;
|
|
9192
|
+
}
|
|
9193
|
+
}
|
|
9194
|
+
return outcomes;
|
|
9195
|
+
}
|
|
8692
9196
|
/**
|
|
8693
9197
|
* Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
|
|
8694
9198
|
* or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}
|