@adonis-agora/durable 0.40.0 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/dist/assets/spa/assets/index-CWAgwrAE.js +16 -0
- package/dist/assets/spa/assets/index-CWAgwrAE.js.map +1 -0
- package/dist/assets/spa/index.html +1 -1
- package/dist/src/checkpoints.d.ts +15 -0
- package/dist/src/checkpoints.d.ts.map +1 -1
- package/dist/src/checkpoints.js +24 -0
- package/dist/src/checkpoints.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +80 -2
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +301 -16
- package/dist/src/engine.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/interfaces.d.ts +9 -0
- package/dist/src/interfaces.d.ts.map +1 -1
- package/package.json +23 -19
- package/skills/durable-reliability/SKILL.md +3 -1
- package/dist/assets/spa/assets/index-yJslNGUY.js +0 -16
- package/dist/assets/spa/assets/index-yJslNGUY.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { InMemoryAdmissionBackend } from './admission.js';
|
|
2
2
|
import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
|
|
3
|
-
import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
|
|
3
|
+
import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
|
|
4
4
|
import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
|
|
5
5
|
import { parseDuration } from './duration.js';
|
|
6
6
|
import { Entities } from './entities.js';
|
|
@@ -305,9 +305,16 @@ export class WorkflowEngine {
|
|
|
305
305
|
await this.completeRemoteResult(result);
|
|
306
306
|
},
|
|
307
307
|
// A heartbeat for an in-flight long step resets its liveness window (see callRemote) and is
|
|
308
|
-
// persisted (throttled) on the step's checkpoint so liveness is visible cross-process.
|
|
308
|
+
// persisted (throttled) on the step's checkpoint so liveness is visible cross-process. With no
|
|
309
|
+
// in-memory waiter (the step suspended DURABLY, possibly on another instance) the beat rearms
|
|
310
|
+
// the step's durable LEASE instead, so a worker still holding a long step is never presumed
|
|
311
|
+
// lost and double-dispatched (see rearmStepLease).
|
|
309
312
|
async (beat) => {
|
|
310
|
-
this.heartbeatResets.get(beat.stepId)
|
|
313
|
+
const reset = this.heartbeatResets.get(beat.stepId);
|
|
314
|
+
if (reset)
|
|
315
|
+
reset();
|
|
316
|
+
else
|
|
317
|
+
await this.rearmStepLease(beat.runId, beat.seq);
|
|
311
318
|
this.persistHeartbeat(beat);
|
|
312
319
|
},
|
|
313
320
|
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
@@ -1270,14 +1277,30 @@ export class WorkflowEngine {
|
|
|
1270
1277
|
}
|
|
1271
1278
|
return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
|
|
1272
1279
|
});
|
|
1273
|
-
for (const
|
|
1280
|
+
for (const listed of incomplete) {
|
|
1274
1281
|
// A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
|
|
1275
1282
|
// (its worker crashed). Skip the ones still owned.
|
|
1276
|
-
const acquired = await this.store.tryLockRun(
|
|
1283
|
+
const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
1277
1284
|
if (!acquired)
|
|
1278
1285
|
continue;
|
|
1286
|
+
// Decide on the run as it is NOW that the lease is held, never on the listing. The listing is a
|
|
1287
|
+
// snapshot taken before this loop reached the row, and a live run moves on in between: the turn
|
|
1288
|
+
// that was executing it settles and releases its lease, a fast step result resumes it, it may
|
|
1289
|
+
// even complete. Trusting the listed `running` reads that as a crashed turn — and the code below
|
|
1290
|
+
// counts a recovery attempt (or, past `maxRecoveryAttempts`, writes `dead` over a live or
|
|
1291
|
+
// finished run) and re-enqueues it for a second execution. Only a run that still reads
|
|
1292
|
+
// `running` under the lease is an orphan; release the lease taken just to look at anything else.
|
|
1293
|
+
const run = await this.store.getRun(listed.id);
|
|
1294
|
+
if (run?.status !== 'running') {
|
|
1295
|
+
await this.store.releaseRunLock(listed.id, this.instanceId);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1279
1298
|
// Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
|
|
1280
1299
|
const settled = await this.countRecovery(run);
|
|
1300
|
+
// `null`: the run left `running` between the re-read above and the dead-letter write (a
|
|
1301
|
+
// lease-free `cancel()`), so it is not ours to touch — countRecovery already released the lease.
|
|
1302
|
+
if (settled === null)
|
|
1303
|
+
continue;
|
|
1281
1304
|
if (settled) {
|
|
1282
1305
|
results.push(settled);
|
|
1283
1306
|
continue;
|
|
@@ -1306,7 +1329,8 @@ export class WorkflowEngine {
|
|
|
1306
1329
|
/**
|
|
1307
1330
|
* Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
|
|
1308
1331
|
* `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
|
|
1309
|
-
* result to skip the resume,
|
|
1332
|
+
* result to skip the resume, `null` to skip it silently (the run is no longer `running`), or
|
|
1333
|
+
* `undefined` to proceed.
|
|
1310
1334
|
*/
|
|
1311
1335
|
async countRecovery(run) {
|
|
1312
1336
|
const attempts = (run.recoveryAttempts ?? 0) + 1;
|
|
@@ -1315,8 +1339,17 @@ export class WorkflowEngine {
|
|
|
1315
1339
|
message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
|
|
1316
1340
|
code: 'max_recovery_attempts',
|
|
1317
1341
|
};
|
|
1318
|
-
|
|
1342
|
+
// Conditional on still-`running`, like the `pending` flip in recoverIncomplete: the lease does
|
|
1343
|
+
// not fence `cancel()`, which writes `cancelled` without it, so a cancel landing after the
|
|
1344
|
+
// caller's re-read must not be overwritten with `dead`.
|
|
1345
|
+
const applied = await this.store.updateRunIf(run.id, ['running'], {
|
|
1346
|
+
status: 'dead',
|
|
1347
|
+
error,
|
|
1348
|
+
updatedAt: new Date(),
|
|
1349
|
+
});
|
|
1319
1350
|
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
1351
|
+
if (!applied)
|
|
1352
|
+
return null;
|
|
1320
1353
|
this.emit({
|
|
1321
1354
|
type: 'run.failed',
|
|
1322
1355
|
runId: run.id,
|
|
@@ -2429,9 +2462,205 @@ export class WorkflowEngine {
|
|
|
2429
2462
|
}
|
|
2430
2463
|
// continue: persist any local steps the replay ran, dispatch the blocking ops, then suspend. When
|
|
2431
2464
|
// those resolve (a result lands, a timer fires) `resume` brings us back for the next turn.
|
|
2432
|
-
const wakeAt = await this.applyCommands(run, decision.commands);
|
|
2465
|
+
const wakeAt = await this.applyCommands(run, decision.commands, true);
|
|
2433
2466
|
return this.settleRun(run, { kind: 'suspended', wakeAt });
|
|
2434
2467
|
}
|
|
2468
|
+
/**
|
|
2469
|
+
* A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step is
|
|
2470
|
+
* still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
|
|
2471
|
+
* deadline to park the run on (so the next decision lands exactly when it's due), or `undefined` when
|
|
2472
|
+
* there is nothing to wait for.
|
|
2473
|
+
*
|
|
2474
|
+
* WHY THIS EXISTS. A worker process killed mid-step (an OOM kill, exit 137 — a pod's memory ceiling,
|
|
2475
|
+
* not a bug in anyone's code) takes its in-flight job with it, and the transport cannot always put it
|
|
2476
|
+
* back: the `bullmq` transport's terminal-failure bridge (`worker.on('failed')`) only exists inside a
|
|
2477
|
+
* JS consumer, so a first-class Python fleet (see `docs/python.mdx`) has none; the `queue` transport's
|
|
2478
|
+
* reclaim sweep gives up past `maxStalledCount`, and never runs at all on an adapter without
|
|
2479
|
+
* `recoverStalledJobs`. NOTHING then settles the checkpoint. It stays `pending` — "out for delivery" —
|
|
2480
|
+
* and the `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
|
|
2481
|
+
* double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
|
|
2482
|
+
* The run wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it again,
|
|
2483
|
+
* and sleeps. Forever — `pending`, `attempts = 1`, `wakeAt` NULL.
|
|
2484
|
+
*
|
|
2485
|
+
* THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
|
|
2486
|
+
* share — so `remoteRedispatchMs` simply did not apply to a fan-out):
|
|
2487
|
+
* - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
|
|
2488
|
+
* - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
|
|
2489
|
+
* - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
|
|
2490
|
+
* ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
|
|
2491
|
+
* double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
|
|
2492
|
+
* - past the bound the step is failed `remote_step_lost` and the failure enters the run's history
|
|
2493
|
+
* (see {@link remoteHistory}), so the workflow's own error path surfaces it on the next turn
|
|
2494
|
+
* instead of the engine looping. The run is parked to wake immediately for exactly that turn.
|
|
2495
|
+
*/
|
|
2496
|
+
async redriveLostCall(run, cp) {
|
|
2497
|
+
// Settled (or a non-remote seq): the checkpoint is the truth, nothing to re-drive.
|
|
2498
|
+
if (cp.kind !== 'remote' || cp.status !== 'pending')
|
|
2499
|
+
return undefined;
|
|
2500
|
+
if (this.remoteRedispatchMs == null)
|
|
2501
|
+
return undefined;
|
|
2502
|
+
// First sight of this pending step under the self-heal: stamp its lease (clock-space, persisted,
|
|
2503
|
+
// stable across replays and crashes) and park the run on it.
|
|
2504
|
+
if (cp.wakeAt == null) {
|
|
2505
|
+
const lease = this.clock() + this.remoteRedispatchMs;
|
|
2506
|
+
await this.store.saveCheckpoint({ ...cp, wakeAt: lease });
|
|
2507
|
+
return this.stepLeaseWakeAt(lease);
|
|
2508
|
+
}
|
|
2509
|
+
if (this.clock() < cp.wakeAt)
|
|
2510
|
+
return this.stepLeaseWakeAt(cp.wakeAt);
|
|
2511
|
+
// The lease lapsed with no result and no heartbeat: the dispatch is presumed lost.
|
|
2512
|
+
if (cp.attempts >= this.remoteRedispatchMax) {
|
|
2513
|
+
const error = {
|
|
2514
|
+
message: `remote step "${cp.name}" lost — no result after ${cp.attempts} dispatch(es)`,
|
|
2515
|
+
code: 'remote_step_lost',
|
|
2516
|
+
retryable: false,
|
|
2517
|
+
};
|
|
2518
|
+
const now = new Date();
|
|
2519
|
+
await this.store.saveCheckpoint({
|
|
2520
|
+
...cp,
|
|
2521
|
+
status: 'failed',
|
|
2522
|
+
error,
|
|
2523
|
+
wakeAt: undefined,
|
|
2524
|
+
finishedAt: now,
|
|
2525
|
+
events: [...(cp.events ?? []), lostStepEvent(this.clock(), cp.attempts, 'lost')],
|
|
2526
|
+
});
|
|
2527
|
+
this.emit({
|
|
2528
|
+
type: 'step.failed',
|
|
2529
|
+
runId: run.id,
|
|
2530
|
+
seq: cp.seq,
|
|
2531
|
+
name: cp.name,
|
|
2532
|
+
kind: 'remote',
|
|
2533
|
+
error,
|
|
2534
|
+
});
|
|
2535
|
+
// Wake now: the failure is in history, so the very next turn's replay raises it inside the
|
|
2536
|
+
// gather and the run settles on the workflow's own error path.
|
|
2537
|
+
return this.clock();
|
|
2538
|
+
}
|
|
2539
|
+
const attempts = cp.attempts + 1;
|
|
2540
|
+
const lease = this.clock() + this.remoteRedispatchMs;
|
|
2541
|
+
const at = new Date();
|
|
2542
|
+
await this.store.saveCheckpoint({
|
|
2543
|
+
...cp,
|
|
2544
|
+
attempts,
|
|
2545
|
+
wakeAt: lease,
|
|
2546
|
+
// The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
|
|
2547
|
+
enqueuedAt: at,
|
|
2548
|
+
startedAt: at,
|
|
2549
|
+
finishedAt: at,
|
|
2550
|
+
// Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
|
|
2551
|
+
events: [...(cp.events ?? []), lostStepEvent(this.clock(), attempts, 'redispatched')],
|
|
2552
|
+
});
|
|
2553
|
+
await this.dispatchRemoteTask({
|
|
2554
|
+
runId: run.id,
|
|
2555
|
+
seq: cp.seq,
|
|
2556
|
+
name: cp.name,
|
|
2557
|
+
stepId: cp.stepId ?? stepId(run.id, cp.seq),
|
|
2558
|
+
group: cp.workerGroup ?? tenantGroup(sanitizeQueueToken(cp.name), run.namespace),
|
|
2559
|
+
input: cp.input,
|
|
2560
|
+
attempt: attempts,
|
|
2561
|
+
});
|
|
2562
|
+
this.emit({
|
|
2563
|
+
type: 'step.started',
|
|
2564
|
+
runId: run.id,
|
|
2565
|
+
seq: cp.seq,
|
|
2566
|
+
name: cp.name,
|
|
2567
|
+
kind: 'remote',
|
|
2568
|
+
redispatched: true,
|
|
2569
|
+
});
|
|
2570
|
+
return this.stepLeaseWakeAt(lease);
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
|
|
2574
|
+
* {@link reconcileMs} safety net would have woken the run anyway. A long lease (a fan-out step that
|
|
2575
|
+
* legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run — an
|
|
2576
|
+
* early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
|
|
2577
|
+
*/
|
|
2578
|
+
stepLeaseWakeAt(lease) {
|
|
2579
|
+
const reconcile = this.reconcileMs;
|
|
2580
|
+
return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
|
|
2581
|
+
}
|
|
2582
|
+
/** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
|
|
2583
|
+
* throttle for {@link rearmStepLease}, the sibling of {@link heartbeatPersists}. */
|
|
2584
|
+
stepLeaseRearms = new Map();
|
|
2585
|
+
/**
|
|
2586
|
+
* A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
|
|
2587
|
+
* that is still working on it is never presumed dead and double-dispatched. The durable counterpart
|
|
2588
|
+
* of the in-memory `heartbeatResets` rearm (which only exists on the instance that dispatched a
|
|
2589
|
+
* `timeoutMs` step). No-op unless the lost-dispatch self-heal is on — without `remoteRedispatchMs`
|
|
2590
|
+
* nothing ever re-dispatches, so there is no lease to defend.
|
|
2591
|
+
*
|
|
2592
|
+
* THROTTLED, for the same reason {@link persistHeartbeat} is: beats arrive every few seconds from a
|
|
2593
|
+
* hot batch loop, and defending a lease must not turn into a per-beat SELECT + UPDATE storm. The
|
|
2594
|
+
* throttle is the lease itself rather than a fixed interval — a renewal is only WORTH a write once
|
|
2595
|
+
* the window is half spent, because it buys at most one window either way. So:
|
|
2596
|
+
* - a beat that finds more than half the window still on the clock writes nothing, and records the
|
|
2597
|
+
* half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
|
|
2598
|
+
* - a beat at or past the half-spent mark renews, leaving a full half-window of headroom — the
|
|
2599
|
+
* lease can never lapse under a worker whose beats are anywhere near their normal cadence.
|
|
2600
|
+
*
|
|
2601
|
+
* That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step
|
|
2602
|
+
* (with a 30-minute window: two writes an hour), independent of beat frequency. The in-memory mark
|
|
2603
|
+
* is a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step —
|
|
2604
|
+
* and the durable half-spent check re-derives the same answer from the checkpoint, so nothing
|
|
2605
|
+
* depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
|
|
2606
|
+
*
|
|
2607
|
+
* NEVER REJECTS. This runs on the transport's SERIAL heartbeat handler, so a store hiccup thrown out
|
|
2608
|
+
* of here would take the whole fleet's beat loop with it — and every lease would then lapse at once,
|
|
2609
|
+
* a far worse failure than the write storm the throttle removes. So the entire rearm is best-effort,
|
|
2610
|
+
* exactly like {@link persistHeartbeat}: a missed renewal costs at most one re-drive of a step that
|
|
2611
|
+
* is idempotent by contract, and because the in-memory mark is advanced only AFTER a successful
|
|
2612
|
+
* write, the very next beat retries the renewal rather than waiting out a window.
|
|
2613
|
+
*/
|
|
2614
|
+
async rearmStepLease(runId, seq) {
|
|
2615
|
+
if (this.remoteRedispatchMs == null)
|
|
2616
|
+
return;
|
|
2617
|
+
try {
|
|
2618
|
+
await this.renewStepLease(runId, seq);
|
|
2619
|
+
}
|
|
2620
|
+
catch {
|
|
2621
|
+
/* best-effort: a failed renewal must not break the transport's serial heartbeat loop */
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
/** {@link rearmStepLease}'s body — every durable read/write of the lease renewal, so the caller's one
|
|
2625
|
+
* guard covers all of them (the read, the checkpoint write, and the run's wake carry-forward). */
|
|
2626
|
+
async renewStepLease(runId, seq) {
|
|
2627
|
+
if (this.remoteRedispatchMs == null)
|
|
2628
|
+
return;
|
|
2629
|
+
const id = stepId(runId, seq);
|
|
2630
|
+
const now = this.clock();
|
|
2631
|
+
const lookAgainAt = this.stepLeaseRearms.get(id);
|
|
2632
|
+
if (lookAgainAt !== undefined && now < lookAgainAt)
|
|
2633
|
+
return;
|
|
2634
|
+
const cp = await this.store.getCheckpoint(runId, seq);
|
|
2635
|
+
if (cp?.kind !== 'remote' || cp.status !== 'pending') {
|
|
2636
|
+
// Settled (or gone): stop tracking it, so a long-lived worker's stale beats don't keep the entry.
|
|
2637
|
+
this.stepLeaseRearms.delete(id);
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
|
|
2641
|
+
// More than half the window left: nothing to buy, and nothing to look at until it is half spent.
|
|
2642
|
+
if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
|
|
2643
|
+
this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
const renewed = now + this.remoteRedispatchMs;
|
|
2647
|
+
const previous = cp.wakeAt;
|
|
2648
|
+
await this.store.saveCheckpoint({ ...cp, wakeAt: renewed });
|
|
2649
|
+
if (this.stepLeaseRearms.size > 1024)
|
|
2650
|
+
this.stepLeaseRearms.clear();
|
|
2651
|
+
this.stepLeaseRearms.set(id, renewed - halfWindow);
|
|
2652
|
+
// If the run is parked EXACTLY on this step's old lease, carry its wake forward too — otherwise a
|
|
2653
|
+
// beating worker costs one wasted turn per renewal window. Only on an exact match: any other
|
|
2654
|
+
// `wakeAt` belongs to some other timer (a sleep, a sibling's shorter lease) and moving it would
|
|
2655
|
+
// delay that instead. Failing HERE (after the checkpoint write landed) is harmless: the run wakes
|
|
2656
|
+
// on the old lease, finds it un-lapsed, and re-parks on the renewed one.
|
|
2657
|
+
if (previous == null)
|
|
2658
|
+
return;
|
|
2659
|
+
const run = await this.store.getRun(runId);
|
|
2660
|
+
if (run?.status === 'suspended' && run.wakeAt === previous) {
|
|
2661
|
+
await this.store.updateRun(runId, { wakeAt: renewed, updatedAt: new Date() });
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2435
2664
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
2436
2665
|
async remoteHistory(runId) {
|
|
2437
2666
|
const checkpoints = await this.store.listCheckpoints(runId);
|
|
@@ -2475,8 +2704,13 @@ export class WorkflowEngine {
|
|
|
2475
2704
|
return events.sort((a, b) => a.seq - b.seq);
|
|
2476
2705
|
}
|
|
2477
2706
|
/** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
|
|
2478
|
-
* Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
|
|
2479
|
-
|
|
2707
|
+
* Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
|
|
2708
|
+
*
|
|
2709
|
+
* `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the one
|
|
2710
|
+
* that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
|
|
2711
|
+
* {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only to
|
|
2712
|
+
* record what already ran, and must never dispatch anything new. */
|
|
2713
|
+
async applyCommands(run, commands, redriveLost = false) {
|
|
2480
2714
|
let wakeAt;
|
|
2481
2715
|
for (const cmd of commands) {
|
|
2482
2716
|
const at = new Date();
|
|
@@ -2519,13 +2753,29 @@ export class WorkflowEngine {
|
|
|
2519
2753
|
// already exists — pending OR terminal — skip the save + dispatch entirely; its result lands
|
|
2520
2754
|
// independently via the remote-result path (keyed by seq, so concurrent calls never clobber).
|
|
2521
2755
|
// Mirrors the `startChild` `getRun(childId)` guard below.
|
|
2522
|
-
|
|
2756
|
+
//
|
|
2757
|
+
// What that guard must NOT do is treat "a checkpoint exists" as "somebody is still going to
|
|
2758
|
+
// deliver it". A dispatched job can be LOST — the worker was killed mid-step, so the job died
|
|
2759
|
+
// with the process and no result will ever come — and the checkpoint still reads `pending`.
|
|
2760
|
+
// Skipping such a command on every later turn is how a fan-out orphans its run (see
|
|
2761
|
+
// {@link redriveLostCall}, which owns that decision; a settled checkpoint always wins).
|
|
2762
|
+
const existing = await this.store.getCheckpoint(run.id, cmd.seq);
|
|
2763
|
+
if (existing) {
|
|
2764
|
+
const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : undefined;
|
|
2765
|
+
if (leaseAt != null)
|
|
2766
|
+
wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
|
|
2523
2767
|
continue;
|
|
2768
|
+
}
|
|
2524
2769
|
// Route this cross-SDK-worker `call` by the SAME name-based token a native `ctx.step`
|
|
2525
2770
|
// dispatches with (and a worker subscribes to per handler name), so a decision-driven remote
|
|
2526
2771
|
// step and an in-process one land on the identical queue. The command's own `group` is not a
|
|
2527
2772
|
// routing input; the run's namespace is, so a tenant's fan-out stays on the tenant's pool.
|
|
2528
2773
|
const callToken = tenantGroup(sanitizeQueueToken(cmd.name), run.namespace);
|
|
2774
|
+
// The dispatched step's LEASE: when the lost-dispatch self-heal is on (`remoteRedispatchMs`),
|
|
2775
|
+
// stamp the deadline by which a result — or a step heartbeat renewing it — must have landed,
|
|
2776
|
+
// and suspend the run ON it, so the re-drive lands when it's due instead of waiting for the
|
|
2777
|
+
// reconcile sweep. Unset (the default) leaves `wakeAt` NULL exactly as before.
|
|
2778
|
+
const lease = this.remoteRedispatchMs == null ? undefined : this.clock() + this.remoteRedispatchMs;
|
|
2529
2779
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
2530
2780
|
runId: run.id,
|
|
2531
2781
|
seq: cmd.seq,
|
|
@@ -2534,6 +2784,7 @@ export class WorkflowEngine {
|
|
|
2534
2784
|
status: 'pending',
|
|
2535
2785
|
input: cmd.input,
|
|
2536
2786
|
attempts: 1,
|
|
2787
|
+
wakeAt: lease,
|
|
2537
2788
|
workerGroup: callToken,
|
|
2538
2789
|
enqueuedAt: at,
|
|
2539
2790
|
startedAt: at,
|
|
@@ -2561,6 +2812,10 @@ export class WorkflowEngine {
|
|
|
2561
2812
|
name: cmd.name,
|
|
2562
2813
|
kind: 'remote',
|
|
2563
2814
|
});
|
|
2815
|
+
if (lease != null) {
|
|
2816
|
+
const leaseWake = this.stepLeaseWakeAt(lease);
|
|
2817
|
+
wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
|
|
2818
|
+
}
|
|
2564
2819
|
}
|
|
2565
2820
|
else if (cmd.kind === 'sleep') {
|
|
2566
2821
|
const deadline = this.clock() + cmd.ms;
|
|
@@ -2721,19 +2976,26 @@ export class WorkflowEngine {
|
|
|
2721
2976
|
for (const cp of snapshot)
|
|
2722
2977
|
replay.set(cp.seq, cp);
|
|
2723
2978
|
const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
|
|
2979
|
+
// Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
|
|
2980
|
+
// `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
|
|
2981
|
+
// return expression is evaluated, while the settle's own write is still in flight. A first turn
|
|
2982
|
+
// (the one that flipped `pending` -> `running`) then sits unlocked but still reading `running`,
|
|
2983
|
+
// which is exactly the shape `listOrphanedRuns` returns for a crashed turn: the recovery sweep
|
|
2984
|
+
// counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
|
|
2985
|
+
// execution. The lease must outlive the state it protects.
|
|
2724
2986
|
try {
|
|
2725
2987
|
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
2726
2988
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
2727
2989
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
2728
2990
|
const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
|
|
2729
|
-
return this.settleRun(run, { kind: 'completed', output });
|
|
2991
|
+
return await this.settleRun(run, { kind: 'completed', output });
|
|
2730
2992
|
}
|
|
2731
2993
|
catch (err) {
|
|
2732
2994
|
if (err instanceof ContinueAsNew) {
|
|
2733
2995
|
// Hand off to a fresh execution with a clean history: persist the next run (`<id>~N`), then
|
|
2734
2996
|
// complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
|
|
2735
2997
|
if (this.leaseLostRuns.has(run.id))
|
|
2736
|
-
return this.echoCurrentStatus(run.id);
|
|
2998
|
+
return await this.echoCurrentStatus(run.id);
|
|
2737
2999
|
const nextId = nextContinuationId(run.id);
|
|
2738
3000
|
// Persist the continuation BEFORE the parent's terminal write — the durable ordering that
|
|
2739
3001
|
// makes the chain crash-safe. The old order (complete parent, then create the continuation
|
|
@@ -2770,7 +3032,7 @@ export class WorkflowEngine {
|
|
|
2770
3032
|
// No live worker can run the next dispatch (capability/protocol) — park `blocked` with the
|
|
2771
3033
|
// structured reason + diagnostics instead of suspending on a timer that would re-drive into the
|
|
2772
3034
|
// same void. The blocked-recovery poll re-drives it when a capable+compatible worker appears.
|
|
2773
|
-
return this.parkBlocked(run, err.plan);
|
|
3035
|
+
return await this.parkBlocked(run, err.plan);
|
|
2774
3036
|
}
|
|
2775
3037
|
if (err instanceof WorkflowSuspended) {
|
|
2776
3038
|
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
@@ -2840,7 +3102,7 @@ export class WorkflowEngine {
|
|
|
2840
3102
|
continue;
|
|
2841
3103
|
await this.runCompensation(run, comp, i);
|
|
2842
3104
|
}
|
|
2843
|
-
return this.settleRun(run, { kind: 'failed', error });
|
|
3105
|
+
return await this.settleRun(run, { kind: 'failed', error });
|
|
2844
3106
|
}
|
|
2845
3107
|
finally {
|
|
2846
3108
|
// Release the recovery lease once the run reaches a terminal/suspended state, so the
|
|
@@ -3299,7 +3561,16 @@ export class WorkflowEngine {
|
|
|
3299
3561
|
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
|
|
3300
3562
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
3301
3563
|
// from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
|
|
3302
|
-
|
|
3564
|
+
let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
|
|
3565
|
+
// A `pending` step in the SNAPSHOT may already have its result: the snapshot is taken when this
|
|
3566
|
+
// execution starts, and the result can land any time after. Every branch below that sees
|
|
3567
|
+
// `pending` either suspends on it or WRITES the checkpoint back (`{ ...existing, wakeAt }` stamping
|
|
3568
|
+
// the lost-dispatch lease, or a re-dispatch) — written from a stale row, that overwrites the landed
|
|
3569
|
+
// `completed` result with `pending` and parks the run on a lease an hour out. So a pending step is
|
|
3570
|
+
// re-read from the store before anything acts on it (one read, only at a suspension point).
|
|
3571
|
+
if (existing?.status === 'pending' && replay?.has(seq)) {
|
|
3572
|
+
existing = (await this.store.getCheckpoint(runId, seq)) ?? existing;
|
|
3573
|
+
}
|
|
3303
3574
|
if (existing && existing.name !== step.name) {
|
|
3304
3575
|
throw new NonDeterminismError(runId, seq, step.name, existing.name);
|
|
3305
3576
|
}
|
|
@@ -3350,6 +3621,12 @@ export class WorkflowEngine {
|
|
|
3350
3621
|
enqueuedAt: reEnqueuedAt,
|
|
3351
3622
|
startedAt: reEnqueuedAt,
|
|
3352
3623
|
finishedAt: reEnqueuedAt,
|
|
3624
|
+
// Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
|
|
3625
|
+
// trail distinguishes a lost-dispatch re-drive from a failure retry.
|
|
3626
|
+
events: [
|
|
3627
|
+
...(existing.events ?? []),
|
|
3628
|
+
lostStepEvent(this.clock(), reAttempt, 'redispatched'),
|
|
3629
|
+
],
|
|
3353
3630
|
});
|
|
3354
3631
|
await this.dispatchRemoteTask({
|
|
3355
3632
|
runId,
|
|
@@ -3362,6 +3639,14 @@ export class WorkflowEngine {
|
|
|
3362
3639
|
attempt: reAttempt,
|
|
3363
3640
|
transport,
|
|
3364
3641
|
});
|
|
3642
|
+
this.emit({
|
|
3643
|
+
type: 'step.started',
|
|
3644
|
+
runId,
|
|
3645
|
+
seq,
|
|
3646
|
+
name: step.name,
|
|
3647
|
+
kind: 'remote',
|
|
3648
|
+
redispatched: true,
|
|
3649
|
+
});
|
|
3365
3650
|
throw new WorkflowSuspended(nextDeadline, [seq]);
|
|
3366
3651
|
}
|
|
3367
3652
|
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|