@adonis-agora/durable 0.6.1 → 0.8.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 +64 -0
- package/README.md +1 -1
- package/dist/commands/main.d.ts +3 -2
- package/dist/commands/main.d.ts.map +1 -1
- package/dist/commands/main.js +3 -2
- package/dist/commands/main.js.map +1 -1
- package/dist/commands/make_workflow.d.ts +4 -3
- package/dist/commands/make_workflow.d.ts.map +1 -1
- package/dist/commands/make_workflow.js +4 -3
- package/dist/commands/make_workflow.js.map +1 -1
- package/dist/configure.d.ts +6 -5
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +9 -6
- package/dist/configure.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +34 -3
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +110 -6
- 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 +41 -4
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/base-workflow.d.ts +78 -0
- package/dist/src/base-workflow.d.ts.map +1 -0
- package/dist/src/base-workflow.js +106 -0
- package/dist/src/base-workflow.js.map +1 -0
- package/dist/src/codec-state-store.d.ts +13 -0
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +13 -0
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/control-flow-signal.d.ts +50 -0
- package/dist/src/control-flow-signal.d.ts.map +1 -0
- package/dist/src/control-flow-signal.js +54 -0
- package/dist/src/control-flow-signal.js.map +1 -0
- package/dist/src/control-plane-redis/redis-control-plane.d.ts +57 -5
- package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
- package/dist/src/control-plane-redis/redis-control-plane.js +100 -2
- package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
- package/dist/src/dashboard/auth.d.ts +89 -0
- package/dist/src/dashboard/auth.d.ts.map +1 -0
- package/dist/src/dashboard/auth.js +113 -0
- package/dist/src/dashboard/auth.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +17 -0
- package/dist/src/dashboard/define_config.d.ts.map +1 -1
- package/dist/src/dashboard/define_config.js +23 -1
- package/dist/src/dashboard/define_config.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +6 -1
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +5 -1
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/dashboard/login_page.d.ts +23 -0
- package/dist/src/dashboard/login_page.d.ts.map +1 -0
- package/dist/src/dashboard/login_page.js +170 -0
- package/dist/src/dashboard/login_page.js.map +1 -0
- package/dist/src/dashboard/session_cookie.d.ts +42 -0
- package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
- package/dist/src/dashboard/session_cookie.js +97 -0
- package/dist/src/dashboard/session_cookie.js.map +1 -0
- package/dist/src/define_config.d.ts +11 -2
- 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 +91 -3
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +331 -44
- package/dist/src/engine.js.map +1 -1
- package/dist/src/entities.js +2 -2
- package/dist/src/entities.js.map +1 -1
- package/dist/src/errors.d.ts +6 -1
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +7 -2
- package/dist/src/errors.js.map +1 -1
- package/dist/src/event-accumulators.d.ts +1 -1
- package/dist/src/event-accumulators.d.ts.map +1 -1
- package/dist/src/events.d.ts +16 -0
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +26 -1
- package/dist/src/events.js.map +1 -1
- package/dist/src/hooks/steps.d.ts +43 -0
- package/dist/src/hooks/steps.d.ts.map +1 -0
- package/dist/src/hooks/steps.js +46 -0
- package/dist/src/hooks/steps.js.map +1 -0
- package/dist/src/hooks/workflows.d.ts +3 -2
- package/dist/src/hooks/workflows.d.ts.map +1 -1
- package/dist/src/hooks/workflows.js +3 -2
- package/dist/src/hooks/workflows.js.map +1 -1
- package/dist/src/index.d.ts +8 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +160 -43
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/services/main.d.ts +13 -0
- package/dist/src/services/main.d.ts.map +1 -0
- package/dist/src/services/main.js +17 -0
- package/dist/src/services/main.js.map +1 -0
- package/dist/src/step-discovery.d.ts +42 -0
- package/dist/src/step-discovery.d.ts.map +1 -0
- package/dist/src/step-discovery.js +127 -0
- package/dist/src/step-discovery.js.map +1 -0
- package/dist/src/step-name-symbol.d.ts +63 -0
- package/dist/src/step-name-symbol.d.ts.map +1 -0
- package/dist/src/step-name-symbol.js +42 -0
- package/dist/src/step-name-symbol.js.map +1 -0
- package/dist/src/step-ref.d.ts +70 -0
- package/dist/src/step-ref.d.ts.map +1 -0
- package/dist/src/step-ref.js +93 -0
- package/dist/src/step-ref.js.map +1 -0
- package/dist/src/stores/lucid-schema.d.ts +1 -0
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +15 -0
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +13 -0
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +43 -0
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/tenant-group.d.ts +18 -0
- package/dist/src/tenant-group.d.ts.map +1 -0
- package/dist/src/tenant-group.js +24 -0
- package/dist/src/tenant-group.js.map +1 -0
- package/dist/src/testing/in-memory-state-store.d.ts +16 -0
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +29 -0
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +33 -2
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/transport-conformance.js +7 -12
- package/dist/src/testing-kit/transport-conformance.js.map +1 -1
- package/dist/src/transports/db.d.ts +14 -5
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +20 -15
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts +15 -5
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +19 -17
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/workflow-als.d.ts +21 -0
- package/dist/src/workflow-als.d.ts.map +1 -0
- package/dist/src/workflow-als.js +22 -0
- package/dist/src/workflow-als.js.map +1 -0
- package/dist/src/workflow-ctx.d.ts +27 -8
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +183 -19
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts +21 -15
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +17 -13
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +9 -27
- package/dist/src/workflow-ref.d.ts.map +1 -1
- package/dist/src/workflow-ref.js +12 -34
- package/dist/src/workflow-ref.js.map +1 -1
- package/dist/stubs/database/migrations/create_durable_tables.stub +13 -1
- package/dist/stubs/make/workflow/main.stub +5 -4
- package/package.json +11 -1
- package/dist/src/remote-step-factory.d.ts +0 -15
- package/dist/src/remote-step-factory.d.ts.map +0 -1
- package/dist/src/remote-step-factory.js +0 -12
- package/dist/src/remote-step-factory.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -7,8 +7,11 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
|
|
|
7
7
|
import { EventAccumulators } from './event-accumulators.js';
|
|
8
8
|
import { eventMatchOf, eventMatches, eventPrefix } from './events.js';
|
|
9
9
|
import { breakpointToken, stepId } from './protocol.js';
|
|
10
|
+
import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
|
|
10
11
|
import { SingletonGate } from './singleton-gate.js';
|
|
12
|
+
import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
|
|
11
13
|
import { TransportPool } from './transport-pool.js';
|
|
14
|
+
import { workflowAls } from './workflow-als.js';
|
|
12
15
|
import { createWorkflowCtx, } from './workflow-ctx.js';
|
|
13
16
|
import { workflowName, } from './workflow-ref.js';
|
|
14
17
|
/** Union of a workflow's static tags and a run's start-time tags, de-duplicated, or undefined if none. */
|
|
@@ -52,6 +55,11 @@ export class WorkflowEngine {
|
|
|
52
55
|
store;
|
|
53
56
|
/** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
|
|
54
57
|
pool;
|
|
58
|
+
/** The primary task transport (first of the pool), used to build an on-the-fly remote-workflow
|
|
59
|
+
* executor for {@link WorkflowEngineDeps.remoteByConvention}. Undefined when no transport is wired. */
|
|
60
|
+
primaryTransport;
|
|
61
|
+
/** Convention-dispatch opt-in — see {@link WorkflowEngineDeps.remoteByConvention}. */
|
|
62
|
+
remoteByConvention;
|
|
55
63
|
controlPlane;
|
|
56
64
|
clock;
|
|
57
65
|
instanceId;
|
|
@@ -110,6 +118,8 @@ export class WorkflowEngine {
|
|
|
110
118
|
constructor(deps) {
|
|
111
119
|
this.store = deps.store;
|
|
112
120
|
this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
|
|
121
|
+
this.primaryTransport = deps.transports?.[0]?.transport ?? deps.transport;
|
|
122
|
+
this.remoteByConvention = deps.remoteByConvention ?? false;
|
|
113
123
|
this.controlPlane = deps.controlPlane;
|
|
114
124
|
this.clock = deps.clock ?? Date.now;
|
|
115
125
|
this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
|
|
@@ -294,7 +304,28 @@ export class WorkflowEngine {
|
|
|
294
304
|
this.latest.set(name, registered);
|
|
295
305
|
}
|
|
296
306
|
/**
|
|
297
|
-
*
|
|
307
|
+
* Convention-dispatch resolution (opt-in via {@link WorkflowEngineDeps.remoteByConvention}): when
|
|
308
|
+
* `name` isn't locally registered but a LIVE worker group of the same name exists, auto-register a
|
|
309
|
+
* remote workflow routed to that group (bare group, no partition) so its runs advance over the
|
|
310
|
+
* broker like any `registerRemote`'d one. Returns whether `name` is registered afterwards. A no-op
|
|
311
|
+
* (returns `false`) when the opt-in is off, no transport is wired, or no matching group is live.
|
|
312
|
+
*/
|
|
313
|
+
async ensureConventionWorkflow(name) {
|
|
314
|
+
if (!this.remoteByConvention || !this.primaryTransport)
|
|
315
|
+
return false;
|
|
316
|
+
if (this.latest.get(name))
|
|
317
|
+
return true;
|
|
318
|
+
const groups = await this.pool.listWorkerGroups();
|
|
319
|
+
if (!groups.includes(name))
|
|
320
|
+
return false;
|
|
321
|
+
this.registerRemote(name, '1', {
|
|
322
|
+
group: name,
|
|
323
|
+
executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
|
|
324
|
+
});
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Register a flow-control queue referenced by `ctx.step(name, input, { queue })`. Caps concurrent
|
|
298
329
|
* in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
|
|
299
330
|
* durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
|
|
300
331
|
*/
|
|
@@ -394,7 +425,12 @@ export class WorkflowEngine {
|
|
|
394
425
|
}
|
|
395
426
|
async start(workflow, input, runId, opts) {
|
|
396
427
|
const name = workflowName(workflow);
|
|
397
|
-
|
|
428
|
+
let registered = this.latest.get(name);
|
|
429
|
+
// remoteByConvention: an unregistered workflow whose name matches a LIVE worker group is routed to
|
|
430
|
+
// it as a remote workflow (bare group). Off by default → the original "not registered" throw.
|
|
431
|
+
if (!registered && (await this.ensureConventionWorkflow(name))) {
|
|
432
|
+
registered = this.latest.get(name);
|
|
433
|
+
}
|
|
398
434
|
if (!registered)
|
|
399
435
|
throw new Error(`workflow ${name} is not registered`);
|
|
400
436
|
// Validate the input up front — a bad payload is rejected before any run is created.
|
|
@@ -421,7 +457,7 @@ export class WorkflowEngine {
|
|
|
421
457
|
workflow: name,
|
|
422
458
|
workflowVersion: registered.version,
|
|
423
459
|
status: 'pending',
|
|
424
|
-
namespace: this.namespace,
|
|
460
|
+
namespace: opts?.namespace ?? this.namespace,
|
|
425
461
|
input,
|
|
426
462
|
tags,
|
|
427
463
|
searchAttributes: opts?.searchAttributes,
|
|
@@ -496,7 +532,14 @@ export class WorkflowEngine {
|
|
|
496
532
|
}
|
|
497
533
|
// Pin to the version the run STARTED on — replay is positional, so running a changed
|
|
498
534
|
// workflow body against old checkpoints would corrupt the run.
|
|
499
|
-
|
|
535
|
+
let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
536
|
+
// remoteByConvention: a run recovered on an instance that never registered this workflow (e.g. a
|
|
537
|
+
// crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
|
|
538
|
+
if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
|
|
539
|
+
registered =
|
|
540
|
+
this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
541
|
+
this.latest.get(run.workflow);
|
|
542
|
+
}
|
|
500
543
|
if (!registered) {
|
|
501
544
|
throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
|
|
502
545
|
}
|
|
@@ -550,7 +593,13 @@ export class WorkflowEngine {
|
|
|
550
593
|
continue;
|
|
551
594
|
const error = { message: 'execution timeout', code: 'execution_timeout' };
|
|
552
595
|
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
553
|
-
this.emit({
|
|
596
|
+
this.emit({
|
|
597
|
+
type: 'run.failed',
|
|
598
|
+
runId: run.id,
|
|
599
|
+
workflow: run.workflow,
|
|
600
|
+
namespace: run.namespace,
|
|
601
|
+
error,
|
|
602
|
+
});
|
|
554
603
|
}
|
|
555
604
|
}
|
|
556
605
|
}
|
|
@@ -597,7 +646,13 @@ export class WorkflowEngine {
|
|
|
597
646
|
};
|
|
598
647
|
await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
|
|
599
648
|
await this.store.releaseRunLock(run.id);
|
|
600
|
-
this.emit({
|
|
649
|
+
this.emit({
|
|
650
|
+
type: 'run.failed',
|
|
651
|
+
runId: run.id,
|
|
652
|
+
workflow: run.workflow,
|
|
653
|
+
namespace: run.namespace,
|
|
654
|
+
error,
|
|
655
|
+
});
|
|
601
656
|
this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
|
|
602
657
|
return { runId: run.id, status: 'dead', error };
|
|
603
658
|
}
|
|
@@ -659,7 +714,9 @@ export class WorkflowEngine {
|
|
|
659
714
|
if (!run)
|
|
660
715
|
return null;
|
|
661
716
|
const id = newRunId ?? `${runId}~retry~${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
662
|
-
|
|
717
|
+
// Inherit the original run's namespace so a retry lands in the SAME worker-pool partition (an
|
|
718
|
+
// operator-routed run keeps its tenant on retry, rather than falling back to the engine's own).
|
|
719
|
+
await this.start(run.workflow, input, id, { tags: run.tags, namespace: run.namespace });
|
|
663
720
|
return { runId: id };
|
|
664
721
|
}
|
|
665
722
|
/**
|
|
@@ -677,9 +734,17 @@ export class WorkflowEngine {
|
|
|
677
734
|
* Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
|
|
678
735
|
* suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
|
|
679
736
|
* `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
|
|
737
|
+
*
|
|
738
|
+
* Pass `{ terminal: true }` to wait for a strictly TERMINAL state only (completed/failed/cancelled/
|
|
739
|
+
* dead) — a `suspended` run (e.g. one parked on `ctx.sleep`/`ctx.waitForSignal`) does NOT resolve;
|
|
740
|
+
* the wait continues until the run reaches an end state. This is what `BaseWorkflow.start` needs so
|
|
741
|
+
* "I want the result" blocks through a suspension instead of returning early with no output. The
|
|
742
|
+
* default (`terminal` unset/false) resolves on any settled state, suspended included.
|
|
680
743
|
*/
|
|
681
744
|
waitForRun(runId, opts) {
|
|
745
|
+
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
|
|
682
746
|
const isSettled = (s) => s !== 'pending' && s !== 'running';
|
|
747
|
+
const shouldResolve = opts?.terminal ? isTerminal : isSettled;
|
|
683
748
|
const toResult = (run) => ({
|
|
684
749
|
runId,
|
|
685
750
|
status: run.status,
|
|
@@ -709,7 +774,7 @@ export class WorkflowEngine {
|
|
|
709
774
|
};
|
|
710
775
|
const check = () => {
|
|
711
776
|
void this.store.getRun(runId).then((run) => {
|
|
712
|
-
if (run &&
|
|
777
|
+
if (run && shouldResolve(run.status))
|
|
713
778
|
finish(run);
|
|
714
779
|
});
|
|
715
780
|
};
|
|
@@ -753,6 +818,17 @@ export class WorkflowEngine {
|
|
|
753
818
|
* 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
|
|
754
819
|
* payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
|
|
755
820
|
* of the same logical event (default: a fresh uuid, so each publish triggers once).
|
|
821
|
+
*
|
|
822
|
+
* Reliable (buffered) delivery — mirrors `signalWithStart`'s reliability contract, so document it
|
|
823
|
+
* exactly: a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator /
|
|
824
|
+
* starts ≥1 subscriber, is NOT buffered (fan-out stays live-only). A publish that touches NEITHER
|
|
825
|
+
* buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
|
|
826
|
+
* silently dropped just because nobody was listening yet. That buffered copy is consumed by the
|
|
827
|
+
* FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload — point-to-point
|
|
828
|
+
* on redelivery, never by a later-registered onEvent subscriber. Right after buffering, this
|
|
829
|
+
* re-checks `listSignalWaiters` ONCE (sandwich parity with `signal`'s take → buffer → re-check) so a
|
|
830
|
+
* waiter that registers in the sliver between the initial miss and the buffer write is still paired
|
|
831
|
+
* instead of leaving both rows stranded (`waitForEvent`'s post-registration scan is the mirror half).
|
|
756
832
|
*/
|
|
757
833
|
async publishEvent(name, payload, opts) {
|
|
758
834
|
let touched = 0;
|
|
@@ -786,20 +862,45 @@ export class WorkflowEngine {
|
|
|
786
862
|
}
|
|
787
863
|
}
|
|
788
864
|
}
|
|
865
|
+
// Nobody received it live (no waiter matched, no subscriber exists/started) — buffer ONE copy
|
|
866
|
+
// unless the caller opted out. `touched > 0` covers BOTH the waiter fan-out and the subscriber loop.
|
|
867
|
+
if (touched > 0 || opts?.buffer === false)
|
|
868
|
+
return touched;
|
|
869
|
+
const bufferedId = globalThis.crypto.randomUUID();
|
|
870
|
+
await this.store.bufferEvent({ name, payload, id: bufferedId, publishedAt: this.clock() });
|
|
871
|
+
// Re-check: a waiter may have registered in the window between the miss above and this buffer
|
|
872
|
+
// write (the events-side mirror of engine.signal's interleaving proof). Only the FIRST
|
|
873
|
+
// late-registered matching waiter matters — only one buffered copy exists.
|
|
874
|
+
const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
|
|
875
|
+
const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
|
|
876
|
+
if (!lateWaiter)
|
|
877
|
+
return touched;
|
|
878
|
+
if (!(await this.store.removeBufferedEvent(bufferedId)))
|
|
879
|
+
return touched; // claimed elsewhere already
|
|
880
|
+
// `takeSignalWaiter` is safe here even though it deletes ANY row for the token: an event token
|
|
881
|
+
// embeds this ONE `waitForEvent` call's own `runId#seq` (see eventToken in events.ts), so no other
|
|
882
|
+
// registration could ever share it.
|
|
883
|
+
const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
|
|
884
|
+
if (waiter) {
|
|
885
|
+
const settled = await this.deliverSignal(waiter, payload);
|
|
886
|
+
if (settled)
|
|
887
|
+
touched += 1;
|
|
888
|
+
}
|
|
889
|
+
// If `waiter` is null, that exact registration resolved itself some other way in the interim (most
|
|
890
|
+
// likely its own timeout deadline) — the buffered copy is already spent, so drop it rather than
|
|
891
|
+
// resuming a run a second time or re-buffering for a THIRD, unrelated waiter to pick up later.
|
|
789
892
|
return touched;
|
|
790
893
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
return null;
|
|
798
|
-
}
|
|
894
|
+
/**
|
|
895
|
+
* Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
|
|
896
|
+
* resume the run. Shared by {@link signal}'s direct hit and its post-buffer re-check reclaim — both
|
|
897
|
+
* resolve a waiter the exact same way.
|
|
898
|
+
*/
|
|
899
|
+
async deliverSignal(waiter, payload) {
|
|
799
900
|
await this.store.saveCheckpoint(instantCheckpoint({
|
|
800
901
|
runId: waiter.runId,
|
|
801
902
|
seq: waiter.seq,
|
|
802
|
-
name: `signal:${token}`,
|
|
903
|
+
name: `signal:${waiter.token}`,
|
|
803
904
|
kind: 'signal',
|
|
804
905
|
output: payload,
|
|
805
906
|
// Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
|
|
@@ -810,6 +911,40 @@ export class WorkflowEngine {
|
|
|
810
911
|
}));
|
|
811
912
|
return this.resume(waiter.runId);
|
|
812
913
|
}
|
|
914
|
+
/**
|
|
915
|
+
* Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
|
|
916
|
+
* the run result — or null if no run is (or ends up) waiting for it.
|
|
917
|
+
*
|
|
918
|
+
* Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check): a waiter can
|
|
919
|
+
* register in the sliver between this method's initial `takeSignalWaiter` miss and its
|
|
920
|
+
* `bufferSignal` write, and the naive old flow (buffer and return) would then leave BOTH a buffered
|
|
921
|
+
* payload and a registered waiter sitting in the store with nothing left to pair them — a lost wake,
|
|
922
|
+
* forever suspended. The interleaving proof: the waiter side does check → put → re-check, this side
|
|
923
|
+
* does take → buffer → re-check; whichever side's SECOND look runs last always observes the other
|
|
924
|
+
* side's write, and the destructive `take*` ops arbitrate so a signal is never delivered twice.
|
|
925
|
+
*/
|
|
926
|
+
async signal(token, payload) {
|
|
927
|
+
const waiter = await this.store.takeSignalWaiter(token);
|
|
928
|
+
if (waiter)
|
|
929
|
+
return this.deliverSignal(waiter, payload);
|
|
930
|
+
// No one was waiting yet — buffer it so the next `waitForSignal(token)` consumes it instead of
|
|
931
|
+
// dropping it (reliable signals; the basis of `signalWithStart`).
|
|
932
|
+
await this.store.bufferSignal(token, payload);
|
|
933
|
+
// Re-check: a waiter may have registered in the window between the miss above and this buffer
|
|
934
|
+
// write (see the interleaving proof above).
|
|
935
|
+
const lateWaiter = await this.store.takeSignalWaiter(token);
|
|
936
|
+
if (!lateWaiter)
|
|
937
|
+
return null;
|
|
938
|
+
const reclaimed = await this.store.takeBufferedSignal(token);
|
|
939
|
+
if (!reclaimed) {
|
|
940
|
+
// The waiter side won the race to consume the buffer itself (its own checkpoint+resume already
|
|
941
|
+
// delivered the payload) in the sliver between our takeSignalWaiter and takeBufferedSignal just
|
|
942
|
+
// above. We've already (destructively) taken `lateWaiter` off the store, but there is nothing
|
|
943
|
+
// left to deliver — drop it deliberately rather than resuming the run a second time.
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
return this.deliverSignal(lateWaiter, reclaimed.payload);
|
|
947
|
+
}
|
|
813
948
|
/**
|
|
814
949
|
* Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
|
|
815
950
|
* signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
|
|
@@ -848,6 +983,27 @@ export class WorkflowEngine {
|
|
|
848
983
|
notifyParent(runId, completion) {
|
|
849
984
|
void this.signal(`child:${runId}`, completion).catch(() => undefined);
|
|
850
985
|
}
|
|
986
|
+
/**
|
|
987
|
+
* Deferred child start shared by the in-process ctx host and the remote `startChild` command.
|
|
988
|
+
* Deferred (microtask) so a fast child can't reentrantly resume a still-suspending parent. A start
|
|
989
|
+
* that THROWS (unregistered/unroutable workflow, input validation, singleton back-pressure, store
|
|
990
|
+
* failure) must NOT be swallowed: the parent is already suspended on `child:<childId>` (the waiter
|
|
991
|
+
* is put BEFORE start on every path), so a silent drop parks it in suspended-forever, invisibly
|
|
992
|
+
* re-attempting on every recovery wake — a misconfigured remote child looks exactly like a healthy
|
|
993
|
+
* long wait. Instead the failure is delivered to that waiter like a failed child (notifyParent):
|
|
994
|
+
* the parent resumes and fails loudly with the cause. For a fire-and-forget `ctx.startChild`
|
|
995
|
+
* (spawn — no waiter) the completion is buffered; a later join by the same id consumes it and
|
|
996
|
+
* correctly observes the failed start.
|
|
997
|
+
*/
|
|
998
|
+
startChildDeferred(workflow, input, childId, opts) {
|
|
999
|
+
queueMicrotask(() => void this.start(workflow, input, childId, opts).catch((err) => {
|
|
1000
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1001
|
+
this.notifyParent(childId, {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
error: `child workflow "${workflow}" failed to start: ${message}`,
|
|
1004
|
+
});
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
851
1007
|
/**
|
|
852
1008
|
* Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
|
|
853
1009
|
* `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
|
|
@@ -885,7 +1041,13 @@ export class WorkflowEngine {
|
|
|
885
1041
|
}
|
|
886
1042
|
const error = { message: 'cancelled' };
|
|
887
1043
|
await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
|
|
888
|
-
this.emit({
|
|
1044
|
+
this.emit({
|
|
1045
|
+
type: 'run.failed',
|
|
1046
|
+
runId,
|
|
1047
|
+
workflow: run.workflow,
|
|
1048
|
+
namespace: run.namespace,
|
|
1049
|
+
error,
|
|
1050
|
+
});
|
|
889
1051
|
await this.cancelChildren(runId, opts);
|
|
890
1052
|
// Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
|
|
891
1053
|
// actually running this run learns of it and can abort cooperatively (the store already records
|
|
@@ -1204,6 +1366,7 @@ export class WorkflowEngine {
|
|
|
1204
1366
|
type: 'run.completed',
|
|
1205
1367
|
runId: run.id,
|
|
1206
1368
|
workflow: run.workflow,
|
|
1369
|
+
namespace: run.namespace,
|
|
1207
1370
|
output: outcome.output,
|
|
1208
1371
|
});
|
|
1209
1372
|
void this.notifyParent(run.id, { ok: true, value: outcome.output });
|
|
@@ -1215,13 +1378,28 @@ export class WorkflowEngine {
|
|
|
1215
1378
|
type: 'run.failed',
|
|
1216
1379
|
runId: run.id,
|
|
1217
1380
|
workflow: run.workflow,
|
|
1381
|
+
namespace: run.namespace,
|
|
1218
1382
|
error: outcome.error,
|
|
1219
1383
|
});
|
|
1220
1384
|
void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
|
|
1221
1385
|
return { runId: run.id, status: 'failed', error: outcome.error };
|
|
1222
1386
|
}
|
|
1387
|
+
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
|
|
1388
|
+
// was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
|
|
1389
|
+
// sibling mid-turn — plain `cancel()` writes `cancelled` directly, without waiting for the target's
|
|
1390
|
+
// in-flight turn to notice), this now-stale "suspended" outcome must not resurrect it: re-check the
|
|
1391
|
+
// CURRENT persisted status right before writing and echo it instead of clobbering a real cancel.
|
|
1392
|
+
const latest = await this.store.getRun(run.id);
|
|
1393
|
+
if (latest?.status === 'cancelled') {
|
|
1394
|
+
return { runId: run.id, status: 'cancelled', error: latest.error };
|
|
1395
|
+
}
|
|
1223
1396
|
await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
|
|
1224
|
-
this.emit({
|
|
1397
|
+
this.emit({
|
|
1398
|
+
type: 'run.suspended',
|
|
1399
|
+
runId: run.id,
|
|
1400
|
+
workflow: run.workflow,
|
|
1401
|
+
namespace: run.namespace,
|
|
1402
|
+
});
|
|
1225
1403
|
return { runId: run.id, status: 'suspended' };
|
|
1226
1404
|
}
|
|
1227
1405
|
async runRemoteExecution(run, registered) {
|
|
@@ -1229,12 +1407,22 @@ export class WorkflowEngine {
|
|
|
1229
1407
|
if (run.status === 'pending') {
|
|
1230
1408
|
await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
|
|
1231
1409
|
run.status = 'running';
|
|
1232
|
-
this.emit({
|
|
1410
|
+
this.emit({
|
|
1411
|
+
type: 'run.started',
|
|
1412
|
+
runId: run.id,
|
|
1413
|
+
workflow: run.workflow,
|
|
1414
|
+
namespace: run.namespace,
|
|
1415
|
+
});
|
|
1233
1416
|
}
|
|
1234
1417
|
if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
1235
1418
|
const wakeAt = this.singletons.retryWakeAt();
|
|
1236
1419
|
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
1237
|
-
this.emit({
|
|
1420
|
+
this.emit({
|
|
1421
|
+
type: 'run.suspended',
|
|
1422
|
+
runId: run.id,
|
|
1423
|
+
workflow: run.workflow,
|
|
1424
|
+
namespace: run.namespace,
|
|
1425
|
+
});
|
|
1238
1426
|
await this.store.releaseRunLock(run.id);
|
|
1239
1427
|
return { runId: run.id, status: 'suspended' };
|
|
1240
1428
|
}
|
|
@@ -1249,7 +1437,13 @@ export class WorkflowEngine {
|
|
|
1249
1437
|
stack: err instanceof Error ? err.stack : undefined,
|
|
1250
1438
|
};
|
|
1251
1439
|
await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
|
|
1252
|
-
this.emit({
|
|
1440
|
+
this.emit({
|
|
1441
|
+
type: 'run.failed',
|
|
1442
|
+
runId: run.id,
|
|
1443
|
+
workflow: run.workflow,
|
|
1444
|
+
namespace: run.namespace,
|
|
1445
|
+
error,
|
|
1446
|
+
});
|
|
1253
1447
|
return { runId: run.id, status: 'failed', error };
|
|
1254
1448
|
}
|
|
1255
1449
|
if (decision.status === 'completed') {
|
|
@@ -1375,6 +1569,10 @@ export class WorkflowEngine {
|
|
|
1375
1569
|
// Mirrors the `startChild` `getRun(childId)` guard below.
|
|
1376
1570
|
if (await this.store.getCheckpoint(run.id, cmd.seq))
|
|
1377
1571
|
continue;
|
|
1572
|
+
// Route this cross-SDK-worker `call` by the SAME name-based token a native `ctx.step`
|
|
1573
|
+
// dispatches with (and a worker subscribes to per handler name), so a decision-driven remote
|
|
1574
|
+
// step and an in-process one land on the identical queue. The command carries no partition.
|
|
1575
|
+
const callToken = tenantGroup(sanitizeQueueToken(cmd.name), undefined);
|
|
1378
1576
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1379
1577
|
runId: run.id,
|
|
1380
1578
|
seq: cmd.seq,
|
|
@@ -1383,13 +1581,13 @@ export class WorkflowEngine {
|
|
|
1383
1581
|
status: 'pending',
|
|
1384
1582
|
input: cmd.input,
|
|
1385
1583
|
attempts: 1,
|
|
1386
|
-
workerGroup:
|
|
1584
|
+
workerGroup: callToken,
|
|
1387
1585
|
enqueuedAt: at,
|
|
1388
1586
|
startedAt: at,
|
|
1389
1587
|
finishedAt: at,
|
|
1390
1588
|
// A `ctx.gather_calls([...])` fan-out stamps every dispatched `call` in the fan with the
|
|
1391
1589
|
// same group, so the dashboard renders the remote steps as one parallel fan (parity with
|
|
1392
|
-
// the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.
|
|
1590
|
+
// the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.step`.
|
|
1393
1591
|
parallelGroup: cmd.parallelGroup,
|
|
1394
1592
|
}));
|
|
1395
1593
|
await this.pool.dispatch({
|
|
@@ -1397,7 +1595,7 @@ export class WorkflowEngine {
|
|
|
1397
1595
|
seq: cmd.seq,
|
|
1398
1596
|
name: cmd.name,
|
|
1399
1597
|
stepId: id,
|
|
1400
|
-
group:
|
|
1598
|
+
group: callToken,
|
|
1401
1599
|
input: cmd.input,
|
|
1402
1600
|
traceparent: this.traceparent?.(),
|
|
1403
1601
|
context: this.context?.(),
|
|
@@ -1433,19 +1631,33 @@ export class WorkflowEngine {
|
|
|
1433
1631
|
// engine.signal(name, payload) delivers it. If the signal was already delivered (buffered
|
|
1434
1632
|
// before the workflow reached this point — e.g. signalWithStart), resolve it now and re-drive
|
|
1435
1633
|
// on a macrotask, AFTER this turn suspends and frees the run lock (a re-entrant resume bails).
|
|
1436
|
-
const
|
|
1437
|
-
if (buffered) {
|
|
1634
|
+
const deliverBuffered = async (payload) => {
|
|
1438
1635
|
await this.store.saveCheckpoint(instantCheckpoint({
|
|
1439
1636
|
runId: run.id,
|
|
1440
1637
|
seq: cmd.seq,
|
|
1441
1638
|
name: `signal:${cmd.signal}`,
|
|
1442
1639
|
kind: 'signal',
|
|
1443
|
-
output:
|
|
1640
|
+
output: payload,
|
|
1444
1641
|
}));
|
|
1445
1642
|
setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
|
|
1643
|
+
};
|
|
1644
|
+
const buffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
1645
|
+
if (buffered) {
|
|
1646
|
+
await deliverBuffered(buffered.payload);
|
|
1446
1647
|
}
|
|
1447
1648
|
else {
|
|
1649
|
+
// Same reorder as the in-process ctx.waitForSignal: register on a miss, then re-check once
|
|
1650
|
+
// more before suspending — closing the lost-wake window where a signal races in between the
|
|
1651
|
+
// miss above and the registration below (see the interleaving proof at engine.signal).
|
|
1448
1652
|
await this.store.putSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
|
|
1653
|
+
const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
1654
|
+
if (lateBuffered) {
|
|
1655
|
+
// Resolved it ourselves — remove OUR OWN row via the exact match (not
|
|
1656
|
+
// takeSignalWaiter(cmd.signal), which deletes ANY row for this token and could steal a
|
|
1657
|
+
// different run's waiter that has since claimed the same token).
|
|
1658
|
+
await this.store.removeSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
|
|
1659
|
+
await deliverBuffered(lateBuffered.payload);
|
|
1660
|
+
}
|
|
1449
1661
|
}
|
|
1450
1662
|
}
|
|
1451
1663
|
else if (cmd.kind === 'startChild') {
|
|
@@ -1466,7 +1678,7 @@ export class WorkflowEngine {
|
|
|
1466
1678
|
parallelGroup: cmd.parallelGroup,
|
|
1467
1679
|
});
|
|
1468
1680
|
if (!(await this.store.getRun(childId))) {
|
|
1469
|
-
|
|
1681
|
+
this.startChildDeferred(cmd.workflow, cmd.input, childId);
|
|
1470
1682
|
}
|
|
1471
1683
|
}
|
|
1472
1684
|
else {
|
|
@@ -1483,14 +1695,24 @@ export class WorkflowEngine {
|
|
|
1483
1695
|
if (run.status === 'pending') {
|
|
1484
1696
|
await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
|
|
1485
1697
|
run.status = 'running';
|
|
1486
|
-
this.emit({
|
|
1698
|
+
this.emit({
|
|
1699
|
+
type: 'run.started',
|
|
1700
|
+
runId: run.id,
|
|
1701
|
+
workflow: run.workflow,
|
|
1702
|
+
namespace: run.namespace,
|
|
1703
|
+
});
|
|
1487
1704
|
}
|
|
1488
1705
|
// Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
|
|
1489
1706
|
// (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
|
|
1490
1707
|
if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
1491
1708
|
const wakeAt = this.singletons.retryWakeAt();
|
|
1492
1709
|
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
1493
|
-
this.emit({
|
|
1710
|
+
this.emit({
|
|
1711
|
+
type: 'run.suspended',
|
|
1712
|
+
runId: run.id,
|
|
1713
|
+
workflow: run.workflow,
|
|
1714
|
+
namespace: run.namespace,
|
|
1715
|
+
});
|
|
1494
1716
|
await this.store.releaseRunLock(run.id);
|
|
1495
1717
|
return { runId: run.id, status: 'suspended' };
|
|
1496
1718
|
}
|
|
@@ -1507,7 +1729,10 @@ export class WorkflowEngine {
|
|
|
1507
1729
|
replay.set(cp.seq, cp);
|
|
1508
1730
|
const ctx = createWorkflowCtx(this.ctxHostFor(replay), run.id, compensations, run.workflow);
|
|
1509
1731
|
try {
|
|
1510
|
-
|
|
1732
|
+
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
1733
|
+
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
1734
|
+
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
1735
|
+
const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
|
|
1511
1736
|
return this.settleRun(run, { kind: 'completed', output });
|
|
1512
1737
|
}
|
|
1513
1738
|
catch (err) {
|
|
@@ -1521,7 +1746,12 @@ export class WorkflowEngine {
|
|
|
1521
1746
|
error: undefined,
|
|
1522
1747
|
updatedAt: new Date(),
|
|
1523
1748
|
});
|
|
1524
|
-
this.emit({
|
|
1749
|
+
this.emit({
|
|
1750
|
+
type: 'run.completed',
|
|
1751
|
+
runId: run.id,
|
|
1752
|
+
workflow: run.workflow,
|
|
1753
|
+
namespace: run.namespace,
|
|
1754
|
+
});
|
|
1525
1755
|
void this.notifyParent(run.id, { ok: true, value: undefined });
|
|
1526
1756
|
const nextId = nextContinuationId(run.id);
|
|
1527
1757
|
queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
|
|
@@ -1539,7 +1769,13 @@ export class WorkflowEngine {
|
|
|
1539
1769
|
}
|
|
1540
1770
|
const error = { message: 'cancelled' };
|
|
1541
1771
|
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
1542
|
-
this.emit({
|
|
1772
|
+
this.emit({
|
|
1773
|
+
type: 'run.failed',
|
|
1774
|
+
runId: run.id,
|
|
1775
|
+
workflow: run.workflow,
|
|
1776
|
+
namespace: run.namespace,
|
|
1777
|
+
error,
|
|
1778
|
+
});
|
|
1543
1779
|
return { runId: run.id, status: 'cancelled', error };
|
|
1544
1780
|
}
|
|
1545
1781
|
return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
@@ -1569,14 +1805,21 @@ export class WorkflowEngine {
|
|
|
1569
1805
|
/**
|
|
1570
1806
|
* Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
|
|
1571
1807
|
* step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
|
|
1572
|
-
* compensation is skipped so it can't mask the original failure.
|
|
1808
|
+
* compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
|
|
1809
|
+
* a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
|
|
1810
|
+
* awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
|
|
1573
1811
|
*/
|
|
1574
1812
|
async runCompensation(run, comp) {
|
|
1575
1813
|
const name = `compensate:${comp.name}`;
|
|
1576
1814
|
for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
|
|
1577
1815
|
const startedAt = Date.now();
|
|
1578
1816
|
try {
|
|
1579
|
-
|
|
1817
|
+
if ('fn' in comp) {
|
|
1818
|
+
await comp.fn();
|
|
1819
|
+
}
|
|
1820
|
+
else {
|
|
1821
|
+
await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
|
|
1822
|
+
}
|
|
1580
1823
|
this.emit({
|
|
1581
1824
|
type: 'step.completed',
|
|
1582
1825
|
runId: run.id,
|
|
@@ -1602,6 +1845,43 @@ export class WorkflowEngine {
|
|
|
1602
1845
|
}
|
|
1603
1846
|
}
|
|
1604
1847
|
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Dispatch a saga undo step to a worker and await its result INLINE (the saga unwind already runs
|
|
1850
|
+
* outside the positional replay, so this is not checkpointed — it's a one-shot dispatch + await).
|
|
1851
|
+
* Routes by the undo def's own name/partition token, applies its own optional input schema, and
|
|
1852
|
+
* honours its own liveness `timeoutMs` via the heartbeat window. Throws on a worker-reported failure
|
|
1853
|
+
* or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
|
|
1854
|
+
*/
|
|
1855
|
+
async dispatchCompensation(run, def, args, attempt) {
|
|
1856
|
+
if (this.pool.size === 0)
|
|
1857
|
+
throw new Error('dispatched compensation requires a Transport');
|
|
1858
|
+
const token = tenantGroup(sanitizeQueueToken(def.name), def.partition);
|
|
1859
|
+
const validInput = def.input ? def.input.parse(args) : args;
|
|
1860
|
+
const id = `${run.id}:compensate:${def.name}:${attempt}`;
|
|
1861
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
1862
|
+
this.pending.set(id, { resolve, reject });
|
|
1863
|
+
});
|
|
1864
|
+
try {
|
|
1865
|
+
await this.pool.dispatch({
|
|
1866
|
+
runId: run.id,
|
|
1867
|
+
seq: -1,
|
|
1868
|
+
name: def.name,
|
|
1869
|
+
stepId: id,
|
|
1870
|
+
group: token,
|
|
1871
|
+
input: validInput,
|
|
1872
|
+
traceparent: this.traceparent?.(),
|
|
1873
|
+
context: this.context?.(),
|
|
1874
|
+
attempt: 1,
|
|
1875
|
+
});
|
|
1876
|
+
await (def.timeoutMs
|
|
1877
|
+
? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
|
|
1878
|
+
: resultPromise);
|
|
1879
|
+
}
|
|
1880
|
+
catch (err) {
|
|
1881
|
+
this.pending.delete(id);
|
|
1882
|
+
throw err;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1605
1885
|
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
1606
1886
|
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
1607
1887
|
* module and the engine stays the orchestrator. */
|
|
@@ -1617,7 +1897,7 @@ export class WorkflowEngine {
|
|
|
1617
1897
|
callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission),
|
|
1618
1898
|
// Defer so a fast child can't reentrantly resume a still-running parent.
|
|
1619
1899
|
startChild: (workflow, input, id, priority) => {
|
|
1620
|
-
|
|
1900
|
+
this.startChildDeferred(workflow, input, id, { priority });
|
|
1621
1901
|
},
|
|
1622
1902
|
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
1623
1903
|
upsertSearchAttributes: async (runId, attrs) => {
|
|
@@ -1760,7 +2040,13 @@ export class WorkflowEngine {
|
|
|
1760
2040
|
this.queueWaiters.get(queue)?.delete(runId);
|
|
1761
2041
|
this.stepQueue.set(id, queue);
|
|
1762
2042
|
}
|
|
1763
|
-
|
|
2043
|
+
// Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
|
|
2044
|
+
// call carries none — the engine passes the input through untouched then).
|
|
2045
|
+
const validInput = step.input ? step.input.parse(input) : input;
|
|
2046
|
+
// Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
|
|
2047
|
+
// used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
|
|
2048
|
+
// token the worker subscribes to per handler name serves this step.
|
|
2049
|
+
const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
|
|
1764
2050
|
const enqueuedAt = new Date();
|
|
1765
2051
|
// Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
|
|
1766
2052
|
await this.store.saveCheckpoint({
|
|
@@ -1772,7 +2058,7 @@ export class WorkflowEngine {
|
|
|
1772
2058
|
status: 'pending',
|
|
1773
2059
|
input: validInput,
|
|
1774
2060
|
attempts: attempt,
|
|
1775
|
-
workerGroup:
|
|
2061
|
+
workerGroup: token,
|
|
1776
2062
|
enqueuedAt,
|
|
1777
2063
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
1778
2064
|
finishedAt: enqueuedAt,
|
|
@@ -1782,7 +2068,7 @@ export class WorkflowEngine {
|
|
|
1782
2068
|
seq,
|
|
1783
2069
|
name: step.name,
|
|
1784
2070
|
stepId: id,
|
|
1785
|
-
group:
|
|
2071
|
+
group: token,
|
|
1786
2072
|
input: validInput,
|
|
1787
2073
|
traceparent: this.traceparent?.(),
|
|
1788
2074
|
context: this.context?.(),
|
|
@@ -1859,7 +2145,8 @@ export class WorkflowEngine {
|
|
|
1859
2145
|
async callRemoteInMemory(runId, seq, step, input, transport) {
|
|
1860
2146
|
if (this.pool.size === 0)
|
|
1861
2147
|
throw new Error('remote steps require a Transport');
|
|
1862
|
-
const validInput = step.input.parse(input);
|
|
2148
|
+
const validInput = step.input ? step.input.parse(input) : input;
|
|
2149
|
+
const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
|
|
1863
2150
|
const id = stepId(runId, seq);
|
|
1864
2151
|
const enqueuedAt = new Date();
|
|
1865
2152
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
@@ -1878,7 +2165,7 @@ export class WorkflowEngine {
|
|
|
1878
2165
|
seq,
|
|
1879
2166
|
name: step.name,
|
|
1880
2167
|
stepId: id,
|
|
1881
|
-
group:
|
|
2168
|
+
group: token,
|
|
1882
2169
|
input: validInput,
|
|
1883
2170
|
traceparent: this.traceparent?.(),
|
|
1884
2171
|
context: this.context?.(),
|
|
@@ -1888,7 +2175,7 @@ export class WorkflowEngine {
|
|
|
1888
2175
|
const resolution = step.timeoutMs
|
|
1889
2176
|
? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)
|
|
1890
2177
|
: await resultPromise;
|
|
1891
|
-
const output = step.output.parse(resolution.output);
|
|
2178
|
+
const output = (step.output ? step.output.parse(resolution.output) : resolution.output);
|
|
1892
2179
|
// The worker reports when it actually picked the task up; fall back to dispatch time if a
|
|
1893
2180
|
// transport doesn't carry it (queue-wait then reads as zero rather than going negative).
|
|
1894
2181
|
const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
|
|
@@ -1901,7 +2188,7 @@ export class WorkflowEngine {
|
|
|
1901
2188
|
output,
|
|
1902
2189
|
events: resolution.events,
|
|
1903
2190
|
attempts: attempt,
|
|
1904
|
-
workerGroup:
|
|
2191
|
+
workerGroup: token,
|
|
1905
2192
|
enqueuedAt,
|
|
1906
2193
|
startedAt,
|
|
1907
2194
|
});
|