@adonis-agora/durable 0.35.0 → 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 +117 -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 +216 -8
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +849 -121
- 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. */
|
|
@@ -170,7 +194,15 @@ export class WorkflowEngine {
|
|
|
170
194
|
/** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
|
|
171
195
|
stepQueue = new Map();
|
|
172
196
|
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
173
|
-
inflight = new
|
|
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
|
|
@@ -791,11 +1087,29 @@ export class WorkflowEngine {
|
|
|
791
1087
|
if (!registered) {
|
|
792
1088
|
throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
|
|
793
1089
|
}
|
|
794
|
-
return this.track(this.execute(run, registered.fn));
|
|
1090
|
+
return this.track(runId, this.execute(run, registered.fn));
|
|
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
|
+
})());
|
|
795
1109
|
}
|
|
796
1110
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
797
|
-
track(p) {
|
|
798
|
-
this.inflight.
|
|
1111
|
+
track(runId, p) {
|
|
1112
|
+
this.inflight.set(p, runId);
|
|
799
1113
|
void p.finally(() => this.inflight.delete(p));
|
|
800
1114
|
return p;
|
|
801
1115
|
}
|
|
@@ -821,8 +1135,14 @@ export class WorkflowEngine {
|
|
|
821
1135
|
* resume). A HOT continue-as-new loop — one that hands off to a fresh link faster than it settles —
|
|
822
1136
|
* therefore keeps `postSettle`/`inflight` non-empty and consumes the ENTIRE `timeoutMs` before this
|
|
823
1137
|
* returns. That is by design (tearing down mid-continuation would orphan the continuation's writes);
|
|
824
|
-
* on timeout the frontier link is left persisted
|
|
1138
|
+
* on timeout the frontier link is left persisted with the lease released, so the next boot's recovery re-drives it
|
|
825
1139
|
* (no loss) — the cost is spending the full timeout on shutdown. Size `timeoutMs` accordingly.
|
|
1140
|
+
*
|
|
1141
|
+
* On return — whether the executions settled or the timeout won — `drain()` releases the recovery
|
|
1142
|
+
* locks of runs still tracked in-flight in this process (best-effort, swallowed per-run). This
|
|
1143
|
+
* process is going away, so a held lease would only delay the next pod's `recoverIncomplete` until
|
|
1144
|
+
* expiry; after release the next pod replays deterministically (a pending step re-dispatches the same
|
|
1145
|
+
* `stepId`, idempotent by the durable contract; a completed step short-circuits).
|
|
826
1146
|
*/
|
|
827
1147
|
async drain(timeoutMs = 10_000) {
|
|
828
1148
|
this.draining = true;
|
|
@@ -838,10 +1158,31 @@ export class WorkflowEngine {
|
|
|
838
1158
|
// `Promise<'timeout'>` sentinel is distinguishable from `allSettled`'s array so the race is
|
|
839
1159
|
// unambiguous even when everything resolves in the same tick.
|
|
840
1160
|
while (this.inflight.size > 0 || this.postSettle.size > 0) {
|
|
841
|
-
const pending = Promise.allSettled([...this.inflight, ...this.postSettle]);
|
|
1161
|
+
const pending = Promise.allSettled([...this.inflight.keys(), ...this.postSettle]);
|
|
842
1162
|
const outcome = await Promise.race([pending, timer]);
|
|
843
|
-
if (outcome === 'timeout')
|
|
1163
|
+
if (outcome === 'timeout') {
|
|
1164
|
+
await this.releaseInflightLocks();
|
|
844
1165
|
return;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
await this.releaseInflightLocks();
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Best-effort release of the recovery locks still held by in-flight executions. Called on `drain()`
|
|
1172
|
+
* return so a shutting-down process hands off fast. Never throws (swallowed per-run).
|
|
1173
|
+
*/
|
|
1174
|
+
async releaseInflightLocks() {
|
|
1175
|
+
for (const runId of [...this.inflight.values()]) {
|
|
1176
|
+
try {
|
|
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);
|
|
1182
|
+
}
|
|
1183
|
+
catch {
|
|
1184
|
+
/* best-effort: drain() must never throw because of this */
|
|
1185
|
+
}
|
|
845
1186
|
}
|
|
846
1187
|
}
|
|
847
1188
|
/**
|
|
@@ -853,21 +1194,24 @@ export class WorkflowEngine {
|
|
|
853
1194
|
for (const reg of new Set(this.latest.values())) {
|
|
854
1195
|
if (reg.executionTimeoutMs == null)
|
|
855
1196
|
continue;
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
})),
|
|
868
|
-
];
|
|
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
|
+
});
|
|
869
1208
|
for (const run of inflight) {
|
|
870
|
-
|
|
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)
|
|
871
1215
|
continue;
|
|
872
1216
|
const error = { message: 'execution timeout', code: 'execution_timeout' };
|
|
873
1217
|
// Conditional on the exact statuses just queried: an await separates this loop's iterations
|
|
@@ -888,6 +1232,20 @@ export class WorkflowEngine {
|
|
|
888
1232
|
namespace: run.namespace,
|
|
889
1233
|
error,
|
|
890
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));
|
|
891
1249
|
}
|
|
892
1250
|
}
|
|
893
1251
|
}
|
|
@@ -899,7 +1257,17 @@ export class WorkflowEngine {
|
|
|
899
1257
|
if (this.draining)
|
|
900
1258
|
return [];
|
|
901
1259
|
const results = [];
|
|
902
|
-
|
|
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
|
+
});
|
|
903
1271
|
for (const run of incomplete) {
|
|
904
1272
|
// A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
|
|
905
1273
|
// (its worker crashed). Skip the ones still owned.
|
|
@@ -914,8 +1282,20 @@ export class WorkflowEngine {
|
|
|
914
1282
|
}
|
|
915
1283
|
// Re-enqueue rather than resume inline: recovery must NOT block (boot, or a poll tick) on a
|
|
916
1284
|
// long workflow step. A dispatcher/worker re-runs it, replaying its checkpoints.
|
|
917
|
-
|
|
918
|
-
|
|
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;
|
|
919
1299
|
await this.runDispatcher.dispatch(run.id);
|
|
920
1300
|
results.push({ runId: run.id, status: 'pending' });
|
|
921
1301
|
}
|
|
@@ -934,7 +1314,7 @@ export class WorkflowEngine {
|
|
|
934
1314
|
code: 'max_recovery_attempts',
|
|
935
1315
|
};
|
|
936
1316
|
await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
|
|
937
|
-
await this.store.releaseRunLock(run.id);
|
|
1317
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
938
1318
|
this.emit({
|
|
939
1319
|
type: 'run.failed',
|
|
940
1320
|
runId: run.id,
|
|
@@ -957,7 +1337,8 @@ export class WorkflowEngine {
|
|
|
957
1337
|
* not due re-suspends cheaply without running new work.
|
|
958
1338
|
*/
|
|
959
1339
|
async resumeDueTimers(nowMs = this.clock()) {
|
|
960
|
-
|
|
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));
|
|
961
1342
|
const blocked = await this.dueBlockedRuns(nowMs);
|
|
962
1343
|
return this.resumeLeased([...due, ...blocked], nowMs);
|
|
963
1344
|
}
|
|
@@ -1187,10 +1568,15 @@ export class WorkflowEngine {
|
|
|
1187
1568
|
resolve(toResult(run));
|
|
1188
1569
|
};
|
|
1189
1570
|
const check = () => {
|
|
1190
|
-
|
|
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) => {
|
|
1191
1576
|
if (run && shouldResolve(run.status))
|
|
1192
1577
|
finish(run);
|
|
1193
|
-
})
|
|
1578
|
+
})
|
|
1579
|
+
.catch(() => undefined);
|
|
1194
1580
|
};
|
|
1195
1581
|
// React only to this run's settling events (not its every step event), and subscribe BEFORE the
|
|
1196
1582
|
// initial read so a run that settles in between isn't missed.
|
|
@@ -1211,14 +1597,26 @@ export class WorkflowEngine {
|
|
|
1211
1597
|
if (this.draining)
|
|
1212
1598
|
return []; // shutting down — don't pick up new runs
|
|
1213
1599
|
const results = [];
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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()));
|
|
1222
1620
|
return results;
|
|
1223
1621
|
}
|
|
1224
1622
|
/**
|
|
@@ -1397,7 +1795,14 @@ export class WorkflowEngine {
|
|
|
1397
1795
|
notifyParent(runId, completion) {
|
|
1398
1796
|
// Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
|
|
1399
1797
|
// store writes that must be awaited by `drain()` even though the settling child returned already.
|
|
1400
|
-
|
|
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}`));
|
|
1401
1806
|
// A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
|
|
1402
1807
|
// origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
|
|
1403
1808
|
// parent that failed on that child and is retried later consumes this success (buffered or
|
|
@@ -1405,7 +1810,7 @@ export class WorkflowEngine {
|
|
|
1405
1810
|
// fix attempt must not poison the origin's token.
|
|
1406
1811
|
const at = runId.lastIndexOf('~retry~');
|
|
1407
1812
|
if (at !== -1 && completion.ok) {
|
|
1408
|
-
this.trackEffect(
|
|
1813
|
+
this.trackEffect(deliver(`child:${runId.slice(0, at)}`));
|
|
1409
1814
|
}
|
|
1410
1815
|
}
|
|
1411
1816
|
/**
|
|
@@ -1454,6 +1859,11 @@ export class WorkflowEngine {
|
|
|
1454
1859
|
// (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
|
|
1455
1860
|
if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
|
|
1456
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);
|
|
1457
1867
|
if (this.controlPlane) {
|
|
1458
1868
|
void this.controlPlane
|
|
1459
1869
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
@@ -1540,9 +1950,15 @@ export class WorkflowEngine {
|
|
|
1540
1950
|
*/
|
|
1541
1951
|
async getRunChildren(parentRunId) {
|
|
1542
1952
|
const childIds = new Set();
|
|
1543
|
-
|
|
1544
|
-
|
|
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:')) {
|
|
1545
1960
|
childIds.add(w.token.slice('child:'.length));
|
|
1961
|
+
}
|
|
1546
1962
|
}
|
|
1547
1963
|
// Targeted read: only the `signal:child:` / `spawn:` checkpoints, not the whole history. Falls
|
|
1548
1964
|
// back to a full listCheckpoints + in-JS prefix scan for a custom store that omits the method.
|
|
@@ -1569,6 +1985,20 @@ export class WorkflowEngine {
|
|
|
1569
1985
|
listSignalWaiters(prefix) {
|
|
1570
1986
|
return this.store.listSignalWaiters(prefix);
|
|
1571
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
|
+
}
|
|
1572
2002
|
/** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
|
|
1573
2003
|
* `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
|
|
1574
2004
|
* workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
|
|
@@ -1588,13 +2018,10 @@ export class WorkflowEngine {
|
|
|
1588
2018
|
* (only the BullMQ transport implements `groupHealth`). */
|
|
1589
2019
|
async workerHealth(extra = []) {
|
|
1590
2020
|
const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
out.push(health);
|
|
1596
|
-
}
|
|
1597
|
-
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);
|
|
1598
2025
|
}
|
|
1599
2026
|
/**
|
|
1600
2027
|
* Persist a streamed local-step lifecycle event from a remote workflow worker (see
|
|
@@ -1756,8 +2183,17 @@ export class WorkflowEngine {
|
|
|
1756
2183
|
}
|
|
1757
2184
|
}
|
|
1758
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.
|
|
1759
2191
|
void this.store
|
|
1760
2192
|
.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
|
|
2193
|
+
.then((renewed) => {
|
|
2194
|
+
if (!renewed)
|
|
2195
|
+
this.leaseLostRuns.add(run.id);
|
|
2196
|
+
})
|
|
1761
2197
|
.catch(() => undefined);
|
|
1762
2198
|
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
1763
2199
|
renew.unref?.();
|
|
@@ -1782,6 +2218,7 @@ export class WorkflowEngine {
|
|
|
1782
2218
|
}
|
|
1783
2219
|
finally {
|
|
1784
2220
|
clearInterval(renew);
|
|
2221
|
+
this.leaseLostRuns.delete(run.id);
|
|
1785
2222
|
}
|
|
1786
2223
|
}
|
|
1787
2224
|
/**
|
|
@@ -1798,6 +2235,13 @@ export class WorkflowEngine {
|
|
|
1798
2235
|
* keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
|
|
1799
2236
|
*/
|
|
1800
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);
|
|
1801
2245
|
const updatedAt = new Date();
|
|
1802
2246
|
if (outcome.kind === 'completed') {
|
|
1803
2247
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the
|
|
@@ -1825,8 +2269,15 @@ export class WorkflowEngine {
|
|
|
1825
2269
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
1826
2270
|
}
|
|
1827
2271
|
if (outcome.kind === 'failed') {
|
|
1828
|
-
// Same race as the `completed` branch above — guard identically.
|
|
1829
|
-
|
|
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'), {
|
|
1830
2281
|
status: 'failed',
|
|
1831
2282
|
error: outcome.error,
|
|
1832
2283
|
updatedAt,
|
|
@@ -2177,10 +2628,48 @@ export class WorkflowEngine {
|
|
|
2177
2628
|
// dashboard renders the fan as one parallel group. Undefined for a lone (non-fan) child.
|
|
2178
2629
|
parallelGroup: cmd.parallelGroup,
|
|
2179
2630
|
});
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
+
}
|
|
2184
2673
|
}
|
|
2185
2674
|
}
|
|
2186
2675
|
else {
|
|
@@ -2215,7 +2704,7 @@ export class WorkflowEngine {
|
|
|
2215
2704
|
workflow: run.workflow,
|
|
2216
2705
|
namespace: run.namespace,
|
|
2217
2706
|
});
|
|
2218
|
-
await this.store.releaseRunLock(run.id);
|
|
2707
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
2219
2708
|
return { runId: run.id, status: 'suspended' };
|
|
2220
2709
|
}
|
|
2221
2710
|
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
@@ -2239,9 +2728,22 @@ export class WorkflowEngine {
|
|
|
2239
2728
|
}
|
|
2240
2729
|
catch (err) {
|
|
2241
2730
|
if (err instanceof ContinueAsNew) {
|
|
2242
|
-
// Hand off to a fresh execution with a clean history:
|
|
2243
|
-
//
|
|
2244
|
-
|
|
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);
|
|
2245
2747
|
await this.store.updateRun(run.id, {
|
|
2246
2748
|
status: 'completed',
|
|
2247
2749
|
output: undefined,
|
|
@@ -2255,11 +2757,11 @@ export class WorkflowEngine {
|
|
|
2255
2757
|
namespace: run.namespace,
|
|
2256
2758
|
});
|
|
2257
2759
|
this.notifyParent(run.id, { ok: true, value: undefined });
|
|
2258
|
-
const nextId = nextContinuationId(run.id);
|
|
2259
2760
|
// Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
|
|
2260
2761
|
// continuation has entered `inflight` and settled, so `drain()` can't return before the next
|
|
2261
|
-
// run's store writes land. Still deferred
|
|
2262
|
-
|
|
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 });
|
|
2263
2765
|
return { runId: run.id, status: 'completed' };
|
|
2264
2766
|
}
|
|
2265
2767
|
if (err instanceof WorkflowBlocked) {
|
|
@@ -2271,15 +2773,37 @@ export class WorkflowEngine {
|
|
|
2271
2773
|
if (err instanceof WorkflowSuspended) {
|
|
2272
2774
|
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
2273
2775
|
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
2274
|
-
|
|
2275
|
-
|
|
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) {
|
|
2276
2793
|
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
2277
2794
|
const comp = compensations[i];
|
|
2278
2795
|
if (comp)
|
|
2279
|
-
await this.runCompensation(run, comp);
|
|
2796
|
+
await this.runCompensation(run, comp, i);
|
|
2280
2797
|
}
|
|
2281
2798
|
const error = { message: 'cancelled' };
|
|
2282
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
|
+
}
|
|
2283
2807
|
this.emit({
|
|
2284
2808
|
type: 'run.failed',
|
|
2285
2809
|
runId: run.id,
|
|
@@ -2289,7 +2813,16 @@ export class WorkflowEngine {
|
|
|
2289
2813
|
});
|
|
2290
2814
|
return { runId: run.id, status: 'cancelled', error };
|
|
2291
2815
|
}
|
|
2292
|
-
|
|
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;
|
|
2293
2826
|
}
|
|
2294
2827
|
const error = {
|
|
2295
2828
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -2303,14 +2836,15 @@ export class WorkflowEngine {
|
|
|
2303
2836
|
const comp = compensations[i];
|
|
2304
2837
|
if (!comp)
|
|
2305
2838
|
continue;
|
|
2306
|
-
await this.runCompensation(run, comp);
|
|
2839
|
+
await this.runCompensation(run, comp, i);
|
|
2307
2840
|
}
|
|
2308
2841
|
return this.settleRun(run, { kind: 'failed', error });
|
|
2309
2842
|
}
|
|
2310
2843
|
finally {
|
|
2311
2844
|
// Release the recovery lease once the run reaches a terminal/suspended state, so the
|
|
2312
|
-
// next instance (or the timer poller) can pick it up promptly.
|
|
2313
|
-
|
|
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);
|
|
2314
2848
|
}
|
|
2315
2849
|
}
|
|
2316
2850
|
/**
|
|
@@ -2319,17 +2853,39 @@ export class WorkflowEngine {
|
|
|
2319
2853
|
* compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
|
|
2320
2854
|
* a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
|
|
2321
2855
|
* awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
|
|
2322
|
-
|
|
2323
|
-
|
|
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) {
|
|
2324
2865
|
const name = `compensate:${comp.name}`;
|
|
2325
|
-
|
|
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) {
|
|
2326
2879
|
const startedAt = Date.now();
|
|
2327
2880
|
try {
|
|
2328
2881
|
if ('fn' in comp) {
|
|
2329
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' }));
|
|
2330
2886
|
}
|
|
2331
2887
|
else {
|
|
2332
|
-
await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
|
|
2888
|
+
await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt, seq);
|
|
2333
2889
|
}
|
|
2334
2890
|
this.emit({
|
|
2335
2891
|
type: 'step.completed',
|
|
@@ -2342,6 +2898,24 @@ export class WorkflowEngine {
|
|
|
2342
2898
|
return;
|
|
2343
2899
|
}
|
|
2344
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);
|
|
2345
2919
|
if (attempt >= this.compensationRetries) {
|
|
2346
2920
|
this.emit({
|
|
2347
2921
|
type: 'step.failed',
|
|
@@ -2349,7 +2923,7 @@ export class WorkflowEngine {
|
|
|
2349
2923
|
workflow: run.workflow,
|
|
2350
2924
|
name,
|
|
2351
2925
|
kind: 'local',
|
|
2352
|
-
error
|
|
2926
|
+
error,
|
|
2353
2927
|
durationMs: Date.now() - startedAt,
|
|
2354
2928
|
});
|
|
2355
2929
|
}
|
|
@@ -2357,42 +2931,146 @@ export class WorkflowEngine {
|
|
|
2357
2931
|
}
|
|
2358
2932
|
}
|
|
2359
2933
|
/**
|
|
2360
|
-
* Dispatch a saga undo step to a worker and await its result
|
|
2361
|
-
*
|
|
2362
|
-
*
|
|
2363
|
-
*
|
|
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
|
|
2364
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.
|
|
2365
2945
|
*/
|
|
2366
|
-
async dispatchCompensation(run, def, args, attempt) {
|
|
2946
|
+
async dispatchCompensation(run, def, args, attempt, seq) {
|
|
2367
2947
|
if (this.pool.size === 0)
|
|
2368
2948
|
throw new Error('dispatched compensation requires a Transport');
|
|
2369
2949
|
const token = stepGroup(def, run.namespace);
|
|
2370
2950
|
const validInput = def.input ? def.input.parse(args) : args;
|
|
2371
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);
|
|
2372
2954
|
const resultPromise = new Promise((resolve, reject) => {
|
|
2373
2955
|
this.pending.set(id, { resolve, reject });
|
|
2374
2956
|
});
|
|
2957
|
+
const enqueuedAt = new Date();
|
|
2375
2958
|
try {
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
|
2388
2993
|
? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
|
|
2389
|
-
: 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);
|
|
2390
2998
|
}
|
|
2391
|
-
|
|
2999
|
+
finally {
|
|
2392
3000
|
this.pending.delete(id);
|
|
2393
|
-
throw err;
|
|
2394
3001
|
}
|
|
2395
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
|
+
}
|
|
2396
3074
|
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
2397
3075
|
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
2398
3076
|
* module and the engine stays the orchestrator. */
|
|
@@ -2586,15 +3264,18 @@ export class WorkflowEngine {
|
|
|
2586
3264
|
* {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
|
|
2587
3265
|
* capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
|
|
2588
3266
|
async dueBlockedRuns(nowMs) {
|
|
2589
|
-
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
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;
|
|
2598
3279
|
}
|
|
2599
3280
|
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
|
|
2600
3281
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
@@ -2612,7 +3293,7 @@ export class WorkflowEngine {
|
|
|
2612
3293
|
// checkpoint, and let the result resume the run on whichever instance receives it — so a worker
|
|
2613
3294
|
// pod can scale down or crash mid-step without losing the run or re-running completed work.
|
|
2614
3295
|
if (step.timeoutMs)
|
|
2615
|
-
return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace);
|
|
3296
|
+
return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission);
|
|
2616
3297
|
if (existing?.status === 'pending') {
|
|
2617
3298
|
// Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
|
|
2618
3299
|
// (worker crashed with no result, or the transport dropped the job) would hang here forever — a
|
|
@@ -2621,17 +3302,17 @@ export class WorkflowEngine {
|
|
|
2621
3302
|
// re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
|
|
2622
3303
|
// fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
|
|
2623
3304
|
if (this.remoteRedispatchMs == null)
|
|
2624
|
-
throw new WorkflowSuspended();
|
|
3305
|
+
throw new WorkflowSuspended(undefined, [seq]);
|
|
2625
3306
|
// Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
|
|
2626
3307
|
// stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
|
|
2627
3308
|
// becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
|
|
2628
3309
|
if (existing.wakeAt == null) {
|
|
2629
3310
|
const wakeAt = this.clock() + this.remoteRedispatchMs;
|
|
2630
3311
|
await this.store.saveCheckpoint({ ...existing, wakeAt });
|
|
2631
|
-
throw new WorkflowSuspended(wakeAt);
|
|
3312
|
+
throw new WorkflowSuspended(wakeAt, [seq]);
|
|
2632
3313
|
}
|
|
2633
3314
|
if (this.clock() < existing.wakeAt)
|
|
2634
|
-
throw new WorkflowSuspended(existing.wakeAt);
|
|
3315
|
+
throw new WorkflowSuspended(existing.wakeAt, [seq]);
|
|
2635
3316
|
// Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
|
|
2636
3317
|
// `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
|
|
2637
3318
|
if (existing.attempts >= this.remoteRedispatchMax) {
|
|
@@ -2662,7 +3343,7 @@ export class WorkflowEngine {
|
|
|
2662
3343
|
attempt: reAttempt,
|
|
2663
3344
|
transport,
|
|
2664
3345
|
});
|
|
2665
|
-
throw new WorkflowSuspended(nextDeadline);
|
|
3346
|
+
throw new WorkflowSuspended(nextDeadline, [seq]);
|
|
2666
3347
|
}
|
|
2667
3348
|
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|
|
2668
3349
|
// unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
|
|
@@ -2687,6 +3368,7 @@ export class WorkflowEngine {
|
|
|
2687
3368
|
// Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
|
|
2688
3369
|
// run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
|
|
2689
3370
|
// the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
|
|
3371
|
+
let admittedQueue;
|
|
2690
3372
|
if (queue && this.admission.handles(queue)) {
|
|
2691
3373
|
// Admission carries the per-call priority + fairness key (default the runId so each run is its
|
|
2692
3374
|
// own fairness bucket), and the stepId as a STABLE waiter id so the backend tracks one waiter
|
|
@@ -2706,6 +3388,7 @@ export class WorkflowEngine {
|
|
|
2706
3388
|
}
|
|
2707
3389
|
this.queueWaiters.get(queue)?.delete(runId);
|
|
2708
3390
|
this.stepQueue.set(id, queue);
|
|
3391
|
+
admittedQueue = queue;
|
|
2709
3392
|
}
|
|
2710
3393
|
// Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
|
|
2711
3394
|
// call carries none — the engine passes the input through untouched then).
|
|
@@ -2731,6 +3414,10 @@ export class WorkflowEngine {
|
|
|
2731
3414
|
input: validInput,
|
|
2732
3415
|
attempts: attempt,
|
|
2733
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,
|
|
2734
3421
|
enqueuedAt,
|
|
2735
3422
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
2736
3423
|
finishedAt: enqueuedAt,
|
|
@@ -2747,7 +3434,7 @@ export class WorkflowEngine {
|
|
|
2747
3434
|
transport,
|
|
2748
3435
|
});
|
|
2749
3436
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
2750
|
-
throw new WorkflowSuspended();
|
|
3437
|
+
throw new WorkflowSuspended(undefined, [seq]);
|
|
2751
3438
|
}
|
|
2752
3439
|
/**
|
|
2753
3440
|
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
@@ -2802,7 +3489,7 @@ export class WorkflowEngine {
|
|
|
2802
3489
|
}
|
|
2803
3490
|
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
|
|
2804
3491
|
// before the terminal-run early-returns below, so a cancellation can't leak the slot.
|
|
2805
|
-
await this.releaseQueueSlot(cp
|
|
3492
|
+
await this.releaseQueueSlot(cp);
|
|
2806
3493
|
// Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
|
|
2807
3494
|
// or resume (the run is already terminal). This is the engine side of cooperative cancellation.
|
|
2808
3495
|
if (run && (run.status === 'cancelled' || run.status === 'completed'))
|
|
@@ -2861,16 +3548,22 @@ export class WorkflowEngine {
|
|
|
2861
3548
|
for (const runId of runIds)
|
|
2862
3549
|
void this.resume(runId).catch(() => undefined);
|
|
2863
3550
|
}
|
|
2864
|
-
/**
|
|
2865
|
-
|
|
2866
|
-
|
|
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);
|
|
2867
3561
|
if (queue === undefined)
|
|
2868
3562
|
return;
|
|
2869
|
-
this.
|
|
2870
|
-
await this.admission.release(queue, id);
|
|
3563
|
+
await this.admission.release(queue, cp.stepId);
|
|
2871
3564
|
}
|
|
2872
3565
|
/** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
|
|
2873
|
-
async callRemoteInMemory(runId, seq, step, input, transport, runNamespace) {
|
|
3566
|
+
async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
|
|
2874
3567
|
if (this.pool.size === 0)
|
|
2875
3568
|
throw new Error('remote steps require a Transport');
|
|
2876
3569
|
const validInput = step.input ? step.input.parse(input) : input;
|
|
@@ -2879,6 +3572,39 @@ export class WorkflowEngine {
|
|
|
2879
3572
|
// emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
|
|
2880
3573
|
await this.ensureRoutable(token, step.requires);
|
|
2881
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) {
|
|
2882
3608
|
const enqueuedAt = new Date();
|
|
2883
3609
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
2884
3610
|
// Retry policy differs from a LOCAL step on purpose: a local `ctx.step` retries any non-fatal
|
|
@@ -2991,7 +3717,9 @@ export class WorkflowEngine {
|
|
|
2991
3717
|
// while RUNNING". Without the split, a dispatch-anchored window false-failed a healthy
|
|
2992
3718
|
// single-concurrency fleet whose next batch queued ~15min behind the current one.
|
|
2993
3719
|
let seenBeat = false;
|
|
2994
|
-
|
|
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);
|
|
2995
3723
|
const cleanup = () => {
|
|
2996
3724
|
clearTimeout(timer);
|
|
2997
3725
|
this.heartbeatResets.delete(id);
|