@adonis-agora/durable 0.1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/assets/dashboard.html +446 -0
- package/dist/commands/main.d.ts +15 -0
- package/dist/commands/main.d.ts.map +1 -0
- package/dist/commands/main.js +15 -0
- package/dist/commands/main.js.map +1 -0
- package/dist/commands/retry.d.ts +16 -0
- package/dist/commands/retry.d.ts.map +1 -0
- package/dist/commands/retry.js +34 -0
- package/dist/commands/retry.js.map +1 -0
- package/dist/commands/runs.d.ts +17 -0
- package/dist/commands/runs.d.ts.map +1 -0
- package/dist/commands/runs.js +40 -0
- package/dist/commands/runs.js.map +1 -0
- package/dist/commands/work.d.ts +18 -0
- package/dist/commands/work.d.ts.map +1 -0
- package/dist/commands/work.js +58 -0
- package/dist/commands/work.js.map +1 -0
- package/dist/configure.d.ts +15 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +26 -0
- package/dist/configure.js.map +1 -0
- package/dist/providers/dashboard_provider.d.ts +27 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -0
- package/dist/providers/dashboard_provider.js +94 -0
- package/dist/providers/dashboard_provider.js.map +1 -0
- package/dist/providers/durable_provider.d.ts +36 -0
- package/dist/providers/durable_provider.d.ts.map +1 -0
- package/dist/providers/durable_provider.js +113 -0
- package/dist/providers/durable_provider.js.map +1 -0
- package/dist/src/admission-redis/index.d.ts +2 -0
- package/dist/src/admission-redis/index.d.ts.map +1 -0
- package/dist/src/admission-redis/index.js +2 -0
- package/dist/src/admission-redis/index.js.map +1 -0
- package/dist/src/admission-redis/redis-admission-backend.d.ts +83 -0
- package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -0
- package/dist/src/admission-redis/redis-admission-backend.js +200 -0
- package/dist/src/admission-redis/redis-admission-backend.js.map +1 -0
- package/dist/src/admission.d.ts +49 -0
- package/dist/src/admission.d.ts.map +1 -0
- package/dist/src/admission.js +30 -0
- package/dist/src/admission.js.map +1 -0
- package/dist/src/backoff.d.ts +5 -0
- package/dist/src/backoff.d.ts.map +1 -0
- package/dist/src/backoff.js +11 -0
- package/dist/src/backoff.js.map +1 -0
- package/dist/src/checkpoints.d.ts +42 -0
- package/dist/src/checkpoints.d.ts.map +1 -0
- package/dist/src/checkpoints.js +57 -0
- package/dist/src/checkpoints.js.map +1 -0
- package/dist/src/codec-state-store.d.ts +60 -0
- package/dist/src/codec-state-store.d.ts.map +1 -0
- package/dist/src/codec-state-store.js +129 -0
- package/dist/src/codec-state-store.js.map +1 -0
- package/dist/src/commands/index.d.ts +3 -0
- package/dist/src/commands/index.d.ts.map +1 -0
- package/dist/src/commands/index.js +3 -0
- package/dist/src/commands/index.js.map +1 -0
- package/dist/src/commands/runs.d.ts +24 -0
- package/dist/src/commands/runs.d.ts.map +1 -0
- package/dist/src/commands/runs.js +39 -0
- package/dist/src/commands/runs.js.map +1 -0
- package/dist/src/commands/worker.d.ts +61 -0
- package/dist/src/commands/worker.d.ts.map +1 -0
- package/dist/src/commands/worker.js +74 -0
- package/dist/src/commands/worker.js.map +1 -0
- package/dist/src/completion.d.ts +15 -0
- package/dist/src/completion.d.ts.map +1 -0
- package/dist/src/completion.js +10 -0
- package/dist/src/completion.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +48 -0
- package/dist/src/dashboard/define_config.d.ts.map +1 -0
- package/dist/src/dashboard/define_config.js +57 -0
- package/dist/src/dashboard/define_config.js.map +1 -0
- package/dist/src/dashboard/handlers.d.ts +47 -0
- package/dist/src/dashboard/handlers.d.ts.map +1 -0
- package/dist/src/dashboard/handlers.js +163 -0
- package/dist/src/dashboard/handlers.js.map +1 -0
- package/dist/src/dashboard/html.d.ts +7 -0
- package/dist/src/dashboard/html.d.ts.map +1 -0
- package/dist/src/dashboard/html.js +24 -0
- package/dist/src/dashboard/html.js.map +1 -0
- package/dist/src/dashboard/index.d.ts +8 -0
- package/dist/src/dashboard/index.d.ts.map +1 -0
- package/dist/src/dashboard/index.js +6 -0
- package/dist/src/dashboard/index.js.map +1 -0
- package/dist/src/define_config.d.ts +72 -0
- package/dist/src/define_config.d.ts.map +1 -0
- package/dist/src/define_config.js +8 -0
- package/dist/src/define_config.js.map +1 -0
- package/dist/src/diagnostics-bridge.d.ts +22 -0
- package/dist/src/diagnostics-bridge.d.ts.map +1 -0
- package/dist/src/diagnostics-bridge.js +25 -0
- package/dist/src/diagnostics-bridge.js.map +1 -0
- package/dist/src/duration.d.ts +6 -0
- package/dist/src/duration.d.ts.map +1 -0
- package/dist/src/duration.js +39 -0
- package/dist/src/duration.js.map +1 -0
- package/dist/src/engine.d.ts +583 -0
- package/dist/src/engine.d.ts.map +1 -0
- package/dist/src/engine.js +1894 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/entities.d.ts +44 -0
- package/dist/src/entities.d.ts.map +1 -0
- package/dist/src/entities.js +64 -0
- package/dist/src/entities.js.map +1 -0
- package/dist/src/errors.d.ts +68 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +102 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/event-accumulators.d.ts +39 -0
- package/dist/src/event-accumulators.d.ts.map +1 -0
- package/dist/src/event-accumulators.js +70 -0
- package/dist/src/event-accumulators.js.map +1 -0
- package/dist/src/events.d.ts +9 -0
- package/dist/src/events.d.ts.map +1 -0
- package/dist/src/events.js +35 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +37 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/interfaces.d.ts +909 -0
- package/dist/src/interfaces.d.ts.map +1 -0
- package/dist/src/interfaces.js +2 -0
- package/dist/src/interfaces.js.map +1 -0
- package/dist/src/metrics.d.ts +44 -0
- package/dist/src/metrics.d.ts.map +1 -0
- package/dist/src/metrics.js +78 -0
- package/dist/src/metrics.js.map +1 -0
- package/dist/src/otel/durable-metrics.d.ts +42 -0
- package/dist/src/otel/durable-metrics.d.ts.map +1 -0
- package/dist/src/otel/durable-metrics.js +75 -0
- package/dist/src/otel/durable-metrics.js.map +1 -0
- package/dist/src/otel/durable-otel.d.ts +20 -0
- package/dist/src/otel/durable-otel.d.ts.map +1 -0
- package/dist/src/otel/durable-otel.js +74 -0
- package/dist/src/otel/durable-otel.js.map +1 -0
- package/dist/src/otel/index.d.ts +4 -0
- package/dist/src/otel/index.d.ts.map +1 -0
- package/dist/src/otel/index.js +4 -0
- package/dist/src/otel/index.js.map +1 -0
- package/dist/src/otel/traceparent.d.ts +17 -0
- package/dist/src/otel/traceparent.d.ts.map +1 -0
- package/dist/src/otel/traceparent.js +21 -0
- package/dist/src/otel/traceparent.js.map +1 -0
- package/dist/src/pollers.d.ts +33 -0
- package/dist/src/pollers.d.ts.map +1 -0
- package/dist/src/pollers.js +76 -0
- package/dist/src/pollers.js.map +1 -0
- package/dist/src/protocol.d.ts +17 -0
- package/dist/src/protocol.d.ts.map +1 -0
- package/dist/src/protocol.js +47 -0
- package/dist/src/protocol.js.map +1 -0
- package/dist/src/queue.d.ts +113 -0
- package/dist/src/queue.d.ts.map +1 -0
- package/dist/src/queue.js +128 -0
- package/dist/src/queue.js.map +1 -0
- package/dist/src/remote-step-factory.d.ts +15 -0
- package/dist/src/remote-step-factory.d.ts.map +1 -0
- package/dist/src/remote-step-factory.js +12 -0
- package/dist/src/remote-step-factory.js.map +1 -0
- package/dist/src/remote-workflow-executor.d.ts +24 -0
- package/dist/src/remote-workflow-executor.d.ts.map +1 -0
- package/dist/src/remote-workflow-executor.js +70 -0
- package/dist/src/remote-workflow-executor.js.map +1 -0
- package/dist/src/scheduler.d.ts +67 -0
- package/dist/src/scheduler.d.ts.map +1 -0
- package/dist/src/scheduler.js +111 -0
- package/dist/src/scheduler.js.map +1 -0
- package/dist/src/search-attributes.d.ts +48 -0
- package/dist/src/search-attributes.d.ts.map +1 -0
- package/dist/src/search-attributes.js +98 -0
- package/dist/src/search-attributes.js.map +1 -0
- package/dist/src/singleton-gate.d.ts +44 -0
- package/dist/src/singleton-gate.d.ts.map +1 -0
- package/dist/src/singleton-gate.js +90 -0
- package/dist/src/singleton-gate.js.map +1 -0
- package/dist/src/step-logger.d.ts +9 -0
- package/dist/src/step-logger.d.ts.map +1 -0
- package/dist/src/step-logger.js +89 -0
- package/dist/src/step-logger.js.map +1 -0
- package/dist/src/stores/factory.d.ts +42 -0
- package/dist/src/stores/factory.d.ts.map +1 -0
- package/dist/src/stores/factory.js +29 -0
- package/dist/src/stores/factory.js.map +1 -0
- package/dist/src/stores/lucid-helpers.d.ts +11 -0
- package/dist/src/stores/lucid-helpers.d.ts.map +1 -0
- package/dist/src/stores/lucid-helpers.js +38 -0
- package/dist/src/stores/lucid-helpers.js.map +1 -0
- package/dist/src/stores/lucid-mappers.d.ts +58 -0
- package/dist/src/stores/lucid-mappers.d.ts.map +1 -0
- package/dist/src/stores/lucid-mappers.js +174 -0
- package/dist/src/stores/lucid-mappers.js.map +1 -0
- package/dist/src/stores/lucid-schema.d.ts +28 -0
- package/dist/src/stores/lucid-schema.d.ts.map +1 -0
- package/dist/src/stores/lucid-schema.js +115 -0
- package/dist/src/stores/lucid-schema.js.map +1 -0
- package/dist/src/stores/lucid.d.ts +69 -0
- package/dist/src/stores/lucid.d.ts.map +1 -0
- package/dist/src/stores/lucid.js +342 -0
- package/dist/src/stores/lucid.js.map +1 -0
- package/dist/src/telescope/dashboard.d.ts +15 -0
- package/dist/src/telescope/dashboard.d.ts.map +1 -0
- package/dist/src/telescope/dashboard.js +113 -0
- package/dist/src/telescope/dashboard.js.map +1 -0
- package/dist/src/telescope/data-providers.d.ts +20 -0
- package/dist/src/telescope/data-providers.d.ts.map +1 -0
- package/dist/src/telescope/data-providers.js +322 -0
- package/dist/src/telescope/data-providers.js.map +1 -0
- package/dist/src/telescope/extension.d.ts +22 -0
- package/dist/src/telescope/extension.d.ts.map +1 -0
- package/dist/src/telescope/extension.js +38 -0
- package/dist/src/telescope/extension.js.map +1 -0
- package/dist/src/telescope/index.d.ts +5 -0
- package/dist/src/telescope/index.d.ts.map +1 -0
- package/dist/src/telescope/index.js +4 -0
- package/dist/src/telescope/index.js.map +1 -0
- package/dist/src/telescope/telescope-sdk.d.ts +123 -0
- package/dist/src/telescope/telescope-sdk.d.ts.map +1 -0
- package/dist/src/telescope/telescope-sdk.js +12 -0
- package/dist/src/telescope/telescope-sdk.js.map +1 -0
- package/dist/src/test-helpers.d.ts +11 -0
- package/dist/src/test-helpers.d.ts.map +1 -0
- package/dist/src/test-helpers.js +12 -0
- package/dist/src/test-helpers.js.map +1 -0
- package/dist/src/testing/in-memory-state-store.d.ts +64 -0
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -0
- package/dist/src/testing/in-memory-state-store.js +259 -0
- package/dist/src/testing/in-memory-state-store.js.map +1 -0
- package/dist/src/testing/in-memory-transport.d.ts +25 -0
- package/dist/src/testing/in-memory-transport.d.ts.map +1 -0
- package/dist/src/testing/in-memory-transport.js +49 -0
- package/dist/src/testing/in-memory-transport.js.map +1 -0
- package/dist/src/testing-kit/admission-backend-conformance.d.ts +18 -0
- package/dist/src/testing-kit/admission-backend-conformance.d.ts.map +1 -0
- package/dist/src/testing-kit/admission-backend-conformance.js +81 -0
- package/dist/src/testing-kit/admission-backend-conformance.js.map +1 -0
- package/dist/src/testing-kit/assertions.d.ts +10 -0
- package/dist/src/testing-kit/assertions.d.ts.map +1 -0
- package/dist/src/testing-kit/assertions.js +41 -0
- package/dist/src/testing-kit/assertions.js.map +1 -0
- package/dist/src/testing-kit/harness.d.ts +28 -0
- package/dist/src/testing-kit/harness.d.ts.map +1 -0
- package/dist/src/testing-kit/harness.js +42 -0
- package/dist/src/testing-kit/harness.js.map +1 -0
- package/dist/src/testing-kit/index.d.ts +8 -0
- package/dist/src/testing-kit/index.d.ts.map +1 -0
- package/dist/src/testing-kit/index.js +8 -0
- package/dist/src/testing-kit/index.js.map +1 -0
- package/dist/src/testing-kit/replay.d.ts +22 -0
- package/dist/src/testing-kit/replay.d.ts.map +1 -0
- package/dist/src/testing-kit/replay.js +38 -0
- package/dist/src/testing-kit/replay.js.map +1 -0
- package/dist/src/testing-kit/state-store-conformance.d.ts +51 -0
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -0
- package/dist/src/testing-kit/state-store-conformance.js +430 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -0
- package/dist/src/testing-kit/steps.d.ts +5 -0
- package/dist/src/testing-kit/steps.d.ts.map +1 -0
- package/dist/src/testing-kit/steps.js +15 -0
- package/dist/src/testing-kit/steps.js.map +1 -0
- package/dist/src/testing-kit/transport-conformance.d.ts +16 -0
- package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -0
- package/dist/src/testing-kit/transport-conformance.js +64 -0
- package/dist/src/testing-kit/transport-conformance.js.map +1 -0
- package/dist/src/tokens.d.ts +18 -0
- package/dist/src/tokens.d.ts.map +1 -0
- package/dist/src/tokens.js +18 -0
- package/dist/src/tokens.js.map +1 -0
- package/dist/src/transport-pool.d.ts +30 -0
- package/dist/src/transport-pool.d.ts.map +1 -0
- package/dist/src/transport-pool.js +82 -0
- package/dist/src/transport-pool.js.map +1 -0
- package/dist/src/transports/db-helpers.d.ts +10 -0
- package/dist/src/transports/db-helpers.d.ts.map +1 -0
- package/dist/src/transports/db-helpers.js +33 -0
- package/dist/src/transports/db-helpers.js.map +1 -0
- package/dist/src/transports/db-schema.d.ts +28 -0
- package/dist/src/transports/db-schema.d.ts.map +1 -0
- package/dist/src/transports/db-schema.js +98 -0
- package/dist/src/transports/db-schema.js.map +1 -0
- package/dist/src/transports/db.d.ts +81 -0
- package/dist/src/transports/db.d.ts.map +1 -0
- package/dist/src/transports/db.js +340 -0
- package/dist/src/transports/db.js.map +1 -0
- package/dist/src/transports/factory.d.ts +79 -0
- package/dist/src/transports/factory.d.ts.map +1 -0
- package/dist/src/transports/factory.js +57 -0
- package/dist/src/transports/factory.js.map +1 -0
- package/dist/src/transports/queue-mock-adapter.d.ts +39 -0
- package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -0
- package/dist/src/transports/queue-mock-adapter.js +86 -0
- package/dist/src/transports/queue-mock-adapter.js.map +1 -0
- package/dist/src/transports/queue.d.ts +84 -0
- package/dist/src/transports/queue.d.ts.map +1 -0
- package/dist/src/transports/queue.js +210 -0
- package/dist/src/transports/queue.js.map +1 -0
- package/dist/src/workflow-ctx.d.ts +64 -0
- package/dist/src/workflow-ctx.d.ts.map +1 -0
- package/dist/src/workflow-ctx.js +457 -0
- package/dist/src/workflow-ctx.js.map +1 -0
- package/dist/src/workflow-ref.d.ts +34 -0
- package/dist/src/workflow-ref.d.ts.map +1 -0
- package/dist/src/workflow-ref.js +28 -0
- package/dist/src/workflow-ref.js.map +1 -0
- package/dist/stubs/config/durable.stub +49 -0
- package/dist/stubs/config/durable_dashboard.stub +17 -0
- package/dist/stubs/database/migrations/create_durable_tables.stub +87 -0
- package/dist/stubs/database/migrations/create_durable_transport_tables.stub +75 -0
- package/dist/stubs/main.d.ts +3 -0
- package/dist/stubs/main.d.ts.map +1 -0
- package/dist/stubs/main.js +4 -0
- package/dist/stubs/main.js.map +1 -0
- package/package.json +154 -0
|
@@ -0,0 +1,1894 @@
|
|
|
1
|
+
import { InMemoryAdmissionBackend } from './admission.js';
|
|
2
|
+
import { backoffDelay } from './backoff.js';
|
|
3
|
+
import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
|
|
4
|
+
import { parseDuration } from './duration.js';
|
|
5
|
+
import { Entities } from './entities.js';
|
|
6
|
+
import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
|
|
7
|
+
import { EventAccumulators } from './event-accumulators.js';
|
|
8
|
+
import { eventMatchOf, eventMatches, eventPrefix } from './events.js';
|
|
9
|
+
import { breakpointToken, stepId } from './protocol.js';
|
|
10
|
+
import { SingletonGate } from './singleton-gate.js';
|
|
11
|
+
import { TransportPool } from './transport-pool.js';
|
|
12
|
+
import { createWorkflowCtx, } from './workflow-ctx.js';
|
|
13
|
+
import { workflowName, } from './workflow-ref.js';
|
|
14
|
+
/** Union of a workflow's static tags and a run's start-time tags, de-duplicated, or undefined if none. */
|
|
15
|
+
function mergeTags(staticTags, runTags) {
|
|
16
|
+
if (!staticTags?.length && !runTags?.length)
|
|
17
|
+
return undefined;
|
|
18
|
+
return [...new Set([...(staticTags ?? []), ...(runTags ?? [])])];
|
|
19
|
+
}
|
|
20
|
+
/** A breakpoint checkpoint's `name` is `breakpoint` (or `breakpoint:<label>`). This name — not the
|
|
21
|
+
* reused `signal` kind — is the explicit marker the dashboard and `continue()` detect it by. */
|
|
22
|
+
const BREAKPOINT = 'breakpoint';
|
|
23
|
+
const isBreakpoint = (cp) => cp.status === 'pending' && cp.name.startsWith(BREAKPOINT);
|
|
24
|
+
const versionKey = (name, version) => `${name}@${version}`;
|
|
25
|
+
/** The id for the next continuation of a run: `r` → `r~1` → `r~2` … (stable, traceable lineage). */
|
|
26
|
+
function nextContinuationId(runId) {
|
|
27
|
+
const m = runId.match(/^(.*)~(\d+)$/);
|
|
28
|
+
return m ? `${m[1]}~${Number(m[2]) + 1}` : `${runId}~1`;
|
|
29
|
+
}
|
|
30
|
+
/** True when version `a` is newer than `b` (numeric when both parse as numbers, else natural sort). */
|
|
31
|
+
function isNewerVersion(a, b) {
|
|
32
|
+
const na = Number(a);
|
|
33
|
+
const nb = Number(b);
|
|
34
|
+
if (!Number.isNaN(na) && !Number.isNaN(nb))
|
|
35
|
+
return na > nb;
|
|
36
|
+
return a.localeCompare(b, undefined, { numeric: true }) > 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The orchestrator. Owns workflow state and replays runs deterministically: each step's
|
|
40
|
+
* result is checkpointed, so on resume a completed step returns its saved output instead of
|
|
41
|
+
* re-executing. Remote steps are dispatched over the Transport; their results checkpoint the
|
|
42
|
+
* same way local steps do.
|
|
43
|
+
*/
|
|
44
|
+
export class WorkflowEngine {
|
|
45
|
+
store;
|
|
46
|
+
/** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
|
|
47
|
+
pool;
|
|
48
|
+
controlPlane;
|
|
49
|
+
clock;
|
|
50
|
+
instanceId;
|
|
51
|
+
leaseMs;
|
|
52
|
+
maxRecoveryAttempts;
|
|
53
|
+
webhookUrl;
|
|
54
|
+
traceparent;
|
|
55
|
+
context;
|
|
56
|
+
/** Establish the originating context ambiently around a local step body (see {@link WorkflowEngineDeps.rehydrate}). Default passthrough. */
|
|
57
|
+
rehydrate;
|
|
58
|
+
compensationRetries;
|
|
59
|
+
/** Persist a `running` checkpoint at the start of a local step body (see {@link WorkflowEngineDeps.trackStepStart}). */
|
|
60
|
+
trackStepStart;
|
|
61
|
+
/** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
|
|
62
|
+
runDispatcher;
|
|
63
|
+
/** Per-key serialization for singleton workflows (admission, back-pressure, notify-on-release). */
|
|
64
|
+
singletons;
|
|
65
|
+
/** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
|
|
66
|
+
workflows = new Map();
|
|
67
|
+
/** The newest registered version per workflow name — used to `start` new runs. */
|
|
68
|
+
latest = new Map();
|
|
69
|
+
/** Event name → workflow names started when that event is published (see `onEvent`). */
|
|
70
|
+
eventTriggers = new Map();
|
|
71
|
+
/** Durable-entity subsystem (registers the `__entity` runner; see `registerEntity`). */
|
|
72
|
+
entities;
|
|
73
|
+
/** Event debounce/batch accumulators (register the `__evt_*` runners; see `accumulators.route`). */
|
|
74
|
+
accumulators;
|
|
75
|
+
/** In-flight remote steps awaiting a worker result, keyed by stepId. */
|
|
76
|
+
pending = new Map();
|
|
77
|
+
/** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
|
|
78
|
+
heartbeatResets = new Map();
|
|
79
|
+
listeners = new Set();
|
|
80
|
+
/** Step interceptors (onion middleware around real local-step execution), first = outermost. */
|
|
81
|
+
interceptors = [];
|
|
82
|
+
/** Callbacks notified (on any instance) when a run is cancelled — for cooperative cancellation. */
|
|
83
|
+
cancelListeners = new Set();
|
|
84
|
+
/** Callbacks notified when a run is enqueued elsewhere — for low-latency cross-pod dispatch. */
|
|
85
|
+
enqueuedListeners = new Set();
|
|
86
|
+
/** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
|
|
87
|
+
deadListeners = new Set();
|
|
88
|
+
/** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
|
|
89
|
+
updateValidators = new Map();
|
|
90
|
+
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
91
|
+
cancelRequested = new Set();
|
|
92
|
+
/** Flow-control admission backend for remote steps (see {@link registerQueue}). */
|
|
93
|
+
admission;
|
|
94
|
+
/** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
|
|
95
|
+
queueWaiters = new Map();
|
|
96
|
+
/** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
|
|
97
|
+
stepQueue = new Map();
|
|
98
|
+
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
99
|
+
inflight = new Set();
|
|
100
|
+
draining = false;
|
|
101
|
+
constructor(deps) {
|
|
102
|
+
this.store = deps.store;
|
|
103
|
+
this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
|
|
104
|
+
this.controlPlane = deps.controlPlane;
|
|
105
|
+
this.clock = deps.clock ?? Date.now;
|
|
106
|
+
this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
|
|
107
|
+
// Wake this instance's admission-blocked runs the moment a slot frees anywhere in the fleet,
|
|
108
|
+
// instead of waiting for their retry tick. Best-effort (the retry tick remains the guarantee).
|
|
109
|
+
this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
|
|
110
|
+
this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
|
|
111
|
+
this.leaseMs = deps.leaseMs ?? 30_000;
|
|
112
|
+
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
113
|
+
this.webhookUrl = deps.webhookUrl;
|
|
114
|
+
this.traceparent = deps.traceparent;
|
|
115
|
+
this.context = deps.context;
|
|
116
|
+
// Default passthrough: with no bridge supplied, a local step body runs exactly as before.
|
|
117
|
+
this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
|
|
118
|
+
this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
|
|
119
|
+
this.trackStepStart = deps.trackStepStart ?? true;
|
|
120
|
+
// Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
|
|
121
|
+
// A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
|
|
122
|
+
// run failures themselves are handled inside `execute` and surfaced as the run's `failed` state.
|
|
123
|
+
this.runDispatcher = deps.runDispatcher ?? {
|
|
124
|
+
dispatch: (runId) => queueMicrotask(() => void this.runOne(runId).catch(() => { })),
|
|
125
|
+
};
|
|
126
|
+
this.singletons = new SingletonGate({
|
|
127
|
+
store: this.store,
|
|
128
|
+
clock: this.clock,
|
|
129
|
+
dispatch: (runId) => {
|
|
130
|
+
this.runDispatcher.dispatch(runId);
|
|
131
|
+
},
|
|
132
|
+
configFor: (run) => (this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
133
|
+
this.latest.get(run.workflow))?.singleton,
|
|
134
|
+
});
|
|
135
|
+
this.pool.bind(async (result) => {
|
|
136
|
+
// In-memory path (a `timeoutMs` step awaiting on THIS instance): resolve its pending promise.
|
|
137
|
+
const waiter = this.pending.get(result.stepId);
|
|
138
|
+
if (waiter) {
|
|
139
|
+
this.pending.delete(result.stepId);
|
|
140
|
+
if (result.status === 'completed') {
|
|
141
|
+
waiter.resolve({
|
|
142
|
+
output: result.output,
|
|
143
|
+
startedAt: result.startedAt,
|
|
144
|
+
events: result.events,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
waiter.reject(new RemoteStepError(result.error));
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Durable path: no in-memory waiter (the step suspended the run, possibly on another
|
|
153
|
+
// instance) → complete the checkpoint and resume the run here.
|
|
154
|
+
await this.completeRemoteResult(result);
|
|
155
|
+
},
|
|
156
|
+
// A heartbeat for an in-flight long step resets its liveness window (see callRemote).
|
|
157
|
+
async (beat) => {
|
|
158
|
+
this.heartbeatResets.get(beat.stepId)?.();
|
|
159
|
+
},
|
|
160
|
+
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
161
|
+
// it's checkpointed live, not all-at-once when the long turn ends.
|
|
162
|
+
async (event) => {
|
|
163
|
+
await this.persistStepEvent(event);
|
|
164
|
+
});
|
|
165
|
+
// Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
|
|
166
|
+
// subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
|
|
167
|
+
// echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
|
|
168
|
+
this.controlPlane?.onControl((msg) => {
|
|
169
|
+
if (msg.from === this.instanceId)
|
|
170
|
+
return;
|
|
171
|
+
if (msg.kind === 'event') {
|
|
172
|
+
// `at` may be a string after JSON transit (Redis) — normalize back to a Date.
|
|
173
|
+
this.deliver({ ...msg.event, at: new Date(msg.event.at) });
|
|
174
|
+
}
|
|
175
|
+
else if (msg.kind === 'cancel') {
|
|
176
|
+
this.notifyCancelled(msg.runId);
|
|
177
|
+
}
|
|
178
|
+
else if (msg.kind === 'enqueued') {
|
|
179
|
+
// A run was enqueued on another instance — let worker subscribers pick it up immediately
|
|
180
|
+
// instead of waiting for the next poll. (Self-broadcasts are already filtered above.)
|
|
181
|
+
for (const fn of this.enqueuedListeners) {
|
|
182
|
+
try {
|
|
183
|
+
fn(msg.runId);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* an enqueue listener must not break the engine */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
this.accumulators = new EventAccumulators(this);
|
|
192
|
+
this.entities = new Entities(this);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
|
|
196
|
+
* per key** over **durable state**, exactly once. Drive it with `signalEntity` (fire) /
|
|
197
|
+
* `ctx.callEntity` (call + await result) and read its state with `getEntityState`. See {@link Entities}.
|
|
198
|
+
*/
|
|
199
|
+
registerEntity(name, config) {
|
|
200
|
+
this.entities.register(name, config);
|
|
201
|
+
}
|
|
202
|
+
/** Send an operation to an entity (fire-and-forget). Ordered + exactly-once per key. */
|
|
203
|
+
signalEntity(name, key, op, arg) {
|
|
204
|
+
return this.entities.signal(name, key, op, arg);
|
|
205
|
+
}
|
|
206
|
+
/** Read an entity's current durable state (published after each op), or undefined if it has none yet. */
|
|
207
|
+
getEntityState(name, key) {
|
|
208
|
+
return this.entities.getState(name, key);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Be notified when a run is enqueued on ANOTHER instance (via the control plane), so a worker can
|
|
212
|
+
* pick it up at once — e.g. `engine.onEnqueued((runId) => engine.runOne(runId))`. Returns an
|
|
213
|
+
* unsubscribe function. Only wire this on instances that should execute runs (workers).
|
|
214
|
+
*/
|
|
215
|
+
onEnqueued(listener) {
|
|
216
|
+
this.enqueuedListeners.add(listener);
|
|
217
|
+
return () => this.enqueuedListeners.delete(listener);
|
|
218
|
+
}
|
|
219
|
+
/** Fire cooperative-cancellation listeners for `runId` (a worker bridge aborts in-flight work). */
|
|
220
|
+
notifyCancelled(runId) {
|
|
221
|
+
for (const fn of this.cancelListeners) {
|
|
222
|
+
try {
|
|
223
|
+
fn(runId);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* a cancel listener must not break the engine */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Register a workflow version. Register multiple versions of the same name to keep in-flight
|
|
232
|
+
* runs working across a breaking change: old runs resume on the version they started on, new
|
|
233
|
+
* runs start on the newest registered version.
|
|
234
|
+
*/
|
|
235
|
+
register(name, version, fn, opts) {
|
|
236
|
+
const registered = {
|
|
237
|
+
name,
|
|
238
|
+
version,
|
|
239
|
+
fn,
|
|
240
|
+
tags: opts?.tags,
|
|
241
|
+
singleton: opts?.singleton,
|
|
242
|
+
executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
|
|
243
|
+
validateInput: opts?.validateInput,
|
|
244
|
+
onEvent: opts?.onEvent,
|
|
245
|
+
eventBatch: opts?.eventBatch,
|
|
246
|
+
};
|
|
247
|
+
this.workflows.set(versionKey(name, version), registered);
|
|
248
|
+
const current = this.latest.get(name);
|
|
249
|
+
if (!current || isNewerVersion(version, current.version))
|
|
250
|
+
this.latest.set(name, registered);
|
|
251
|
+
for (const event of opts?.onEvent ?? []) {
|
|
252
|
+
const subscribers = this.eventTriggers.get(event) ?? new Set();
|
|
253
|
+
subscribers.add(name);
|
|
254
|
+
this.eventTriggers.set(event, subscribers);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
|
|
259
|
+
* as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
|
|
260
|
+
* the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
|
|
261
|
+
* the {@link WorkflowDecision} the worker's replay returns. The worker never touches the store.
|
|
262
|
+
*/
|
|
263
|
+
registerRemote(name, version, opts) {
|
|
264
|
+
const registered = {
|
|
265
|
+
name,
|
|
266
|
+
version,
|
|
267
|
+
// A remote workflow has no in-process body; execute() branches on `remote` before this is read.
|
|
268
|
+
fn: () => {
|
|
269
|
+
throw new Error(`workflow ${name} is remote — it has no in-process body`);
|
|
270
|
+
},
|
|
271
|
+
tags: opts.tags,
|
|
272
|
+
singleton: opts.singleton,
|
|
273
|
+
executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
|
|
274
|
+
validateInput: opts.validateInput,
|
|
275
|
+
remote: { group: opts.group, executor: opts.executor },
|
|
276
|
+
};
|
|
277
|
+
this.workflows.set(versionKey(name, version), registered);
|
|
278
|
+
const current = this.latest.get(name);
|
|
279
|
+
if (!current || isNewerVersion(version, current.version))
|
|
280
|
+
this.latest.set(name, registered);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
|
|
284
|
+
* in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
|
|
285
|
+
* durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
|
|
286
|
+
*/
|
|
287
|
+
registerQueue(config) {
|
|
288
|
+
this.admission.register(config);
|
|
289
|
+
}
|
|
290
|
+
/** Subscribe to lifecycle events. Returns an unsubscribe function. */
|
|
291
|
+
subscribe(listener) {
|
|
292
|
+
this.listeners.add(listener);
|
|
293
|
+
return () => this.listeners.delete(listener);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Register a {@link StepInterceptor} — onion middleware run around the real execution of every
|
|
297
|
+
* local `ctx.step` (timing, logging, tracing, error enrichment, context propagation). First
|
|
298
|
+
* registered is outermost; interceptors fire only when a step executes, never on replay. Returns
|
|
299
|
+
* an unsubscribe function.
|
|
300
|
+
*/
|
|
301
|
+
use(interceptor) {
|
|
302
|
+
this.interceptors.push(interceptor);
|
|
303
|
+
return () => {
|
|
304
|
+
const i = this.interceptors.indexOf(interceptor);
|
|
305
|
+
if (i >= 0)
|
|
306
|
+
this.interceptors.splice(i, 1);
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Fold the registered interceptors around a local step body (identity when there are none), then
|
|
311
|
+
* run the whole thing inside the re-hydrated originating context. The carrier is read at execution
|
|
312
|
+
* time from {@link context} — the SAME reader stamped on dispatched remote tasks — so a local step
|
|
313
|
+
* sees the live tenant / user / trace ids ambiently (via the {@link rehydrate} bridge). Default
|
|
314
|
+
* `rehydrate` is a passthrough, so this is byte-identical to a bare body call when unwired.
|
|
315
|
+
*/
|
|
316
|
+
interceptStep(invocation, body) {
|
|
317
|
+
const carrier = this.context?.();
|
|
318
|
+
const run = () => {
|
|
319
|
+
if (this.interceptors.length === 0)
|
|
320
|
+
return body();
|
|
321
|
+
const chain = this.interceptors.reduceRight((next, interceptor) => () => interceptor(invocation, next), body);
|
|
322
|
+
return chain();
|
|
323
|
+
};
|
|
324
|
+
return this.rehydrate(carrier, run);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Be notified when a run is cancelled — on ANY instance, via the transport control plane. A
|
|
328
|
+
* worker bridge can use this for cooperative cancellation: abort the in-flight work for `runId`
|
|
329
|
+
* instead of finishing it just to have the result discarded. Returns an unsubscribe function.
|
|
330
|
+
*/
|
|
331
|
+
onCancel(listener) {
|
|
332
|
+
this.cancelListeners.add(listener);
|
|
333
|
+
return () => this.cancelListeners.delete(listener);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Be notified when a run is **dead-lettered** — moved to `dead` after exceeding
|
|
337
|
+
* `maxRecoveryAttempts`. The listener receives the dead run (status `dead`, with its error), so a
|
|
338
|
+
* DLQ handler can do something other than just leaving it parked: alert, push to a real queue, or
|
|
339
|
+
* start a dead-letter workflow (e.g. `engine.onDead((run) => engine.start('pipeline-dlq', run, ...))`).
|
|
340
|
+
* Returns an unsubscribe function.
|
|
341
|
+
*/
|
|
342
|
+
onDead(listener) {
|
|
343
|
+
this.deadListeners.add(listener);
|
|
344
|
+
return () => this.deadListeners.delete(listener);
|
|
345
|
+
}
|
|
346
|
+
notifyDead(run) {
|
|
347
|
+
for (const fn of this.deadListeners) {
|
|
348
|
+
try {
|
|
349
|
+
fn(run);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
/* a dead-letter handler must not break recovery */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
|
|
357
|
+
* control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
|
|
358
|
+
emit(event) {
|
|
359
|
+
const full = { ...event, at: new Date() };
|
|
360
|
+
this.deliver(full);
|
|
361
|
+
if (this.controlPlane) {
|
|
362
|
+
void this.controlPlane
|
|
363
|
+
.publishControl({ kind: 'event', event: full, from: this.instanceId })
|
|
364
|
+
.catch(() => {
|
|
365
|
+
// control-plane delivery is best-effort observability; never break execution
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Deliver an event to local subscribers only (no re-broadcast) — used for both locally-produced
|
|
370
|
+
* events and ones received from the control plane, so an event shows up once on every instance. */
|
|
371
|
+
deliver(event) {
|
|
372
|
+
for (const listener of this.listeners) {
|
|
373
|
+
try {
|
|
374
|
+
listener(event);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// a misbehaving subscriber must never break workflow execution
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async start(workflow, input, runId, opts) {
|
|
382
|
+
const name = workflowName(workflow);
|
|
383
|
+
const registered = this.latest.get(name);
|
|
384
|
+
if (!registered)
|
|
385
|
+
throw new Error(`workflow ${name} is not registered`);
|
|
386
|
+
// Validate the input up front — a bad payload is rejected before any run is created.
|
|
387
|
+
await registered.validateInput?.(input);
|
|
388
|
+
// Idempotent by run id: a redelivered trigger (at-least-once queues) or a scheduler re-tick for
|
|
389
|
+
// the same id is a no-op, returning the existing run's state instead of starting a duplicate.
|
|
390
|
+
const prior = await this.store.getRun(runId);
|
|
391
|
+
if (prior) {
|
|
392
|
+
return { runId, status: prior.status, output: prior.output, error: prior.error };
|
|
393
|
+
}
|
|
394
|
+
const now = new Date();
|
|
395
|
+
// A singleton workflow stamps a `singleton:<key>` tag so the admission gate (in execute) can find
|
|
396
|
+
// the other in-flight runs sharing the key via a tag+status query.
|
|
397
|
+
const tags = mergeTags(registered.tags, registered.singleton
|
|
398
|
+
? [...(opts?.tags ?? []), this.singletons.tag(registered.singleton, input)]
|
|
399
|
+
: opts?.tags);
|
|
400
|
+
// Singleton back-pressure: reject a start that would grow the same-key backlog past
|
|
401
|
+
// `limit + maxQueueDepth` (no-op when no maxQueueDepth is configured).
|
|
402
|
+
if (registered.singleton) {
|
|
403
|
+
await this.singletons.assertCapacity(name, registered.singleton, input);
|
|
404
|
+
}
|
|
405
|
+
const run = {
|
|
406
|
+
id: runId,
|
|
407
|
+
workflow: name,
|
|
408
|
+
workflowVersion: registered.version,
|
|
409
|
+
status: 'pending',
|
|
410
|
+
input,
|
|
411
|
+
tags,
|
|
412
|
+
searchAttributes: opts?.searchAttributes,
|
|
413
|
+
priority: opts?.priority,
|
|
414
|
+
createdAt: now,
|
|
415
|
+
updatedAt: now,
|
|
416
|
+
};
|
|
417
|
+
await this.store.createRun(run);
|
|
418
|
+
// The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
|
|
419
|
+
// NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
|
|
420
|
+
await this.runDispatcher.dispatch(runId);
|
|
421
|
+
// Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
|
|
422
|
+
// plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
|
|
423
|
+
if (this.controlPlane) {
|
|
424
|
+
void this.controlPlane
|
|
425
|
+
.publishControl({ kind: 'enqueued', runId, from: this.instanceId })
|
|
426
|
+
.catch(() => undefined);
|
|
427
|
+
}
|
|
428
|
+
return { runId, status: 'pending' };
|
|
429
|
+
}
|
|
430
|
+
/** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
|
|
431
|
+
getRun(runId) {
|
|
432
|
+
return this.store.getRun(runId);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* List persisted runs matching `query` (newest activity first, per the store's own ordering). The
|
|
436
|
+
* engine's read API for inspection surfaces (the dashboard, the `durable:runs` CLI) so they query
|
|
437
|
+
* through the engine rather than reaching for its private store.
|
|
438
|
+
*/
|
|
439
|
+
listRuns(query) {
|
|
440
|
+
return this.store.listRuns(query);
|
|
441
|
+
}
|
|
442
|
+
/** List a run's step checkpoints (its timeline). Part of the engine's read API; see {@link listRuns}. */
|
|
443
|
+
listCheckpoints(runId) {
|
|
444
|
+
return this.store.listCheckpoints(runId);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Hard-delete a run and its whole child subtree from the store, returning the number of runs
|
|
448
|
+
* removed (0 if `runId` is unknown). Children are collected BEFORE this run's checkpoints are
|
|
449
|
+
* deleted (the parent→children edge is read from them). Use for retention/cleanup; this is a
|
|
450
|
+
* destructive store operation, not a cancellation — a running run should be {@link cancel}led first.
|
|
451
|
+
*/
|
|
452
|
+
async deleteRun(runId) {
|
|
453
|
+
const run = await this.store.getRun(runId);
|
|
454
|
+
if (!run)
|
|
455
|
+
return 0;
|
|
456
|
+
// Collect children BEFORE deleting this run's checkpoints (getRunChildren reads them).
|
|
457
|
+
let deleted = 0;
|
|
458
|
+
for (const childId of await this.getRunChildren(runId)) {
|
|
459
|
+
deleted += await this.deleteRun(childId);
|
|
460
|
+
}
|
|
461
|
+
await this.store.deleteRun(runId);
|
|
462
|
+
return deleted + 1;
|
|
463
|
+
}
|
|
464
|
+
async resume(runId) {
|
|
465
|
+
const run = await this.store.getRun(runId);
|
|
466
|
+
if (!run)
|
|
467
|
+
throw new Error(`run ${runId} not found`);
|
|
468
|
+
// A definitively-finished run must not be re-executed (e.g. a worker result landing after the
|
|
469
|
+
// run was cancelled, or a duplicate resume) — that would replay the body and clobber the
|
|
470
|
+
// terminal state. `failed` is intentionally NOT terminal here: retry resumes a failed run.
|
|
471
|
+
if (run.status === 'cancelled' || run.status === 'completed' || run.status === 'dead') {
|
|
472
|
+
return { runId, status: run.status, output: run.output, error: run.error };
|
|
473
|
+
}
|
|
474
|
+
// Pin to the version the run STARTED on — replay is positional, so running a changed
|
|
475
|
+
// workflow body against old checkpoints would corrupt the run.
|
|
476
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
477
|
+
if (!registered) {
|
|
478
|
+
throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
|
|
479
|
+
}
|
|
480
|
+
return this.track(this.execute(run, registered.fn));
|
|
481
|
+
}
|
|
482
|
+
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
483
|
+
track(p) {
|
|
484
|
+
this.inflight.add(p);
|
|
485
|
+
void p.finally(() => this.inflight.delete(p));
|
|
486
|
+
return p;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
|
|
490
|
+
* in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
|
|
491
|
+
* deploy hands off cleanly instead of leaving runs to the lease timeout.
|
|
492
|
+
*/
|
|
493
|
+
async drain(timeoutMs = 10_000) {
|
|
494
|
+
this.draining = true;
|
|
495
|
+
if (this.inflight.size === 0)
|
|
496
|
+
return;
|
|
497
|
+
const timer = new Promise((resolve) => {
|
|
498
|
+
const t = setTimeout(resolve, timeoutMs);
|
|
499
|
+
t.unref?.();
|
|
500
|
+
});
|
|
501
|
+
await Promise.race([Promise.allSettled([...this.inflight]), timer]);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
|
|
505
|
+
* timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
|
|
506
|
+
* `execution_timeout` error (terminal, so a late step result can't resurrect it).
|
|
507
|
+
*/
|
|
508
|
+
async sweepTimeouts(now = this.clock()) {
|
|
509
|
+
for (const reg of new Set(this.latest.values())) {
|
|
510
|
+
if (reg.executionTimeoutMs == null)
|
|
511
|
+
continue;
|
|
512
|
+
const deadline = now - reg.executionTimeoutMs;
|
|
513
|
+
const inflight = [
|
|
514
|
+
...(await this.store.listRuns({ workflow: reg.name, status: 'running' })),
|
|
515
|
+
...(await this.store.listRuns({ workflow: reg.name, status: 'suspended' })),
|
|
516
|
+
];
|
|
517
|
+
for (const run of inflight) {
|
|
518
|
+
if (run.createdAt.getTime() > deadline)
|
|
519
|
+
continue;
|
|
520
|
+
const error = { message: 'execution timeout', code: 'execution_timeout' };
|
|
521
|
+
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
522
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Resume every run left incomplete by a crash or deploy. Called on boot. Completed steps
|
|
528
|
+
* replay from their checkpoints, so only the work that had not finished runs again.
|
|
529
|
+
*/
|
|
530
|
+
async recoverIncomplete(nowMs = this.clock()) {
|
|
531
|
+
if (this.draining)
|
|
532
|
+
return [];
|
|
533
|
+
const results = [];
|
|
534
|
+
for (const run of await this.store.listIncompleteRuns()) {
|
|
535
|
+
// A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
|
|
536
|
+
// (its worker crashed). Skip the ones still owned.
|
|
537
|
+
const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
538
|
+
if (!acquired)
|
|
539
|
+
continue;
|
|
540
|
+
// Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
|
|
541
|
+
const settled = await this.countRecovery(run);
|
|
542
|
+
if (settled) {
|
|
543
|
+
results.push(settled);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
// Re-enqueue rather than resume inline: recovery must NOT block (boot, or a poll tick) on a
|
|
547
|
+
// long workflow step. A dispatcher/worker re-runs it, replaying its checkpoints.
|
|
548
|
+
await this.store.releaseRunLock(run.id);
|
|
549
|
+
await this.store.updateRun(run.id, { status: 'pending', updatedAt: new Date() });
|
|
550
|
+
await this.runDispatcher.dispatch(run.id);
|
|
551
|
+
results.push({ runId: run.id, status: 'pending' });
|
|
552
|
+
}
|
|
553
|
+
return results;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
|
|
557
|
+
* `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
|
|
558
|
+
* result to skip the resume, or `undefined` to proceed.
|
|
559
|
+
*/
|
|
560
|
+
async countRecovery(run) {
|
|
561
|
+
const attempts = (run.recoveryAttempts ?? 0) + 1;
|
|
562
|
+
if (this.maxRecoveryAttempts != null && attempts > this.maxRecoveryAttempts) {
|
|
563
|
+
const error = {
|
|
564
|
+
message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
|
|
565
|
+
code: 'max_recovery_attempts',
|
|
566
|
+
};
|
|
567
|
+
await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
|
|
568
|
+
await this.store.releaseRunLock(run.id);
|
|
569
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
570
|
+
this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
|
|
571
|
+
return { runId: run.id, status: 'dead', error };
|
|
572
|
+
}
|
|
573
|
+
// Count BEFORE resuming, so a crash mid-resume still advances the counter.
|
|
574
|
+
await this.store.updateRun(run.id, { recoveryAttempts: attempts, updatedAt: new Date() });
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
|
|
579
|
+
* boot. A run still not due re-suspends cheaply without running new work.
|
|
580
|
+
*/
|
|
581
|
+
async resumeDueTimers(nowMs = this.clock()) {
|
|
582
|
+
return this.resumeLeased(await this.store.listDueTimers(nowMs), nowMs);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
|
|
586
|
+
* exactly one instance runs it), then resumes the body. Returns the result, or null if another
|
|
587
|
+
* instance holds the lease or the engine is draining. The default in-process dispatcher calls this;
|
|
588
|
+
* a broker-backed worker calls it per consumed run id.
|
|
589
|
+
*/
|
|
590
|
+
async runOne(runId) {
|
|
591
|
+
if (this.draining)
|
|
592
|
+
return null;
|
|
593
|
+
const nowMs = this.clock();
|
|
594
|
+
const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
595
|
+
if (!acquired)
|
|
596
|
+
return null;
|
|
597
|
+
return this.resume(runId);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Re-enqueue a run for a worker to (re-)execute — the dispatch-model **retry**. Sets it back to
|
|
601
|
+
* `pending`, clears any stale lease, and dispatches; a worker resumes it (replaying its checkpoints,
|
|
602
|
+
* re-attempting the failed step). Returns the enqueued state immediately — never runs the body
|
|
603
|
+
* inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
|
|
604
|
+
* the HTTP request on workflow execution.
|
|
605
|
+
*/
|
|
606
|
+
async requeue(runId) {
|
|
607
|
+
const run = await this.store.getRun(runId);
|
|
608
|
+
if (!run)
|
|
609
|
+
return null;
|
|
610
|
+
await this.store.releaseRunLock(runId);
|
|
611
|
+
await this.store.updateRun(runId, { status: 'pending', updatedAt: new Date() });
|
|
612
|
+
await this.runDispatcher.dispatch(runId);
|
|
613
|
+
return { runId, status: 'pending' };
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
|
|
617
|
+
* fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
|
|
618
|
+
* the original stays inspectable. Returns the new run's id, or null if `runId` is unknown.
|
|
619
|
+
*/
|
|
620
|
+
async retryWithInput(runId, input, newRunId) {
|
|
621
|
+
const run = await this.store.getRun(runId);
|
|
622
|
+
if (!run)
|
|
623
|
+
return null;
|
|
624
|
+
const id = newRunId ?? `${runId}~retry~${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
625
|
+
await this.start(run.workflow, input, id, { tags: run.tags });
|
|
626
|
+
return { runId: id };
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Pick up and execute every `pending` run — the poll-based side of dispatch for a worker pod with
|
|
630
|
+
* no broker. Runs enqueued by other pods (or by a caller using a no-op dispatcher) sit `pending`
|
|
631
|
+
* in the store until polled; leasing ensures exactly one pod runs each. Call periodically alongside
|
|
632
|
+
* {@link resumeDueTimers}.
|
|
633
|
+
*/
|
|
634
|
+
async runPending(nowMs = this.clock()) {
|
|
635
|
+
// Oldest-first (FIFO), capped per call so a backlog drains over several polls without one sweep
|
|
636
|
+
// fetching unboundedly. A run not picked up this tick is picked up the next.
|
|
637
|
+
return this.resumeLeased(await this.store.listPendingRuns(100), nowMs);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
|
|
641
|
+
* suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
|
|
642
|
+
* `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
|
|
643
|
+
*/
|
|
644
|
+
waitForRun(runId, opts) {
|
|
645
|
+
const isSettled = (s) => s !== 'pending' && s !== 'running';
|
|
646
|
+
const toResult = (run) => ({
|
|
647
|
+
runId,
|
|
648
|
+
status: run.status,
|
|
649
|
+
output: run.output,
|
|
650
|
+
error: run.error,
|
|
651
|
+
});
|
|
652
|
+
return new Promise((resolve, reject) => {
|
|
653
|
+
let done = false;
|
|
654
|
+
let off = () => { };
|
|
655
|
+
const timer = opts?.timeoutMs != null
|
|
656
|
+
? setTimeout(() => {
|
|
657
|
+
if (done)
|
|
658
|
+
return;
|
|
659
|
+
done = true;
|
|
660
|
+
off();
|
|
661
|
+
reject(new Error(`waitForRun(${runId}) timed out after ${opts.timeoutMs}ms`));
|
|
662
|
+
}, opts.timeoutMs)
|
|
663
|
+
: undefined;
|
|
664
|
+
const finish = (run) => {
|
|
665
|
+
if (done)
|
|
666
|
+
return;
|
|
667
|
+
done = true;
|
|
668
|
+
if (timer)
|
|
669
|
+
clearTimeout(timer);
|
|
670
|
+
off();
|
|
671
|
+
resolve(toResult(run));
|
|
672
|
+
};
|
|
673
|
+
const check = () => {
|
|
674
|
+
void this.store.getRun(runId).then((run) => {
|
|
675
|
+
if (run && isSettled(run.status))
|
|
676
|
+
finish(run);
|
|
677
|
+
});
|
|
678
|
+
};
|
|
679
|
+
// React only to this run's settling events (not its every step event), and subscribe BEFORE the
|
|
680
|
+
// initial read so a run that settles in between isn't missed.
|
|
681
|
+
off = this.subscribe((ev) => {
|
|
682
|
+
if (ev.runId === runId &&
|
|
683
|
+
(ev.type === 'run.completed' || ev.type === 'run.failed' || ev.type === 'run.suspended')) {
|
|
684
|
+
check();
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
check();
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Resume each run only if this instance can acquire its recovery lease — so when several
|
|
692
|
+
* replicas recover or poll at once, each run is picked up by exactly one of them.
|
|
693
|
+
*/
|
|
694
|
+
async resumeLeased(runs, nowMs = this.clock(), onLocked) {
|
|
695
|
+
if (this.draining)
|
|
696
|
+
return []; // shutting down — don't pick up new runs
|
|
697
|
+
const results = [];
|
|
698
|
+
for (const run of runs) {
|
|
699
|
+
const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
700
|
+
if (!acquired)
|
|
701
|
+
continue;
|
|
702
|
+
// A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
|
|
703
|
+
const settled = onLocked ? await onLocked(run) : undefined;
|
|
704
|
+
results.push(settled ?? (await this.resume(run.id)));
|
|
705
|
+
}
|
|
706
|
+
return results;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Deliver an external signal to the run waiting on `token` and resume it with `payload`.
|
|
710
|
+
* Returns the run result, or null if no run is waiting for that token.
|
|
711
|
+
*/
|
|
712
|
+
/**
|
|
713
|
+
* Publish a named event. It does two things, and returns how many runs it touched (the sum):
|
|
714
|
+
* 1. **Resumes** every in-flight run waiting on it via `ctx.waitForEvent(name, { match })` whose
|
|
715
|
+
* match the payload satisfies (fan-out, vs `signal`'s point-to-point token).
|
|
716
|
+
* 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
|
|
717
|
+
* payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
|
|
718
|
+
* of the same logical event (default: a fresh uuid, so each publish triggers once).
|
|
719
|
+
*/
|
|
720
|
+
async publishEvent(name, payload, opts) {
|
|
721
|
+
let touched = 0;
|
|
722
|
+
const waiters = await this.store.listSignalWaiters(eventPrefix(name));
|
|
723
|
+
for (const w of waiters) {
|
|
724
|
+
if (eventMatches(payload, eventMatchOf(w.token))) {
|
|
725
|
+
await this.signal(w.token, payload);
|
|
726
|
+
touched += 1;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const subscribers = this.eventTriggers.get(name);
|
|
730
|
+
if (subscribers?.size) {
|
|
731
|
+
const eventId = opts?.id ?? globalThis.crypto.randomUUID();
|
|
732
|
+
for (const workflow of subscribers) {
|
|
733
|
+
// A subscriber that rejects the payload (validateInput) must not block the others or the
|
|
734
|
+
// waiters — its run simply never starts, mirroring fire-and-forget dead-letter routing.
|
|
735
|
+
try {
|
|
736
|
+
const batch = this.latest.get(workflow)?.eventBatch;
|
|
737
|
+
if (batch) {
|
|
738
|
+
// Coalesce: route the event into a per-workflow accumulator (one long-lived run that
|
|
739
|
+
// debounces/batches and then starts the target with the collected payload(s)).
|
|
740
|
+
await this.accumulators.route(workflow, batch, payload);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
await this.start(workflow, payload, `evt:${eventId}:${workflow}`);
|
|
744
|
+
}
|
|
745
|
+
touched += 1;
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// skip this subscriber
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return touched;
|
|
753
|
+
}
|
|
754
|
+
async signal(token, payload) {
|
|
755
|
+
const waiter = await this.store.takeSignalWaiter(token);
|
|
756
|
+
if (!waiter) {
|
|
757
|
+
// No one is waiting yet — buffer it so the next `waitForSignal(token)` consumes it instead of
|
|
758
|
+
// dropping it (reliable signals; the basis of `signalWithStart`).
|
|
759
|
+
await this.store.bufferSignal(token, payload);
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
await this.store.saveCheckpoint(instantCheckpoint({
|
|
763
|
+
runId: waiter.runId,
|
|
764
|
+
seq: waiter.seq,
|
|
765
|
+
name: `signal:${token}`,
|
|
766
|
+
kind: 'signal',
|
|
767
|
+
output: payload,
|
|
768
|
+
}));
|
|
769
|
+
return this.resume(waiter.runId);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
|
|
773
|
+
* signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
|
|
774
|
+
* consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
|
|
775
|
+
* pattern: one long-lived run per key (the `runId`) that loops on `waitForSignal`, fed events by
|
|
776
|
+
* many `signalWithStart` calls. `start` is idempotent by `runId`, so concurrent callers converge on
|
|
777
|
+
* one run. (Use a per-run `token`, e.g. derived from `runId`, so the signal targets this entity.)
|
|
778
|
+
*/
|
|
779
|
+
async signalWithStart(workflow, input, runId, signal, opts) {
|
|
780
|
+
// `start` is overloaded per ref kind (class | string); a `WorkflowRef` union fits neither
|
|
781
|
+
// overload, so resolve to the string overload (the engine handles both at runtime).
|
|
782
|
+
await this.start(workflow, input, runId, opts); // idempotent: no-op if run exists
|
|
783
|
+
await this.signal(signal.token, signal.payload);
|
|
784
|
+
return { runId };
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Report the result of a `ctx.task(name, …)` back to its run (async completion). The external
|
|
788
|
+
* worker that the task dispatched to calls this when done; the run resumes with `result`. Returns
|
|
789
|
+
* null if no run is waiting on the task (e.g. a duplicate/late delivery) — a safe no-op.
|
|
790
|
+
*/
|
|
791
|
+
async completeTask(runId, name, result) {
|
|
792
|
+
return this.signal(`task:${runId}:${name}`, {
|
|
793
|
+
ok: true,
|
|
794
|
+
value: result,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
/** Report that a `ctx.task` failed — the run resumes and throws a FatalError at the task. */
|
|
798
|
+
async failTask(runId, name, error) {
|
|
799
|
+
return this.signal(`task:${runId}:${name}`, { ok: false, error });
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Notify a parent that's waiting on `runId` as a child of its terminal outcome (the `ctx.child`
|
|
803
|
+
* rendezvous). A no-op when no parent is waiting, so `execute()` can call it on every run without
|
|
804
|
+
* knowing about the child feature.
|
|
805
|
+
*/
|
|
806
|
+
notifyParent(runId, completion) {
|
|
807
|
+
void this.signal(`child:${runId}`, completion).catch(() => undefined);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
|
|
811
|
+
* `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
|
|
812
|
+
* steps' compensations run in reverse (visible as `compensate:<step>` events), then it's marked
|
|
813
|
+
* cancelled. Without it, cancellation is immediate (no undo).
|
|
814
|
+
*/
|
|
815
|
+
async cancel(runId, opts) {
|
|
816
|
+
const run = await this.store.getRun(runId);
|
|
817
|
+
if (!run)
|
|
818
|
+
return null;
|
|
819
|
+
// Already finished — nothing to cancel (and don't clobber a completed/dead run). This also stops
|
|
820
|
+
// the child cascade below from looping on already-cancelled runs.
|
|
821
|
+
if (run.status === 'completed' || run.status === 'cancelled' || run.status === 'dead') {
|
|
822
|
+
return { runId, status: run.status, output: run.output, error: run.error };
|
|
823
|
+
}
|
|
824
|
+
// Compensating cancel: resume the run with a cancellation pending — replay re-registers the saga,
|
|
825
|
+
// and at the suspension point execute() runs the undo and marks it cancelled. Run that resume in
|
|
826
|
+
// the BACKGROUND so the caller (e.g. an HTTP request) never blocks on replaying the workflow +
|
|
827
|
+
// compensations. `execute` holds the run's lease, so this can't double-run one a live worker owns
|
|
828
|
+
// (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
|
|
829
|
+
if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
|
|
830
|
+
this.cancelRequested.add(runId);
|
|
831
|
+
if (this.controlPlane) {
|
|
832
|
+
void this.controlPlane
|
|
833
|
+
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
834
|
+
.catch(() => undefined);
|
|
835
|
+
}
|
|
836
|
+
queueMicrotask(() => {
|
|
837
|
+
void this.resume(runId)
|
|
838
|
+
.then(() => this.notifyCancelled(runId))
|
|
839
|
+
.catch(() => undefined);
|
|
840
|
+
});
|
|
841
|
+
await this.cancelChildren(runId, opts);
|
|
842
|
+
return { runId, status: run.status };
|
|
843
|
+
}
|
|
844
|
+
const error = { message: 'cancelled' };
|
|
845
|
+
await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
|
|
846
|
+
this.emit({ type: 'run.failed', runId, workflow: run.workflow, error });
|
|
847
|
+
await this.cancelChildren(runId, opts);
|
|
848
|
+
// Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
|
|
849
|
+
// actually running this run learns of it and can abort cooperatively (the store already records
|
|
850
|
+
// `cancelled`, but a busy worker won't re-read it).
|
|
851
|
+
this.notifyCancelled(runId);
|
|
852
|
+
if (this.controlPlane) {
|
|
853
|
+
void this.controlPlane
|
|
854
|
+
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
855
|
+
.catch(() => undefined);
|
|
856
|
+
}
|
|
857
|
+
// A cancelled singleton run frees its slot — wake the next gated waiter now (notify-on-release).
|
|
858
|
+
void this.singletons.wakeNext(run).catch(() => undefined);
|
|
859
|
+
return { runId, status: 'cancelled', error };
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Bulk-cancel every run matching a filter — e.g. cancel all `order` runs tagged `vip`, or every run
|
|
863
|
+
* whose `tier` search attribute is `free`. The filter is a {@link RunQuery} (workflow / status / tag
|
|
864
|
+
* / search-attribute predicates), so it reuses the same matching the dashboard list uses. Each match
|
|
865
|
+
* is run through {@link cancel}, so the same plumbing applies per run: child cascade, the optional
|
|
866
|
+
* saga `compensate`, local cancel listeners, and the control-plane broadcast that tells the owning
|
|
867
|
+
* worker to abort. Returns one {@link RunResult} per matched run (already-finished matches report
|
|
868
|
+
* their terminal status — `cancel` is a no-op on them, never clobbering a completed/dead run).
|
|
869
|
+
*/
|
|
870
|
+
async cancelWhere(filter, opts) {
|
|
871
|
+
const runs = await this.store.listRuns(filter);
|
|
872
|
+
const results = [];
|
|
873
|
+
for (const run of runs) {
|
|
874
|
+
const r = await this.cancel(run.id, opts);
|
|
875
|
+
if (r)
|
|
876
|
+
results.push(r);
|
|
877
|
+
}
|
|
878
|
+
return results;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Cascade cancellation to a run's children — both awaited (`ctx.child`, found via its live
|
|
882
|
+
* `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
|
|
883
|
+
* checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
|
|
884
|
+
* at finished / already-cancelled runs (no loops, no re-cancel).
|
|
885
|
+
*/
|
|
886
|
+
async cancelChildren(parentRunId, opts) {
|
|
887
|
+
for (const id of await this.getRunChildren(parentRunId)) {
|
|
888
|
+
await this.cancel(id, opts).catch(() => undefined);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* The ids of the runs a run spawned — both awaited (`ctx.child`) and fire-and-forget
|
|
893
|
+
* (`ctx.startChild`, found via its `spawn:<id>` checkpoint). The canonical parent→children edge,
|
|
894
|
+
* used for both cancellation cascades and the dashboard run-tree.
|
|
895
|
+
*
|
|
896
|
+
* An awaited child is discovered two ways, because the live `child:<id>` waiter only exists WHILE
|
|
897
|
+
* the parent is suspended on it: the waiter resolves and is consumed the moment the child settles,
|
|
898
|
+
* so a completed parent (or a completed awaited child) would otherwise drop out of the tree. The
|
|
899
|
+
* `signal:child:<id>` checkpoint (the placeholder written when the parent first awaits the child,
|
|
900
|
+
* overwritten as completed/failed when it settles) persists across completion, so we read both and
|
|
901
|
+
* dedupe — the edge stays stable for a finished run, not just a live one.
|
|
902
|
+
*/
|
|
903
|
+
async getRunChildren(parentRunId) {
|
|
904
|
+
const childIds = new Set();
|
|
905
|
+
for (const w of await this.store.listSignalWaiters('child:')) {
|
|
906
|
+
if (w.runId === parentRunId)
|
|
907
|
+
childIds.add(w.token.slice('child:'.length));
|
|
908
|
+
}
|
|
909
|
+
// Targeted read: only the `signal:child:` / `spawn:` checkpoints, not the whole history. Falls
|
|
910
|
+
// back to a full listCheckpoints + in-JS prefix scan for a custom store that omits the method.
|
|
911
|
+
const prefixes = ['signal:child:', 'spawn:'];
|
|
912
|
+
const childCheckpoints = this.store.listCheckpointsByNamePrefix
|
|
913
|
+
? await this.store.listCheckpointsByNamePrefix(parentRunId, prefixes)
|
|
914
|
+
: (await this.store.listCheckpoints(parentRunId)).filter((cp) => prefixes.some((p) => cp.name.startsWith(p)));
|
|
915
|
+
for (const cp of childCheckpoints) {
|
|
916
|
+
if (cp.name.startsWith('signal:child:'))
|
|
917
|
+
childIds.add(cp.name.slice('signal:child:'.length));
|
|
918
|
+
if (cp.name.startsWith('spawn:') && typeof cp.output === 'string')
|
|
919
|
+
childIds.add(cp.output);
|
|
920
|
+
}
|
|
921
|
+
return [...childIds];
|
|
922
|
+
}
|
|
923
|
+
/** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
|
|
924
|
+
* `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
|
|
925
|
+
* workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
|
|
926
|
+
* with backlog and ZERO workers is still reported (the alert case has no heartbeat to discover). */
|
|
927
|
+
knownGroups(extra = []) {
|
|
928
|
+
const groups = new Set(extra);
|
|
929
|
+
for (const def of this.workflows.values()) {
|
|
930
|
+
if (def.remote?.group)
|
|
931
|
+
groups.add(def.remote.group);
|
|
932
|
+
}
|
|
933
|
+
return [...groups];
|
|
934
|
+
}
|
|
935
|
+
/** Per-group worker health (queue backlog + live worker heartbeats). Covers {@link knownGroups}
|
|
936
|
+
* (so a registered group with backlog and ZERO workers still reports — the alert case) UNION the
|
|
937
|
+
* groups discovered from live heartbeats (so a local-step group like `pipeline`, not derivable
|
|
938
|
+
* from registrations, shows once its workers beat). Empty when no transport can introspect health
|
|
939
|
+
* (only the BullMQ transport implements `groupHealth`). */
|
|
940
|
+
async workerHealth(extra = []) {
|
|
941
|
+
const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
|
|
942
|
+
const out = [];
|
|
943
|
+
for (const group of groups) {
|
|
944
|
+
const health = await this.pool.groupHealth(group);
|
|
945
|
+
if (health)
|
|
946
|
+
out.push(health);
|
|
947
|
+
}
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Persist a streamed local-step lifecycle event from a remote workflow worker (see
|
|
952
|
+
* {@link WorkflowStepEvent}). A Python `@workflow` runs its `ctx.step`s inline over one turn that
|
|
953
|
+
* can last minutes; the worker streams each step's start/finish so the engine checkpoints it LIVE —
|
|
954
|
+
* a step shows `running` the moment its body begins, then resolves to `completed`/`failed` with its
|
|
955
|
+
* real wall-clock window and sub-process events — instead of every step appearing at once when the
|
|
956
|
+
* turn ends. The turn's final `recordStep` command re-persists the same (runId, seq) checkpoint
|
|
957
|
+
* idempotently, so this is purely additive observability and never changes the run's outcome.
|
|
958
|
+
*/
|
|
959
|
+
async persistStepEvent(event) {
|
|
960
|
+
const startedAt = new Date(event.startedAt);
|
|
961
|
+
if (event.phase === 'running') {
|
|
962
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
963
|
+
runId: event.runId,
|
|
964
|
+
seq: event.seq,
|
|
965
|
+
name: event.name,
|
|
966
|
+
kind: 'local',
|
|
967
|
+
status: 'running',
|
|
968
|
+
events: event.events,
|
|
969
|
+
attempts: 1,
|
|
970
|
+
enqueuedAt: startedAt,
|
|
971
|
+
startedAt,
|
|
972
|
+
finishedAt: startedAt, // placeholder until the step settles
|
|
973
|
+
}));
|
|
974
|
+
this.emit({
|
|
975
|
+
type: 'step.started',
|
|
976
|
+
runId: event.runId,
|
|
977
|
+
seq: event.seq,
|
|
978
|
+
name: event.name,
|
|
979
|
+
kind: 'local',
|
|
980
|
+
});
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const failed = event.phase === 'failed';
|
|
984
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
985
|
+
runId: event.runId,
|
|
986
|
+
seq: event.seq,
|
|
987
|
+
name: event.name,
|
|
988
|
+
kind: 'local',
|
|
989
|
+
status: failed ? 'failed' : 'completed',
|
|
990
|
+
output: failed ? undefined : event.output,
|
|
991
|
+
error: failed ? event.error : undefined,
|
|
992
|
+
events: event.events,
|
|
993
|
+
attempts: 1,
|
|
994
|
+
enqueuedAt: startedAt,
|
|
995
|
+
startedAt,
|
|
996
|
+
finishedAt: event.finishedAt != null ? new Date(event.finishedAt) : new Date(),
|
|
997
|
+
}));
|
|
998
|
+
this.emit({
|
|
999
|
+
type: failed ? 'step.failed' : 'step.completed',
|
|
1000
|
+
runId: event.runId,
|
|
1001
|
+
seq: event.seq,
|
|
1002
|
+
name: event.name,
|
|
1003
|
+
kind: 'local',
|
|
1004
|
+
output: failed ? undefined : event.output,
|
|
1005
|
+
error: failed ? event.error : undefined,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Announce a local step's body has begun and (when `trackStepStart`) checkpoint it as `running`,
|
|
1010
|
+
* so it's visible in flight rather than appearing only on completion. The checkpoint is a
|
|
1011
|
+
* placeholder overwritten by {@link completeStep}/{@link failStep}; it never short-circuits replay
|
|
1012
|
+
* (only `completed` does), so a crash mid-body just re-runs the step. The `step.started` event
|
|
1013
|
+
* fires regardless — the live SSE view sees the start even with persistence off.
|
|
1014
|
+
*/
|
|
1015
|
+
async startStep(step) {
|
|
1016
|
+
if (this.trackStepStart) {
|
|
1017
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1018
|
+
runId: step.runId,
|
|
1019
|
+
seq: step.seq,
|
|
1020
|
+
name: step.name,
|
|
1021
|
+
kind: step.kind,
|
|
1022
|
+
status: 'running',
|
|
1023
|
+
input: step.input,
|
|
1024
|
+
events: step.events,
|
|
1025
|
+
attempts: step.attempts,
|
|
1026
|
+
workerGroup: step.workerGroup,
|
|
1027
|
+
enqueuedAt: step.enqueuedAt,
|
|
1028
|
+
startedAt: step.startedAt,
|
|
1029
|
+
finishedAt: step.startedAt, // placeholder until the body settles
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
1032
|
+
this.emit({
|
|
1033
|
+
type: 'step.started',
|
|
1034
|
+
runId: step.runId,
|
|
1035
|
+
seq: step.seq,
|
|
1036
|
+
name: step.name,
|
|
1037
|
+
kind: step.kind,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/** Checkpoint a finished step and announce it — the two things that must always happen together. */
|
|
1041
|
+
async completeStep(step) {
|
|
1042
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1043
|
+
runId: step.runId,
|
|
1044
|
+
seq: step.seq,
|
|
1045
|
+
name: step.name,
|
|
1046
|
+
kind: step.kind,
|
|
1047
|
+
status: 'completed',
|
|
1048
|
+
input: step.input,
|
|
1049
|
+
output: step.output,
|
|
1050
|
+
events: step.events,
|
|
1051
|
+
attempts: step.attempts,
|
|
1052
|
+
workerGroup: step.workerGroup,
|
|
1053
|
+
enqueuedAt: step.enqueuedAt,
|
|
1054
|
+
startedAt: step.startedAt,
|
|
1055
|
+
finishedAt: new Date(),
|
|
1056
|
+
}));
|
|
1057
|
+
this.emit({
|
|
1058
|
+
type: 'step.completed',
|
|
1059
|
+
runId: step.runId,
|
|
1060
|
+
seq: step.seq,
|
|
1061
|
+
name: step.name,
|
|
1062
|
+
kind: step.kind,
|
|
1063
|
+
output: step.output,
|
|
1064
|
+
queueMs: step.startedAt.getTime() - step.enqueuedAt.getTime(),
|
|
1065
|
+
durationMs: Date.now() - step.startedAt.getTime(),
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
/** Checkpoint a step that failed terminally, so the failure point is visible (not just the run). */
|
|
1069
|
+
async failStep(step) {
|
|
1070
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1071
|
+
runId: step.runId,
|
|
1072
|
+
seq: step.seq,
|
|
1073
|
+
name: step.name,
|
|
1074
|
+
kind: step.kind,
|
|
1075
|
+
status: 'failed',
|
|
1076
|
+
input: step.input,
|
|
1077
|
+
error: step.error,
|
|
1078
|
+
events: step.events,
|
|
1079
|
+
attempts: step.attempts,
|
|
1080
|
+
workerGroup: step.workerGroup,
|
|
1081
|
+
enqueuedAt: step.enqueuedAt,
|
|
1082
|
+
startedAt: step.startedAt,
|
|
1083
|
+
finishedAt: new Date(),
|
|
1084
|
+
}));
|
|
1085
|
+
this.emit({
|
|
1086
|
+
type: 'step.failed',
|
|
1087
|
+
runId: step.runId,
|
|
1088
|
+
seq: step.seq,
|
|
1089
|
+
name: step.name,
|
|
1090
|
+
kind: step.kind,
|
|
1091
|
+
error: step.error,
|
|
1092
|
+
queueMs: step.startedAt.getTime() - step.enqueuedAt.getTime(),
|
|
1093
|
+
durationMs: Date.now() - step.startedAt.getTime(),
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
async execute(run, fn) {
|
|
1097
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
1098
|
+
// Hold the lease for the WHOLE execution — whatever path got us here (leased sweep, a signal, a
|
|
1099
|
+
// remote result, a dashboard action). The leased sweeps already own it; the event-driven paths
|
|
1100
|
+
// don't, so acquire it here. If another instance owns it, don't double-run. While we run, renew
|
|
1101
|
+
// the lease periodically so a long run keeps it (a crashed worker's lease still expires and is
|
|
1102
|
+
// reclaimed by periodic recovery).
|
|
1103
|
+
const lockNow = this.clock();
|
|
1104
|
+
if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
|
|
1105
|
+
if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
|
|
1106
|
+
return { runId: run.id, status: run.status };
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const renew = setInterval(() => {
|
|
1110
|
+
void this.store
|
|
1111
|
+
.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
|
|
1112
|
+
.catch(() => undefined);
|
|
1113
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
1114
|
+
renew.unref?.();
|
|
1115
|
+
try {
|
|
1116
|
+
// A remote (e.g. Python) workflow is advanced by dispatching workflow tasks, not by running an
|
|
1117
|
+
// in-process body — but everything around it (lease, recovery, timers, the resume that lands us
|
|
1118
|
+
// here on a step result) is identical, so it branches here under the same lease.
|
|
1119
|
+
const result = registered?.remote
|
|
1120
|
+
? await this.runRemoteExecution(run, registered)
|
|
1121
|
+
: await this.runExecution(run, fn, registered);
|
|
1122
|
+
// Notify-on-release: a singleton run that just reached a terminal state freed a slot — wake the
|
|
1123
|
+
// next gated waiter(s) now instead of waiting for the ~1s retry timer. Fire-and-forget so it
|
|
1124
|
+
// never blocks the settling run; the durable timer is still the cross-instance/crash fallback.
|
|
1125
|
+
if (registered?.singleton &&
|
|
1126
|
+
(result.status === 'completed' ||
|
|
1127
|
+
result.status === 'failed' ||
|
|
1128
|
+
result.status === 'cancelled' ||
|
|
1129
|
+
result.status === 'dead')) {
|
|
1130
|
+
void this.singletons.wakeNext(run).catch(() => undefined);
|
|
1131
|
+
}
|
|
1132
|
+
return result;
|
|
1133
|
+
}
|
|
1134
|
+
finally {
|
|
1135
|
+
clearInterval(renew);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
|
|
1140
|
+
* a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
|
|
1141
|
+
* {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
|
|
1142
|
+
* us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
|
|
1143
|
+
*/
|
|
1144
|
+
/**
|
|
1145
|
+
* Apply a terminal/suspended transition once: persist the new run status, emit the matching
|
|
1146
|
+
* lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
|
|
1147
|
+
* executors funnel their completed/failed/suspended outcomes through here so a status update can
|
|
1148
|
+
* never drift from its event or `notifyParent` call. Does NOT touch the run lock — each executor
|
|
1149
|
+
* keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
|
|
1150
|
+
*/
|
|
1151
|
+
async settleRun(run, outcome) {
|
|
1152
|
+
const updatedAt = new Date();
|
|
1153
|
+
if (outcome.kind === 'completed') {
|
|
1154
|
+
// Clear any error from an earlier failed-then-retried attempt — a completed run is a success.
|
|
1155
|
+
await this.store.updateRun(run.id, {
|
|
1156
|
+
status: 'completed',
|
|
1157
|
+
output: outcome.output,
|
|
1158
|
+
error: undefined,
|
|
1159
|
+
updatedAt,
|
|
1160
|
+
});
|
|
1161
|
+
this.emit({
|
|
1162
|
+
type: 'run.completed',
|
|
1163
|
+
runId: run.id,
|
|
1164
|
+
workflow: run.workflow,
|
|
1165
|
+
output: outcome.output,
|
|
1166
|
+
});
|
|
1167
|
+
void this.notifyParent(run.id, { ok: true, value: outcome.output });
|
|
1168
|
+
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
1169
|
+
}
|
|
1170
|
+
if (outcome.kind === 'failed') {
|
|
1171
|
+
await this.store.updateRun(run.id, { status: 'failed', error: outcome.error, updatedAt });
|
|
1172
|
+
this.emit({
|
|
1173
|
+
type: 'run.failed',
|
|
1174
|
+
runId: run.id,
|
|
1175
|
+
workflow: run.workflow,
|
|
1176
|
+
error: outcome.error,
|
|
1177
|
+
});
|
|
1178
|
+
void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
|
|
1179
|
+
return { runId: run.id, status: 'failed', error: outcome.error };
|
|
1180
|
+
}
|
|
1181
|
+
await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
|
|
1182
|
+
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
|
|
1183
|
+
return { runId: run.id, status: 'suspended' };
|
|
1184
|
+
}
|
|
1185
|
+
async runRemoteExecution(run, registered) {
|
|
1186
|
+
const remote = registered.remote;
|
|
1187
|
+
if (run.status === 'pending') {
|
|
1188
|
+
await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
|
|
1189
|
+
run.status = 'running';
|
|
1190
|
+
this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
|
|
1191
|
+
}
|
|
1192
|
+
if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
1193
|
+
const wakeAt = this.singletons.retryWakeAt();
|
|
1194
|
+
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
1195
|
+
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
|
|
1196
|
+
await this.store.releaseRunLock(run.id);
|
|
1197
|
+
return { runId: run.id, status: 'suspended' };
|
|
1198
|
+
}
|
|
1199
|
+
const history = await this.remoteHistory(run.id);
|
|
1200
|
+
let decision;
|
|
1201
|
+
try {
|
|
1202
|
+
decision = await remote.executor.advance(run, history);
|
|
1203
|
+
}
|
|
1204
|
+
catch (err) {
|
|
1205
|
+
const error = {
|
|
1206
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1207
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
1208
|
+
};
|
|
1209
|
+
await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
|
|
1210
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
1211
|
+
return { runId: run.id, status: 'failed', error };
|
|
1212
|
+
}
|
|
1213
|
+
if (decision.status === 'completed') {
|
|
1214
|
+
// Persist the local steps THIS turn ran before marking the run done. A workflow that runs
|
|
1215
|
+
// straight to completion in a single turn (every step inline, never suspending — e.g. a Python
|
|
1216
|
+
// @workflow whose body is a sequence of ctx.step calls) emits ALL its recordStep commands on
|
|
1217
|
+
// this terminal turn; without applying them the run shows `completed` with zero recorded steps.
|
|
1218
|
+
// Only this turn's NEW steps are present (prior turns' steps replay as `found`, emitting no
|
|
1219
|
+
// command), so there's no duplication.
|
|
1220
|
+
await this.applyCommands(run, decision.commands);
|
|
1221
|
+
return this.settleRun(run, { kind: 'completed', output: decision.output });
|
|
1222
|
+
}
|
|
1223
|
+
if (decision.status === 'failed') {
|
|
1224
|
+
// Same as completed: persist the steps this turn ran — including the failed one (the worker
|
|
1225
|
+
// records a failed-step command before raising) — so the dashboard shows WHERE it failed.
|
|
1226
|
+
await this.applyCommands(run, decision.commands);
|
|
1227
|
+
return this.settleRun(run, {
|
|
1228
|
+
kind: 'failed',
|
|
1229
|
+
error: decision.error ?? { message: 'workflow failed' },
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
if (decision.status === 'cancelled') {
|
|
1233
|
+
// The worker bailed at an op boundary because the run was cancelled mid-turn — `cancel` already
|
|
1234
|
+
// set status=cancelled, cascaded to children and emitted the lifecycle event. Persist the steps
|
|
1235
|
+
// that DID run this turn (partial progress / where it stopped) and reassert `cancelled`
|
|
1236
|
+
// (idempotent with `cancel`'s write; preserves the existing error). The point is to NOT resurrect
|
|
1237
|
+
// the run to `suspended` or flip it to `failed` — both of which a normal turn result would do.
|
|
1238
|
+
await this.applyCommands(run, decision.commands);
|
|
1239
|
+
await this.store.updateRun(run.id, { status: 'cancelled', updatedAt: new Date() });
|
|
1240
|
+
return { runId: run.id, status: 'cancelled' };
|
|
1241
|
+
}
|
|
1242
|
+
// continue: persist any local steps the replay ran, dispatch the blocking ops, then suspend. When
|
|
1243
|
+
// those resolve (a result lands, a timer fires) `resume` brings us back for the next turn.
|
|
1244
|
+
const wakeAt = await this.applyCommands(run, decision.commands);
|
|
1245
|
+
return this.settleRun(run, { kind: 'suspended', wakeAt });
|
|
1246
|
+
}
|
|
1247
|
+
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
1248
|
+
async remoteHistory(runId) {
|
|
1249
|
+
const checkpoints = await this.store.listCheckpoints(runId);
|
|
1250
|
+
const kindOf = {
|
|
1251
|
+
remote: 'call',
|
|
1252
|
+
local: 'step',
|
|
1253
|
+
sleep: 'timer',
|
|
1254
|
+
signal: 'signal',
|
|
1255
|
+
};
|
|
1256
|
+
const events = [];
|
|
1257
|
+
for (const cp of checkpoints) {
|
|
1258
|
+
if (cp.status === 'completed' || cp.status === 'failed') {
|
|
1259
|
+
// A child run resolves THROUGH the signal machinery (a `child:<id>` waiter notified on the
|
|
1260
|
+
// child's terminal state), so its checkpoint is kind `signal` with a `signal:child:` name and
|
|
1261
|
+
// a Completion payload. Surface it as a `child` event with the value/error unwrapped.
|
|
1262
|
+
if (cp.kind === 'signal' && cp.name.startsWith('signal:child:')) {
|
|
1263
|
+
const completion = cp.output;
|
|
1264
|
+
events.push({
|
|
1265
|
+
seq: cp.seq,
|
|
1266
|
+
kind: 'child',
|
|
1267
|
+
output: completion?.ok ? completion.value : undefined,
|
|
1268
|
+
error: completion && completion.ok === false ? { message: completion.error } : undefined,
|
|
1269
|
+
});
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
events.push({
|
|
1273
|
+
seq: cp.seq,
|
|
1274
|
+
kind: kindOf[cp.kind] ?? 'step',
|
|
1275
|
+
// A signal checkpoint's name is the internal `signal:<token>`, not the workflow-level signal
|
|
1276
|
+
// name the replay used — omit it so the replay matches on seq + kind (its determinism anchor).
|
|
1277
|
+
name: cp.kind === 'signal' ? undefined : cp.name,
|
|
1278
|
+
output: cp.status === 'completed' ? cp.output : undefined,
|
|
1279
|
+
error: cp.status === 'failed' ? cp.error : undefined,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
else if (cp.kind === 'sleep' && cp.wakeAt != null && cp.wakeAt <= this.clock()) {
|
|
1283
|
+
// a still-`pending` sleep whose deadline has passed reads as a resolved timer on replay.
|
|
1284
|
+
events.push({ seq: cp.seq, kind: 'timer', name: cp.name });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return events.sort((a, b) => a.seq - b.seq);
|
|
1288
|
+
}
|
|
1289
|
+
/** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
|
|
1290
|
+
* Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
|
|
1291
|
+
async applyCommands(run, commands) {
|
|
1292
|
+
let wakeAt;
|
|
1293
|
+
for (const cmd of commands) {
|
|
1294
|
+
const at = new Date();
|
|
1295
|
+
const id = stepId(run.id, cmd.seq);
|
|
1296
|
+
if (cmd.kind === 'recordStep') {
|
|
1297
|
+
// Prefer the step's real wall-clock window + sub-process events (carried by the command, and
|
|
1298
|
+
// already streamed live via persistStepEvent) so the checkpoint shows a true duration and its
|
|
1299
|
+
// p-process trail — not a 0ms placeholder. Fall back to apply-time for older workers.
|
|
1300
|
+
const startedAt = cmd.startedAt != null ? new Date(cmd.startedAt) : at;
|
|
1301
|
+
const finishedAt = cmd.finishedAt != null ? new Date(cmd.finishedAt) : at;
|
|
1302
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1303
|
+
runId: run.id,
|
|
1304
|
+
seq: cmd.seq,
|
|
1305
|
+
name: cmd.name,
|
|
1306
|
+
kind: 'local',
|
|
1307
|
+
status: cmd.error ? 'failed' : 'completed',
|
|
1308
|
+
output: cmd.output,
|
|
1309
|
+
error: cmd.error,
|
|
1310
|
+
events: cmd.events,
|
|
1311
|
+
attempts: 1,
|
|
1312
|
+
enqueuedAt: startedAt,
|
|
1313
|
+
startedAt,
|
|
1314
|
+
finishedAt,
|
|
1315
|
+
}));
|
|
1316
|
+
this.emit({
|
|
1317
|
+
type: cmd.error ? 'step.failed' : 'step.completed',
|
|
1318
|
+
runId: run.id,
|
|
1319
|
+
seq: cmd.seq,
|
|
1320
|
+
name: cmd.name,
|
|
1321
|
+
kind: 'local',
|
|
1322
|
+
output: cmd.output,
|
|
1323
|
+
error: cmd.error,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
else if (cmd.kind === 'call') {
|
|
1327
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1328
|
+
runId: run.id,
|
|
1329
|
+
seq: cmd.seq,
|
|
1330
|
+
name: cmd.name,
|
|
1331
|
+
kind: 'remote',
|
|
1332
|
+
status: 'pending',
|
|
1333
|
+
input: cmd.input,
|
|
1334
|
+
attempts: 1,
|
|
1335
|
+
workerGroup: cmd.group,
|
|
1336
|
+
enqueuedAt: at,
|
|
1337
|
+
startedAt: at,
|
|
1338
|
+
finishedAt: at,
|
|
1339
|
+
}));
|
|
1340
|
+
await this.pool.dispatch({
|
|
1341
|
+
runId: run.id,
|
|
1342
|
+
seq: cmd.seq,
|
|
1343
|
+
name: cmd.name,
|
|
1344
|
+
stepId: id,
|
|
1345
|
+
group: cmd.group,
|
|
1346
|
+
input: cmd.input,
|
|
1347
|
+
traceparent: this.traceparent?.(),
|
|
1348
|
+
context: this.context?.(),
|
|
1349
|
+
attempt: 1,
|
|
1350
|
+
}, undefined);
|
|
1351
|
+
this.emit({
|
|
1352
|
+
type: 'step.started',
|
|
1353
|
+
runId: run.id,
|
|
1354
|
+
seq: cmd.seq,
|
|
1355
|
+
name: cmd.name,
|
|
1356
|
+
kind: 'remote',
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
else if (cmd.kind === 'sleep') {
|
|
1360
|
+
const deadline = this.clock() + cmd.ms;
|
|
1361
|
+
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1362
|
+
runId: run.id,
|
|
1363
|
+
seq: cmd.seq,
|
|
1364
|
+
name: `sleep:${cmd.seq}`,
|
|
1365
|
+
kind: 'sleep',
|
|
1366
|
+
status: 'pending',
|
|
1367
|
+
attempts: 1,
|
|
1368
|
+
wakeAt: deadline,
|
|
1369
|
+
enqueuedAt: at,
|
|
1370
|
+
startedAt: at,
|
|
1371
|
+
finishedAt: at,
|
|
1372
|
+
}));
|
|
1373
|
+
wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
|
|
1374
|
+
}
|
|
1375
|
+
else if (cmd.kind === 'waitSignal') {
|
|
1376
|
+
// Park on a signal: register a waiter at this seq so engine.signal(token) lands the resolving
|
|
1377
|
+
// `signal` checkpoint here and resumes the run. The token is the signal name, so an external
|
|
1378
|
+
// engine.signal(name, payload) delivers it. If the signal was already delivered (buffered
|
|
1379
|
+
// before the workflow reached this point — e.g. signalWithStart), resolve it now and re-drive
|
|
1380
|
+
// on a macrotask, AFTER this turn suspends and frees the run lock (a re-entrant resume bails).
|
|
1381
|
+
const buffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
1382
|
+
if (buffered) {
|
|
1383
|
+
await this.store.saveCheckpoint(instantCheckpoint({
|
|
1384
|
+
runId: run.id,
|
|
1385
|
+
seq: cmd.seq,
|
|
1386
|
+
name: `signal:${cmd.signal}`,
|
|
1387
|
+
kind: 'signal',
|
|
1388
|
+
output: buffered.payload,
|
|
1389
|
+
}));
|
|
1390
|
+
setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
await this.store.putSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
else if (cmd.kind === 'startChild') {
|
|
1397
|
+
// Start a child run and await it (the worker's ctx.start_child suspends until the child's
|
|
1398
|
+
// result is in history). Mirror the in-process `ctx.child`: register a `child:<id>` waiter at
|
|
1399
|
+
// this seq — the child notifies it on its terminal state (engine.notifyParent) — then start the
|
|
1400
|
+
// child once, deferred so a fast child can't reentrantly resume this still-suspending parent,
|
|
1401
|
+
// and id-idempotent so replay/recovery never double-starts it.
|
|
1402
|
+
const childId = `${run.id}.child.${cmd.seq}`;
|
|
1403
|
+
await this.store.putSignalWaiter({
|
|
1404
|
+
token: `child:${childId}`,
|
|
1405
|
+
runId: run.id,
|
|
1406
|
+
seq: cmd.seq,
|
|
1407
|
+
});
|
|
1408
|
+
if (!(await this.store.getRun(childId))) {
|
|
1409
|
+
queueMicrotask(() => void this.start(cmd.workflow, cmd.input, childId).catch(() => undefined));
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
else {
|
|
1413
|
+
throw new Error(`remote workflow command '${cmd.kind}' is not supported yet`);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return wakeAt;
|
|
1417
|
+
}
|
|
1418
|
+
/** The run body, lease held + renewed by {@link execute}. */
|
|
1419
|
+
async runExecution(run, fn, registered) {
|
|
1420
|
+
// First execution of an enqueued run: mark it running and announce the start, BEFORE the singleton
|
|
1421
|
+
// gate — `singletons.admit` only counts `running`/`suspended` runs, so a still-`pending` run could
|
|
1422
|
+
// never be admitted. A resumed run is already past `pending`, so this fires exactly once.
|
|
1423
|
+
if (run.status === 'pending') {
|
|
1424
|
+
await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
|
|
1425
|
+
run.status = 'running';
|
|
1426
|
+
this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
|
|
1427
|
+
}
|
|
1428
|
+
// Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
|
|
1429
|
+
// (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
|
|
1430
|
+
if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
1431
|
+
const wakeAt = this.singletons.retryWakeAt();
|
|
1432
|
+
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
1433
|
+
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
|
|
1434
|
+
await this.store.releaseRunLock(run.id);
|
|
1435
|
+
return { runId: run.id, status: 'suspended' };
|
|
1436
|
+
}
|
|
1437
|
+
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
1438
|
+
const compensations = [];
|
|
1439
|
+
// Load this run's checkpoints ONCE and key them by seq, so replaying the completed prefix reads
|
|
1440
|
+
// from memory instead of one `getCheckpoint` SELECT per primitive (the O(N²) replay-reads fix).
|
|
1441
|
+
// Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
|
|
1442
|
+
// this resume wakes on, or one written later in this same execution) is absent from the map, and
|
|
1443
|
+
// the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
|
|
1444
|
+
const snapshot = await this.store.listCheckpoints(run.id);
|
|
1445
|
+
const replay = new Map();
|
|
1446
|
+
for (const cp of snapshot)
|
|
1447
|
+
replay.set(cp.seq, cp);
|
|
1448
|
+
const ctx = createWorkflowCtx(this.ctxHostFor(replay), run.id, compensations, run.workflow);
|
|
1449
|
+
try {
|
|
1450
|
+
const output = await fn(ctx, run.input);
|
|
1451
|
+
return this.settleRun(run, { kind: 'completed', output });
|
|
1452
|
+
}
|
|
1453
|
+
catch (err) {
|
|
1454
|
+
if (err instanceof ContinueAsNew) {
|
|
1455
|
+
// Hand off to a fresh execution with a clean history: complete this run, then start the next
|
|
1456
|
+
// (`<id>~N`) with the new input. Deferred + idempotent by the continuation id, so a crash
|
|
1457
|
+
// mid-handoff re-derives the same next run instead of forking.
|
|
1458
|
+
await this.store.updateRun(run.id, {
|
|
1459
|
+
status: 'completed',
|
|
1460
|
+
output: undefined,
|
|
1461
|
+
error: undefined,
|
|
1462
|
+
updatedAt: new Date(),
|
|
1463
|
+
});
|
|
1464
|
+
this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow });
|
|
1465
|
+
void this.notifyParent(run.id, { ok: true, value: undefined });
|
|
1466
|
+
const nextId = nextContinuationId(run.id);
|
|
1467
|
+
queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
|
|
1468
|
+
return { runId: run.id, status: 'completed' };
|
|
1469
|
+
}
|
|
1470
|
+
if (err instanceof WorkflowSuspended) {
|
|
1471
|
+
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
1472
|
+
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
1473
|
+
if (this.cancelRequested.has(run.id)) {
|
|
1474
|
+
this.cancelRequested.delete(run.id);
|
|
1475
|
+
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
1476
|
+
const comp = compensations[i];
|
|
1477
|
+
if (comp)
|
|
1478
|
+
await this.runCompensation(run, comp);
|
|
1479
|
+
}
|
|
1480
|
+
const error = { message: 'cancelled' };
|
|
1481
|
+
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
1482
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
1483
|
+
return { runId: run.id, status: 'cancelled', error };
|
|
1484
|
+
}
|
|
1485
|
+
return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
1486
|
+
}
|
|
1487
|
+
const error = {
|
|
1488
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1489
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
1490
|
+
};
|
|
1491
|
+
// Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
|
|
1492
|
+
// are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
|
|
1493
|
+
// swallowed) in the dashboard/telescope; a failing one is still skipped so it can't mask the
|
|
1494
|
+
// original failure or strand the rest. (Compensations should be idempotent.)
|
|
1495
|
+
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
1496
|
+
const comp = compensations[i];
|
|
1497
|
+
if (!comp)
|
|
1498
|
+
continue;
|
|
1499
|
+
await this.runCompensation(run, comp);
|
|
1500
|
+
}
|
|
1501
|
+
return this.settleRun(run, { kind: 'failed', error });
|
|
1502
|
+
}
|
|
1503
|
+
finally {
|
|
1504
|
+
// Release the recovery lease once the run reaches a terminal/suspended state, so the
|
|
1505
|
+
// next instance (or the timer poller) can pick it up promptly.
|
|
1506
|
+
await this.store.releaseRunLock(run.id);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
|
|
1511
|
+
* step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
|
|
1512
|
+
* compensation is skipped so it can't mask the original failure.
|
|
1513
|
+
*/
|
|
1514
|
+
async runCompensation(run, comp) {
|
|
1515
|
+
const name = `compensate:${comp.name}`;
|
|
1516
|
+
for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
|
|
1517
|
+
const startedAt = Date.now();
|
|
1518
|
+
try {
|
|
1519
|
+
await comp.fn();
|
|
1520
|
+
this.emit({
|
|
1521
|
+
type: 'step.completed',
|
|
1522
|
+
runId: run.id,
|
|
1523
|
+
workflow: run.workflow,
|
|
1524
|
+
name,
|
|
1525
|
+
kind: 'local',
|
|
1526
|
+
durationMs: Date.now() - startedAt,
|
|
1527
|
+
});
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
catch (err) {
|
|
1531
|
+
if (attempt >= this.compensationRetries) {
|
|
1532
|
+
this.emit({
|
|
1533
|
+
type: 'step.failed',
|
|
1534
|
+
runId: run.id,
|
|
1535
|
+
workflow: run.workflow,
|
|
1536
|
+
name,
|
|
1537
|
+
kind: 'local',
|
|
1538
|
+
error: { message: err instanceof Error ? err.message : String(err) },
|
|
1539
|
+
durationMs: Date.now() - startedAt,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
1546
|
+
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
1547
|
+
* module and the engine stays the orchestrator. */
|
|
1548
|
+
ctxHostFor(replay) {
|
|
1549
|
+
return {
|
|
1550
|
+
store: this.store,
|
|
1551
|
+
replay,
|
|
1552
|
+
clock: this.clock,
|
|
1553
|
+
webhookUrl: this.webhookUrl,
|
|
1554
|
+
startStep: (s) => this.startStep(s),
|
|
1555
|
+
completeStep: (s) => this.completeStep(s),
|
|
1556
|
+
failStep: (s) => this.failStep(s),
|
|
1557
|
+
callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission),
|
|
1558
|
+
// Defer so a fast child can't reentrantly resume a still-running parent.
|
|
1559
|
+
startChild: (workflow, input, id, priority) => {
|
|
1560
|
+
queueMicrotask(() => void this.start(workflow, input, id, { priority }).catch(() => undefined));
|
|
1561
|
+
},
|
|
1562
|
+
signalEntity: (name, key, op, arg, reply) => {
|
|
1563
|
+
queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => undefined));
|
|
1564
|
+
},
|
|
1565
|
+
interceptStep: (invocation, body) => this.interceptStep(invocation, body),
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Resume a run paused at a {@link WorkflowCtx.breakpoint} (e.g. the dashboard "continue" button).
|
|
1570
|
+
* Finds the run's pending breakpoint checkpoint and signals it. Returns null if the run isn't
|
|
1571
|
+
* paused at a breakpoint.
|
|
1572
|
+
*/
|
|
1573
|
+
async continue(runId) {
|
|
1574
|
+
const checkpoints = await this.store.listCheckpoints(runId);
|
|
1575
|
+
const bp = checkpoints.find(isBreakpoint);
|
|
1576
|
+
if (!bp)
|
|
1577
|
+
return null;
|
|
1578
|
+
return this.signal(breakpointToken(runId, bp.seq), undefined);
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Read the latest value a run published for `key` via {@link WorkflowCtx.setEvent} — a
|
|
1582
|
+
* side-effect-free query of a live (or finished) run's state. Returns `undefined` if the run
|
|
1583
|
+
* never published that key. The suspend-model counterpart of a Temporal query.
|
|
1584
|
+
*/
|
|
1585
|
+
async getEvent(runId, key) {
|
|
1586
|
+
const name = `event:${key}`;
|
|
1587
|
+
// Targeted read: the highest-seq checkpoint for this name is the most recent value (a re-published
|
|
1588
|
+
// key overwrites at a higher seq), matching the old "last in seq order wins" scan. Falls back to a
|
|
1589
|
+
// full listCheckpoints scan (last match in seq-ascending order wins) for a store that omits it.
|
|
1590
|
+
if (this.store.getLatestCheckpointByName) {
|
|
1591
|
+
const latest = await this.store.getLatestCheckpointByName(runId, name);
|
|
1592
|
+
return latest?.output;
|
|
1593
|
+
}
|
|
1594
|
+
// listCheckpoints is ordered by seq ascending, so the last match is the most recent value.
|
|
1595
|
+
let latest;
|
|
1596
|
+
for (const cp of await this.store.listCheckpoints(runId))
|
|
1597
|
+
if (cp.name === name)
|
|
1598
|
+
latest = cp.output;
|
|
1599
|
+
return latest;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Register a validator gating `engine.update(runId, name, …)` for runs of `workflow`. The
|
|
1603
|
+
* validator runs BEFORE the update is delivered, so a rejection leaves the run untouched. One
|
|
1604
|
+
* validator per (workflow, update name); registering again replaces it.
|
|
1605
|
+
*/
|
|
1606
|
+
registerUpdateValidator(workflow, name, validate) {
|
|
1607
|
+
this.updateValidators.set(`${workflow}:${name}`, validate);
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Deliver a validated update to the run waiting at `ctx.onUpdate(name)`. Runs the registered
|
|
1611
|
+
* validator (if any) first: on rejection returns `{ accepted: false, reason }` without disturbing
|
|
1612
|
+
* the run; otherwise delivers `arg` and resumes, returning `{ accepted: true, run }` (`run` is null
|
|
1613
|
+
* if nothing was waiting — a too-early or duplicate update).
|
|
1614
|
+
*/
|
|
1615
|
+
async update(runId, name, arg) {
|
|
1616
|
+
const run = await this.store.getRun(runId);
|
|
1617
|
+
if (!run)
|
|
1618
|
+
return { accepted: false, reason: `run ${runId} not found` };
|
|
1619
|
+
const validate = this.updateValidators.get(`${run.workflow}:${name}`);
|
|
1620
|
+
if (validate) {
|
|
1621
|
+
try {
|
|
1622
|
+
const reason = await validate(arg);
|
|
1623
|
+
if (typeof reason === 'string' && reason.length > 0)
|
|
1624
|
+
return { accepted: false, reason };
|
|
1625
|
+
}
|
|
1626
|
+
catch (err) {
|
|
1627
|
+
return { accepted: false, reason: err instanceof Error ? err.message : String(err) };
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
const result = await this.signal(`update:${runId}:${name}`, arg);
|
|
1631
|
+
return { accepted: true, run: result };
|
|
1632
|
+
}
|
|
1633
|
+
async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
|
|
1634
|
+
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
1635
|
+
// from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
|
|
1636
|
+
const existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
|
|
1637
|
+
if (existing && existing.name !== step.name) {
|
|
1638
|
+
throw new NonDeterminismError(runId, seq, step.name, existing.name);
|
|
1639
|
+
}
|
|
1640
|
+
if (existing?.status === 'completed')
|
|
1641
|
+
return existing.output;
|
|
1642
|
+
if (this.pool.size === 0)
|
|
1643
|
+
throw new Error('remote steps require a Transport');
|
|
1644
|
+
// A step with a liveness `timeoutMs` keeps the in-memory await + heartbeat path (re-dispatch a
|
|
1645
|
+
// presumed-dead worker). Without one, the call SUSPENDS DURABLY: dispatch, persist a `pending`
|
|
1646
|
+
// checkpoint, and let the result resume the run on whichever instance receives it — so a worker
|
|
1647
|
+
// pod can scale down or crash mid-step without losing the run or re-running completed work.
|
|
1648
|
+
if (step.timeoutMs)
|
|
1649
|
+
return this.callRemoteInMemory(runId, seq, step, input, transport);
|
|
1650
|
+
if (existing?.status === 'pending')
|
|
1651
|
+
throw new WorkflowSuspended(); // dispatched; keep waiting
|
|
1652
|
+
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|
|
1653
|
+
// unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
|
|
1654
|
+
// The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
|
|
1655
|
+
// first time we see it, so it's stable across replays and survives a crash.
|
|
1656
|
+
let attempt = 1;
|
|
1657
|
+
if (existing?.status === 'failed') {
|
|
1658
|
+
const maxAttempts = Math.max(1, step.retries ?? 1);
|
|
1659
|
+
const retryable = existing.error?.retryable !== false;
|
|
1660
|
+
if (!retryable || existing.attempts >= maxAttempts)
|
|
1661
|
+
throw new RemoteStepError(existing.error);
|
|
1662
|
+
if (existing.wakeAt == null) {
|
|
1663
|
+
const wakeAt = this.clock() + backoffDelay(existing.attempts, step);
|
|
1664
|
+
await this.store.saveCheckpoint({ ...existing, wakeAt });
|
|
1665
|
+
throw new WorkflowSuspended(wakeAt);
|
|
1666
|
+
}
|
|
1667
|
+
if (this.clock() < existing.wakeAt)
|
|
1668
|
+
throw new WorkflowSuspended(existing.wakeAt);
|
|
1669
|
+
attempt = existing.attempts + 1;
|
|
1670
|
+
}
|
|
1671
|
+
const id = stepId(runId, seq);
|
|
1672
|
+
// Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
|
|
1673
|
+
// run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
|
|
1674
|
+
// the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
|
|
1675
|
+
if (queue && this.admission.handles(queue)) {
|
|
1676
|
+
// Admission carries the per-call priority + fairness key (default the runId so each run is its
|
|
1677
|
+
// own fairness bucket), and the stepId as a STABLE waiter id so the backend tracks one waiter
|
|
1678
|
+
// across this call's durable retries. Ordering lives in the backend (in-process by default, or
|
|
1679
|
+
// global) — this is the dispatch/admission layer, not the positional replay path.
|
|
1680
|
+
const decision = await this.admission.tryAdmit(queue, {
|
|
1681
|
+
priority: admission?.priority,
|
|
1682
|
+
key: admission?.fairnessKey ?? runId,
|
|
1683
|
+
waiterId: id,
|
|
1684
|
+
});
|
|
1685
|
+
if (!decision.ok) {
|
|
1686
|
+
// Remember this run as blocked on `queue` so a freed-slot signal can wake it early.
|
|
1687
|
+
const waiters = this.queueWaiters.get(queue) ?? new Set();
|
|
1688
|
+
waiters.add(runId);
|
|
1689
|
+
this.queueWaiters.set(queue, waiters);
|
|
1690
|
+
throw new WorkflowSuspended(decision.retryAt);
|
|
1691
|
+
}
|
|
1692
|
+
this.queueWaiters.get(queue)?.delete(runId);
|
|
1693
|
+
this.stepQueue.set(id, queue);
|
|
1694
|
+
}
|
|
1695
|
+
const validInput = step.input.parse(input);
|
|
1696
|
+
const enqueuedAt = new Date();
|
|
1697
|
+
// Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
|
|
1698
|
+
await this.store.saveCheckpoint({
|
|
1699
|
+
runId,
|
|
1700
|
+
seq,
|
|
1701
|
+
name: step.name,
|
|
1702
|
+
kind: 'remote',
|
|
1703
|
+
stepId: id,
|
|
1704
|
+
status: 'pending',
|
|
1705
|
+
input: validInput,
|
|
1706
|
+
attempts: attempt,
|
|
1707
|
+
workerGroup: step.group,
|
|
1708
|
+
enqueuedAt,
|
|
1709
|
+
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
1710
|
+
finishedAt: enqueuedAt,
|
|
1711
|
+
});
|
|
1712
|
+
await this.pool.dispatch({
|
|
1713
|
+
runId,
|
|
1714
|
+
seq,
|
|
1715
|
+
name: step.name,
|
|
1716
|
+
stepId: id,
|
|
1717
|
+
group: step.group,
|
|
1718
|
+
input: validInput,
|
|
1719
|
+
traceparent: this.traceparent?.(),
|
|
1720
|
+
context: this.context?.(),
|
|
1721
|
+
priority: admission?.priority,
|
|
1722
|
+
attempt,
|
|
1723
|
+
}, transport);
|
|
1724
|
+
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
1725
|
+
throw new WorkflowSuspended();
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Complete a durable remote step from its worker result and resume the run — runs on whichever
|
|
1729
|
+
* instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
|
|
1730
|
+
* A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
|
|
1731
|
+
*/
|
|
1732
|
+
async completeRemoteResult(result) {
|
|
1733
|
+
const cp = await this.store.getCheckpoint(result.runId, result.seq);
|
|
1734
|
+
if (!cp || cp.status !== 'pending')
|
|
1735
|
+
return;
|
|
1736
|
+
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
|
|
1737
|
+
// before the cancelled-run early-return below, so a cancellation can't leak the slot.
|
|
1738
|
+
await this.releaseQueueSlot(cp.stepId);
|
|
1739
|
+
// Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
|
|
1740
|
+
// or resume (the run is already terminal). This is the engine side of cooperative cancellation.
|
|
1741
|
+
const run = await this.store.getRun(result.runId);
|
|
1742
|
+
if (run && (run.status === 'cancelled' || run.status === 'completed'))
|
|
1743
|
+
return;
|
|
1744
|
+
const finishedAt = new Date();
|
|
1745
|
+
const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
|
|
1746
|
+
await this.store.saveCheckpoint({
|
|
1747
|
+
...cp,
|
|
1748
|
+
status: result.status,
|
|
1749
|
+
output: result.status === 'completed' ? result.output : cp.output,
|
|
1750
|
+
error: result.error,
|
|
1751
|
+
events: result.events ?? cp.events,
|
|
1752
|
+
startedAt,
|
|
1753
|
+
finishedAt,
|
|
1754
|
+
});
|
|
1755
|
+
this.emit({
|
|
1756
|
+
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
1757
|
+
runId: result.runId,
|
|
1758
|
+
seq: result.seq,
|
|
1759
|
+
name: cp.name,
|
|
1760
|
+
kind: cp.kind,
|
|
1761
|
+
output: result.output,
|
|
1762
|
+
error: result.error,
|
|
1763
|
+
queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
1764
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
1765
|
+
});
|
|
1766
|
+
await this.resume(result.runId);
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
1770
|
+
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
1771
|
+
* retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
|
|
1772
|
+
*/
|
|
1773
|
+
wakeQueueWaiters(queue) {
|
|
1774
|
+
const waiters = this.queueWaiters.get(queue);
|
|
1775
|
+
if (!waiters || waiters.size === 0)
|
|
1776
|
+
return;
|
|
1777
|
+
const runIds = [...waiters];
|
|
1778
|
+
waiters.clear();
|
|
1779
|
+
for (const runId of runIds)
|
|
1780
|
+
void this.resume(runId).catch(() => undefined);
|
|
1781
|
+
}
|
|
1782
|
+
/** Release the flow-control slot a dispatched step held (if any), by its stepId. */
|
|
1783
|
+
async releaseQueueSlot(id) {
|
|
1784
|
+
const queue = this.stepQueue.get(id);
|
|
1785
|
+
if (queue === undefined)
|
|
1786
|
+
return;
|
|
1787
|
+
this.stepQueue.delete(id);
|
|
1788
|
+
await this.admission.release(queue, id);
|
|
1789
|
+
}
|
|
1790
|
+
/** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
|
|
1791
|
+
async callRemoteInMemory(runId, seq, step, input, transport) {
|
|
1792
|
+
if (this.pool.size === 0)
|
|
1793
|
+
throw new Error('remote steps require a Transport');
|
|
1794
|
+
const validInput = step.input.parse(input);
|
|
1795
|
+
const id = stepId(runId, seq);
|
|
1796
|
+
const enqueuedAt = new Date();
|
|
1797
|
+
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
1798
|
+
// Retry policy differs from a LOCAL step on purpose: a local `ctx.step` retries any non-fatal
|
|
1799
|
+
// throw (the work is in-process), but a remote step only re-dispatches on a liveness TIMEOUT
|
|
1800
|
+
// (presumed-dead worker). A worker that *reported* an error returned a deterministic verdict, so
|
|
1801
|
+
// we surface it to the workflow instead of hammering the worker. Timeout retries need a window
|
|
1802
|
+
// to detect death, so they're gated on `timeoutMs` being set.
|
|
1803
|
+
const maxAttempts = step.timeoutMs ? Math.max(1, step.retries ?? 1) : 1;
|
|
1804
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1805
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
1806
|
+
this.pending.set(id, { resolve, reject });
|
|
1807
|
+
});
|
|
1808
|
+
await this.pool.dispatch({
|
|
1809
|
+
runId,
|
|
1810
|
+
seq,
|
|
1811
|
+
name: step.name,
|
|
1812
|
+
stepId: id,
|
|
1813
|
+
group: step.group,
|
|
1814
|
+
input: validInput,
|
|
1815
|
+
traceparent: this.traceparent?.(),
|
|
1816
|
+
context: this.context?.(),
|
|
1817
|
+
attempt,
|
|
1818
|
+
}, transport);
|
|
1819
|
+
try {
|
|
1820
|
+
const resolution = step.timeoutMs
|
|
1821
|
+
? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)
|
|
1822
|
+
: await resultPromise;
|
|
1823
|
+
const output = step.output.parse(resolution.output);
|
|
1824
|
+
// The worker reports when it actually picked the task up; fall back to dispatch time if a
|
|
1825
|
+
// transport doesn't carry it (queue-wait then reads as zero rather than going negative).
|
|
1826
|
+
const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
|
|
1827
|
+
await this.completeStep({
|
|
1828
|
+
runId,
|
|
1829
|
+
seq,
|
|
1830
|
+
name: step.name,
|
|
1831
|
+
kind: 'remote',
|
|
1832
|
+
input: validInput,
|
|
1833
|
+
output,
|
|
1834
|
+
events: resolution.events,
|
|
1835
|
+
attempts: attempt,
|
|
1836
|
+
workerGroup: step.group,
|
|
1837
|
+
enqueuedAt,
|
|
1838
|
+
startedAt,
|
|
1839
|
+
});
|
|
1840
|
+
return output;
|
|
1841
|
+
}
|
|
1842
|
+
catch (err) {
|
|
1843
|
+
this.pending.delete(id);
|
|
1844
|
+
if (err instanceof RemoteStepTimeout && attempt < maxAttempts)
|
|
1845
|
+
continue;
|
|
1846
|
+
throw err;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Await a remote result, but reject with `RemoteStepTimeout` if neither the result nor a heartbeat
|
|
1852
|
+
* arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
|
|
1853
|
+
* window, so a worker that keeps beating stays alive past `timeoutMs`.
|
|
1854
|
+
*/
|
|
1855
|
+
awaitWithHeartbeat(id, resultPromise, timeoutMs) {
|
|
1856
|
+
return new Promise((resolve, reject) => {
|
|
1857
|
+
let timer;
|
|
1858
|
+
const cleanup = () => {
|
|
1859
|
+
clearTimeout(timer);
|
|
1860
|
+
this.heartbeatResets.delete(id);
|
|
1861
|
+
};
|
|
1862
|
+
const arm = () => {
|
|
1863
|
+
timer = setTimeout(() => {
|
|
1864
|
+
cleanup();
|
|
1865
|
+
this.pending.delete(id);
|
|
1866
|
+
reject(new RemoteStepTimeout(id, timeoutMs));
|
|
1867
|
+
}, timeoutMs);
|
|
1868
|
+
timer.unref?.();
|
|
1869
|
+
};
|
|
1870
|
+
this.heartbeatResets.set(id, () => {
|
|
1871
|
+
clearTimeout(timer);
|
|
1872
|
+
arm();
|
|
1873
|
+
});
|
|
1874
|
+
arm();
|
|
1875
|
+
resultPromise.then((value) => {
|
|
1876
|
+
cleanup();
|
|
1877
|
+
resolve(value);
|
|
1878
|
+
}, (err) => {
|
|
1879
|
+
cleanup();
|
|
1880
|
+
reject(err);
|
|
1881
|
+
});
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
/** Raised inside the workflow when a remote worker reports a step failure. */
|
|
1886
|
+
export class RemoteStepError extends Error {
|
|
1887
|
+
stepError;
|
|
1888
|
+
constructor(stepError) {
|
|
1889
|
+
super(stepError?.message ?? 'remote step failed');
|
|
1890
|
+
this.name = 'RemoteStepError';
|
|
1891
|
+
this.stepError = stepError;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
//# sourceMappingURL=engine.js.map
|