@adonis-agora/durable 0.35.1 → 0.36.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 +111 -0
- package/dist/assets/spa/assets/index-DU6cGcJz.css +1 -0
- package/dist/assets/spa/assets/index-Dx5lG7Vu.js +16 -0
- package/dist/assets/spa/assets/index-Dx5lG7Vu.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/commands/export_run.d.ts +18 -0
- package/dist/commands/export_run.d.ts.map +1 -0
- package/dist/commands/export_run.js +45 -0
- package/dist/commands/export_run.js.map +1 -0
- package/dist/commands/main.d.ts +1 -1
- package/dist/commands/main.d.ts.map +1 -1
- package/dist/commands/main.js +3 -1
- package/dist/commands/main.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +7 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +36 -1
- 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 +18 -0
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/admission-redis/redis-admission-backend.d.ts +2 -1
- package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
- package/dist/src/admission-redis/redis-admission-backend.js +35 -8
- package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
- package/dist/src/backoff.d.ts +7 -0
- package/dist/src/backoff.d.ts.map +1 -1
- package/dist/src/backoff.js +12 -2
- package/dist/src/backoff.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +4 -2
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +23 -4
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/commands/worker.d.ts +2 -0
- package/dist/src/commands/worker.d.ts.map +1 -1
- package/dist/src/commands/worker.js +18 -1
- package/dist/src/commands/worker.js.map +1 -1
- package/dist/src/dashboard/gateway-adapter.d.ts +13 -1
- package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
- package/dist/src/dashboard/gateway-adapter.js +12 -0
- package/dist/src/dashboard/gateway-adapter.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts +47 -1
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +141 -8
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/dashboard/openapi.d.ts +13 -0
- package/dist/src/dashboard/openapi.d.ts.map +1 -0
- package/dist/src/dashboard/openapi.js +395 -0
- package/dist/src/dashboard/openapi.js.map +1 -0
- package/dist/src/dashboard/run-filter.d.ts +8 -3
- package/dist/src/dashboard/run-filter.d.ts.map +1 -1
- package/dist/src/dashboard/run-filter.js +35 -4
- package/dist/src/dashboard/run-filter.js.map +1 -1
- package/dist/src/define_config.d.ts +24 -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 +204 -7
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +820 -115
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +9 -1
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +10 -1
- 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 +116 -7
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/pollers.d.ts +26 -2
- package/dist/src/pollers.d.ts.map +1 -1
- package/dist/src/pollers.js +47 -4
- package/dist/src/pollers.js.map +1 -1
- package/dist/src/queue.d.ts +9 -1
- package/dist/src/queue.d.ts.map +1 -1
- package/dist/src/queue.js +26 -7
- package/dist/src/queue.js.map +1 -1
- package/dist/src/run-value-facets.d.ts +1 -1
- package/dist/src/run-value-facets.d.ts.map +1 -1
- package/dist/src/run-value-facets.js +7 -1
- package/dist/src/run-value-facets.js.map +1 -1
- package/dist/src/scheduler.d.ts +15 -1
- package/dist/src/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler.js +36 -1
- package/dist/src/scheduler.js.map +1 -1
- package/dist/src/stores/lucid-mappers.d.ts +5 -0
- package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
- package/dist/src/stores/lucid-mappers.js +8 -0
- package/dist/src/stores/lucid-mappers.js.map +1 -1
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +53 -7
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +10 -3
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +74 -67
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +4 -2
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +54 -8
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/replay.d.ts +12 -0
- package/dist/src/testing-kit/replay.d.ts.map +1 -1
- package/dist/src/testing-kit/replay.js +33 -0
- package/dist/src/testing-kit/replay.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 +68 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +0 -5
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +92 -10
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts +2 -0
- 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/serialization.d.ts +2 -1
- package/dist/src/transports/bullmq/serialization.d.ts.map +1 -1
- package/dist/src/transports/bullmq/serialization.js +9 -3
- package/dist/src/transports/bullmq/serialization.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 +89 -35
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts +7 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +23 -1
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +77 -14
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +1 -0
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +6 -0
- package/dist/src/workflow-ref.d.ts.map +1 -1
- package/dist/src/workflow-ref.js.map +1 -1
- package/package.json +2 -2
- package/dist/assets/spa/assets/index-CNnTzTNK.css +0 -1
- package/dist/assets/spa/assets/index-DxLx4LRM.js +0 -16
- package/dist/assets/spa/assets/index-DxLx4LRM.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { InMemoryAdmissionBackend } from './admission.js';
|
|
2
|
-
import { backoffDelay } from './backoff.js';
|
|
2
|
+
import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
|
|
3
3
|
import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
|
|
4
4
|
import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
|
|
5
5
|
import { parseDuration } from './duration.js';
|
|
@@ -11,6 +11,7 @@ import { asHeartbeat } from './observability-scope.js';
|
|
|
11
11
|
import { breakpointToken, stepId } from './protocol.js';
|
|
12
12
|
import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
|
|
13
13
|
import { scanRunValueFacets } from './run-value-facets.js';
|
|
14
|
+
import { scheduleWindow } from './scheduler.js';
|
|
14
15
|
import { SingletonGate } from './singleton-gate.js';
|
|
15
16
|
import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
|
|
16
17
|
import { TransportPool } from './transport-pool.js';
|
|
@@ -52,6 +53,13 @@ const SETTLE_ELIGIBLE_STATUSES = [
|
|
|
52
53
|
'failed',
|
|
53
54
|
];
|
|
54
55
|
const versionKey = (name, version) => `${name}@${version}`;
|
|
56
|
+
/**
|
|
57
|
+
* First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
|
|
58
|
+
* persists at `COMPENSATION_SEQ_BASE - idx` (-2, -3, …). Negative so it can never collide with the
|
|
59
|
+
* positional replay's `>= 0` seqs, and starting at -2 so it also never collides with the legacy
|
|
60
|
+
* un-checkpointed `-1` undo dispatches of older engine versions.
|
|
61
|
+
*/
|
|
62
|
+
const COMPENSATION_SEQ_BASE = -2;
|
|
55
63
|
/**
|
|
56
64
|
* The routing token a dispatched step lands on: the step's own (broker-safe) name, suffixed by the
|
|
57
65
|
* tenant the RUN belongs to. `runNamespace` — not the executing engine's namespace — is the tenant
|
|
@@ -115,6 +123,18 @@ export class WorkflowEngine {
|
|
|
115
123
|
/** Establish the originating context ambiently around a local step body (see {@link WorkflowEngineDeps.rehydrate}). Default passthrough. */
|
|
116
124
|
rehydrate;
|
|
117
125
|
compensationRetries;
|
|
126
|
+
compensationTimeoutMs;
|
|
127
|
+
retention;
|
|
128
|
+
// -Infinity so the FIRST sweep always runs (a fake/test clock may start near 0).
|
|
129
|
+
#lastRetentionSweepAt = Number.NEGATIVE_INFINITY;
|
|
130
|
+
/** Archival hooks run (awaited, in order) before a retention eviction deletes a run. */
|
|
131
|
+
evictListeners = new Set();
|
|
132
|
+
stalledAfterMs;
|
|
133
|
+
/** Pager hooks for the stranded-run signature (see {@link onStalled}). */
|
|
134
|
+
stalledListeners = new Set();
|
|
135
|
+
/** `runId → run.updatedAt ms` at last stalled notification, so one episode pages once. */
|
|
136
|
+
#notifiedStalled = new Map();
|
|
137
|
+
#lastStalledSweepAt = Number.NEGATIVE_INFINITY;
|
|
118
138
|
/** Persist a `running` checkpoint at the start of a local step body (see {@link WorkflowEngineDeps.trackStepStart}). */
|
|
119
139
|
trackStepStart;
|
|
120
140
|
/** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
|
|
@@ -163,6 +183,10 @@ export class WorkflowEngine {
|
|
|
163
183
|
updateValidators = new Map();
|
|
164
184
|
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
165
185
|
cancelRequested = new Set();
|
|
186
|
+
/** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
|
|
187
|
+
#allSchedules = [];
|
|
188
|
+
/** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
|
|
189
|
+
#schedulePauseOverrides = new Map();
|
|
166
190
|
/** Flow-control admission backend for remote steps (see {@link registerQueue}). */
|
|
167
191
|
admission;
|
|
168
192
|
/** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
|
|
@@ -171,6 +195,14 @@ export class WorkflowEngine {
|
|
|
171
195
|
stepQueue = new Map();
|
|
172
196
|
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
173
197
|
inflight = new Map();
|
|
198
|
+
/**
|
|
199
|
+
* Runs whose recovery lease this instance LOST while their turn was still executing — a renew
|
|
200
|
+
* came back false (another instance took over after expiry), or drain released the lease on
|
|
201
|
+
* timeout. A fenced turn must not settle: its outcome was computed by a now-zombie executor, and
|
|
202
|
+
* the new owner is (re)executing the same run — writing would clobber/interleave with the owner's
|
|
203
|
+
* writes. `settleRun` checks this and degrades to an echo; `execute`'s finally clears the mark.
|
|
204
|
+
*/
|
|
205
|
+
leaseLostRuns = new Set();
|
|
174
206
|
/**
|
|
175
207
|
* Post-settle side effects (parent notify, singleton wake) fired fire-and-forget AFTER a run's
|
|
176
208
|
* status was persisted. They run OFF the `execute()` path — the caller of `execute()` must never
|
|
@@ -229,6 +261,9 @@ export class WorkflowEngine {
|
|
|
229
261
|
// Default passthrough: with no bridge supplied, a local step body runs exactly as before.
|
|
230
262
|
this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
|
|
231
263
|
this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
|
|
264
|
+
this.compensationTimeoutMs = Math.max(1_000, deps.compensationTimeoutMs ?? 300_000);
|
|
265
|
+
this.retention = deps.retention;
|
|
266
|
+
this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
|
|
232
267
|
this.trackStepStart = deps.trackStepStart ?? true;
|
|
233
268
|
// Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
|
|
234
269
|
// A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
|
|
@@ -291,6 +326,11 @@ export class WorkflowEngine {
|
|
|
291
326
|
else if (msg.kind === 'cancel') {
|
|
292
327
|
this.notifyCancelled(msg.runId);
|
|
293
328
|
}
|
|
329
|
+
else if (msg.kind === 'schedulePause') {
|
|
330
|
+
// A console on another instance paused/resumed a schedule — apply it here so THIS ticker
|
|
331
|
+
// honors it too (runtime-only; a deploy resets to the config).
|
|
332
|
+
this.#schedulePauseOverrides.set(msg.key, msg.paused);
|
|
333
|
+
}
|
|
294
334
|
else if (msg.kind === 'enqueued') {
|
|
295
335
|
// A run was enqueued on another instance — let worker subscribers pick it up immediately
|
|
296
336
|
// instead of waiting for the next poll. (Self-broadcasts are already filtered above.)
|
|
@@ -322,6 +362,83 @@ export class WorkflowEngine {
|
|
|
322
362
|
* and fires. Idempotent per `key`: the first schedule registered for a key wins and a later duplicate
|
|
323
363
|
* key is ignored with a warning (so re-scanning, or a config entry overriding it, can't double-fire).
|
|
324
364
|
*/
|
|
365
|
+
/**
|
|
366
|
+
* Adopt the FULL schedule set the worker loop ticks with (config + discovered, already merged) so
|
|
367
|
+
* the runtime schedule-control surface (list/pause/resume/trigger) operates on exactly what fires.
|
|
368
|
+
* Called by `runTick`; idempotent.
|
|
369
|
+
*/
|
|
370
|
+
adoptSchedules(schedules) {
|
|
371
|
+
this.#allSchedules = schedules;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The runtime pause override for a schedule key: `true`/`false` when the console set one (wins
|
|
375
|
+
* over the config's `paused`), `undefined` when untouched. Runtime-only — a deploy resets it.
|
|
376
|
+
*/
|
|
377
|
+
schedulePauseOverride(key) {
|
|
378
|
+
return this.#schedulePauseOverrides.get(key);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The schedules this engine ticks, with their live control state and fire windows — what a
|
|
382
|
+
* console's Schedules tab lists. `paused` reflects the EFFECTIVE state (runtime override, else
|
|
383
|
+
* config); `lastRunStatus` is the current window's run, when it exists.
|
|
384
|
+
*/
|
|
385
|
+
async listSchedules(now = this.clock()) {
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const s of [...this.#allSchedules, ...this.#discoveredSchedules]) {
|
|
388
|
+
if (out.some((x) => x.key === s.key))
|
|
389
|
+
continue; // adopted set already contains discovered ones post-merge
|
|
390
|
+
const window = scheduleWindow(s, now);
|
|
391
|
+
const currentRun = await this.store.getRun(window.runId).catch(() => null);
|
|
392
|
+
const override = this.#schedulePauseOverrides.get(s.key);
|
|
393
|
+
out.push({
|
|
394
|
+
key: s.key,
|
|
395
|
+
workflow: s.workflow,
|
|
396
|
+
cron: s.cron,
|
|
397
|
+
everyMs: s.everyMs,
|
|
398
|
+
timezone: s.timezone,
|
|
399
|
+
overlap: s.overlap,
|
|
400
|
+
namespace: s.namespace,
|
|
401
|
+
paused: override ?? s.paused ?? false,
|
|
402
|
+
pausedAtRuntime: override !== undefined,
|
|
403
|
+
lastFireAt: window.lastFireAt,
|
|
404
|
+
nextFireAt: window.nextFireAt,
|
|
405
|
+
currentWindowRunId: window.runId,
|
|
406
|
+
lastRunStatus: currentRun?.status,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Pause/resume a schedule at RUNTIME, fleet-wide: applied on this instance and broadcast on the
|
|
413
|
+
* control plane so every ticking peer applies it too. Runtime-only state — a deploy resets it to
|
|
414
|
+
* the config's `paused`; pin the config for permanence. Returns false for an unknown key.
|
|
415
|
+
*/
|
|
416
|
+
setSchedulePaused(key, paused) {
|
|
417
|
+
const known = this.#allSchedules.some((s) => s.key === key) ||
|
|
418
|
+
this.#discoveredSchedules.some((s) => s.key === key);
|
|
419
|
+
if (!known)
|
|
420
|
+
return false;
|
|
421
|
+
this.#schedulePauseOverrides.set(key, paused);
|
|
422
|
+
if (this.controlPlane) {
|
|
423
|
+
void this.controlPlane
|
|
424
|
+
.publishControl({ kind: 'schedulePause', key, paused, from: this.instanceId })
|
|
425
|
+
.catch(() => undefined);
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Fire a schedule's CURRENT window now (console "run now"). Idempotent by the window's
|
|
431
|
+
* deterministic run id — triggering a window that already fired returns the existing run instead
|
|
432
|
+
* of forking a duplicate. Returns null for an unknown key.
|
|
433
|
+
*/
|
|
434
|
+
async triggerSchedule(key, now = this.clock()) {
|
|
435
|
+
const s = this.#allSchedules.find((x) => x.key === key) ??
|
|
436
|
+
this.#discoveredSchedules.find((x) => x.key === key);
|
|
437
|
+
if (!s)
|
|
438
|
+
return null;
|
|
439
|
+
const window = scheduleWindow(s, now);
|
|
440
|
+
return this.start(s.workflow, s.input, window.runId, { namespace: s.namespace });
|
|
441
|
+
}
|
|
325
442
|
registerSchedules(schedules) {
|
|
326
443
|
const seen = new Set(this.#discoveredSchedules.map((s) => s.key));
|
|
327
444
|
for (const schedule of schedules) {
|
|
@@ -387,6 +504,7 @@ export class WorkflowEngine {
|
|
|
387
504
|
version,
|
|
388
505
|
fn,
|
|
389
506
|
tags: opts?.tags,
|
|
507
|
+
origin: opts?.origin,
|
|
390
508
|
singleton: opts?.singleton,
|
|
391
509
|
executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
|
|
392
510
|
validateInput: opts?.validateInput,
|
|
@@ -620,6 +738,13 @@ export class WorkflowEngine {
|
|
|
620
738
|
// the same id is a no-op, returning the existing run's state instead of starting a duplicate.
|
|
621
739
|
const prior = await this.store.getRun(runId);
|
|
622
740
|
if (prior) {
|
|
741
|
+
// A PENDING prior still needs an executor: re-nudge the dispatcher so a redelivered trigger —
|
|
742
|
+
// or a continuation persisted pre-settle (see the ContinueAsNew handoff) picked up by a custom
|
|
743
|
+
// dispatcher — doesn't sit `pending` until the next poll. Idempotent: execution is leased, so
|
|
744
|
+
// a duplicate dispatch is a cheap no-op at the worker.
|
|
745
|
+
if (dispatch && prior.status === 'pending') {
|
|
746
|
+
await this.runDispatcher.dispatch(runId);
|
|
747
|
+
}
|
|
623
748
|
return { runId, status: prior.status, output: prior.output, error: prior.error };
|
|
624
749
|
}
|
|
625
750
|
const now = new Date();
|
|
@@ -633,11 +758,19 @@ export class WorkflowEngine {
|
|
|
633
758
|
if (registered.singleton) {
|
|
634
759
|
await this.singletons.assertCapacity(name, registered.singleton, input);
|
|
635
760
|
}
|
|
761
|
+
// Delayed start: park the run on its durable wake timer instead of dispatching now (see
|
|
762
|
+
// StartOptions.startAt). The timer poller path that wakes sleeps does the rest.
|
|
763
|
+
const startAtMs = opts?.startAt instanceof Date ? opts.startAt.getTime() : opts?.startAt;
|
|
764
|
+
// Compared against the ENGINE clock (injectable), not wall time — the same clock the timer
|
|
765
|
+
// poller will use to decide the run is due.
|
|
766
|
+
const delayed = startAtMs !== undefined && startAtMs > this.clock();
|
|
636
767
|
const run = {
|
|
637
768
|
id: runId,
|
|
638
769
|
workflow: name,
|
|
639
770
|
workflowVersion: registered.version,
|
|
640
|
-
status: 'pending',
|
|
771
|
+
status: delayed ? 'suspended' : 'pending',
|
|
772
|
+
...(delayed ? { wakeAt: startAtMs } : {}),
|
|
773
|
+
origin: opts?.origin ?? registered.origin,
|
|
641
774
|
namespace: opts?.namespace ?? this.namespace,
|
|
642
775
|
input,
|
|
643
776
|
tags,
|
|
@@ -646,11 +779,27 @@ export class WorkflowEngine {
|
|
|
646
779
|
createdAt: now,
|
|
647
780
|
updatedAt: now,
|
|
648
781
|
};
|
|
649
|
-
|
|
782
|
+
try {
|
|
783
|
+
await this.store.createRun(run);
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
// Lost a true createRun RACE (two pods scheduler-ticking the same window, concurrent
|
|
787
|
+
// `signalWithStart`s for one entity id): the read-before-create above saw no prior, but the
|
|
788
|
+
// store's duplicate-id rejection says someone else won the insert in the gap. Converge on the
|
|
789
|
+
// winner — re-read and report ITS state, exactly like the prior-run branch — instead of
|
|
790
|
+
// surfacing a unique-constraint error (which would abort a scheduler tick, or worse, lose the
|
|
791
|
+
// signal half of a signalWithStart). A re-read that finds nothing means the throw was a real
|
|
792
|
+
// store failure: rethrow it.
|
|
793
|
+
const winner = await this.store.getRun(runId);
|
|
794
|
+
if (winner) {
|
|
795
|
+
return { runId, status: winner.status, output: winner.output, error: winner.error };
|
|
796
|
+
}
|
|
797
|
+
throw err;
|
|
798
|
+
}
|
|
650
799
|
// The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
|
|
651
800
|
// NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
|
|
652
801
|
// An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
|
|
653
|
-
if (dispatch) {
|
|
802
|
+
if (dispatch && !delayed) {
|
|
654
803
|
await this.runDispatcher.dispatch(runId);
|
|
655
804
|
// Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
|
|
656
805
|
// plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
|
|
@@ -660,7 +809,7 @@ export class WorkflowEngine {
|
|
|
660
809
|
.catch(() => undefined);
|
|
661
810
|
}
|
|
662
811
|
}
|
|
663
|
-
return { runId, status:
|
|
812
|
+
return { runId, status: run.status };
|
|
664
813
|
}
|
|
665
814
|
/**
|
|
666
815
|
* Bridge an INTERNAL run handoff — the next execution of a `continue-as-new`, or a deferred child
|
|
@@ -745,6 +894,142 @@ export class WorkflowEngine {
|
|
|
745
894
|
* deleted (the parent→children edge is read from them). Use for retention/cleanup; this is a
|
|
746
895
|
* destructive store operation, not a cancellation — a running run should be {@link cancel}led first.
|
|
747
896
|
*/
|
|
897
|
+
/**
|
|
898
|
+
* Register a pager hook for STRANDED runs — the failure `docs/reliability/failure-modes.mdx` calls
|
|
899
|
+
* "the stranded signature": an in-flight run untouched past `stalledAfterMs` that is either
|
|
900
|
+
* suspended with NO wake timer (wake-forever) or parked on an old pending remote step whose worker
|
|
901
|
+
* heartbeat has gone silent. The worker tick sweeps for these (throttled to one pass a minute) and
|
|
902
|
+
* notifies each episode ONCE (re-notifies only after the run progresses and strands again). Wire
|
|
903
|
+
* your alerting here exactly like {@link onDead}. Returns an unsubscribe fn.
|
|
904
|
+
*/
|
|
905
|
+
onStalled(listener) {
|
|
906
|
+
this.stalledListeners.add(listener);
|
|
907
|
+
return () => this.stalledListeners.delete(listener);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Detect stranded in-flight runs and notify {@link onStalled} listeners. Self-throttled to one
|
|
911
|
+
* pass a minute; a no-op without listeners. Returns the runs notified THIS pass.
|
|
912
|
+
*/
|
|
913
|
+
async sweepStalled(now = this.clock()) {
|
|
914
|
+
if (this.stalledListeners.size === 0 || this.draining)
|
|
915
|
+
return [];
|
|
916
|
+
if (now - this.#lastStalledSweepAt < 60_000)
|
|
917
|
+
return [];
|
|
918
|
+
this.#lastStalledSweepAt = now;
|
|
919
|
+
const candidates = await this.store.listRuns({
|
|
920
|
+
statuses: ['suspended', 'running'],
|
|
921
|
+
namespace: this.namespace,
|
|
922
|
+
updatedBefore: now - this.stalledAfterMs,
|
|
923
|
+
limit: 100,
|
|
924
|
+
});
|
|
925
|
+
const notified = [];
|
|
926
|
+
for (const run of candidates) {
|
|
927
|
+
// One notification per episode: skip a run we already paged about that hasn't moved since.
|
|
928
|
+
if (this.#notifiedStalled.get(run.id) === run.updatedAt.getTime())
|
|
929
|
+
continue;
|
|
930
|
+
// The stranded signature, disambiguated from healthy long work:
|
|
931
|
+
// (a) suspended with NO wake timer — nothing will ever re-drive it (reconcileMs: 0 deployments);
|
|
932
|
+
// (b) an old pending REMOTE step whose worker heartbeat is silent — dispatched and lost.
|
|
933
|
+
// A long `ctx.sleep` (wakeAt in the future) and a beating long step are NOT stalled.
|
|
934
|
+
let stalePending = null;
|
|
935
|
+
for (const cp of await this.store.listCheckpoints(run.id)) {
|
|
936
|
+
if (cp.kind !== 'remote' || cp.status !== 'pending')
|
|
937
|
+
continue;
|
|
938
|
+
const ageMs = now - cp.enqueuedAt.getTime();
|
|
939
|
+
const heartbeatAgeMs = cp.lastHeartbeatAt ? now - cp.lastHeartbeatAt.getTime() : null;
|
|
940
|
+
if (ageMs < this.stalledAfterMs)
|
|
941
|
+
continue;
|
|
942
|
+
if (heartbeatAgeMs !== null && heartbeatAgeMs < this.stalledAfterMs)
|
|
943
|
+
continue; // alive mid-flight
|
|
944
|
+
if (!stalePending || ageMs > stalePending.ageMs) {
|
|
945
|
+
stalePending = {
|
|
946
|
+
seq: cp.seq,
|
|
947
|
+
name: cp.name,
|
|
948
|
+
attempts: cp.attempts,
|
|
949
|
+
ageMs,
|
|
950
|
+
heartbeatAgeMs,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const wakeForever = run.status === 'suspended' && run.wakeAt === undefined;
|
|
955
|
+
if (!wakeForever && !stalePending)
|
|
956
|
+
continue;
|
|
957
|
+
const info = {
|
|
958
|
+
run,
|
|
959
|
+
ageMs: now - run.updatedAt.getTime(),
|
|
960
|
+
stalePending,
|
|
961
|
+
wakeForever,
|
|
962
|
+
};
|
|
963
|
+
this.#notifiedStalled.set(run.id, run.updatedAt.getTime());
|
|
964
|
+
if (this.#notifiedStalled.size > 1_000)
|
|
965
|
+
this.#notifiedStalled.clear(); // bounded; re-pages at worst
|
|
966
|
+
for (const listener of this.stalledListeners) {
|
|
967
|
+
try {
|
|
968
|
+
listener(info);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
/* a pager hook must not break the sweep */
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
notified.push(info);
|
|
975
|
+
}
|
|
976
|
+
return notified;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Register an archival hook run before a retention eviction deletes a run (it receives the run
|
|
980
|
+
* and its full checkpoint timeline — e.g. write them to S3/file). Awaited in registration order;
|
|
981
|
+
* a hook that THROWS skips that run's deletion, so a broken archive never loses data. Returns an
|
|
982
|
+
* unsubscribe fn.
|
|
983
|
+
*/
|
|
984
|
+
onEvict(listener) {
|
|
985
|
+
this.evictListeners.add(listener);
|
|
986
|
+
return () => this.evictListeners.delete(listener);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Retention sweep: hard-delete terminal runs whose last activity is older than the configured age
|
|
990
|
+
* (see {@link WorkflowEngineDeps.retention}). Self-throttled to one pass per minute per instance —
|
|
991
|
+
* call it every tick, it's a no-op most of the time. Returns how many runs were deleted (subtree
|
|
992
|
+
* counts included). Bounded per pass (100 candidates per status); a large backlog drains across
|
|
993
|
+
* passes.
|
|
994
|
+
*
|
|
995
|
+
* Caveat: eviction cascades DOWN (a deleted run takes its child subtree) but does not know about
|
|
996
|
+
* a still-live PARENT awaiting a custom-id child — size the ages well beyond your longest-running
|
|
997
|
+
* parents.
|
|
998
|
+
*/
|
|
999
|
+
async sweepRetention(now = this.clock()) {
|
|
1000
|
+
if (!this.retention || this.draining)
|
|
1001
|
+
return 0;
|
|
1002
|
+
if (now - this.#lastRetentionSweepAt < 60_000)
|
|
1003
|
+
return 0;
|
|
1004
|
+
this.#lastRetentionSweepAt = now;
|
|
1005
|
+
let evicted = 0;
|
|
1006
|
+
for (const status of ['completed', 'failed', 'cancelled', 'dead']) {
|
|
1007
|
+
const maxAgeMs = this.retention[status];
|
|
1008
|
+
if (maxAgeMs == null || maxAgeMs <= 0)
|
|
1009
|
+
continue;
|
|
1010
|
+
const candidates = await this.store.listRuns({
|
|
1011
|
+
statuses: [status],
|
|
1012
|
+
namespace: this.namespace,
|
|
1013
|
+
updatedBefore: now - maxAgeMs,
|
|
1014
|
+
limit: 100,
|
|
1015
|
+
});
|
|
1016
|
+
for (const run of candidates) {
|
|
1017
|
+
try {
|
|
1018
|
+
if (this.evictListeners.size > 0) {
|
|
1019
|
+
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
1020
|
+
for (const listener of this.evictListeners)
|
|
1021
|
+
await listener(run, checkpoints);
|
|
1022
|
+
}
|
|
1023
|
+
evicted += await this.deleteRun(run.id);
|
|
1024
|
+
}
|
|
1025
|
+
catch (err) {
|
|
1026
|
+
// An archival hook (or the delete itself) failed — keep the run; the next sweep retries.
|
|
1027
|
+
console.warn(`[adonis-durable] retention eviction of run ${run.id} skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return evicted;
|
|
1032
|
+
}
|
|
748
1033
|
async deleteRun(runId) {
|
|
749
1034
|
const run = await this.store.getRun(runId);
|
|
750
1035
|
if (!run)
|
|
@@ -755,6 +1040,17 @@ export class WorkflowEngine {
|
|
|
755
1040
|
deleted += await this.deleteRun(childId);
|
|
756
1041
|
}
|
|
757
1042
|
await this.store.deleteRun(runId);
|
|
1043
|
+
// Sweep this run's token-keyed buffered signals too — they are not run-scoped rows, so the
|
|
1044
|
+
// store's deleteRun can't cascade them. Without this every fire-and-forget child that was never
|
|
1045
|
+
// joined leaks its buffered `child:<id>` completion FOREVER (one row per spawn), and a raced
|
|
1046
|
+
// compensate-cancel can leave a `cancel:<id>` marker behind.
|
|
1047
|
+
for (const token of [`child:${runId}`, `cancel:${runId}`]) {
|
|
1048
|
+
for (;;) {
|
|
1049
|
+
const buffered = await this.store.takeBufferedSignal(token).catch(() => null);
|
|
1050
|
+
if (!buffered)
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
758
1054
|
return deleted + 1;
|
|
759
1055
|
}
|
|
760
1056
|
async resume(runId) {
|
|
@@ -775,7 +1071,7 @@ export class WorkflowEngine {
|
|
|
775
1071
|
if (this.namespace !== undefined &&
|
|
776
1072
|
run.namespace !== undefined &&
|
|
777
1073
|
run.namespace !== this.namespace) {
|
|
778
|
-
await this.store.releaseRunLock(runId);
|
|
1074
|
+
await this.store.releaseRunLock(runId, this.instanceId);
|
|
779
1075
|
throw new NamespaceMismatch();
|
|
780
1076
|
}
|
|
781
1077
|
// Pin to the version the run STARTED on — replay is positional, so running a changed
|
|
@@ -793,6 +1089,24 @@ export class WorkflowEngine {
|
|
|
793
1089
|
}
|
|
794
1090
|
return this.track(runId, this.execute(run, registered.fn));
|
|
795
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Deferred re-drive after a suspended settle whose waited checkpoint(s) already settled — the
|
|
1094
|
+
* delivery's own resume no-oped against this turn's still-held lease (see the WorkflowSuspended
|
|
1095
|
+
* catch in runExecution). Deferred one macrotask so the caller's `finally` releases the lease
|
|
1096
|
+
* first; tracked so `drain()` waits for the re-driven turn's writes.
|
|
1097
|
+
*/
|
|
1098
|
+
recheckWaitSeqs(runId, seqs) {
|
|
1099
|
+
this.trackEffect((async () => {
|
|
1100
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1101
|
+
for (const seq of seqs) {
|
|
1102
|
+
const cp = await this.store.getCheckpoint(runId, seq).catch(() => null);
|
|
1103
|
+
if (cp && (cp.status === 'completed' || cp.status === 'failed')) {
|
|
1104
|
+
await this.resume(runId).catch(() => undefined);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
})());
|
|
1109
|
+
}
|
|
796
1110
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
797
1111
|
track(runId, p) {
|
|
798
1112
|
this.inflight.set(p, runId);
|
|
@@ -860,7 +1174,11 @@ export class WorkflowEngine {
|
|
|
860
1174
|
async releaseInflightLocks() {
|
|
861
1175
|
for (const runId of [...this.inflight.values()]) {
|
|
862
1176
|
try {
|
|
863
|
-
|
|
1177
|
+
// Owner-scoped: only clear leases THIS instance holds. Also fence the still-running turn —
|
|
1178
|
+
// the next pod may re-lease immediately, so this turn's eventual settle must become a no-op
|
|
1179
|
+
// echo instead of clobbering whatever the new owner writes (see settleRun's guard).
|
|
1180
|
+
this.leaseLostRuns.add(runId);
|
|
1181
|
+
await this.store.releaseRunLock(runId, this.instanceId);
|
|
864
1182
|
}
|
|
865
1183
|
catch {
|
|
866
1184
|
/* best-effort: drain() must never throw because of this */
|
|
@@ -876,21 +1194,24 @@ export class WorkflowEngine {
|
|
|
876
1194
|
for (const reg of new Set(this.latest.values())) {
|
|
877
1195
|
if (reg.executionTimeoutMs == null)
|
|
878
1196
|
continue;
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
})),
|
|
891
|
-
];
|
|
1197
|
+
// Push the age predicate into the store: only candidates already past the LATEST version's
|
|
1198
|
+
// timeout come back (bounded), instead of every in-flight run of the workflow per tick. A
|
|
1199
|
+
// run pinned to an older version with a SHORTER timeout is swept a little later (when it
|
|
1200
|
+
// crosses this bound) — the per-run check below still applies its own version's timeout.
|
|
1201
|
+
const inflight = await this.store.listRuns({
|
|
1202
|
+
workflow: reg.name,
|
|
1203
|
+
statuses: ['running', 'suspended'],
|
|
1204
|
+
namespace: this.namespace,
|
|
1205
|
+
createdBefore: now - reg.executionTimeoutMs,
|
|
1206
|
+
limit: 200,
|
|
1207
|
+
});
|
|
892
1208
|
for (const run of inflight) {
|
|
893
|
-
|
|
1209
|
+
// Time out against the version the run STARTED on when that registration is known — a fleet
|
|
1210
|
+
// mid-deploy must not sweep old-version runs by the newest version's (possibly shorter)
|
|
1211
|
+
// timeout. Falls back to the latest registration's timeout for an unknown version.
|
|
1212
|
+
const timeoutMs = this.workflows.get(versionKey(run.workflow, run.workflowVersion))?.executionTimeoutMs ??
|
|
1213
|
+
reg.executionTimeoutMs;
|
|
1214
|
+
if (timeoutMs == null || run.createdAt.getTime() > now - timeoutMs)
|
|
894
1215
|
continue;
|
|
895
1216
|
const error = { message: 'execution timeout', code: 'execution_timeout' };
|
|
896
1217
|
// Conditional on the exact statuses just queried: an await separates this loop's iterations
|
|
@@ -911,6 +1232,20 @@ export class WorkflowEngine {
|
|
|
911
1232
|
namespace: run.namespace,
|
|
912
1233
|
error,
|
|
913
1234
|
});
|
|
1235
|
+
// A sweep is a cancellation — give it `cancel()`'s full semantics, not just the status write:
|
|
1236
|
+
// cascade to the child subtree (they'd otherwise keep running, orphaned but live), tell the
|
|
1237
|
+
// instance/worker actually executing the run to abort cooperatively, wake a waiting parent
|
|
1238
|
+
// (it consumes this like a failed child instead of hanging to its reconcile), and free a
|
|
1239
|
+
// singleton slot.
|
|
1240
|
+
await this.cancelChildren(run.id, undefined);
|
|
1241
|
+
this.notifyCancelled(run.id);
|
|
1242
|
+
if (this.controlPlane) {
|
|
1243
|
+
void this.controlPlane
|
|
1244
|
+
.publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId })
|
|
1245
|
+
.catch(() => undefined);
|
|
1246
|
+
}
|
|
1247
|
+
this.notifyParent(run.id, { ok: false, error: error.message });
|
|
1248
|
+
this.trackEffect(this.singletons.wakeNext(run));
|
|
914
1249
|
}
|
|
915
1250
|
}
|
|
916
1251
|
}
|
|
@@ -922,7 +1257,17 @@ export class WorkflowEngine {
|
|
|
922
1257
|
if (this.draining)
|
|
923
1258
|
return [];
|
|
924
1259
|
const results = [];
|
|
925
|
-
|
|
1260
|
+
// Ask the store for ORPHANS only (running + free/expired lease, bounded): on a healthy fleet
|
|
1261
|
+
// that's ~zero rows per tick, instead of fetching every running run and issuing one doomed
|
|
1262
|
+
// lock-probe UPDATE per row per worker per second. Falls back to the old full-list + in-process
|
|
1263
|
+
// filter for a custom store without the optimized method.
|
|
1264
|
+
const incomplete = await asHeartbeat(async () => {
|
|
1265
|
+
const nowMs = this.clock();
|
|
1266
|
+
if (this.store.listOrphanedRuns) {
|
|
1267
|
+
return this.store.listOrphanedRuns(nowMs, 100, this.namespace);
|
|
1268
|
+
}
|
|
1269
|
+
return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
|
|
1270
|
+
});
|
|
926
1271
|
for (const run of incomplete) {
|
|
927
1272
|
// A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
|
|
928
1273
|
// (its worker crashed). Skip the ones still owned.
|
|
@@ -937,8 +1282,20 @@ export class WorkflowEngine {
|
|
|
937
1282
|
}
|
|
938
1283
|
// Re-enqueue rather than resume inline: recovery must NOT block (boot, or a poll tick) on a
|
|
939
1284
|
// long workflow step. A dispatcher/worker re-runs it, replaying its checkpoints.
|
|
940
|
-
|
|
941
|
-
|
|
1285
|
+
//
|
|
1286
|
+
// Conditional on still-`running`, WHILE the lease is held: a redelivered worker result (or any
|
|
1287
|
+
// other resume path) could have driven this run to a terminal state between the listing above
|
|
1288
|
+
// and here — an unconditional write would clobber `completed` back to `pending` and re-execute
|
|
1289
|
+
// the whole run (double `run.completed`, double parent notify). Same reasoning as every settle
|
|
1290
|
+
// path's SETTLE_ELIGIBLE_STATUSES guard. The lock is released only AFTER the flip (owner-scoped,
|
|
1291
|
+
// so a takeover can't be wiped), closing the release→flip gap the old order left open.
|
|
1292
|
+
const applied = await this.store.updateRunIf(run.id, ['running'], {
|
|
1293
|
+
status: 'pending',
|
|
1294
|
+
updatedAt: new Date(),
|
|
1295
|
+
});
|
|
1296
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
1297
|
+
if (!applied)
|
|
1298
|
+
continue;
|
|
942
1299
|
await this.runDispatcher.dispatch(run.id);
|
|
943
1300
|
results.push({ runId: run.id, status: 'pending' });
|
|
944
1301
|
}
|
|
@@ -957,7 +1314,7 @@ export class WorkflowEngine {
|
|
|
957
1314
|
code: 'max_recovery_attempts',
|
|
958
1315
|
};
|
|
959
1316
|
await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
|
|
960
|
-
await this.store.releaseRunLock(run.id);
|
|
1317
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
961
1318
|
this.emit({
|
|
962
1319
|
type: 'run.failed',
|
|
963
1320
|
runId: run.id,
|
|
@@ -980,7 +1337,8 @@ export class WorkflowEngine {
|
|
|
980
1337
|
* not due re-suspends cheaply without running new work.
|
|
981
1338
|
*/
|
|
982
1339
|
async resumeDueTimers(nowMs = this.clock()) {
|
|
983
|
-
|
|
1340
|
+
// Capped per tick (a backlog drains over several polls) — a store may ignore the cap.
|
|
1341
|
+
const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, 200));
|
|
984
1342
|
const blocked = await this.dueBlockedRuns(nowMs);
|
|
985
1343
|
return this.resumeLeased([...due, ...blocked], nowMs);
|
|
986
1344
|
}
|
|
@@ -1210,10 +1568,15 @@ export class WorkflowEngine {
|
|
|
1210
1568
|
resolve(toResult(run));
|
|
1211
1569
|
};
|
|
1212
1570
|
const check = () => {
|
|
1213
|
-
|
|
1571
|
+
// Swallow a transient store error: the next settling event re-checks, and the timeout still
|
|
1572
|
+
// bounds the wait. Unhandled, one hiccup here was a process-fatal unhandled rejection.
|
|
1573
|
+
void this.store
|
|
1574
|
+
.getRun(runId)
|
|
1575
|
+
.then((run) => {
|
|
1214
1576
|
if (run && shouldResolve(run.status))
|
|
1215
1577
|
finish(run);
|
|
1216
|
-
})
|
|
1578
|
+
})
|
|
1579
|
+
.catch(() => undefined);
|
|
1217
1580
|
};
|
|
1218
1581
|
// React only to this run's settling events (not its every step event), and subscribe BEFORE the
|
|
1219
1582
|
// initial read so a run that settles in between isn't missed.
|
|
@@ -1234,14 +1597,26 @@ export class WorkflowEngine {
|
|
|
1234
1597
|
if (this.draining)
|
|
1235
1598
|
return []; // shutting down — don't pick up new runs
|
|
1236
1599
|
const results = [];
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1600
|
+
// Bounded parallelism: one slow turn (a long local step) must not serialize the other 99 picked
|
|
1601
|
+
// up this tick behind it. The lease acquire stays inside each task, so exactly-one-executor is
|
|
1602
|
+
// unchanged; 8 concurrent turns is enough to hide one straggler without stampeding the store.
|
|
1603
|
+
const CONCURRENCY = 8;
|
|
1604
|
+
let next = 0;
|
|
1605
|
+
const worker = async () => {
|
|
1606
|
+
for (;;) {
|
|
1607
|
+
const run = runs[next];
|
|
1608
|
+
next += 1;
|
|
1609
|
+
if (!run)
|
|
1610
|
+
return;
|
|
1611
|
+
const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
1612
|
+
if (!acquired)
|
|
1613
|
+
continue;
|
|
1614
|
+
// A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
|
|
1615
|
+
const settled = onLocked ? await onLocked(run) : undefined;
|
|
1616
|
+
results.push(settled ?? (await this.resume(run.id)));
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, runs.length) }, () => worker()));
|
|
1245
1620
|
return results;
|
|
1246
1621
|
}
|
|
1247
1622
|
/**
|
|
@@ -1420,7 +1795,14 @@ export class WorkflowEngine {
|
|
|
1420
1795
|
notifyParent(runId, completion) {
|
|
1421
1796
|
// Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
|
|
1422
1797
|
// store writes that must be awaited by `drain()` even though the settling child returned already.
|
|
1423
|
-
|
|
1798
|
+
// One retry + a warning before giving up: a transiently-failed delivery here used to vanish into
|
|
1799
|
+
// trackEffect's swallow, stranding the parent until its reconcile wake. The parent's `ctx.child`
|
|
1800
|
+
// re-registration now also RE-DERIVES a terminal child's completion from the run row itself, so
|
|
1801
|
+
// a lost notify self-heals — but that costs a reconcile interval; don't lose it silently.
|
|
1802
|
+
const deliver = (token) => this.signal(token, completion).catch(() => this.signal(token, completion).catch((err) => {
|
|
1803
|
+
console.warn(`[adonis-durable] failed to deliver child completion on ${token} (the parent recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
|
|
1804
|
+
}));
|
|
1805
|
+
this.trackEffect(deliver(`child:${runId}`));
|
|
1424
1806
|
// A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
|
|
1425
1807
|
// origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
|
|
1426
1808
|
// parent that failed on that child and is retried later consumes this success (buffered or
|
|
@@ -1428,7 +1810,7 @@ export class WorkflowEngine {
|
|
|
1428
1810
|
// fix attempt must not poison the origin's token.
|
|
1429
1811
|
const at = runId.lastIndexOf('~retry~');
|
|
1430
1812
|
if (at !== -1 && completion.ok) {
|
|
1431
|
-
this.trackEffect(
|
|
1813
|
+
this.trackEffect(deliver(`child:${runId.slice(0, at)}`));
|
|
1432
1814
|
}
|
|
1433
1815
|
}
|
|
1434
1816
|
/**
|
|
@@ -1477,6 +1859,11 @@ export class WorkflowEngine {
|
|
|
1477
1859
|
// (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
|
|
1478
1860
|
if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
|
|
1479
1861
|
this.cancelRequested.add(runId);
|
|
1862
|
+
// DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
|
|
1863
|
+
// buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
|
|
1864
|
+
// picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
|
|
1865
|
+
// the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
|
|
1866
|
+
await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
|
|
1480
1867
|
if (this.controlPlane) {
|
|
1481
1868
|
void this.controlPlane
|
|
1482
1869
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
@@ -1563,9 +1950,15 @@ export class WorkflowEngine {
|
|
|
1563
1950
|
*/
|
|
1564
1951
|
async getRunChildren(parentRunId) {
|
|
1565
1952
|
const childIds = new Set();
|
|
1566
|
-
|
|
1567
|
-
|
|
1953
|
+
// Targeted per-run lookup when the store has it (an indexed `WHERE run_id`); the prefix scan
|
|
1954
|
+
// fallback walks EVERY `child:` waiter row for one parent.
|
|
1955
|
+
const waiters = this.store.listSignalWaitersByRunIds
|
|
1956
|
+
? await this.store.listSignalWaitersByRunIds([parentRunId])
|
|
1957
|
+
: await this.store.listSignalWaiters('child:');
|
|
1958
|
+
for (const w of waiters) {
|
|
1959
|
+
if (w.runId === parentRunId && w.token.startsWith('child:')) {
|
|
1568
1960
|
childIds.add(w.token.slice('child:'.length));
|
|
1961
|
+
}
|
|
1569
1962
|
}
|
|
1570
1963
|
// Targeted read: only the `signal:child:` / `spawn:` checkpoints, not the whole history. Falls
|
|
1571
1964
|
// back to a full listCheckpoints + in-JS prefix scan for a custom store that omits the method.
|
|
@@ -1592,6 +1985,20 @@ export class WorkflowEngine {
|
|
|
1592
1985
|
listSignalWaiters(prefix) {
|
|
1593
1986
|
return this.store.listSignalWaiters(prefix);
|
|
1594
1987
|
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Targeted per-run waiter lookup (`run_id IN (...)`) for the dashboard's page-scoped `waiting`
|
|
1990
|
+
* stamp. Prefers the store's indexed method; falls back to filtering the full scan for a custom
|
|
1991
|
+
* store without it.
|
|
1992
|
+
*/
|
|
1993
|
+
async listSignalWaitersByRunIds(runIds) {
|
|
1994
|
+
if (runIds.length === 0)
|
|
1995
|
+
return [];
|
|
1996
|
+
if (this.store.listSignalWaitersByRunIds) {
|
|
1997
|
+
return this.store.listSignalWaitersByRunIds(runIds);
|
|
1998
|
+
}
|
|
1999
|
+
const ids = new Set(runIds);
|
|
2000
|
+
return (await this.store.listSignalWaiters('')).filter((w) => ids.has(w.runId));
|
|
2001
|
+
}
|
|
1595
2002
|
/** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
|
|
1596
2003
|
* `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
|
|
1597
2004
|
* workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
|
|
@@ -1611,13 +2018,10 @@ export class WorkflowEngine {
|
|
|
1611
2018
|
* (only the BullMQ transport implements `groupHealth`). */
|
|
1612
2019
|
async workerHealth(extra = []) {
|
|
1613
2020
|
const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
out.push(health);
|
|
1619
|
-
}
|
|
1620
|
-
return out;
|
|
2021
|
+
// One round per group, in parallel — the dashboard polls this, and each group's health is an
|
|
2022
|
+
// independent SCAN + job-count query; serial rounds made /workers latency scale linearly.
|
|
2023
|
+
const healths = await Promise.all([...groups].map((group) => this.pool.groupHealth(group)));
|
|
2024
|
+
return healths.filter((h) => h != null);
|
|
1621
2025
|
}
|
|
1622
2026
|
/**
|
|
1623
2027
|
* Persist a streamed local-step lifecycle event from a remote workflow worker (see
|
|
@@ -1779,8 +2183,17 @@ export class WorkflowEngine {
|
|
|
1779
2183
|
}
|
|
1780
2184
|
}
|
|
1781
2185
|
const renew = setInterval(() => {
|
|
2186
|
+
// A renew that returns FALSE means the lease was taken over (this turn stalled past expiry
|
|
2187
|
+
// and another instance reclaimed the run) — this executor is now a zombie. Fence it: mark
|
|
2188
|
+
// the run so its eventual settle degrades to a no-op echo instead of clobbering the new
|
|
2189
|
+
// owner's writes. A renew that THROWS (transient store error) is not evidence of takeover,
|
|
2190
|
+
// so it stays swallowed — the next interval retries.
|
|
1782
2191
|
void this.store
|
|
1783
2192
|
.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
|
|
2193
|
+
.then((renewed) => {
|
|
2194
|
+
if (!renewed)
|
|
2195
|
+
this.leaseLostRuns.add(run.id);
|
|
2196
|
+
})
|
|
1784
2197
|
.catch(() => undefined);
|
|
1785
2198
|
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
1786
2199
|
renew.unref?.();
|
|
@@ -1805,6 +2218,7 @@ export class WorkflowEngine {
|
|
|
1805
2218
|
}
|
|
1806
2219
|
finally {
|
|
1807
2220
|
clearInterval(renew);
|
|
2221
|
+
this.leaseLostRuns.delete(run.id);
|
|
1808
2222
|
}
|
|
1809
2223
|
}
|
|
1810
2224
|
/**
|
|
@@ -1821,6 +2235,13 @@ export class WorkflowEngine {
|
|
|
1821
2235
|
* keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
|
|
1822
2236
|
*/
|
|
1823
2237
|
async settleRun(run, outcome) {
|
|
2238
|
+
// Zombie fence: this turn's lease was lost mid-execution (renew returned false, or drain
|
|
2239
|
+
// released it on timeout) — another instance owns and is (re)executing the run. This outcome
|
|
2240
|
+
// was computed by a stale executor; report the store's current state instead of writing. The
|
|
2241
|
+
// status-CAS below would NOT catch this case: the new owner's run is legitimately back in a
|
|
2242
|
+
// settle-eligible status while it executes.
|
|
2243
|
+
if (this.leaseLostRuns.has(run.id))
|
|
2244
|
+
return this.echoCurrentStatus(run.id);
|
|
1824
2245
|
const updatedAt = new Date();
|
|
1825
2246
|
if (outcome.kind === 'completed') {
|
|
1826
2247
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the
|
|
@@ -1848,8 +2269,15 @@ export class WorkflowEngine {
|
|
|
1848
2269
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
1849
2270
|
}
|
|
1850
2271
|
if (outcome.kind === 'failed') {
|
|
1851
|
-
// Same race as the `completed` branch above — guard identically.
|
|
1852
|
-
|
|
2272
|
+
// Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
|
|
2273
|
+
// `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
|
|
2274
|
+
// re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
|
|
2275
|
+
// `run.failed` and RE-NOTIFY the parent, whose waiter the first notify already consumed, so
|
|
2276
|
+
// the duplicate gets BUFFERED as a stale failure that poisons a later retry's rendezvous
|
|
2277
|
+
// (observed: a cascade-requeued child's fresh success lost to the stale buffered failure). A
|
|
2278
|
+
// legitimate retry always transitions through `pending`/`running` first (requeue flips the
|
|
2279
|
+
// status), so it is unaffected.
|
|
2280
|
+
const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
|
|
1853
2281
|
status: 'failed',
|
|
1854
2282
|
error: outcome.error,
|
|
1855
2283
|
updatedAt,
|
|
@@ -2200,10 +2628,48 @@ export class WorkflowEngine {
|
|
|
2200
2628
|
// dashboard renders the fan as one parallel group. Undefined for a lone (non-fan) child.
|
|
2201
2629
|
parallelGroup: cmd.parallelGroup,
|
|
2202
2630
|
});
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2631
|
+
// Lost-wake guard (mirrors the in-process `ctx.child`/`ctx.all`): the child may have ALREADY
|
|
2632
|
+
// finished while no waiter was registered — consume its buffered completion, or re-derive the
|
|
2633
|
+
// completion from the child's own terminal run row when even the buffer was lost (crash
|
|
2634
|
+
// between the child's terminal write and its parent notify). Without this the re-registered
|
|
2635
|
+
// waiter waits forever on a signal that already fired.
|
|
2636
|
+
const deliverChild = async (payload) => {
|
|
2637
|
+
await this.store.removeSignalWaiter({
|
|
2638
|
+
token: `child:${childId}`,
|
|
2639
|
+
runId: run.id,
|
|
2640
|
+
seq: cmd.seq,
|
|
2641
|
+
});
|
|
2642
|
+
await this.store.saveCheckpoint(instantCheckpoint({
|
|
2643
|
+
runId: run.id,
|
|
2644
|
+
seq: cmd.seq,
|
|
2645
|
+
name: `signal:child:${childId}`,
|
|
2646
|
+
kind: 'signal',
|
|
2647
|
+
output: payload,
|
|
2648
|
+
parallelGroup: cmd.parallelGroup,
|
|
2649
|
+
}));
|
|
2650
|
+
// Re-drive on a macrotask, AFTER this turn suspends and frees the run lock (same reorder
|
|
2651
|
+
// as the buffered `waitSignal` delivery above).
|
|
2652
|
+
setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
|
|
2653
|
+
};
|
|
2654
|
+
const bufferedChild = await this.store.takeBufferedSignal(`child:${childId}`);
|
|
2655
|
+
if (bufferedChild) {
|
|
2656
|
+
await deliverChild(bufferedChild.payload);
|
|
2657
|
+
}
|
|
2658
|
+
else {
|
|
2659
|
+
const child = await this.store.getRun(childId);
|
|
2660
|
+
if (!child) {
|
|
2661
|
+
// Same inheritance rule as the in-process `ctx.child` path: the child belongs to the run
|
|
2662
|
+
// that spawned it, not to whichever engine happened to advance that run.
|
|
2663
|
+
this.startChildDeferred(cmd.workflow, cmd.input, childId, { namespace: run.namespace });
|
|
2664
|
+
}
|
|
2665
|
+
else if (child.status === 'completed' ||
|
|
2666
|
+
child.status === 'failed' ||
|
|
2667
|
+
child.status === 'cancelled' ||
|
|
2668
|
+
child.status === 'dead') {
|
|
2669
|
+
await deliverChild(child.status === 'completed'
|
|
2670
|
+
? { ok: true, value: child.output }
|
|
2671
|
+
: { ok: false, error: child.error?.message ?? child.status });
|
|
2672
|
+
}
|
|
2207
2673
|
}
|
|
2208
2674
|
}
|
|
2209
2675
|
else {
|
|
@@ -2238,7 +2704,7 @@ export class WorkflowEngine {
|
|
|
2238
2704
|
workflow: run.workflow,
|
|
2239
2705
|
namespace: run.namespace,
|
|
2240
2706
|
});
|
|
2241
|
-
await this.store.releaseRunLock(run.id);
|
|
2707
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
2242
2708
|
return { runId: run.id, status: 'suspended' };
|
|
2243
2709
|
}
|
|
2244
2710
|
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
@@ -2262,9 +2728,22 @@ export class WorkflowEngine {
|
|
|
2262
2728
|
}
|
|
2263
2729
|
catch (err) {
|
|
2264
2730
|
if (err instanceof ContinueAsNew) {
|
|
2265
|
-
// Hand off to a fresh execution with a clean history:
|
|
2266
|
-
//
|
|
2267
|
-
|
|
2731
|
+
// Hand off to a fresh execution with a clean history: persist the next run (`<id>~N`), then
|
|
2732
|
+
// complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
|
|
2733
|
+
if (this.leaseLostRuns.has(run.id))
|
|
2734
|
+
return this.echoCurrentStatus(run.id);
|
|
2735
|
+
const nextId = nextContinuationId(run.id);
|
|
2736
|
+
// Persist the continuation BEFORE the parent's terminal write — the durable ordering that
|
|
2737
|
+
// makes the chain crash-safe. The old order (complete parent, then create the continuation
|
|
2738
|
+
// from an in-memory deferred start) left a window where a hard crash (SIGKILL/OOM) lost the
|
|
2739
|
+
// chain forever: a `completed` parent with no continuation is indistinguishable from a
|
|
2740
|
+
// normally-finished run, and no recovery path re-derives the next link (recoverIncomplete
|
|
2741
|
+
// only scans `running`). With the continuation persisted first, a crash in the gap leaves
|
|
2742
|
+
// the parent `running` — recovery replays it, ContinueAsNew re-throws, and this createRun
|
|
2743
|
+
// converges on the already-persisted continuation (idempotent by the continuation id).
|
|
2744
|
+
// `dispatch: false`: the tracked handoff below owns the pickup. The continuation inherits
|
|
2745
|
+
// the parent RUN's namespace so a tenant's chain stays in its worker-pool partition.
|
|
2746
|
+
await this.startCore(run.workflow, err.input, nextId, { namespace: run.namespace }, false);
|
|
2268
2747
|
await this.store.updateRun(run.id, {
|
|
2269
2748
|
status: 'completed',
|
|
2270
2749
|
output: undefined,
|
|
@@ -2278,11 +2757,11 @@ export class WorkflowEngine {
|
|
|
2278
2757
|
namespace: run.namespace,
|
|
2279
2758
|
});
|
|
2280
2759
|
this.notifyParent(run.id, { ok: true, value: undefined });
|
|
2281
|
-
const nextId = nextContinuationId(run.id);
|
|
2282
2760
|
// Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
|
|
2283
2761
|
// continuation has entered `inflight` and settled, so `drain()` can't return before the next
|
|
2284
|
-
// run's store writes land. Still deferred
|
|
2285
|
-
|
|
2762
|
+
// run's store writes land. Still deferred; its startCore call converges on the run persisted
|
|
2763
|
+
// above (prior-run branch) and drives the pickup.
|
|
2764
|
+
this.handoffRun(run.workflow, err.input, nextId, { namespace: run.namespace });
|
|
2286
2765
|
return { runId: run.id, status: 'completed' };
|
|
2287
2766
|
}
|
|
2288
2767
|
if (err instanceof WorkflowBlocked) {
|
|
@@ -2294,15 +2773,37 @@ export class WorkflowEngine {
|
|
|
2294
2773
|
if (err instanceof WorkflowSuspended) {
|
|
2295
2774
|
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
2296
2775
|
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
2297
|
-
|
|
2298
|
-
|
|
2776
|
+
// The intent lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same
|
|
2777
|
+
// boot), and the DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or
|
|
2778
|
+
// a cross-pod handoff mid-cancellation can't silently forget the cancel and settle the run
|
|
2779
|
+
// back to `suspended` as if nothing happened. Peek the marker (take + re-buffer) rather than
|
|
2780
|
+
// consume it: it must survive a crash mid-unwind; it's drained below once `cancelled` is
|
|
2781
|
+
// durably recorded.
|
|
2782
|
+
let cancelling = this.cancelRequested.delete(run.id);
|
|
2783
|
+
if (!cancelling) {
|
|
2784
|
+
const marker = await this.store.takeBufferedSignal(`cancel:${run.id}`);
|
|
2785
|
+
if (marker) {
|
|
2786
|
+
cancelling = true;
|
|
2787
|
+
await this.store
|
|
2788
|
+
.bufferSignal(`cancel:${run.id}`, marker.payload)
|
|
2789
|
+
.catch(() => undefined);
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
if (cancelling) {
|
|
2299
2793
|
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
2300
2794
|
const comp = compensations[i];
|
|
2301
2795
|
if (comp)
|
|
2302
|
-
await this.runCompensation(run, comp);
|
|
2796
|
+
await this.runCompensation(run, comp, i);
|
|
2303
2797
|
}
|
|
2304
2798
|
const error = { message: 'cancelled' };
|
|
2305
2799
|
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
2800
|
+
// The cancellation is durably recorded — consume the marker(s) so a later retry of this
|
|
2801
|
+
// run id doesn't get spuriously re-cancelled.
|
|
2802
|
+
for (;;) {
|
|
2803
|
+
const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
|
|
2804
|
+
if (!m)
|
|
2805
|
+
break;
|
|
2806
|
+
}
|
|
2306
2807
|
this.emit({
|
|
2307
2808
|
type: 'run.failed',
|
|
2308
2809
|
runId: run.id,
|
|
@@ -2312,7 +2813,16 @@ export class WorkflowEngine {
|
|
|
2312
2813
|
});
|
|
2313
2814
|
return { runId: run.id, status: 'cancelled', error };
|
|
2314
2815
|
}
|
|
2315
|
-
|
|
2816
|
+
const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
2817
|
+
// Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
|
|
2818
|
+
// executing wrote its checkpoint durably, but its resume no-oped against our held lease —
|
|
2819
|
+
// and this settle just parked the run on nothing but the reconcile fallback (or nothing at
|
|
2820
|
+
// all with reconcileMs: 0). Re-check the seqs this suspension waits on and re-drive if one
|
|
2821
|
+
// already settled.
|
|
2822
|
+
if (settled.status === 'suspended' && err.waitSeqs?.length) {
|
|
2823
|
+
this.recheckWaitSeqs(run.id, err.waitSeqs);
|
|
2824
|
+
}
|
|
2825
|
+
return settled;
|
|
2316
2826
|
}
|
|
2317
2827
|
const error = {
|
|
2318
2828
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -2326,14 +2836,15 @@ export class WorkflowEngine {
|
|
|
2326
2836
|
const comp = compensations[i];
|
|
2327
2837
|
if (!comp)
|
|
2328
2838
|
continue;
|
|
2329
|
-
await this.runCompensation(run, comp);
|
|
2839
|
+
await this.runCompensation(run, comp, i);
|
|
2330
2840
|
}
|
|
2331
2841
|
return this.settleRun(run, { kind: 'failed', error });
|
|
2332
2842
|
}
|
|
2333
2843
|
finally {
|
|
2334
2844
|
// Release the recovery lease once the run reaches a terminal/suspended state, so the
|
|
2335
|
-
// next instance (or the timer poller) can pick it up promptly.
|
|
2336
|
-
|
|
2845
|
+
// next instance (or the timer poller) can pick it up promptly. Owner-scoped: if this turn's
|
|
2846
|
+
// lease was already taken over (zombie executor), the new owner's lease is left untouched.
|
|
2847
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
2337
2848
|
}
|
|
2338
2849
|
}
|
|
2339
2850
|
/**
|
|
@@ -2342,17 +2853,39 @@ export class WorkflowEngine {
|
|
|
2342
2853
|
* compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
|
|
2343
2854
|
* a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
|
|
2344
2855
|
* awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
|
|
2345
|
-
|
|
2346
|
-
|
|
2856
|
+
*
|
|
2857
|
+
* DURABLE: each compensation owns a reserved NEGATIVE checkpoint seq (`-2 - idx`, disjoint from
|
|
2858
|
+
* the positional replay's `>= 0` space and the legacy `-1`), written `pending` before a dispatch
|
|
2859
|
+
* and settled on its outcome. A re-driven unwind — crash mid-unwind, a lease takeover replaying
|
|
2860
|
+
* the failing body back into this catch, a worker result consumed by another pod — therefore
|
|
2861
|
+
* SKIPS undos already done and RESUMES the attempt count, instead of re-running the whole unwind
|
|
2862
|
+
* blind from in-memory state that no longer exists.
|
|
2863
|
+
*/
|
|
2864
|
+
async runCompensation(run, comp, idx) {
|
|
2347
2865
|
const name = `compensate:${comp.name}`;
|
|
2348
|
-
|
|
2866
|
+
const seq = COMPENSATION_SEQ_BASE - idx;
|
|
2867
|
+
const existing = await this.store.getCheckpoint(run.id, seq);
|
|
2868
|
+
// Already undone by a previous drive of this unwind (possibly on another pod) — skip.
|
|
2869
|
+
if (existing?.status === 'completed')
|
|
2870
|
+
return;
|
|
2871
|
+
const priorAttempts = existing?.attempts ?? 0;
|
|
2872
|
+
// Permanently failed and already reported — skip (never mask the original failure).
|
|
2873
|
+
if (existing?.status === 'failed' && priorAttempts >= this.compensationRetries)
|
|
2874
|
+
return;
|
|
2875
|
+
// A `pending` row re-enters AT its recorded attempt (await the in-flight dispatch rather than
|
|
2876
|
+
// blindly re-issuing); a `failed` row resumes the count after its recorded attempts.
|
|
2877
|
+
const firstAttempt = existing?.status === 'pending' ? Math.max(1, priorAttempts) : priorAttempts + 1;
|
|
2878
|
+
for (let attempt = firstAttempt; attempt <= this.compensationRetries; attempt += 1) {
|
|
2349
2879
|
const startedAt = Date.now();
|
|
2350
2880
|
try {
|
|
2351
2881
|
if ('fn' in comp) {
|
|
2352
2882
|
await comp.fn();
|
|
2883
|
+
// Persist the completed undo so a re-driven unwind never re-runs it — crash-safe skipping
|
|
2884
|
+
// instead of leaning solely on the "compensations are idempotent" contract.
|
|
2885
|
+
await this.store.saveCheckpoint(instantCheckpoint({ runId: run.id, seq, name, kind: 'local' }));
|
|
2353
2886
|
}
|
|
2354
2887
|
else {
|
|
2355
|
-
await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
|
|
2888
|
+
await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt, seq);
|
|
2356
2889
|
}
|
|
2357
2890
|
this.emit({
|
|
2358
2891
|
type: 'step.completed',
|
|
@@ -2365,6 +2898,24 @@ export class WorkflowEngine {
|
|
|
2365
2898
|
return;
|
|
2366
2899
|
}
|
|
2367
2900
|
catch (err) {
|
|
2901
|
+
// Record the failed attempt (best-effort) so a re-driven unwind resumes the count.
|
|
2902
|
+
const error = { message: err instanceof Error ? err.message : String(err) };
|
|
2903
|
+
const at = new Date();
|
|
2904
|
+
await this.store
|
|
2905
|
+
.saveCheckpoint({
|
|
2906
|
+
runId: run.id,
|
|
2907
|
+
seq,
|
|
2908
|
+
name,
|
|
2909
|
+
kind: 'fn' in comp ? 'local' : 'remote',
|
|
2910
|
+
stepId: `${run.id}:compensate:${comp.name}:${attempt}`,
|
|
2911
|
+
status: 'failed',
|
|
2912
|
+
attempts: attempt,
|
|
2913
|
+
error,
|
|
2914
|
+
enqueuedAt: existing?.enqueuedAt ?? at,
|
|
2915
|
+
startedAt: at,
|
|
2916
|
+
finishedAt: at,
|
|
2917
|
+
})
|
|
2918
|
+
.catch(() => undefined);
|
|
2368
2919
|
if (attempt >= this.compensationRetries) {
|
|
2369
2920
|
this.emit({
|
|
2370
2921
|
type: 'step.failed',
|
|
@@ -2372,7 +2923,7 @@ export class WorkflowEngine {
|
|
|
2372
2923
|
workflow: run.workflow,
|
|
2373
2924
|
name,
|
|
2374
2925
|
kind: 'local',
|
|
2375
|
-
error
|
|
2926
|
+
error,
|
|
2376
2927
|
durationMs: Date.now() - startedAt,
|
|
2377
2928
|
});
|
|
2378
2929
|
}
|
|
@@ -2380,42 +2931,146 @@ export class WorkflowEngine {
|
|
|
2380
2931
|
}
|
|
2381
2932
|
}
|
|
2382
2933
|
/**
|
|
2383
|
-
* Dispatch a saga undo step to a worker and await its result
|
|
2384
|
-
*
|
|
2385
|
-
*
|
|
2386
|
-
*
|
|
2934
|
+
* Dispatch a saga undo step to a worker and await its result. Routes by the undo def's own
|
|
2935
|
+
* name/partition token, applies its own optional input schema, and honours its own liveness
|
|
2936
|
+
* `timeoutMs` via the heartbeat window (else the engine's `compensationTimeoutMs` bounds the
|
|
2937
|
+
* wait, so a lost result can't hold the unwind open forever). Throws on a worker-reported failure
|
|
2387
2938
|
* or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
|
|
2939
|
+
*
|
|
2940
|
+
* The undo is CHECKPOINTED at its reserved negative `seq` (pending before dispatch, settled on the
|
|
2941
|
+
* outcome), which is what makes the await multi-pod-safe: a result popped by ANOTHER engine
|
|
2942
|
+
* instance sharing the results queue lands in its `completeRemoteResult`, settles this row, and
|
|
2943
|
+
* the checkpoint poll below observes it — the old in-memory-only waiter simply dropped that result
|
|
2944
|
+
* (`getCheckpoint(runId, -1)` → null) and hung the failing run's unwind forever.
|
|
2388
2945
|
*/
|
|
2389
|
-
async dispatchCompensation(run, def, args, attempt) {
|
|
2946
|
+
async dispatchCompensation(run, def, args, attempt, seq) {
|
|
2390
2947
|
if (this.pool.size === 0)
|
|
2391
2948
|
throw new Error('dispatched compensation requires a Transport');
|
|
2392
2949
|
const token = stepGroup(def, run.namespace);
|
|
2393
2950
|
const validInput = def.input ? def.input.parse(args) : args;
|
|
2394
2951
|
const id = `${run.id}:compensate:${def.name}:${attempt}`;
|
|
2952
|
+
const name = `compensate:${def.name}`;
|
|
2953
|
+
const existing = await this.store.getCheckpoint(run.id, seq);
|
|
2395
2954
|
const resultPromise = new Promise((resolve, reject) => {
|
|
2396
2955
|
this.pending.set(id, { resolve, reject });
|
|
2397
2956
|
});
|
|
2957
|
+
const enqueuedAt = new Date();
|
|
2398
2958
|
try {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2959
|
+
// A pending row already at (or past) this attempt means the dispatch itself already happened
|
|
2960
|
+
// (crash after dispatch, or a re-drive while the worker is mid-undo): re-await the in-flight
|
|
2961
|
+
// undo instead of double-issuing it.
|
|
2962
|
+
const alreadyDispatched = existing?.status === 'pending' && existing.attempts >= attempt;
|
|
2963
|
+
if (!alreadyDispatched) {
|
|
2964
|
+
// Persist the pending checkpoint BEFORE dispatching — the durable anchor for the cross-pod
|
|
2965
|
+
// settle and for re-drive resumption.
|
|
2966
|
+
await this.store.saveCheckpoint({
|
|
2967
|
+
runId: run.id,
|
|
2968
|
+
seq,
|
|
2969
|
+
name,
|
|
2970
|
+
kind: 'remote',
|
|
2971
|
+
stepId: id,
|
|
2972
|
+
status: 'pending',
|
|
2973
|
+
input: validInput,
|
|
2974
|
+
attempts: attempt,
|
|
2975
|
+
workerGroup: token,
|
|
2976
|
+
enqueuedAt,
|
|
2977
|
+
startedAt: enqueuedAt,
|
|
2978
|
+
finishedAt: enqueuedAt,
|
|
2979
|
+
});
|
|
2980
|
+
await this.pool.dispatch({
|
|
2981
|
+
runId: run.id,
|
|
2982
|
+
seq,
|
|
2983
|
+
name: def.name,
|
|
2984
|
+
stepId: id,
|
|
2985
|
+
group: token,
|
|
2986
|
+
input: validInput,
|
|
2987
|
+
traceparent: this.traceparent?.(),
|
|
2988
|
+
context: this.context?.(),
|
|
2989
|
+
attempt: 1,
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
const awaited = def.timeoutMs
|
|
2411
2993
|
? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
|
|
2412
|
-
: resultPromise
|
|
2994
|
+
: resultPromise;
|
|
2995
|
+
await this.awaitCompensationOutcome(run.id, seq, name, id, token, attempt, awaited,
|
|
2996
|
+
// With a heartbeat window the def's own liveness handling governs; otherwise bound the wait.
|
|
2997
|
+
def.timeoutMs ? undefined : this.compensationTimeoutMs, enqueuedAt);
|
|
2413
2998
|
}
|
|
2414
|
-
|
|
2999
|
+
finally {
|
|
2415
3000
|
this.pending.delete(id);
|
|
2416
|
-
throw err;
|
|
2417
3001
|
}
|
|
2418
3002
|
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Resolve one dispatched undo attempt from EITHER source: the in-memory waiter (the result landed
|
|
3005
|
+
* on THIS pod — persist the settled row ourselves, since the waiter path bypasses
|
|
3006
|
+
* `completeRemoteResult`) or the checkpoint row (settled by another instance sharing the results
|
|
3007
|
+
* queue, where the in-memory waiter never fires). Bounded by `timeoutMs` when given. Resolves on a
|
|
3008
|
+
* completed undo; rejects on a worker-reported failure or timeout.
|
|
3009
|
+
*/
|
|
3010
|
+
awaitCompensationOutcome(runId, seq, name, stepId, token, attempt, awaited, timeoutMs, enqueuedAt) {
|
|
3011
|
+
return new Promise((resolve, reject) => {
|
|
3012
|
+
let done = false;
|
|
3013
|
+
let timer;
|
|
3014
|
+
let poll;
|
|
3015
|
+
const cleanup = () => {
|
|
3016
|
+
if (poll)
|
|
3017
|
+
clearInterval(poll);
|
|
3018
|
+
if (timer)
|
|
3019
|
+
clearTimeout(timer);
|
|
3020
|
+
};
|
|
3021
|
+
const finish = (fn) => {
|
|
3022
|
+
if (done)
|
|
3023
|
+
return;
|
|
3024
|
+
done = true;
|
|
3025
|
+
cleanup();
|
|
3026
|
+
fn();
|
|
3027
|
+
};
|
|
3028
|
+
poll = setInterval(() => {
|
|
3029
|
+
void this.store
|
|
3030
|
+
.getCheckpoint(runId, seq)
|
|
3031
|
+
.then((cp) => {
|
|
3032
|
+
if (!cp || cp.attempts < attempt)
|
|
3033
|
+
return;
|
|
3034
|
+
if (cp.status === 'completed')
|
|
3035
|
+
finish(resolve);
|
|
3036
|
+
else if (cp.status === 'failed') {
|
|
3037
|
+
finish(() => reject(new RemoteStepError(cp.error)));
|
|
3038
|
+
}
|
|
3039
|
+
})
|
|
3040
|
+
.catch(() => undefined);
|
|
3041
|
+
}, 1_000);
|
|
3042
|
+
poll.unref?.();
|
|
3043
|
+
if (timeoutMs != null) {
|
|
3044
|
+
timer = setTimeout(() => {
|
|
3045
|
+
finish(() => reject(new RemoteStepError({
|
|
3046
|
+
message: `compensation "${name}" timed out after ${timeoutMs}ms with no worker result`,
|
|
3047
|
+
code: 'compensation_timeout',
|
|
3048
|
+
})));
|
|
3049
|
+
}, timeoutMs);
|
|
3050
|
+
timer.unref?.();
|
|
3051
|
+
}
|
|
3052
|
+
awaited.then((resolution) => {
|
|
3053
|
+
void this.store
|
|
3054
|
+
.saveCheckpoint({
|
|
3055
|
+
runId,
|
|
3056
|
+
seq,
|
|
3057
|
+
name,
|
|
3058
|
+
kind: 'remote',
|
|
3059
|
+
stepId,
|
|
3060
|
+
status: 'completed',
|
|
3061
|
+
output: resolution.output,
|
|
3062
|
+
events: resolution.events,
|
|
3063
|
+
attempts: attempt,
|
|
3064
|
+
workerGroup: token,
|
|
3065
|
+
enqueuedAt,
|
|
3066
|
+
startedAt: resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt,
|
|
3067
|
+
finishedAt: new Date(),
|
|
3068
|
+
})
|
|
3069
|
+
.catch(() => undefined)
|
|
3070
|
+
.then(() => finish(resolve));
|
|
3071
|
+
}, (err) => finish(() => reject(err)));
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
2419
3074
|
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
2420
3075
|
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
2421
3076
|
* module and the engine stays the orchestrator. */
|
|
@@ -2609,15 +3264,18 @@ export class WorkflowEngine {
|
|
|
2609
3264
|
* {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
|
|
2610
3265
|
* capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
|
|
2611
3266
|
async dueBlockedRuns(nowMs) {
|
|
2612
|
-
|
|
2613
|
-
//
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
3267
|
+
// Both predicates push into the store now (`wakeBefore` + namespace + a bound): only DUE
|
|
3268
|
+
// blocked runs come back, instead of every blocked run per tick filtered in memory. An engine
|
|
3269
|
+
// with no namespace still drives every pool's blocked runs (the filter is simply omitted); a
|
|
3270
|
+
// legacy run row without a namespace reads back as 'default' from every shipped store, so the
|
|
3271
|
+
// pushdown matches the old in-memory semantics.
|
|
3272
|
+
const blocked = await asHeartbeat(() => this.store.listRuns({
|
|
3273
|
+
statuses: ['blocked'],
|
|
3274
|
+
namespace: this.namespace,
|
|
3275
|
+
wakeBefore: nowMs,
|
|
3276
|
+
limit: 200,
|
|
3277
|
+
}));
|
|
3278
|
+
return blocked;
|
|
2621
3279
|
}
|
|
2622
3280
|
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
|
|
2623
3281
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
@@ -2635,7 +3293,7 @@ export class WorkflowEngine {
|
|
|
2635
3293
|
// checkpoint, and let the result resume the run on whichever instance receives it — so a worker
|
|
2636
3294
|
// pod can scale down or crash mid-step without losing the run or re-running completed work.
|
|
2637
3295
|
if (step.timeoutMs)
|
|
2638
|
-
return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace);
|
|
3296
|
+
return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission);
|
|
2639
3297
|
if (existing?.status === 'pending') {
|
|
2640
3298
|
// Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
|
|
2641
3299
|
// (worker crashed with no result, or the transport dropped the job) would hang here forever — a
|
|
@@ -2644,17 +3302,17 @@ export class WorkflowEngine {
|
|
|
2644
3302
|
// re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
|
|
2645
3303
|
// fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
|
|
2646
3304
|
if (this.remoteRedispatchMs == null)
|
|
2647
|
-
throw new WorkflowSuspended();
|
|
3305
|
+
throw new WorkflowSuspended(undefined, [seq]);
|
|
2648
3306
|
// Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
|
|
2649
3307
|
// stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
|
|
2650
3308
|
// becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
|
|
2651
3309
|
if (existing.wakeAt == null) {
|
|
2652
3310
|
const wakeAt = this.clock() + this.remoteRedispatchMs;
|
|
2653
3311
|
await this.store.saveCheckpoint({ ...existing, wakeAt });
|
|
2654
|
-
throw new WorkflowSuspended(wakeAt);
|
|
3312
|
+
throw new WorkflowSuspended(wakeAt, [seq]);
|
|
2655
3313
|
}
|
|
2656
3314
|
if (this.clock() < existing.wakeAt)
|
|
2657
|
-
throw new WorkflowSuspended(existing.wakeAt);
|
|
3315
|
+
throw new WorkflowSuspended(existing.wakeAt, [seq]);
|
|
2658
3316
|
// Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
|
|
2659
3317
|
// `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
|
|
2660
3318
|
if (existing.attempts >= this.remoteRedispatchMax) {
|
|
@@ -2685,7 +3343,7 @@ export class WorkflowEngine {
|
|
|
2685
3343
|
attempt: reAttempt,
|
|
2686
3344
|
transport,
|
|
2687
3345
|
});
|
|
2688
|
-
throw new WorkflowSuspended(nextDeadline);
|
|
3346
|
+
throw new WorkflowSuspended(nextDeadline, [seq]);
|
|
2689
3347
|
}
|
|
2690
3348
|
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|
|
2691
3349
|
// unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
|
|
@@ -2710,6 +3368,7 @@ export class WorkflowEngine {
|
|
|
2710
3368
|
// Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
|
|
2711
3369
|
// run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
|
|
2712
3370
|
// the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
|
|
3371
|
+
let admittedQueue;
|
|
2713
3372
|
if (queue && this.admission.handles(queue)) {
|
|
2714
3373
|
// Admission carries the per-call priority + fairness key (default the runId so each run is its
|
|
2715
3374
|
// own fairness bucket), and the stepId as a STABLE waiter id so the backend tracks one waiter
|
|
@@ -2729,6 +3388,7 @@ export class WorkflowEngine {
|
|
|
2729
3388
|
}
|
|
2730
3389
|
this.queueWaiters.get(queue)?.delete(runId);
|
|
2731
3390
|
this.stepQueue.set(id, queue);
|
|
3391
|
+
admittedQueue = queue;
|
|
2732
3392
|
}
|
|
2733
3393
|
// Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
|
|
2734
3394
|
// call carries none — the engine passes the input through untouched then).
|
|
@@ -2754,6 +3414,10 @@ export class WorkflowEngine {
|
|
|
2754
3414
|
input: validInput,
|
|
2755
3415
|
attempts: attempt,
|
|
2756
3416
|
workerGroup: token,
|
|
3417
|
+
// Persist which flow-control queue this step's admitted slot belongs to, so the slot is
|
|
3418
|
+
// released by WHICHEVER instance receives the result — the in-memory stepQueue map only
|
|
3419
|
+
// covers the dispatching pod, and a slot released by no one starves the queue fleet-wide.
|
|
3420
|
+
queue: admittedQueue,
|
|
2757
3421
|
enqueuedAt,
|
|
2758
3422
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
2759
3423
|
finishedAt: enqueuedAt,
|
|
@@ -2770,7 +3434,7 @@ export class WorkflowEngine {
|
|
|
2770
3434
|
transport,
|
|
2771
3435
|
});
|
|
2772
3436
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
2773
|
-
throw new WorkflowSuspended();
|
|
3437
|
+
throw new WorkflowSuspended(undefined, [seq]);
|
|
2774
3438
|
}
|
|
2775
3439
|
/**
|
|
2776
3440
|
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
@@ -2825,7 +3489,7 @@ export class WorkflowEngine {
|
|
|
2825
3489
|
}
|
|
2826
3490
|
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
|
|
2827
3491
|
// before the terminal-run early-returns below, so a cancellation can't leak the slot.
|
|
2828
|
-
await this.releaseQueueSlot(cp
|
|
3492
|
+
await this.releaseQueueSlot(cp);
|
|
2829
3493
|
// Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
|
|
2830
3494
|
// or resume (the run is already terminal). This is the engine side of cooperative cancellation.
|
|
2831
3495
|
if (run && (run.status === 'cancelled' || run.status === 'completed'))
|
|
@@ -2884,16 +3548,22 @@ export class WorkflowEngine {
|
|
|
2884
3548
|
for (const runId of runIds)
|
|
2885
3549
|
void this.resume(runId).catch(() => undefined);
|
|
2886
3550
|
}
|
|
2887
|
-
/**
|
|
2888
|
-
|
|
2889
|
-
|
|
3551
|
+
/**
|
|
3552
|
+
* Release the flow-control slot a dispatched step held (if any). The queue comes from the
|
|
3553
|
+
* CHECKPOINT (durable — works on whichever instance receives the result), falling back to the
|
|
3554
|
+
* dispatching pod's in-memory map for checkpoints written before the `queue` column existed.
|
|
3555
|
+
* Exactly-once is anchored on the caller's pending→settled transition: only the delivery that
|
|
3556
|
+
* finds the checkpoint still `pending` releases.
|
|
3557
|
+
*/
|
|
3558
|
+
async releaseQueueSlot(cp) {
|
|
3559
|
+
const queue = cp.queue ?? this.stepQueue.get(cp.stepId);
|
|
3560
|
+
this.stepQueue.delete(cp.stepId);
|
|
2890
3561
|
if (queue === undefined)
|
|
2891
3562
|
return;
|
|
2892
|
-
this.
|
|
2893
|
-
await this.admission.release(queue, id);
|
|
3563
|
+
await this.admission.release(queue, cp.stepId);
|
|
2894
3564
|
}
|
|
2895
3565
|
/** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
|
|
2896
|
-
async callRemoteInMemory(runId, seq, step, input, transport, runNamespace) {
|
|
3566
|
+
async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
|
|
2897
3567
|
if (this.pool.size === 0)
|
|
2898
3568
|
throw new Error('remote steps require a Transport');
|
|
2899
3569
|
const validInput = step.input ? step.input.parse(input) : input;
|
|
@@ -2902,6 +3572,39 @@ export class WorkflowEngine {
|
|
|
2902
3572
|
// emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
|
|
2903
3573
|
await this.ensureRoutable(token, step.requires);
|
|
2904
3574
|
const id = stepId(runId, seq);
|
|
3575
|
+
// Flow control — the same admission gate the durable path applies. A `{ queue }` on a
|
|
3576
|
+
// `timeoutMs` step used to be SILENTLY ignored (this path branched off before the gate), so the
|
|
3577
|
+
// documented cap simply didn't hold for liveness-timeout steps. The slot spans every retry
|
|
3578
|
+
// attempt of this call and is released in the `finally` below on THIS pod (the await lives
|
|
3579
|
+
// here); it is deliberately NOT persisted on the checkpoint — a cross-pod result consumer
|
|
3580
|
+
// releasing from the row would double-free against this finally.
|
|
3581
|
+
let admittedQueue;
|
|
3582
|
+
if (queue && this.admission.handles(queue)) {
|
|
3583
|
+
const decision = await this.admission.tryAdmit(queue, {
|
|
3584
|
+
priority: admission?.priority,
|
|
3585
|
+
key: admission?.fairnessKey ?? runId,
|
|
3586
|
+
waiterId: id,
|
|
3587
|
+
});
|
|
3588
|
+
if (!decision.ok) {
|
|
3589
|
+
const waiters = this.queueWaiters.get(queue) ?? new Set();
|
|
3590
|
+
waiters.add(runId);
|
|
3591
|
+
this.queueWaiters.set(queue, waiters);
|
|
3592
|
+
throw new WorkflowSuspended(decision.retryAt);
|
|
3593
|
+
}
|
|
3594
|
+
this.queueWaiters.get(queue)?.delete(runId);
|
|
3595
|
+
admittedQueue = queue;
|
|
3596
|
+
}
|
|
3597
|
+
try {
|
|
3598
|
+
return await this.callRemoteInMemoryAdmitted(runId, seq, step, validInput, token, id, transport);
|
|
3599
|
+
}
|
|
3600
|
+
finally {
|
|
3601
|
+
if (admittedQueue !== undefined) {
|
|
3602
|
+
await this.admission.release(admittedQueue, id).catch(() => undefined);
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
/** The dispatch/await/retry body of {@link callRemoteInMemory}, once routable and admitted. */
|
|
3607
|
+
async callRemoteInMemoryAdmitted(runId, seq, step, validInput, token, id, transport) {
|
|
2905
3608
|
const enqueuedAt = new Date();
|
|
2906
3609
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
2907
3610
|
// Retry policy differs from a LOCAL step on purpose: a local `ctx.step` retries any non-fatal
|
|
@@ -3014,7 +3717,9 @@ export class WorkflowEngine {
|
|
|
3014
3717
|
// while RUNNING". Without the split, a dispatch-anchored window false-failed a healthy
|
|
3015
3718
|
// single-concurrency fleet whose next batch queued ~15min behind the current one.
|
|
3016
3719
|
let seenBeat = false;
|
|
3017
|
-
|
|
3720
|
+
// Clamp to Node's setTimeout max (2³¹−1 ms): past it Node fires the timer ~immediately, which
|
|
3721
|
+
// would turn a generous liveness window into an instant RemoteStepTimeout.
|
|
3722
|
+
const window = () => Math.min(seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs), MAX_BACKOFF_MS);
|
|
3018
3723
|
const cleanup = () => {
|
|
3019
3724
|
clearTimeout(timer);
|
|
3020
3725
|
this.heartbeatResets.delete(id);
|