@adonis-agora/durable 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +261 -0
- package/README.md +53 -22
- package/dist/assets/spa/assets/index-DupjrK7N.js +16 -0
- package/dist/assets/spa/assets/index-DupjrK7N.js.map +1 -0
- package/dist/assets/spa/index.html +1 -1
- package/dist/commands/durable_worker.d.ts.map +1 -1
- package/dist/commands/durable_worker.js +9 -8
- package/dist/commands/durable_worker.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +1 -0
- package/dist/configure.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +2 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +50 -6
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +143 -34
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
- package/dist/src/admission-redis/redis-admission-backend.js +3 -1
- package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
- package/dist/src/ambient-step.d.ts +24 -0
- package/dist/src/ambient-step.d.ts.map +1 -0
- package/dist/src/ambient-step.js +42 -0
- package/dist/src/ambient-step.js.map +1 -0
- package/dist/src/base-workflow.d.ts +10 -1
- package/dist/src/base-workflow.d.ts.map +1 -1
- package/dist/src/base-workflow.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +28 -2
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +61 -3
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/commands/worker.d.ts +4 -1
- package/dist/src/commands/worker.d.ts.map +1 -1
- package/dist/src/commands/worker.js +27 -3
- package/dist/src/commands/worker.js.map +1 -1
- package/dist/src/config_types.d.ts +6 -0
- package/dist/src/config_types.d.ts.map +1 -1
- package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
- package/dist/src/control-plane-redis/redis-control-plane.js +22 -6
- package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
- package/dist/src/control-planes/factory.d.ts +6 -0
- package/dist/src/control-planes/factory.d.ts.map +1 -1
- package/dist/src/control-planes/factory.js +1 -0
- package/dist/src/control-planes/factory.js.map +1 -1
- package/dist/src/dashboard/gateway-adapter.d.ts +15 -5
- package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
- package/dist/src/dashboard/gateway-adapter.js +71 -7
- package/dist/src/dashboard/gateway-adapter.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts +53 -1
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +200 -20
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +2 -2
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +2 -2
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/dashboard/openapi.d.ts.map +1 -1
- package/dist/src/dashboard/openapi.js +71 -1
- package/dist/src/dashboard/openapi.js.map +1 -1
- package/dist/src/define_config.d.ts +122 -8
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/diagnostics-bridge.d.ts +16 -3
- package/dist/src/diagnostics-bridge.d.ts.map +1 -1
- package/dist/src/diagnostics-bridge.js +9 -3
- package/dist/src/diagnostics-bridge.js.map +1 -1
- package/dist/src/discovery-roots.d.ts +92 -0
- package/dist/src/discovery-roots.d.ts.map +1 -0
- package/dist/src/discovery-roots.js +150 -0
- package/dist/src/discovery-roots.js.map +1 -0
- package/dist/src/engine.d.ts +418 -20
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +1523 -159
- package/dist/src/engine.js.map +1 -1
- package/dist/src/entities.d.ts +36 -1
- package/dist/src/entities.d.ts.map +1 -1
- package/dist/src/entities.js +65 -4
- package/dist/src/entities.js.map +1 -1
- package/dist/src/errors.d.ts +36 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +44 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/event-trigger-bridge.d.ts +8 -0
- package/dist/src/event-trigger-bridge.d.ts.map +1 -1
- package/dist/src/event-trigger-bridge.js +24 -3
- package/dist/src/event-trigger-bridge.js.map +1 -1
- package/dist/src/handshake/announced.d.ts +27 -0
- package/dist/src/handshake/announced.d.ts.map +1 -1
- package/dist/src/handshake/announced.js +62 -0
- package/dist/src/handshake/announced.js.map +1 -1
- package/dist/src/handshake/directory.d.ts +92 -0
- package/dist/src/handshake/directory.d.ts.map +1 -0
- package/dist/src/handshake/directory.js +139 -0
- package/dist/src/handshake/directory.js.map +1 -0
- package/dist/src/hooks/barrel-sources.d.ts +28 -0
- package/dist/src/hooks/barrel-sources.d.ts.map +1 -0
- package/dist/src/hooks/barrel-sources.js +92 -0
- package/dist/src/hooks/barrel-sources.js.map +1 -0
- package/dist/src/hooks/steps.d.ts +12 -3
- package/dist/src/hooks/steps.d.ts.map +1 -1
- package/dist/src/hooks/steps.js +7 -11
- package/dist/src/hooks/steps.js.map +1 -1
- package/dist/src/hooks/workflows.d.ts +12 -3
- package/dist/src/hooks/workflows.d.ts.map +1 -1
- package/dist/src/hooks/workflows.js +7 -13
- package/dist/src/hooks/workflows.js.map +1 -1
- package/dist/src/index.d.ts +12 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +12 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +309 -21
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js +10 -0
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/metrics.d.ts.map +1 -1
- package/dist/src/metrics.js +5 -0
- package/dist/src/metrics.js.map +1 -1
- package/dist/src/namespaces.d.ts +17 -0
- package/dist/src/namespaces.d.ts.map +1 -0
- package/dist/src/namespaces.js +24 -0
- package/dist/src/namespaces.js.map +1 -0
- package/dist/src/otel/durable-metrics.d.ts.map +1 -1
- package/dist/src/otel/durable-metrics.js +5 -0
- package/dist/src/otel/durable-metrics.js.map +1 -1
- package/dist/src/otel/durable-otel.d.ts.map +1 -1
- package/dist/src/otel/durable-otel.js +5 -0
- package/dist/src/otel/durable-otel.js.map +1 -1
- package/dist/src/pollers.d.ts +10 -1
- package/dist/src/pollers.d.ts.map +1 -1
- package/dist/src/pollers.js +15 -5
- package/dist/src/pollers.js.map +1 -1
- package/dist/src/protocol.d.ts +12 -0
- package/dist/src/protocol.d.ts.map +1 -1
- package/dist/src/protocol.js +27 -1
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/remote-event.d.ts +6 -0
- package/dist/src/remote-event.d.ts.map +1 -0
- package/dist/src/remote-event.js +19 -0
- package/dist/src/remote-event.js.map +1 -0
- package/dist/src/remote-workflow-executor.d.ts +34 -12
- package/dist/src/remote-workflow-executor.d.ts.map +1 -1
- package/dist/src/remote-workflow-executor.js +42 -40
- package/dist/src/remote-workflow-executor.js.map +1 -1
- package/dist/src/run-detail-summary.d.ts +90 -3
- package/dist/src/run-detail-summary.d.ts.map +1 -1
- package/dist/src/run-detail-summary.js +360 -2
- package/dist/src/run-detail-summary.js.map +1 -1
- package/dist/src/run-facets.d.ts +48 -0
- package/dist/src/run-facets.d.ts.map +1 -0
- package/dist/src/run-facets.js +100 -0
- package/dist/src/run-facets.js.map +1 -0
- package/dist/src/run-gateway/interface.d.ts +27 -1
- package/dist/src/run-gateway/interface.d.ts.map +1 -1
- package/dist/src/run-gateway/proxy-run-gateway.d.ts +32 -4
- package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
- package/dist/src/run-gateway/proxy-run-gateway.js +101 -5
- package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
- package/dist/src/run-gateway/run-request-responder.d.ts +18 -5
- package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
- package/dist/src/run-gateway/run-request-responder.js +155 -32
- package/dist/src/run-gateway/run-request-responder.js.map +1 -1
- package/dist/src/run-gateway/store-run-gateway.d.ts +23 -1
- package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
- package/dist/src/run-gateway/store-run-gateway.js +38 -0
- package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
- package/dist/src/search-attributes-schema.d.ts +44 -0
- package/dist/src/search-attributes-schema.d.ts.map +1 -0
- package/dist/src/search-attributes-schema.js +51 -0
- package/dist/src/search-attributes-schema.js.map +1 -0
- package/dist/src/singleton-admission.d.ts +75 -0
- package/dist/src/singleton-admission.d.ts.map +1 -0
- package/dist/src/singleton-admission.js +156 -0
- package/dist/src/singleton-admission.js.map +1 -0
- package/dist/src/singleton-gate.d.ts +23 -8
- package/dist/src/singleton-gate.d.ts.map +1 -1
- package/dist/src/singleton-gate.js +59 -12
- package/dist/src/singleton-gate.js.map +1 -1
- package/dist/src/standard-schema.d.ts +83 -0
- package/dist/src/standard-schema.d.ts.map +1 -0
- package/dist/src/standard-schema.js +31 -0
- package/dist/src/standard-schema.js.map +1 -0
- package/dist/src/step-discovery.d.ts +21 -11
- package/dist/src/step-discovery.d.ts.map +1 -1
- package/dist/src/step-discovery.js +86 -45
- package/dist/src/step-discovery.js.map +1 -1
- package/dist/src/step-ref.d.ts +38 -12
- package/dist/src/step-ref.d.ts.map +1 -1
- package/dist/src/step-ref.js +5 -13
- package/dist/src/step-ref.js.map +1 -1
- package/dist/src/stores/factory.d.ts +6 -0
- package/dist/src/stores/factory.d.ts.map +1 -1
- package/dist/src/stores/factory.js +4 -1
- package/dist/src/stores/factory.js.map +1 -1
- package/dist/src/stores/lucid-helpers.d.ts +2 -0
- package/dist/src/stores/lucid-helpers.d.ts.map +1 -1
- package/dist/src/stores/lucid-helpers.js +7 -0
- package/dist/src/stores/lucid-helpers.js.map +1 -1
- package/dist/src/stores/lucid-mappers.d.ts +25 -0
- package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
- package/dist/src/stores/lucid-mappers.js +81 -18
- package/dist/src/stores/lucid-mappers.js.map +1 -1
- package/dist/src/stores/lucid-schema.d.ts +59 -1
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +271 -25
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +61 -2
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +346 -41
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/telescope/dashboard.d.ts.map +1 -1
- package/dist/src/telescope/dashboard.js +30 -1
- package/dist/src/telescope/dashboard.js.map +1 -1
- package/dist/src/telescope/data-providers.d.ts +19 -1
- package/dist/src/telescope/data-providers.d.ts.map +1 -1
- package/dist/src/telescope/data-providers.js +119 -3
- package/dist/src/telescope/data-providers.js.map +1 -1
- package/dist/src/telescope/extension.d.ts.map +1 -1
- package/dist/src/telescope/extension.js +2 -1
- package/dist/src/telescope/extension.js.map +1 -1
- package/dist/src/telescope/index.d.ts +1 -1
- package/dist/src/telescope/index.d.ts.map +1 -1
- package/dist/src/telescope/index.js +1 -1
- package/dist/src/telescope/index.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +44 -15
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +204 -41
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts +16 -0
- package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts.map +1 -0
- package/dist/src/testing-kit/checkpoint-aggregation-contract.js +228 -0
- package/dist/src/testing-kit/checkpoint-aggregation-contract.js.map +1 -0
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +355 -3
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transport-pool.d.ts +15 -1
- package/dist/src/transport-pool.d.ts.map +1 -1
- package/dist/src/transport-pool.js +50 -4
- package/dist/src/transport-pool.js.map +1 -1
- package/dist/src/transports/adaptive-concurrency.d.ts +168 -0
- package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -0
- package/dist/src/transports/adaptive-concurrency.js +353 -0
- package/dist/src/transports/adaptive-concurrency.js.map +1 -0
- package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +1 -143
- package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
- package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -311
- package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +49 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +330 -38
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts +18 -2
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
- package/dist/src/transports/bullmq/deps.js +44 -2
- package/dist/src/transports/bullmq/deps.js.map +1 -1
- package/dist/src/transports/db-helpers.d.ts +39 -0
- package/dist/src/transports/db-helpers.d.ts.map +1 -1
- package/dist/src/transports/db-helpers.js +73 -0
- package/dist/src/transports/db-helpers.js.map +1 -1
- package/dist/src/transports/db-remote.d.ts +53 -0
- package/dist/src/transports/db-remote.d.ts.map +1 -0
- package/dist/src/transports/db-remote.js +147 -0
- package/dist/src/transports/db-remote.js.map +1 -0
- package/dist/src/transports/db-schema.d.ts +44 -1
- package/dist/src/transports/db-schema.d.ts.map +1 -1
- package/dist/src/transports/db-schema.js +227 -88
- package/dist/src/transports/db-schema.js.map +1 -1
- package/dist/src/transports/db.d.ts +189 -6
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +1291 -137
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/event-emitter.d.ts +8 -0
- package/dist/src/transports/event-emitter.d.ts.map +1 -1
- package/dist/src/transports/event-emitter.js +36 -3
- package/dist/src/transports/event-emitter.js.map +1 -1
- package/dist/src/transports/factory.d.ts +110 -5
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +56 -9
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/transports/queue-mock-adapter.d.ts +7 -2
- package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -1
- package/dist/src/transports/queue-mock-adapter.js +18 -2
- package/dist/src/transports/queue-mock-adapter.js.map +1 -1
- package/dist/src/transports/queue-remote.d.ts +146 -0
- package/dist/src/transports/queue-remote.d.ts.map +1 -0
- package/dist/src/transports/queue-remote.js +398 -0
- package/dist/src/transports/queue-remote.js.map +1 -0
- package/dist/src/transports/queue.d.ts +159 -6
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +747 -37
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/transports/task-failure.d.ts +31 -0
- package/dist/src/transports/task-failure.d.ts.map +1 -0
- package/dist/src/transports/task-failure.js +50 -0
- package/dist/src/transports/task-failure.js.map +1 -0
- package/dist/src/workflow-als.d.ts +1 -1
- package/dist/src/workflow-als.d.ts.map +1 -1
- package/dist/src/workflow-ctx.d.ts +10 -0
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +97 -15
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts +32 -41
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +65 -88
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +24 -0
- package/dist/src/workflow-ref.d.ts.map +1 -1
- package/dist/src/workflow-ref.js.map +1 -1
- package/dist/stubs/config/durable.stub +6 -0
- package/dist/stubs/config/durable_dashboard.stub +7 -3
- package/dist/stubs/database/migrations/create_durable_tables.stub +2 -1
- package/dist/stubs/database/migrations/create_durable_transport_messages.stub +43 -0
- package/dist/stubs/database/migrations/create_durable_transport_tables.stub +12 -9
- package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +1 -1
- package/dist/stubs/make/workflow/main.stub +1 -1
- package/package.json +6 -4
- package/skills/durable-cluster/SKILL.md +80 -11
- package/skills/durable-determinism/SKILL.md +11 -5
- package/skills/durable-reliability/SKILL.md +77 -37
- package/skills/durable-setup/SKILL.md +51 -22
- package/skills/durable-transports-stores/SKILL.md +130 -19
- package/skills/durable-workflows/SKILL.md +71 -18
- package/dist/assets/spa/assets/index-DdB9zxuX.js +0 -16
- package/dist/assets/spa/assets/index-DdB9zxuX.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { InMemoryAdmissionBackend } from './admission.js';
|
|
2
3
|
import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
|
|
3
4
|
import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
|
|
@@ -6,21 +7,28 @@ import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } fro
|
|
|
6
7
|
import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
|
|
7
8
|
import { parseDuration } from './duration.js';
|
|
8
9
|
import { Entities } from './entities.js';
|
|
9
|
-
import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
|
|
10
|
+
import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, RemoteWorkflowTimeout, WorkflowSuspended, } from './errors.js';
|
|
10
11
|
import { EventAccumulators } from './event-accumulators.js';
|
|
11
12
|
import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
|
|
12
13
|
import { childIdsOf, FOLDED_PREFIX, fanOfWaiter, parseStepFanGroup, stepFanGroup, } from './fan-join.js';
|
|
13
14
|
import { aggregateAnnouncements } from './handshake/announced.js';
|
|
15
|
+
import { buildWorkflowDirectory } from './handshake/directory.js';
|
|
14
16
|
import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
|
|
17
|
+
import { DEFAULT_NAMESPACE } from './namespaces.js';
|
|
15
18
|
import { asHeartbeat } from './observability-scope.js';
|
|
16
19
|
import { assertRoutes, codeAndDetails, HANDOFF_SEQ_BASE, handoffHandlerId, handoffName, liveHandoff, onErrorRunId, retiredHandoffName, routeFor, } from './on-error.js';
|
|
17
20
|
import { breakpointToken, stepId } from './protocol.js';
|
|
21
|
+
import { markRemoteEvent } from './remote-event.js';
|
|
18
22
|
import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
|
|
23
|
+
import { loadFanMembers, loadRunDetail, } from './run-detail-summary.js';
|
|
24
|
+
import { runFacetsOf } from './run-facets.js';
|
|
19
25
|
import { scanRunValueFacets } from './run-value-facets.js';
|
|
20
26
|
import { listSignalWaitersOfRuns } from './run-waiting.js';
|
|
21
27
|
import { scheduleWindow } from './scheduler.js';
|
|
22
28
|
import { ScheduleClient } from './schedules.js';
|
|
29
|
+
import { SINGLETON_RESERVED_TAGS, withoutAdmission, withSingletonTracked, } from './singleton-admission.js';
|
|
23
30
|
import { SingletonGate } from './singleton-gate.js';
|
|
31
|
+
import { validateStandard, validateStandardSync, } from './standard-schema.js';
|
|
24
32
|
import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
|
|
25
33
|
import { TransportPool } from './transport-pool.js';
|
|
26
34
|
import { workflowAls } from './workflow-als.js';
|
|
@@ -74,6 +82,15 @@ const COMPENSATION_SEQ_BASE = -2;
|
|
|
74
82
|
const STEP_FAN_CHUNK = 200;
|
|
75
83
|
/** The default {@link WorkflowEngineDeps.timerBatchSize}. */
|
|
76
84
|
const DEFAULT_TIMER_BATCH = 500;
|
|
85
|
+
/** The most results of one run settled by one batched write (see `completeRemoteResult`). */
|
|
86
|
+
const RESULT_BATCH_MAX = 256;
|
|
87
|
+
/**
|
|
88
|
+
* How soon (ms) orphan recovery re-drives a REMOTE workflow run found `running` with a call in flight:
|
|
89
|
+
* its turn was lost (an inline advance timed out, or the replica applying a decision died), so it is
|
|
90
|
+
* parked on this short wake rather than the whole `reconcileMs` window. Re-driving dispatches a fresh
|
|
91
|
+
* turn; the calls already in flight are not re-dispatched (their pending checkpoints dedupe them).
|
|
92
|
+
*/
|
|
93
|
+
const REMOTE_TURN_REDRIVE_MS = 1_000;
|
|
77
94
|
/** Fisher–Yates, in place. */
|
|
78
95
|
function shuffle(items) {
|
|
79
96
|
for (let i = items.length - 1; i > 0; i -= 1) {
|
|
@@ -143,11 +160,21 @@ export class WorkflowEngine {
|
|
|
143
160
|
/** Whether the worker tick fires {@link schedules} (the `persistedSchedules` option). */
|
|
144
161
|
persistedSchedulesEnabled;
|
|
145
162
|
leaseMs;
|
|
163
|
+
/**
|
|
164
|
+
* The engine clock when this instance was constructed. Every lease THIS incarnation writes ends at
|
|
165
|
+
* or after `bootedAt + leaseMs`; a lease held under {@link instanceId} that ends before that was
|
|
166
|
+
* written by a previous process that ran under the same id (a pod restarted after a crash) — see
|
|
167
|
+
* {@link selfReclaimable}.
|
|
168
|
+
*/
|
|
169
|
+
bootedAt;
|
|
146
170
|
maxRecoveryAttempts;
|
|
147
171
|
reconcileMs;
|
|
172
|
+
heartbeatPersistThrottleMs;
|
|
148
173
|
timerBatchSize;
|
|
174
|
+
eventBufferTtlMs;
|
|
149
175
|
remoteRedispatchMs;
|
|
150
176
|
remoteRedispatchMax;
|
|
177
|
+
remoteAdvanceSilenceMs;
|
|
151
178
|
webhookUrl;
|
|
152
179
|
traceparent;
|
|
153
180
|
context;
|
|
@@ -200,7 +227,10 @@ export class WorkflowEngine {
|
|
|
200
227
|
accumulators;
|
|
201
228
|
/** In-flight remote steps awaiting a worker result, keyed by stepId. */
|
|
202
229
|
pending = new Map();
|
|
203
|
-
/**
|
|
230
|
+
/** Each run's step results waiting to be settled on this instance — see {@link completeRemoteResult}. */
|
|
231
|
+
resultQueues = new Map();
|
|
232
|
+
/** Per-step "reset the liveness timer" callbacks, called with the beat's worker timestamp when a
|
|
233
|
+
* heartbeat arrives. */
|
|
204
234
|
heartbeatResets = new Map();
|
|
205
235
|
listeners = new Set();
|
|
206
236
|
/** Step interceptors (onion middleware around real local-step execution), first = outermost. */
|
|
@@ -211,6 +241,8 @@ export class WorkflowEngine {
|
|
|
211
241
|
enqueuedListeners = new Set();
|
|
212
242
|
/** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
|
|
213
243
|
deadListeners = new Set();
|
|
244
|
+
/** The fleet-default dead-letter workflow's name — see {@link WorkflowEngineDeps.deadLetterWorkflow}. */
|
|
245
|
+
deadLetterWorkflow;
|
|
214
246
|
/** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
|
|
215
247
|
updateValidators = new Map();
|
|
216
248
|
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
@@ -237,6 +269,29 @@ export class WorkflowEngine {
|
|
|
237
269
|
* writes. `settleRun` checks this and degrades to an echo; `execute`'s finally clears the mark.
|
|
238
270
|
*/
|
|
239
271
|
leaseLostRuns = new Set();
|
|
272
|
+
/**
|
|
273
|
+
* The lease handle of each execution in flight in this process, by run id. A path that gives the run
|
|
274
|
+
* lease away mid-execution on purpose (a remote turn suspending on its dispatched decision, see
|
|
275
|
+
* {@link releaseTurnLease}) flags it, so the execution's renew loop stops renewing and never reads its
|
|
276
|
+
* own release as a takeover: a late `false` renew would otherwise fence whoever applies the decision.
|
|
277
|
+
*/
|
|
278
|
+
executionLeases = new Map();
|
|
279
|
+
/** Stable decision handler subscribed on every remote executor's transport (see registerRemote). */
|
|
280
|
+
decisionHandler = (decision) => {
|
|
281
|
+
// A transport that delivers the decision from INSIDE its own `dispatchWorkflowTask` (an in-process
|
|
282
|
+
// broker) would otherwise wait on the run gate the dispatching execution still holds. Apply it once
|
|
283
|
+
// that execution has returned instead; it is tracked, so drain still waits for it.
|
|
284
|
+
if (turnDispatchAls.getStore() === decision?.runId) {
|
|
285
|
+
this.trackEffect(this.completeRemoteDecision(decision));
|
|
286
|
+
return Promise.resolve();
|
|
287
|
+
}
|
|
288
|
+
return this.completeRemoteDecision(decision);
|
|
289
|
+
};
|
|
290
|
+
/** Earliest clock time an awaited turn's deadline is worth reading again (`runId` → clock ms), the
|
|
291
|
+
* read throttle for {@link rearmDecisionDeadline}. */
|
|
292
|
+
decisionDeadlineRearms = new Map();
|
|
293
|
+
/** Whether any registered remote executor carries its own turn window (`decisionTimeoutMs`). */
|
|
294
|
+
anyDecisionTimeout = false;
|
|
240
295
|
/**
|
|
241
296
|
* The in-process execution gate, by run id: the execution of the run currently in flight in THIS
|
|
242
297
|
* process (`tail`), and at most one resume queued behind it (`queued`). See {@link resumeRun}.
|
|
@@ -260,6 +315,10 @@ export class WorkflowEngine {
|
|
|
260
315
|
*/
|
|
261
316
|
postSettle = new Set();
|
|
262
317
|
draining = false;
|
|
318
|
+
/** Shutdown wait already spent before/alongside {@link drain} (see {@link beginDrain}). */
|
|
319
|
+
drainSpentMs = 0;
|
|
320
|
+
/** Re-arms the deadline of every {@link drain} currently waiting, when more wait is reported. */
|
|
321
|
+
drainRearms = new Set();
|
|
263
322
|
constructor(deps) {
|
|
264
323
|
this.store = deps.store;
|
|
265
324
|
this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
|
|
@@ -271,7 +330,9 @@ export class WorkflowEngine {
|
|
|
271
330
|
// instead of waiting for their retry tick. Best-effort (the retry tick remains the guarantee).
|
|
272
331
|
this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
|
|
273
332
|
this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
|
|
274
|
-
|
|
333
|
+
// `''` is the default pool (the transports already treat it as bare): normalize it here so the
|
|
334
|
+
// runs this engine stamps, and the pool it polls, are the default pool's.
|
|
335
|
+
this.namespace = deps.namespace === '' ? DEFAULT_NAMESPACE : deps.namespace;
|
|
275
336
|
// The control-plane's own handshake descriptor — the side negotiated AGAINST each worker so a
|
|
276
337
|
// protocol-incompatible fleet is detected before dispatch (design §7.3). Scoped to this engine's
|
|
277
338
|
// namespace/partition; capabilities/protocol are config-driven (default: current single major).
|
|
@@ -285,7 +346,13 @@ export class WorkflowEngine {
|
|
|
285
346
|
? { namespace: this.namespace }
|
|
286
347
|
: {}),
|
|
287
348
|
});
|
|
288
|
-
|
|
349
|
+
// A non-finite or sub-1 poll falls back to the default: NaN would park a blocked run on an
|
|
350
|
+
// unreachable wake, and 0 would re-drive it in a hot loop.
|
|
351
|
+
const blockedPoll = deps.blockedPollMs;
|
|
352
|
+
this.blockedPollMs =
|
|
353
|
+
blockedPoll !== undefined && Number.isFinite(blockedPoll) && blockedPoll >= 1
|
|
354
|
+
? blockedPoll
|
|
355
|
+
: 5000;
|
|
289
356
|
// Propagate the engine's namespace to its transport(s) so the SAME namespace that partitions the
|
|
290
357
|
// store also partitions the transport's queues/keys — set once on the engine, applied everywhere.
|
|
291
358
|
// The transport makes "default" a no-op (byte-identical names); an empty pool is a no-op too.
|
|
@@ -294,18 +361,34 @@ export class WorkflowEngine {
|
|
|
294
361
|
if (this.namespace !== undefined)
|
|
295
362
|
this.pool.useNamespace(this.namespace);
|
|
296
363
|
this.leaseMs = deps.leaseMs ?? 30_000;
|
|
364
|
+
this.bootedAt = this.clock();
|
|
297
365
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
298
|
-
// Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
|
|
299
|
-
|
|
366
|
+
// Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event). A
|
|
367
|
+
// non-finite or negative value falls back to the default rather than silently disabling the net
|
|
368
|
+
// (NaN) or stamping a wake in the past that re-drives the run every tick (negative).
|
|
369
|
+
const reconcile = deps.reconcileMs;
|
|
370
|
+
this.reconcileMs =
|
|
371
|
+
reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
|
|
372
|
+
? 300_000
|
|
373
|
+
: reconcile || undefined;
|
|
374
|
+
// Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
|
|
375
|
+
// cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
|
|
376
|
+
const throttle = deps.heartbeatPersistThrottleMs;
|
|
377
|
+
this.heartbeatPersistThrottleMs =
|
|
378
|
+
throttle !== undefined && Number.isFinite(throttle) && throttle >= 0 ? throttle : 10_000;
|
|
300
379
|
// A non-finite or sub-1 value falls back to the default: a NaN limit would stall timer recovery.
|
|
301
380
|
const batch = deps.timerBatchSize;
|
|
302
381
|
this.timerBatchSize =
|
|
303
382
|
batch !== undefined && Number.isFinite(batch) && batch >= 1
|
|
304
383
|
? Math.floor(batch)
|
|
305
384
|
: DEFAULT_TIMER_BATCH;
|
|
385
|
+
const ttl = deps.eventBufferTtlMs;
|
|
386
|
+
this.eventBufferTtlMs = ttl !== undefined && Number.isFinite(ttl) && ttl > 0 ? ttl : undefined;
|
|
306
387
|
// Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
|
|
307
388
|
this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
|
|
308
389
|
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
390
|
+
// Opt-in: unset (or 0) keeps the unbounded wait for a remote workflow turn's decision.
|
|
391
|
+
this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs || undefined;
|
|
309
392
|
this.webhookUrl = deps.webhookUrl;
|
|
310
393
|
this.traceparent = deps.traceparent;
|
|
311
394
|
this.context = deps.context;
|
|
@@ -315,6 +398,8 @@ export class WorkflowEngine {
|
|
|
315
398
|
this.compensationTimeoutMs = Math.max(1_000, deps.compensationTimeoutMs ?? 300_000);
|
|
316
399
|
this.retention = deps.retention;
|
|
317
400
|
this.retentionPolicies = deps.retentionPolicies ?? [];
|
|
401
|
+
this.deadLetterWorkflow =
|
|
402
|
+
deps.deadLetterWorkflow !== undefined ? workflowName(deps.deadLetterWorkflow) : undefined;
|
|
318
403
|
this.persistedSchedulesEnabled = deps.persistedSchedules ?? false;
|
|
319
404
|
this.schedules = new ScheduleClient(this.store, {
|
|
320
405
|
start: (workflow, input, runId, opts) => this.start(workflow, input, runId, opts),
|
|
@@ -324,11 +409,22 @@ export class WorkflowEngine {
|
|
|
324
409
|
this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
|
|
325
410
|
this.trackStepStart = deps.trackStepStart ?? true;
|
|
326
411
|
// Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
|
|
327
|
-
// A failed pickup is
|
|
328
|
-
//
|
|
412
|
+
// A failed pickup is reported, never thrown (the run stays `pending`, or leased until its lease
|
|
413
|
+
// lapses, for a `runPending` poll or recovery to retry); run failures themselves are handled
|
|
414
|
+
// inside `execute` and surfaced as the run's `failed` state. What reaches here is the store or
|
|
415
|
+
// the registry refusing the pickup — a lost connection, a missing table, an unregistered version.
|
|
329
416
|
this.usesInProcessDispatch = deps.runDispatcher === undefined;
|
|
417
|
+
//
|
|
418
|
+
// Tracked from the moment of dispatch (see {@link trackEffect}): the pickup leases and reads the
|
|
419
|
+
// run before its execution enters `inflight`, so a bare `queueMicrotask` let `drain()` find both
|
|
420
|
+
// registries empty in that gap and return while the pickup was still about to write — onto a
|
|
421
|
+
// closed connection, or a dropped schema. The one-microtask defer is kept.
|
|
330
422
|
this.runDispatcher = deps.runDispatcher ?? {
|
|
331
|
-
dispatch: (runId) =>
|
|
423
|
+
dispatch: (runId) => this.trackEffect(Promise.resolve()
|
|
424
|
+
.then(() => this.runOne(runId))
|
|
425
|
+
.catch((err) => {
|
|
426
|
+
console.warn(`[adonis-durable] in-process pickup of run ${runId} failed (a pending-run poll or recovery retries it): ${err instanceof Error ? err.message : String(err)}`);
|
|
427
|
+
})),
|
|
332
428
|
};
|
|
333
429
|
this.singletons = new SingletonGate({
|
|
334
430
|
store: this.store,
|
|
@@ -367,10 +463,22 @@ export class WorkflowEngine {
|
|
|
367
463
|
// in-memory waiter (the step suspended DURABLY, possibly on another instance) the beat rearms
|
|
368
464
|
// the step's durable LEASE instead, so a worker still holding a long step is never presumed
|
|
369
465
|
// lost and double-dispatched (see rearmStepLease).
|
|
466
|
+
//
|
|
467
|
+
// A RUN-scoped beat (no `stepId`: a worker replaying a long workflow TURN) keeps that turn alive
|
|
468
|
+
// instead: an inline advance awaiting on THIS instance rearms its in-memory deadline, otherwise
|
|
469
|
+
// the awaited turn's durable re-drive deadline moves forward (see rearmDecisionDeadline).
|
|
370
470
|
async (beat) => {
|
|
471
|
+
if (!beat.stepId) {
|
|
472
|
+
const resetTurn = this.heartbeatResets.get(turnBeatKey(beat.runId));
|
|
473
|
+
if (resetTurn)
|
|
474
|
+
resetTurn(beat.at);
|
|
475
|
+
else
|
|
476
|
+
await this.rearmDecisionDeadline(beat.runId);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
371
479
|
const reset = this.heartbeatResets.get(beat.stepId);
|
|
372
480
|
if (reset)
|
|
373
|
-
reset();
|
|
481
|
+
reset(beat.at);
|
|
374
482
|
else
|
|
375
483
|
await this.rearmStepLease(beat.runId, beat.seq);
|
|
376
484
|
this.persistHeartbeat(beat);
|
|
@@ -388,7 +496,7 @@ export class WorkflowEngine {
|
|
|
388
496
|
return;
|
|
389
497
|
if (msg.kind === 'event') {
|
|
390
498
|
// `at` may be a string after JSON transit (Redis) — normalize back to a Date.
|
|
391
|
-
this.deliver({ ...msg.event, at: new Date(msg.event.at) });
|
|
499
|
+
this.deliver(markRemoteEvent({ ...msg.event, at: new Date(msg.event.at) }));
|
|
392
500
|
}
|
|
393
501
|
else if (msg.kind === 'cancel') {
|
|
394
502
|
this.notifyCancelled(msg.runId);
|
|
@@ -412,7 +520,11 @@ export class WorkflowEngine {
|
|
|
412
520
|
}
|
|
413
521
|
});
|
|
414
522
|
this.accumulators = new EventAccumulators(this);
|
|
415
|
-
this.entities = new Entities(this
|
|
523
|
+
this.entities = new Entities(this, {
|
|
524
|
+
bufferSignal: (token, payload) => this.store.bufferSignal(token, payload),
|
|
525
|
+
wakeWaiter: (token, payload) => this.wakeSignalWaiter(token, payload),
|
|
526
|
+
ensureStarted: (workflow, input, runId) => this.start(workflow, input, runId),
|
|
527
|
+
});
|
|
416
528
|
}
|
|
417
529
|
/**
|
|
418
530
|
* Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
|
|
@@ -537,6 +649,42 @@ export class WorkflowEngine {
|
|
|
537
649
|
return this.entities.signal(name, key, op, arg);
|
|
538
650
|
}
|
|
539
651
|
/** Read an entity's current durable state (published after each op), or undefined if it has none yet. */
|
|
652
|
+
/**
|
|
653
|
+
* The entity lost-wake reconcile: wake (start, if need be) every entity that has ops sitting in its
|
|
654
|
+
* buffer, up to `limit` of them. A workflow-side send stores the op and THEN wakes the entity, so a
|
|
655
|
+
* process that dies in between leaves the op buffered while the entity sleeps; this pass is what
|
|
656
|
+
* applies it. Idempotent — a wake carries a no-op nudge, and the entity reads its buffer in arrival
|
|
657
|
+
* order, consuming each op once (an op with a stable id is skipped if already applied). The worker
|
|
658
|
+
* tick runs it, throttled to one pass a minute. Returns how many entities were woken. A no-op on a
|
|
659
|
+
* store without `listBufferedSignalTokens`.
|
|
660
|
+
*/
|
|
661
|
+
async reconcileEntityWakes(limit = 100) {
|
|
662
|
+
if (this.draining || !this.store.listBufferedSignalTokens)
|
|
663
|
+
return 0;
|
|
664
|
+
const tokens = await this.store.listBufferedSignalTokens('entity:', limit);
|
|
665
|
+
let woken = 0;
|
|
666
|
+
for (const token of tokens) {
|
|
667
|
+
if (this.draining)
|
|
668
|
+
break;
|
|
669
|
+
try {
|
|
670
|
+
if (await this.entities.kickRunId(token))
|
|
671
|
+
woken += 1;
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
console.warn(`[adonis-durable] entity reconcile of ${token} skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return woken;
|
|
678
|
+
}
|
|
679
|
+
/** Throttle stamp for the tick's {@link reconcileEntityWakes} pass. */
|
|
680
|
+
#lastEntityReconcileAt = Number.NEGATIVE_INFINITY;
|
|
681
|
+
/** {@link reconcileEntityWakes}, at most once a minute — the worker tick's entry point. */
|
|
682
|
+
async sweepEntityWakes(now = this.clock()) {
|
|
683
|
+
if (now - this.#lastEntityReconcileAt < 60_000)
|
|
684
|
+
return 0;
|
|
685
|
+
this.#lastEntityReconcileAt = now;
|
|
686
|
+
return this.reconcileEntityWakes();
|
|
687
|
+
}
|
|
540
688
|
getEntityState(name, key) {
|
|
541
689
|
return this.entities.getState(name, key);
|
|
542
690
|
}
|
|
@@ -571,12 +719,15 @@ export class WorkflowEngine {
|
|
|
571
719
|
version,
|
|
572
720
|
fn,
|
|
573
721
|
onError: this.checkedRoutes(name, opts?.onError),
|
|
722
|
+
deadLetterWorkflow: opts?.deadLetterWorkflow !== undefined ? workflowName(opts.deadLetterWorkflow) : undefined,
|
|
574
723
|
tags: opts?.tags,
|
|
575
724
|
origin: opts?.origin,
|
|
576
725
|
singleton: opts?.singleton,
|
|
577
726
|
concurrency: opts?.concurrency,
|
|
578
727
|
executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
|
|
579
728
|
validateInput: opts?.validateInput,
|
|
729
|
+
inputSchema: opts?.inputSchema,
|
|
730
|
+
searchAttributesSchema: opts?.searchAttributesSchema,
|
|
580
731
|
onEvent: opts?.onEvent,
|
|
581
732
|
eventBatch: opts?.eventBatch,
|
|
582
733
|
requires: opts?.requires,
|
|
@@ -602,6 +753,7 @@ export class WorkflowEngine {
|
|
|
602
753
|
name,
|
|
603
754
|
version,
|
|
604
755
|
onError: this.checkedRoutes(name, opts.onError),
|
|
756
|
+
deadLetterWorkflow: opts.deadLetterWorkflow !== undefined ? workflowName(opts.deadLetterWorkflow) : undefined,
|
|
605
757
|
// A remote workflow has no in-process body; execute() branches on `remote` before this is read.
|
|
606
758
|
fn: () => {
|
|
607
759
|
throw new Error(`workflow ${name} is remote — it has no in-process body`);
|
|
@@ -611,34 +763,71 @@ export class WorkflowEngine {
|
|
|
611
763
|
concurrency: opts.concurrency,
|
|
612
764
|
executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
|
|
613
765
|
validateInput: opts.validateInput,
|
|
766
|
+
inputSchema: opts.inputSchema,
|
|
614
767
|
remote: { group: opts.group, executor: opts.executor },
|
|
615
768
|
requires: opts.requires,
|
|
616
769
|
};
|
|
770
|
+
// A dispatched turn's decision is applied by whichever replica consumes it, so every engine that
|
|
771
|
+
// can dispatch a turn also consumes decisions. The handler is one stable function: a transport that
|
|
772
|
+
// keeps a handler set registers it once however many remote workflows share the transport.
|
|
773
|
+
opts.executor.subscribeDecisions?.(this.decisionHandler);
|
|
774
|
+
if (opts.executor.decisionTimeoutMs)
|
|
775
|
+
this.anyDecisionTimeout = true;
|
|
617
776
|
this.workflows.set(versionKey(name, version), registered);
|
|
618
777
|
const current = this.latest.get(name);
|
|
619
778
|
if (!current || isNewerVersion(version, current.version))
|
|
620
779
|
this.latest.set(name, registered);
|
|
621
780
|
}
|
|
622
781
|
/**
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
* workflow is reached by NAME with no `registerRemote` boilerplate. Returns whether `name` is
|
|
628
|
-
* registered afterwards. A no-op (returns `false`) when no transport is wired (nothing to route
|
|
629
|
-
* over) or no matching worker group is live (then `start`/`resume` throws `is not registered`).
|
|
782
|
+
* Register a workflow whose body runs in another SDK, routed to worker `group` — {@link
|
|
783
|
+
* registerRemote} with the executor built for you over this engine's primary transport. `version`
|
|
784
|
+
* defaults to `'1'`; register `name` at a version explicitly to pin starts to it
|
|
785
|
+
* ({@link StartOptions.version}).
|
|
630
786
|
*/
|
|
631
|
-
|
|
787
|
+
remote(name, opts) {
|
|
788
|
+
if (!this.primaryTransport) {
|
|
789
|
+
throw new Error(`@agora/durable: engine.remote('${name}') needs a transport that carries workflow tasks.`);
|
|
790
|
+
}
|
|
791
|
+
const { version, ...rest } = opts;
|
|
792
|
+
this.registerRemote(name, version ?? '1', {
|
|
793
|
+
...rest,
|
|
794
|
+
executor: new RemoteWorkflowExecutor(this.primaryTransport, opts.group),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Convention-dispatch resolution: when `name` isn't locally registered but a LIVE worker group for
|
|
799
|
+
* it exists, auto-register a remote workflow routed to that group so its runs advance over the
|
|
800
|
+
* broker like any `registerRemote`'d one. Always on — the queue name IS the routing, exactly as the
|
|
801
|
+
* aviary engine does — so a Python/NestJS/thin-worker workflow is reached by NAME with no
|
|
802
|
+
* `registerRemote` boilerplate. Returns whether `name` is registered (and routable for `namespace`)
|
|
803
|
+
* afterwards. A no-op (returns `false`) when no transport is wired (nothing to route over) or no
|
|
804
|
+
* matching worker group is live (then `start`/`resume` throws `is not registered`).
|
|
805
|
+
*
|
|
806
|
+
* TENANT-AWARE, per run: the group is the one the RUN's namespace routes to,
|
|
807
|
+
* `<name>@<namespace>` (bare for an absent/`default` namespace) — the token a tenant worker
|
|
808
|
+
* (`partition: <tenant>`) serves, and the same rule steps follow ({@link stepGroup}). So an operator
|
|
809
|
+
* starting a tenant's run reaches the tenant's live `<name>@<tenant>` group, not the bare one, and a
|
|
810
|
+
* convention workflow is re-checked for the namespace of every start.
|
|
811
|
+
*/
|
|
812
|
+
async ensureConventionWorkflow(name, namespace) {
|
|
632
813
|
if (!this.primaryTransport)
|
|
633
814
|
return false;
|
|
634
|
-
|
|
815
|
+
const current = this.latest.get(name);
|
|
816
|
+
if (current && !current.convention)
|
|
635
817
|
return true;
|
|
636
|
-
const
|
|
637
|
-
|
|
818
|
+
const group = conventionGroup(name, namespace);
|
|
819
|
+
// The transport whose keyspace reports the live group: in a mixed pool each transport scans a
|
|
820
|
+
// different keyspace, and dispatching on the primary while the worker beats on another one parks
|
|
821
|
+
// the turn on a queue nobody consumes.
|
|
822
|
+
const transport = await this.pool.transportWithLiveGroup(group);
|
|
823
|
+
if (!transport)
|
|
638
824
|
return false;
|
|
825
|
+
if (current)
|
|
826
|
+
return true;
|
|
639
827
|
this.registerRemote(name, '1', {
|
|
640
828
|
group: name,
|
|
641
|
-
|
|
829
|
+
// Per run: each run dispatches to the group of its own namespace.
|
|
830
|
+
executor: new RemoteWorkflowExecutor(transport, (run) => conventionGroup(run.workflow, run.namespace)),
|
|
642
831
|
});
|
|
643
832
|
const registered = this.workflows.get(versionKey(name, '1'));
|
|
644
833
|
if (registered)
|
|
@@ -658,7 +847,7 @@ export class WorkflowEngine {
|
|
|
658
847
|
heartbeatPersists = new Map();
|
|
659
848
|
/**
|
|
660
849
|
* Persist a worker heartbeat on its step's checkpoint (see {@link StepCheckpoint.lastHeartbeatAt}),
|
|
661
|
-
* best-effort and THROTTLED per step (≥10s between writes) — beats may arrive every few seconds
|
|
850
|
+
* best-effort and THROTTLED per step (≥ `heartbeatPersistThrottleMs`, default 10s, between writes) — beats may arrive every few seconds
|
|
662
851
|
* from a hot batch loop, and liveness observability must not turn into a per-item UPDATE storm.
|
|
663
852
|
* One exemption: a beat CARRYING progress punches through when the last persisted one had none —
|
|
664
853
|
* the automatic pickup beat lands first (no payload) and must not shadow the handler's first real
|
|
@@ -667,16 +856,19 @@ export class WorkflowEngine {
|
|
|
667
856
|
* wholesale past 1024 entries — a rare extra write, never a leak.
|
|
668
857
|
*/
|
|
669
858
|
persistHeartbeat(beat) {
|
|
670
|
-
|
|
859
|
+
const id = beat.stepId;
|
|
860
|
+
if (!this.store.recordStepHeartbeat || !id)
|
|
671
861
|
return;
|
|
672
862
|
const now = this.clock();
|
|
673
|
-
const last = this.heartbeatPersists.get(
|
|
863
|
+
const last = this.heartbeatPersists.get(id);
|
|
674
864
|
const upgradesToProgress = beat.progress !== undefined && last !== undefined && !last.hadProgress;
|
|
675
|
-
if (last !== undefined &&
|
|
865
|
+
if (last !== undefined &&
|
|
866
|
+
now - last.at < this.heartbeatPersistThrottleMs &&
|
|
867
|
+
!upgradesToProgress)
|
|
676
868
|
return;
|
|
677
869
|
if (this.heartbeatPersists.size > 1024)
|
|
678
870
|
this.heartbeatPersists.clear();
|
|
679
|
-
this.heartbeatPersists.set(
|
|
871
|
+
this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
|
|
680
872
|
void this.store
|
|
681
873
|
.recordStepHeartbeat(beat.runId, beat.seq, new Date(beat.at ?? now), beat.progress)
|
|
682
874
|
.catch(() => undefined);
|
|
@@ -691,6 +883,28 @@ export class WorkflowEngine {
|
|
|
691
883
|
startConsumers() {
|
|
692
884
|
this.pool.startConsumers();
|
|
693
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Serve a step body by name on this engine's primary transport — the same
|
|
888
|
+
* `transport.handle(name, fn)` the provider calls for every `stepsPath` handler. It makes the
|
|
889
|
+
* engine a {@link import('./step-discovery.js').StepServer}, so app code can serve steps from an
|
|
890
|
+
* extra directory without reaching for the (provider-private) transport:
|
|
891
|
+
*
|
|
892
|
+
* ```ts
|
|
893
|
+
* // start/durable.ts (a preload)
|
|
894
|
+
* import engine from '@adonis-agora/durable/services/main'
|
|
895
|
+
* import { registerStepsFromDir } from '@adonis-agora/durable'
|
|
896
|
+
* await registerStepsFromDir(engine, app.makePath('app/engine/steps'))
|
|
897
|
+
* ```
|
|
898
|
+
*
|
|
899
|
+
* Throws when no transport is wired or the wired one can't serve handlers (no `handle`).
|
|
900
|
+
*/
|
|
901
|
+
handle(name, fn) {
|
|
902
|
+
const server = this.primaryTransport;
|
|
903
|
+
if (!server || typeof server.handle !== 'function') {
|
|
904
|
+
throw new Error(`@agora/durable: cannot serve step "${name}": the engine has no transport that serves step handlers (no handle()).`);
|
|
905
|
+
}
|
|
906
|
+
server.handle(name, fn);
|
|
907
|
+
}
|
|
694
908
|
/** Subscribe to lifecycle events. Returns an unsubscribe function. */
|
|
695
909
|
subscribe(listener) {
|
|
696
910
|
this.listeners.add(listener);
|
|
@@ -756,6 +970,35 @@ export class WorkflowEngine {
|
|
|
756
970
|
/* a dead-letter handler must not break recovery */
|
|
757
971
|
}
|
|
758
972
|
}
|
|
973
|
+
this.routeDeadLetter(run);
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Hand a `dead` run to its dead-letter workflow, when one is configured: the workflow's own
|
|
977
|
+
* `deadLetterWorkflow`, else the engine's default. Started as `dlq:<runId>` (idempotent: a run
|
|
978
|
+
* dead-lettered twice is handled once; the dashboard links the pair by that id) in the dead run's
|
|
979
|
+
* namespace, with a {@link DeadLetter} input. Tracked, so a drain waits for the start; a failure
|
|
980
|
+
* (e.g. the handler is not registered here) is warned, never thrown into recovery. A handler run
|
|
981
|
+
* that itself goes dead is not routed again — no `dlq:dlq:…` chain.
|
|
982
|
+
*/
|
|
983
|
+
routeDeadLetter(run) {
|
|
984
|
+
if (run.id.startsWith('dlq:'))
|
|
985
|
+
return;
|
|
986
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
987
|
+
this.latest.get(run.workflow);
|
|
988
|
+
const target = registered?.deadLetterWorkflow ?? this.deadLetterWorkflow;
|
|
989
|
+
if (!target)
|
|
990
|
+
return;
|
|
991
|
+
const input = {
|
|
992
|
+
deadRunId: run.id,
|
|
993
|
+
workflow: run.workflow,
|
|
994
|
+
input: run.input,
|
|
995
|
+
...(run.error ? { error: run.error } : {}),
|
|
996
|
+
};
|
|
997
|
+
this.trackEffect(this.start(target, input, `dlq:${run.id}`, {
|
|
998
|
+
...(run.namespace !== undefined ? { namespace: run.namespace } : {}),
|
|
999
|
+
}).catch((err) => {
|
|
1000
|
+
console.warn(`[adonis-durable] could not start dead-letter workflow ${target} for run ${run.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1001
|
+
}));
|
|
759
1002
|
}
|
|
760
1003
|
/** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
|
|
761
1004
|
* control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
|
|
@@ -797,16 +1040,31 @@ export class WorkflowEngine {
|
|
|
797
1040
|
*/
|
|
798
1041
|
async startCore(workflow, input, runId, opts, dispatch) {
|
|
799
1042
|
const name = workflowName(workflow);
|
|
800
|
-
|
|
1043
|
+
// A pinned start (`opts.version`) resolves name@version and stops there: no newest, no
|
|
1044
|
+
// convention fallback, since neither can prove that version exists.
|
|
1045
|
+
const pinned = opts?.version;
|
|
1046
|
+
let registered = pinned === undefined ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
|
|
801
1047
|
// Convention dispatch: an unregistered workflow whose name matches a LIVE worker group is routed
|
|
802
|
-
// to it as a remote workflow
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (!registered)
|
|
808
|
-
|
|
809
|
-
|
|
1048
|
+
// to it as a remote workflow — the group of the namespace this run will carry (see
|
|
1049
|
+
// ensureConventionWorkflow). No registration needed — the queue name is the routing; when no
|
|
1050
|
+
// matching group is live this is a no-op and the "not registered" throw below fires. Re-checked
|
|
1051
|
+
// per start for a convention registration, since the tenant can differ from one start to the next.
|
|
1052
|
+
// A pinned start never falls back to convention: it cannot prove the pinned version exists.
|
|
1053
|
+
if (pinned === undefined && (!registered || registered.convention)) {
|
|
1054
|
+
registered = (await this.ensureConventionWorkflow(name, opts?.namespace ?? this.namespace))
|
|
1055
|
+
? this.latest.get(name)
|
|
1056
|
+
: undefined;
|
|
1057
|
+
}
|
|
1058
|
+
if (!registered) {
|
|
1059
|
+
throw new Error(pinned === undefined
|
|
1060
|
+
? `workflow ${name} is not registered`
|
|
1061
|
+
: `workflow ${name}@${pinned} is not registered`);
|
|
1062
|
+
}
|
|
1063
|
+
// Validate the input up front — a bad payload is rejected before any run is created. The schema
|
|
1064
|
+
// runs first and its OUTPUT (parsed/coerced) is what the run stores; `validateInput` then sees it.
|
|
1065
|
+
if (registered.inputSchema) {
|
|
1066
|
+
input = await validateStandard(registered.inputSchema, input, `workflow "${name}" input`);
|
|
1067
|
+
}
|
|
810
1068
|
await registered.validateInput?.(input);
|
|
811
1069
|
// Idempotent by run id: a redelivered trigger (at-least-once queues) or a scheduler re-tick for
|
|
812
1070
|
// the same id is a no-op, returning the existing run's state instead of starting a duplicate.
|
|
@@ -834,7 +1092,9 @@ export class WorkflowEngine {
|
|
|
834
1092
|
...(registered.singleton ? [this.singletons.tag(registered.singleton, input)] : []),
|
|
835
1093
|
...(quota ? [concurrencyTag(quota.key)] : []),
|
|
836
1094
|
];
|
|
837
|
-
|
|
1095
|
+
// The admission marker is owned by the store's atomic claim: a fresh run can neither supply it nor
|
|
1096
|
+
// inherit it (`retryWithInput` copies the original run's tags).
|
|
1097
|
+
const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags)?.filter((t) => !SINGLETON_RESERVED_TAGS.includes(t));
|
|
838
1098
|
// Singleton back-pressure: reject a start that would grow the same-key backlog past
|
|
839
1099
|
// `limit + maxQueueDepth` (no-op when no maxQueueDepth is configured).
|
|
840
1100
|
if (registered.singleton) {
|
|
@@ -978,6 +1238,39 @@ export class WorkflowEngine {
|
|
|
978
1238
|
listCheckpoints(runId) {
|
|
979
1239
|
return this.store.listCheckpoints(runId);
|
|
980
1240
|
}
|
|
1241
|
+
/**
|
|
1242
|
+
* How the runs matching `query` split across `(status, origin)`, in one aggregate when the store
|
|
1243
|
+
* has {@link StateStore.runFacets} (else counted over a listing). Part of the engine's read API.
|
|
1244
|
+
*/
|
|
1245
|
+
runFacets(query) {
|
|
1246
|
+
return runFacetsOf(this.store, query);
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* A run's detail (see `loadRunDetail`), built from the store's checkpoint aggregates when it has
|
|
1250
|
+
* `checkpointStats` + `queryCheckpoints` — a summarized detail then reads a few rows per wide fan
|
|
1251
|
+
* instead of the whole history, and carries an `etag` that `options.ifNoneMatch` can match.
|
|
1252
|
+
*/
|
|
1253
|
+
getRunDetail(runId, options) {
|
|
1254
|
+
return loadRunDetail(this.detailSource(), runId, options);
|
|
1255
|
+
}
|
|
1256
|
+
/** One page of a summarized fan's members (see `loadFanMembers`), versioned like {@link getRunDetail}. */
|
|
1257
|
+
getFanMembers(runId, group, query) {
|
|
1258
|
+
return loadFanMembers(this.detailSource(), runId, group, query);
|
|
1259
|
+
}
|
|
1260
|
+
detailSource() {
|
|
1261
|
+
const store = this.store;
|
|
1262
|
+
return {
|
|
1263
|
+
getRun: (id) => this.getRun(id),
|
|
1264
|
+
listCheckpoints: (id) => store.listCheckpoints(id),
|
|
1265
|
+
getRunChildren: (id) => this.getRunChildren(id),
|
|
1266
|
+
...(store.checkpointStats && store.queryCheckpoints
|
|
1267
|
+
? {
|
|
1268
|
+
checkpointStats: store.checkpointStats.bind(store),
|
|
1269
|
+
queryCheckpoints: store.queryCheckpoints.bind(store),
|
|
1270
|
+
}
|
|
1271
|
+
: {}),
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
981
1274
|
/**
|
|
982
1275
|
* Hard-delete a run and its whole child subtree from the store, returning the number of runs
|
|
983
1276
|
* removed (0 if `runId` is unknown). Children are collected BEFORE this run's checkpoints are
|
|
@@ -1087,11 +1380,16 @@ export class WorkflowEngine {
|
|
|
1087
1380
|
* parents.
|
|
1088
1381
|
*/
|
|
1089
1382
|
async sweepRetention(now = this.clock()) {
|
|
1090
|
-
if ((!this.retention &&
|
|
1383
|
+
if ((!this.retention &&
|
|
1384
|
+
this.retentionPolicies.length === 0 &&
|
|
1385
|
+
this.eventBufferTtlMs === undefined) ||
|
|
1386
|
+
this.draining) {
|
|
1091
1387
|
return 0;
|
|
1388
|
+
}
|
|
1092
1389
|
if (now - this.#lastRetentionSweepAt < 60_000)
|
|
1093
1390
|
return 0;
|
|
1094
1391
|
this.#lastRetentionSweepAt = now;
|
|
1392
|
+
await this.pruneExpiredBufferedEvents(now);
|
|
1095
1393
|
let evicted = 0;
|
|
1096
1394
|
const passes = [];
|
|
1097
1395
|
for (const status of TERMINAL_RUN_STATUSES) {
|
|
@@ -1102,7 +1400,9 @@ export class WorkflowEngine {
|
|
|
1102
1400
|
}
|
|
1103
1401
|
for (const policy of this.retentionPolicies) {
|
|
1104
1402
|
const statuses = policy.statuses.filter((s) => TERMINAL_RUN_STATUSES.includes(s));
|
|
1105
|
-
|
|
1403
|
+
const byAge = policy.maxAgeMs !== undefined && policy.maxAgeMs > 0;
|
|
1404
|
+
const byCount = policy.maxCount !== undefined && policy.maxCount >= 0;
|
|
1405
|
+
if (statuses.length === 0 || (!byAge && !byCount))
|
|
1106
1406
|
continue;
|
|
1107
1407
|
// A namespaced worker only ever prunes its own partition: a scope naming another one is not its
|
|
1108
1408
|
// to sweep (the operator, `namespace: undefined`, sweeps every partition).
|
|
@@ -1111,15 +1411,32 @@ export class WorkflowEngine {
|
|
|
1111
1411
|
policy.scope.namespace !== this.namespace) {
|
|
1112
1412
|
continue;
|
|
1113
1413
|
}
|
|
1114
|
-
|
|
1414
|
+
const scoped = {
|
|
1115
1415
|
...policy.scope,
|
|
1116
1416
|
namespace: policy.scope.namespace ?? this.namespace,
|
|
1117
1417
|
statuses,
|
|
1118
|
-
|
|
1119
|
-
|
|
1418
|
+
};
|
|
1419
|
+
if (byAge)
|
|
1420
|
+
passes.push({ ...scoped, updatedBefore: now - policy.maxAgeMs });
|
|
1421
|
+
if (byCount) {
|
|
1422
|
+
// Keep the `maxCount` newest (listRuns is newest-first by createdAt): the last one kept marks
|
|
1423
|
+
// the cutoff, and every run created strictly before it goes. A run sharing that start
|
|
1424
|
+
// millisecond is kept too, so a tie never deletes one of the newest `maxCount`.
|
|
1425
|
+
const maxCount = policy.maxCount;
|
|
1426
|
+
if (maxCount === 0) {
|
|
1427
|
+
passes.push(scoped);
|
|
1428
|
+
}
|
|
1429
|
+
else {
|
|
1430
|
+
const [lastKept] = await this.store.listRuns({ ...scoped, page: maxCount, size: 1 });
|
|
1431
|
+
if (lastKept) {
|
|
1432
|
+
passes.push({ ...scoped, createdBefore: lastKept.createdAt.getTime() - 1 });
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1120
1436
|
}
|
|
1121
1437
|
for (const pass of passes) {
|
|
1122
1438
|
const candidates = await this.store.listRuns({ ...pass, size: 100 });
|
|
1439
|
+
const archived = [];
|
|
1123
1440
|
for (const run of candidates) {
|
|
1124
1441
|
try {
|
|
1125
1442
|
if (this.evictListeners.size > 0) {
|
|
@@ -1127,16 +1444,81 @@ export class WorkflowEngine {
|
|
|
1127
1444
|
for (const listener of this.evictListeners)
|
|
1128
1445
|
await listener(run, checkpoints);
|
|
1129
1446
|
}
|
|
1130
|
-
|
|
1447
|
+
archived.push(run.id);
|
|
1131
1448
|
}
|
|
1132
1449
|
catch (err) {
|
|
1133
|
-
// An archival hook
|
|
1450
|
+
// An archival hook failed — keep the run; the next sweep retries.
|
|
1134
1451
|
console.warn(`[adonis-durable] retention eviction of run ${run.id} skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1135
1452
|
}
|
|
1136
1453
|
}
|
|
1454
|
+
try {
|
|
1455
|
+
// One batched delete per pass (the store's `deleteRuns`), subtrees included.
|
|
1456
|
+
evicted += await this.deleteRunTrees(archived);
|
|
1457
|
+
}
|
|
1458
|
+
catch (err) {
|
|
1459
|
+
// The delete failed — keep the runs; the next sweep retries.
|
|
1460
|
+
console.warn(`[adonis-durable] retention eviction of ${archived.length} run(s) skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1461
|
+
}
|
|
1137
1462
|
}
|
|
1138
1463
|
return evicted;
|
|
1139
1464
|
}
|
|
1465
|
+
/**
|
|
1466
|
+
* The {@link eventBufferTtlMs} prune: delete buffered events older than the TTL, a bounded batch
|
|
1467
|
+
* at a time, through the store's `pruneBufferedEvents`. Runs in {@link sweepRetention}'s pass.
|
|
1468
|
+
* Best-effort — a failure is warned and retried on the next pass.
|
|
1469
|
+
*/
|
|
1470
|
+
async pruneExpiredBufferedEvents(now) {
|
|
1471
|
+
const ttl = this.eventBufferTtlMs;
|
|
1472
|
+
if (ttl === undefined || !this.store.pruneBufferedEvents)
|
|
1473
|
+
return;
|
|
1474
|
+
try {
|
|
1475
|
+
for (let pass = 0; pass < 10; pass += 1) {
|
|
1476
|
+
const deleted = await this.store.pruneBufferedEvents(now - ttl, 500);
|
|
1477
|
+
if (deleted < 500)
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (err) {
|
|
1482
|
+
console.warn(`[adonis-durable] buffered-event prune skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Delete the runs `rootIds` and their child subtrees: the ids are collected children-first (the
|
|
1487
|
+
* edge is read from the parent's checkpoints, so before anything is deleted), deleted in one
|
|
1488
|
+
* {@link deleteRunIds} batch, then each one's token-keyed buffered signals are swept like
|
|
1489
|
+
* {@link deleteRun} does. Returns how many runs were deleted.
|
|
1490
|
+
*/
|
|
1491
|
+
async deleteRunTrees(rootIds) {
|
|
1492
|
+
const ids = [];
|
|
1493
|
+
const seen = new Set();
|
|
1494
|
+
const visit = async (id) => {
|
|
1495
|
+
if (seen.has(id))
|
|
1496
|
+
return;
|
|
1497
|
+
seen.add(id);
|
|
1498
|
+
if (!(await this.store.getRun(id)))
|
|
1499
|
+
return;
|
|
1500
|
+
for (const child of await this.getRunChildren(id))
|
|
1501
|
+
await visit(child);
|
|
1502
|
+
ids.push(id);
|
|
1503
|
+
};
|
|
1504
|
+
for (const id of rootIds)
|
|
1505
|
+
await visit(id);
|
|
1506
|
+
await this.deleteRunIds(ids);
|
|
1507
|
+
for (const id of ids)
|
|
1508
|
+
await this.sweepRunBufferedSignals(id);
|
|
1509
|
+
return ids.length;
|
|
1510
|
+
}
|
|
1511
|
+
/** Bulk-delete through the store's `deleteRuns`, or one `deleteRun` per id when it has none. */
|
|
1512
|
+
async deleteRunIds(ids) {
|
|
1513
|
+
if (ids.length === 0)
|
|
1514
|
+
return;
|
|
1515
|
+
if (this.store.deleteRuns) {
|
|
1516
|
+
await this.store.deleteRuns(ids);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
for (const id of ids)
|
|
1520
|
+
await this.store.deleteRun(id);
|
|
1521
|
+
}
|
|
1140
1522
|
async deleteRun(runId) {
|
|
1141
1523
|
const run = await this.store.getRun(runId);
|
|
1142
1524
|
if (!run)
|
|
@@ -1217,6 +1599,7 @@ export class WorkflowEngine {
|
|
|
1217
1599
|
};
|
|
1218
1600
|
for (const run of batch)
|
|
1219
1601
|
await visit(run.id);
|
|
1602
|
+
const doomed = [];
|
|
1220
1603
|
for (const id of ids) {
|
|
1221
1604
|
const status = known.has(id) ? known.get(id) : (await this.store.getRun(id))?.status;
|
|
1222
1605
|
if (status === undefined)
|
|
@@ -1226,10 +1609,13 @@ export class WorkflowEngine {
|
|
|
1226
1609
|
continue;
|
|
1227
1610
|
await this.cancel(id).catch(() => undefined);
|
|
1228
1611
|
}
|
|
1229
|
-
|
|
1230
|
-
await this.sweepRunBufferedSignals(id);
|
|
1231
|
-
deleted += 1;
|
|
1612
|
+
doomed.push(id);
|
|
1232
1613
|
}
|
|
1614
|
+
// One batched delete per page (the store's `deleteRuns`), children before their parent.
|
|
1615
|
+
await this.deleteRunIds(doomed);
|
|
1616
|
+
for (const id of doomed)
|
|
1617
|
+
await this.sweepRunBufferedSignals(id);
|
|
1618
|
+
deleted += doomed.length;
|
|
1233
1619
|
if (batch.length < size)
|
|
1234
1620
|
break;
|
|
1235
1621
|
}
|
|
@@ -1352,7 +1738,7 @@ export class WorkflowEngine {
|
|
|
1352
1738
|
let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
1353
1739
|
// Convention dispatch: a run recovered on an instance that never registered this workflow (e.g. a
|
|
1354
1740
|
// crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
|
|
1355
|
-
if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
|
|
1741
|
+
if (!registered && (await this.ensureConventionWorkflow(run.workflow, run.namespace))) {
|
|
1356
1742
|
registered =
|
|
1357
1743
|
this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
1358
1744
|
this.latest.get(run.workflow);
|
|
@@ -1405,10 +1791,21 @@ export class WorkflowEngine {
|
|
|
1405
1791
|
}
|
|
1406
1792
|
return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === 'pending').length;
|
|
1407
1793
|
}
|
|
1408
|
-
/**
|
|
1794
|
+
/**
|
|
1795
|
+
* Track an in-flight execution so {@link drain} can wait for it.
|
|
1796
|
+
*
|
|
1797
|
+
* The bookkeeping chain must not re-raise: `p.finally(...)` returns a NEW promise that rejects
|
|
1798
|
+
* whenever `p` does, and nothing awaits it. So a turn whose store write failed (a dropped
|
|
1799
|
+
* connection, a missing table) became a process-level unhandled rejection even though the caller
|
|
1800
|
+
* awaiting `p` handled the very same error — fatal under Node's default `--unhandled-rejections`.
|
|
1801
|
+
* The rejection is reported once, to the caller that owns `p`.
|
|
1802
|
+
*/
|
|
1409
1803
|
track(runId, p) {
|
|
1410
1804
|
this.inflight.set(p, runId);
|
|
1411
|
-
|
|
1805
|
+
const untrack = () => {
|
|
1806
|
+
this.inflight.delete(p);
|
|
1807
|
+
};
|
|
1808
|
+
p.then(untrack, untrack);
|
|
1412
1809
|
return p;
|
|
1413
1810
|
}
|
|
1414
1811
|
/**
|
|
@@ -1428,9 +1825,20 @@ export class WorkflowEngine {
|
|
|
1428
1825
|
* (and their per-run lock loops) start nothing once this is called, and {@link runOne} refuses
|
|
1429
1826
|
* broker-delivered runs. Runs already started finish. {@link drain} calls it first; the worker loop
|
|
1430
1827
|
* calls it the moment a stop is requested, so a tick still in flight stops leasing a backlog.
|
|
1828
|
+
*
|
|
1829
|
+
* `spentMs` is wait time ALREADY spent on this shutdown (the worker loop waiting for its in-flight
|
|
1830
|
+
* tick). It is deducted from the budget of a later {@link drain}, so that wait plus the drain stay
|
|
1831
|
+
* within one `drainTimeoutMs`; a drain already waiting is shortened. Only time actually spent
|
|
1832
|
+
* waiting is charged, and reports add up. Non-finite or negative values are ignored, so one bad
|
|
1833
|
+
* report can't turn every later drain into a 0 ms wait.
|
|
1431
1834
|
*/
|
|
1432
|
-
beginDrain() {
|
|
1835
|
+
beginDrain(spentMs = 0) {
|
|
1433
1836
|
this.draining = true;
|
|
1837
|
+
if (Number.isFinite(spentMs) && spentMs > 0) {
|
|
1838
|
+
this.drainSpentMs += spentMs;
|
|
1839
|
+
for (const rearm of this.drainRearms)
|
|
1840
|
+
rearm();
|
|
1841
|
+
}
|
|
1434
1842
|
}
|
|
1435
1843
|
/** True once {@link beginDrain} (or {@link drain}) has been called. */
|
|
1436
1844
|
isDraining() {
|
|
@@ -1454,15 +1862,40 @@ export class WorkflowEngine {
|
|
|
1454
1862
|
* process is going away, so a held lease would only delay the next pod's `recoverIncomplete` until
|
|
1455
1863
|
* expiry; after release the next pod replays deterministically (a pending step re-dispatches the same
|
|
1456
1864
|
* `stepId`, idempotent by the durable contract; a completed step short-circuits).
|
|
1865
|
+
*
|
|
1866
|
+
* `timeoutMs` is the TOTAL shutdown budget: wait already reported through {@link beginDrain}
|
|
1867
|
+
* (`spentMs`) is deducted, so a worker that first waited for its in-flight tick drains only for
|
|
1868
|
+
* what is left. With nothing reported, the full `timeoutMs` applies.
|
|
1457
1869
|
*/
|
|
1458
1870
|
async drain(timeoutMs = 10_000) {
|
|
1459
1871
|
this.beginDrain();
|
|
1460
1872
|
if (this.inflight.size === 0 && this.postSettle.size === 0)
|
|
1461
1873
|
return;
|
|
1874
|
+
// The deadline is `start + timeoutMs - spent`, re-read whenever `beginDrain(spentMs)` reports more
|
|
1875
|
+
// wait — so a shutdown that already waited for an in-flight tick only drains for the remainder.
|
|
1876
|
+
const start = Date.now();
|
|
1877
|
+
let timeout;
|
|
1878
|
+
let rearm = () => { };
|
|
1462
1879
|
const timer = new Promise((resolve) => {
|
|
1463
|
-
|
|
1464
|
-
|
|
1880
|
+
rearm = () => {
|
|
1881
|
+
clearTimeout(timeout);
|
|
1882
|
+
const remaining = Math.max(0, start + timeoutMs - this.drainSpentMs - Date.now());
|
|
1883
|
+
timeout = setTimeout(() => resolve('timeout'), remaining);
|
|
1884
|
+
timeout.unref?.();
|
|
1885
|
+
};
|
|
1886
|
+
rearm();
|
|
1465
1887
|
});
|
|
1888
|
+
this.drainRearms.add(rearm);
|
|
1889
|
+
try {
|
|
1890
|
+
await this.drainUntil(timer);
|
|
1891
|
+
}
|
|
1892
|
+
finally {
|
|
1893
|
+
this.drainRearms.delete(rearm);
|
|
1894
|
+
clearTimeout(timeout);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
/** The {@link drain} wait loop, bounded by `timer`. */
|
|
1898
|
+
async drainUntil(timer) {
|
|
1466
1899
|
// Loop, don't snapshot-once: settling an in-flight run fires post-settle effects (parent notify,
|
|
1467
1900
|
// singleton wake) that can resume — and thus re-inflight — another run, which settles and fires
|
|
1468
1901
|
// more effects. Wait until BOTH sets reach steady-state empty, or the shared timer wins. The
|
|
@@ -1576,18 +2009,38 @@ export class WorkflowEngine {
|
|
|
1576
2009
|
// that's ~zero rows per tick, instead of fetching every running run and issuing one doomed
|
|
1577
2010
|
// lock-probe UPDATE per row per worker per second. Falls back to the old full-list + in-process
|
|
1578
2011
|
// filter for a custom store without the optimized method.
|
|
2012
|
+
//
|
|
2013
|
+
// Self-reclaim: for one lease window after boot, also ask for the runs still leased to THIS
|
|
2014
|
+
// instance id. Under a stable id (a pod name), a SIGKILLed/OOM-killed predecessor's leases read
|
|
2015
|
+
// as "held by me" and would otherwise sit out their full `leaseMs`. After that window no such
|
|
2016
|
+
// lease can still be live, so the extra predicate is dropped and the query is the orphan-only one.
|
|
1579
2017
|
const incomplete = await asHeartbeat(async () => {
|
|
1580
2018
|
const nowMs = this.clock();
|
|
2019
|
+
const owner = nowMs < this.bootedAt + this.leaseMs ? this.instanceId : undefined;
|
|
1581
2020
|
if (this.store.listOrphanedRuns) {
|
|
1582
|
-
return
|
|
2021
|
+
return owner === undefined
|
|
2022
|
+
? this.store.listOrphanedRuns(nowMs, 100, this.namespace)
|
|
2023
|
+
: this.store.listOrphanedRuns(nowMs, 100, this.namespace, owner);
|
|
1583
2024
|
}
|
|
1584
|
-
return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined ||
|
|
2025
|
+
return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined ||
|
|
2026
|
+
r.lockedUntil <= nowMs ||
|
|
2027
|
+
(owner !== undefined && r.lockedBy === owner));
|
|
1585
2028
|
});
|
|
1586
2029
|
for (const listed of incomplete) {
|
|
1587
2030
|
if (this.draining)
|
|
1588
2031
|
break; // stop requested mid-sweep
|
|
1589
|
-
// A
|
|
1590
|
-
//
|
|
2032
|
+
// A run still leased to this instance id: reclaimable only when a PREVIOUS process under the
|
|
2033
|
+
// same id left that lease behind — never one this process holds (see selfReclaimable). Give the
|
|
2034
|
+
// stale lease up so the normal orphan path below can take it; anything else is skipped.
|
|
2035
|
+
if (listed.lockedBy === this.instanceId &&
|
|
2036
|
+
listed.lockedUntil !== undefined &&
|
|
2037
|
+
listed.lockedUntil > nowMs &&
|
|
2038
|
+
!(await this.releaseStaleSelfLease(listed))) {
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
// An IN-PROCESS turn renews its lease, so an acquirable lease means no turn is executing this
|
|
2042
|
+
// run right now. Skip the ones still owned. That is NOT the same as "its worker crashed" — see
|
|
2043
|
+
// the dispatched-step guard below for the case it doesn't cover.
|
|
1591
2044
|
const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
1592
2045
|
if (!acquired)
|
|
1593
2046
|
continue;
|
|
@@ -1603,6 +2056,38 @@ export class WorkflowEngine {
|
|
|
1603
2056
|
await this.store.releaseRunLock(listed.id, this.instanceId);
|
|
1604
2057
|
continue;
|
|
1605
2058
|
}
|
|
2059
|
+
// NOT AN ORPHAN: a run with an in-flight (`pending`) remote step is not executed in process at
|
|
2060
|
+
// all — its work sits on the transport, run by a worker that holds NO run lease, so the free
|
|
2061
|
+
// lease proves nothing about it. By contract such a run is durably SUSPENDED (see
|
|
2062
|
+
// `StepCheckpoint.status`); reading `running` means a turn ended without restoring that. Counting
|
|
2063
|
+
// it as a crash would burn the poison-pill budget on a healthy run and, past
|
|
2064
|
+
// `maxRecoveryAttempts`, dead-letter it while its worker is still executing the step. Re-assert
|
|
2065
|
+
// the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
|
|
2066
|
+
// worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
|
|
2067
|
+
// of `remoteRedispatchMs` / `redispatchPending`.
|
|
2068
|
+
const dispatched = await this.inFlightRemoteStep(run.id);
|
|
2069
|
+
if (dispatched) {
|
|
2070
|
+
// A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
|
|
2071
|
+
// lease here means that turn was lost — the call in flight is not what the run waits on next.
|
|
2072
|
+
// Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
|
|
2073
|
+
// call's own deadline) instead of after the whole reconcile window.
|
|
2074
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
2075
|
+
this.latest.get(run.workflow);
|
|
2076
|
+
const stepWake = this.reconcileWakeAt(dispatched.wakeAt);
|
|
2077
|
+
const wakeAt = registered?.remote
|
|
2078
|
+
? Math.min(stepWake ?? Number.POSITIVE_INFINITY, this.clock() + REMOTE_TURN_REDRIVE_MS)
|
|
2079
|
+
: stepWake;
|
|
2080
|
+
// Conditional on still-`running`: a lease-free `cancel()` may have settled it meanwhile.
|
|
2081
|
+
const parked = await this.store.updateRunIf(run.id, ['running'], {
|
|
2082
|
+
status: 'suspended',
|
|
2083
|
+
wakeAt,
|
|
2084
|
+
updatedAt: new Date(),
|
|
2085
|
+
});
|
|
2086
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
2087
|
+
if (parked)
|
|
2088
|
+
results.push({ runId: run.id, status: 'suspended' });
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
1606
2091
|
// Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
|
|
1607
2092
|
const settled = await this.countRecovery(run);
|
|
1608
2093
|
// `null`: the run left `running` between the re-read above and the dead-letter write (a
|
|
@@ -1634,6 +2119,55 @@ export class WorkflowEngine {
|
|
|
1634
2119
|
}
|
|
1635
2120
|
return results;
|
|
1636
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Whether a lease held under this engine's own {@link instanceId} was left behind by a previous
|
|
2124
|
+
* process that ran under the same id, rather than held by this one. Both must hold:
|
|
2125
|
+
* - nothing in this process executes the run: no execution in its run gate and no execution lease
|
|
2126
|
+
* tracked for it;
|
|
2127
|
+
* - the lease was written before this engine booted: every lease this process writes (acquire or
|
|
2128
|
+
* renew) ends at `clock() + leaseMs >= bootedAt + leaseMs`, so an earlier end proves an earlier
|
|
2129
|
+
* writer. This also covers the brief windows where a path of this process holds the lease
|
|
2130
|
+
* without having entered the run gate yet (a sweep between `tryLockRun` and the resume).
|
|
2131
|
+
*
|
|
2132
|
+
* Sound only when no two LIVE processes share an instance id — the documented requirement.
|
|
2133
|
+
*/
|
|
2134
|
+
selfReclaimable(run, gated = this.runGates.has(run.id)) {
|
|
2135
|
+
if (run.lockedBy !== this.instanceId || run.lockedUntil === undefined)
|
|
2136
|
+
return false;
|
|
2137
|
+
if (gated || this.executionLeases.has(run.id))
|
|
2138
|
+
return false;
|
|
2139
|
+
return run.lockedUntil - this.leaseMs < this.bootedAt;
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Release a lease this instance's previous incarnation left on `listed` (see {@link selfReclaimable}),
|
|
2143
|
+
* so the recovery sweep can take the run like any orphan. The release runs inside the run's
|
|
2144
|
+
* in-process gate, so a resume landing meanwhile here queues behind it instead of executing on the
|
|
2145
|
+
* lease being given up; the run is re-read there and only released if it is still a stale `running`
|
|
2146
|
+
* self-lease. Returns whether the lease was released.
|
|
2147
|
+
*/
|
|
2148
|
+
async releaseStaleSelfLease(listed) {
|
|
2149
|
+
if (!this.selfReclaimable(listed))
|
|
2150
|
+
return false;
|
|
2151
|
+
let released = false;
|
|
2152
|
+
await this.serializeOnRun(listed.id, async () => {
|
|
2153
|
+
const run = await this.store.getRun(listed.id);
|
|
2154
|
+
// The gate is ours now (serializeOnRun registered it before any other resume could queue).
|
|
2155
|
+
if (run?.status !== 'running' || !this.selfReclaimable(run, false))
|
|
2156
|
+
return;
|
|
2157
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
2158
|
+
released = true;
|
|
2159
|
+
});
|
|
2160
|
+
return released;
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* The remote step this run dispatched and still awaits a result for, if any — the durable marker
|
|
2164
|
+
* that tells {@link recoverIncomplete} "this run's work is on the transport, not in a crashed
|
|
2165
|
+
* process". A `pending` remote checkpoint is written before the dispatch and settled by the result.
|
|
2166
|
+
*/
|
|
2167
|
+
async inFlightRemoteStep(runId) {
|
|
2168
|
+
const checkpoints = await this.store.listCheckpoints(runId);
|
|
2169
|
+
return checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
|
|
2170
|
+
}
|
|
1637
2171
|
/**
|
|
1638
2172
|
* Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
|
|
1639
2173
|
* `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
|
|
@@ -1666,6 +2200,9 @@ export class WorkflowEngine {
|
|
|
1666
2200
|
error,
|
|
1667
2201
|
});
|
|
1668
2202
|
this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
|
|
2203
|
+
// `dead` is terminal like `failed`: a parent awaiting this run as a child (ctx.child, a ctx.all /
|
|
2204
|
+
// allSettled fan) must learn it now, as a failed child, not on its next reconcile wake.
|
|
2205
|
+
this.notifyParent(run.id, failedCompletion(error));
|
|
1669
2206
|
return { runId: run.id, status: 'dead', error };
|
|
1670
2207
|
}
|
|
1671
2208
|
// Count BEFORE resuming, so a crash mid-resume still advances the counter.
|
|
@@ -1743,7 +2280,9 @@ export class WorkflowEngine {
|
|
|
1743
2280
|
// live `running` placeholder with its `child:<id>` waiter put back — a separately retried
|
|
1744
2281
|
// child's completion (already buffered, or still to come) then resumes this run.
|
|
1745
2282
|
// Retry child and parent in either order; signal buffering makes it converge.
|
|
1746
|
-
|
|
2283
|
+
// - the run-level `recoveryAttempts` budget goes back to zero (see the final `updateRun`).
|
|
2284
|
+
const resurrecting = run.status === 'failed' || run.status === 'dead';
|
|
2285
|
+
if (resurrecting) {
|
|
1747
2286
|
const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
|
|
1748
2287
|
for (const cp of await this.store.listCheckpoints(runId)) {
|
|
1749
2288
|
// A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
|
|
@@ -1827,9 +2366,27 @@ export class WorkflowEngine {
|
|
|
1827
2366
|
await this.store.releaseRunLock(runId);
|
|
1828
2367
|
// Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
|
|
1829
2368
|
// run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
|
|
2369
|
+
//
|
|
2370
|
+
// RESURRECTING a `failed`/`dead` run also resets `recoveryAttempts`, or the retry is a lie: a run
|
|
2371
|
+
// dead-lettered AT `maxRecoveryAttempts` comes back `pending`, and the next `recoverIncomplete`
|
|
2372
|
+
// pass that finds it orphaned computes `cap + 1` and kills it again with the same
|
|
2373
|
+
// `max_recovery_attempts` error. The budget bounds ONE crash-loop episode; resurrecting a run that
|
|
2374
|
+
// came to rest starts a new one. `0`, not `undefined`, so every store writes a real value.
|
|
2375
|
+
// Gated on `resurrecting`: requeueing a still-live run is not a resurrection, and zeroing there
|
|
2376
|
+
// would let a retry loop keep a crash-looping run alive forever.
|
|
2377
|
+
//
|
|
2378
|
+
// It also drops a stale singleton admission marker (a terminal settle clears it; this covers a
|
|
2379
|
+
// row written before that), so the resurrected run competes for a slot like any waiter.
|
|
2380
|
+
//
|
|
2381
|
+
// And it clears a remote workflow turn's decision marker (`awaitingDecisionTaskId`): the requeued
|
|
2382
|
+
// run dispatches a FRESH turn, so a late decision of the old one must find no marker to match.
|
|
2383
|
+
const staleAdmission = resurrecting ? withoutAdmission(run.tags) : undefined;
|
|
1830
2384
|
await this.store.updateRun(runId, {
|
|
1831
2385
|
status: 'pending',
|
|
1832
2386
|
error: undefined,
|
|
2387
|
+
awaitingDecisionTaskId: undefined,
|
|
2388
|
+
...(resurrecting ? { recoveryAttempts: 0 } : {}),
|
|
2389
|
+
...(staleAdmission ? { tags: staleAdmission } : {}),
|
|
1833
2390
|
updatedAt: new Date(),
|
|
1834
2391
|
});
|
|
1835
2392
|
await this.runDispatcher.dispatch(runId);
|
|
@@ -1839,8 +2396,9 @@ export class WorkflowEngine {
|
|
|
1839
2396
|
* Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
|
|
1840
2397
|
* a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
|
|
1841
2398
|
* the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
|
|
1842
|
-
* step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete`
|
|
1843
|
-
*
|
|
2399
|
+
* step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` parks a run with an
|
|
2400
|
+
* in-flight `pending` remote step back to `suspended` instead of re-driving it, and `requeue` just
|
|
2401
|
+
* replays back to the pending guard. This re-enqueues the same `stepId` so the
|
|
1844
2402
|
* (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
|
|
1845
2403
|
* only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
|
|
1846
2404
|
* run's current status (with the count re-dispatched), or null if the run is unknown.
|
|
@@ -1916,8 +2474,13 @@ export class WorkflowEngine {
|
|
|
1916
2474
|
*/
|
|
1917
2475
|
waitForRun(runId, opts) {
|
|
1918
2476
|
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
|
|
1919
|
-
|
|
1920
|
-
|
|
2477
|
+
// A run `suspended` while it awaits a dispatched remote workflow turn's decision
|
|
2478
|
+
// (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
|
|
2479
|
+
// marker, not a sleep/signal the body asked for. Keep waiting until the decision lands.
|
|
2480
|
+
const isSettled = (run) => run.status !== 'pending' &&
|
|
2481
|
+
run.status !== 'running' &&
|
|
2482
|
+
!(run.status === 'suspended' && run.awaitingDecisionTaskId !== undefined);
|
|
2483
|
+
const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
|
|
1921
2484
|
const toResult = (run) => ({
|
|
1922
2485
|
runId,
|
|
1923
2486
|
status: run.status,
|
|
@@ -1951,7 +2514,7 @@ export class WorkflowEngine {
|
|
|
1951
2514
|
void this.store
|
|
1952
2515
|
.getRun(runId)
|
|
1953
2516
|
.then((run) => {
|
|
1954
|
-
if (run && shouldResolve(run
|
|
2517
|
+
if (run && shouldResolve(run))
|
|
1955
2518
|
finish(run);
|
|
1956
2519
|
})
|
|
1957
2520
|
.catch(() => undefined);
|
|
@@ -1970,6 +2533,12 @@ export class WorkflowEngine {
|
|
|
1970
2533
|
/**
|
|
1971
2534
|
* Resume each run only if this instance can acquire its recovery lease — so when several
|
|
1972
2535
|
* replicas recover or poll at once, each run is picked up by exactly one of them.
|
|
2536
|
+
*
|
|
2537
|
+
* A run this instance cannot resume is SKIPPED (and logged), never allowed to abort the batch: a
|
|
2538
|
+
* leased batch is a mixed bag during a rolling deploy (a pod without a run's workflow throws for
|
|
2539
|
+
* it by design) and a store hiccup can fail one row, but the rest of the batch is runs it can drive
|
|
2540
|
+
* — and every caller is a recovery/poll sweep. The skipped run stays locked only until its lease
|
|
2541
|
+
* expires, then another instance (or a later tick) picks it up.
|
|
1973
2542
|
*/
|
|
1974
2543
|
async resumeLeased(runs, nowMs = this.clock(), onLocked) {
|
|
1975
2544
|
if (this.draining)
|
|
@@ -1992,9 +2561,26 @@ export class WorkflowEngine {
|
|
|
1992
2561
|
const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
1993
2562
|
if (!acquired)
|
|
1994
2563
|
continue;
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2564
|
+
if (this.draining) {
|
|
2565
|
+
// Drain began while the lock was being taken. Starting the run now would hand `drain()` work
|
|
2566
|
+
// it may already have stopped waiting for, and an exit would strand the lease until expiry.
|
|
2567
|
+
// Give the lease back; the run stays for the next instance.
|
|
2568
|
+
await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
try {
|
|
2572
|
+
// A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
|
|
2573
|
+
const settled = onLocked ? await onLocked(run) : undefined;
|
|
2574
|
+
results.push(settled ?? (await this.resumeImplicit(run.id)));
|
|
2575
|
+
}
|
|
2576
|
+
catch (error) {
|
|
2577
|
+
// A foreign-namespace run is simply not ours (resume released its lease) — skip quietly.
|
|
2578
|
+
if (error instanceof NamespaceMismatch)
|
|
2579
|
+
continue;
|
|
2580
|
+
// Loud: a skip is not nothing — the run is still there and not progressing, and the reason
|
|
2581
|
+
// (a version skew, a store outage) is what tells an operator which.
|
|
2582
|
+
console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
|
|
2583
|
+
}
|
|
1998
2584
|
}
|
|
1999
2585
|
};
|
|
2000
2586
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, runs.length) }, () => worker()));
|
|
@@ -2190,6 +2776,16 @@ export class WorkflowEngine {
|
|
|
2190
2776
|
}
|
|
2191
2777
|
return this.deliverSignal(lateWaiter, reclaimed.payload);
|
|
2192
2778
|
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Wake the run parked on `token` with `payload`, if there is one; otherwise do nothing — unlike
|
|
2781
|
+
* {@link signal}, it never buffers. For a wake-up whose real payload is ALREADY buffered on the
|
|
2782
|
+
* token (an entity op, see `Entities.kick`): a run that isn't parked reads that buffer at its next
|
|
2783
|
+
* wait, so there is nothing to keep.
|
|
2784
|
+
*/
|
|
2785
|
+
async wakeSignalWaiter(token, payload) {
|
|
2786
|
+
const waiter = await this.store.takeSignalWaiter(token);
|
|
2787
|
+
return waiter ? this.deliverSignal(waiter, payload) : null;
|
|
2788
|
+
}
|
|
2193
2789
|
/**
|
|
2194
2790
|
* Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
|
|
2195
2791
|
* signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
|
|
@@ -2337,7 +2933,13 @@ export class WorkflowEngine {
|
|
|
2337
2933
|
return { runId, status: run.status };
|
|
2338
2934
|
}
|
|
2339
2935
|
const error = { message: 'cancelled' };
|
|
2340
|
-
|
|
2936
|
+
// Clears a remote turn's decision marker too: a cancelled run awaits no decision.
|
|
2937
|
+
await this.store.updateRun(runId, {
|
|
2938
|
+
status: 'cancelled',
|
|
2939
|
+
error,
|
|
2940
|
+
awaitingDecisionTaskId: undefined,
|
|
2941
|
+
updatedAt: new Date(),
|
|
2942
|
+
});
|
|
2341
2943
|
this.emit({
|
|
2342
2944
|
type: 'run.failed',
|
|
2343
2945
|
runId,
|
|
@@ -2444,6 +3046,38 @@ export class WorkflowEngine {
|
|
|
2444
3046
|
countRuns(query) {
|
|
2445
3047
|
return countRuns(this.store, query);
|
|
2446
3048
|
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Every workflow the live fleet can execute in this engine's partition: what workers DECLARE in
|
|
3051
|
+
* their descriptors, plus the live routing tokens no descriptor explains (a worker too old to
|
|
3052
|
+
* describe itself). Each entry says which kind of evidence it rests on. See
|
|
3053
|
+
* {@link workflowDirectory} for why the list is the length it is.
|
|
3054
|
+
*/
|
|
3055
|
+
async announcedWorkflows() {
|
|
3056
|
+
return (await this.workflowDirectory()).workflows;
|
|
3057
|
+
}
|
|
3058
|
+
/**
|
|
3059
|
+
* {@link announcedWorkflows}, plus why it is empty when it is: no transport here can read the fleet
|
|
3060
|
+
* (`supported: false`), nothing is live, or workers are live only on OTHER partitions
|
|
3061
|
+
* (`otherPartitions`), which otherwise reads exactly like an empty fleet. Two live reads, neither
|
|
3062
|
+
* retained: the descriptor advertisement and the heartbeat keyspace. Only the BullMQ transport reads
|
|
3063
|
+
* them today.
|
|
3064
|
+
*/
|
|
3065
|
+
async workflowDirectory() {
|
|
3066
|
+
const supported = this.pool.introspectsFleet;
|
|
3067
|
+
const [descriptors, heartbeats] = supported
|
|
3068
|
+
? await Promise.all([
|
|
3069
|
+
this.pool.readAllWorkerDescriptors(),
|
|
3070
|
+
this.pool.readAllWorkerHeartbeats(),
|
|
3071
|
+
])
|
|
3072
|
+
: [[], []];
|
|
3073
|
+
return buildWorkflowDirectory({
|
|
3074
|
+
descriptors,
|
|
3075
|
+
heartbeats,
|
|
3076
|
+
namespace: this.namespace,
|
|
3077
|
+
supported,
|
|
3078
|
+
sanitize: sanitizeQueueToken,
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
2447
3081
|
/** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
|
|
2448
3082
|
* `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
|
|
2449
3083
|
* workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
|
|
@@ -2460,7 +3094,7 @@ export class WorkflowEngine {
|
|
|
2460
3094
|
* (so a registered group with backlog and ZERO workers still reports — the alert case) UNION the
|
|
2461
3095
|
* groups discovered from live heartbeats (so a local-step group like `pipeline`, not derivable
|
|
2462
3096
|
* from registrations, shows once its workers beat). Empty when no transport can introspect health
|
|
2463
|
-
* (only the BullMQ
|
|
3097
|
+
* (only the BullMQ, queue and db transports implement `groupHealth`). */
|
|
2464
3098
|
async workerHealth(extra = []) {
|
|
2465
3099
|
const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
|
|
2466
3100
|
// One round per group, in parallel — the dashboard polls this, and each group's health is an
|
|
@@ -2627,7 +3261,13 @@ export class WorkflowEngine {
|
|
|
2627
3261
|
return { runId: run.id, status: run.status };
|
|
2628
3262
|
}
|
|
2629
3263
|
}
|
|
3264
|
+
const lease = { handedOff: false };
|
|
3265
|
+
this.executionLeases.set(run.id, lease);
|
|
2630
3266
|
const renew = setInterval(() => {
|
|
3267
|
+
// A lease this execution gave away on purpose (see releaseTurnLease) is not renewed, and its
|
|
3268
|
+
// `false` renew is not a takeover.
|
|
3269
|
+
if (lease.handedOff)
|
|
3270
|
+
return;
|
|
2631
3271
|
// A renew that returns FALSE means the lease was taken over (this turn stalled past expiry
|
|
2632
3272
|
// and another instance reclaimed the run) — this executor is now a zombie. Fence it: mark
|
|
2633
3273
|
// the run so its eventual settle degrades to a no-op echo instead of clobbering the new
|
|
@@ -2636,7 +3276,7 @@ export class WorkflowEngine {
|
|
|
2636
3276
|
void this.store
|
|
2637
3277
|
.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
|
|
2638
3278
|
.then((renewed) => {
|
|
2639
|
-
if (!renewed)
|
|
3279
|
+
if (!renewed && !lease.handedOff)
|
|
2640
3280
|
this.leaseLostRuns.add(run.id);
|
|
2641
3281
|
})
|
|
2642
3282
|
.catch(() => undefined);
|
|
@@ -2664,6 +3304,8 @@ export class WorkflowEngine {
|
|
|
2664
3304
|
finally {
|
|
2665
3305
|
clearInterval(renew);
|
|
2666
3306
|
this.leaseLostRuns.delete(run.id);
|
|
3307
|
+
if (this.executionLeases.get(run.id) === lease)
|
|
3308
|
+
this.executionLeases.delete(run.id);
|
|
2667
3309
|
}
|
|
2668
3310
|
}
|
|
2669
3311
|
/**
|
|
@@ -2749,7 +3391,7 @@ export class WorkflowEngine {
|
|
|
2749
3391
|
async routeFailure(run, error) {
|
|
2750
3392
|
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
2751
3393
|
const routes = registered?.convention
|
|
2752
|
-
? await this.announcedRoutes(run,
|
|
3394
|
+
? await this.announcedRoutes(run, conventionGroup(run.workflow, run.namespace), error)
|
|
2753
3395
|
: registered?.onError;
|
|
2754
3396
|
const route = routeFor(routes, error);
|
|
2755
3397
|
if (!route)
|
|
@@ -2958,11 +3600,72 @@ export class WorkflowEngine {
|
|
|
2958
3600
|
return wakeAt;
|
|
2959
3601
|
return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
|
|
2960
3602
|
}
|
|
3603
|
+
/**
|
|
3604
|
+
* The result of a turn the singleton gate denied. The store already parked the run `suspended` on
|
|
3605
|
+
* its retry timer INSIDE the admission claim (or left it alone, when a concurrent cancel had
|
|
3606
|
+
* settled it), so report what is persisted rather than writing over it.
|
|
3607
|
+
*/
|
|
3608
|
+
/**
|
|
3609
|
+
* A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
|
|
3610
|
+
* {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
|
|
3611
|
+
* replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
|
|
3612
|
+
* 0.51 (see `legacySingletonHolders`).
|
|
3613
|
+
*/
|
|
3614
|
+
async markStarted(run, singleton) {
|
|
3615
|
+
const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
|
|
3616
|
+
const retag = tags !== run.tags;
|
|
3617
|
+
await this.store.updateRun(run.id, {
|
|
3618
|
+
status: 'running',
|
|
3619
|
+
...(retag ? { tags } : {}),
|
|
3620
|
+
updatedAt: new Date(),
|
|
3621
|
+
});
|
|
3622
|
+
run.status = 'running';
|
|
3623
|
+
if (retag)
|
|
3624
|
+
run.tags = tags;
|
|
3625
|
+
}
|
|
3626
|
+
async singletonWaitResult(run) {
|
|
3627
|
+
const current = await this.store.getRun(run.id);
|
|
3628
|
+
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
3629
|
+
if (!current)
|
|
3630
|
+
throw new Error(`run ${run.id} was deleted during singleton admission`);
|
|
3631
|
+
if (current.status === 'suspended') {
|
|
3632
|
+
this.emit({
|
|
3633
|
+
type: 'run.suspended',
|
|
3634
|
+
runId: current.id,
|
|
3635
|
+
workflow: current.workflow,
|
|
3636
|
+
namespace: current.namespace,
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
return {
|
|
3640
|
+
runId: current.id,
|
|
3641
|
+
status: current.status,
|
|
3642
|
+
output: current.output,
|
|
3643
|
+
error: current.error,
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
2961
3646
|
async runRemoteExecution(run, registered) {
|
|
2962
3647
|
const remote = registered.remote;
|
|
3648
|
+
// A compensating cancel (`cancel(id, { compensate: true })`) resumes the run to unwind it. A remote
|
|
3649
|
+
// workflow registers no engine-side saga compensations, so the unwind is just the `cancelled`
|
|
3650
|
+
// settle — and it must happen even while a dispatched turn is still out (the hold below would
|
|
3651
|
+
// otherwise swallow the cancel's resume, and the in-flight turn's decision would carry the run on
|
|
3652
|
+
// as if it was never cancelled). The late decision then finds the run cancelled and is dropped.
|
|
3653
|
+
if (await this.takeCancelIntent(run.id))
|
|
3654
|
+
return this.compensateAndCancel(run, []);
|
|
3655
|
+
// A dispatched turn is still out and within its liveness window: whatever woke the run (a step
|
|
3656
|
+
// result, a signal, a wait recheck) must not dispatch a second turn over it — that would supersede
|
|
3657
|
+
// the first and drop its decision, re-running whatever local steps that turn ran. Everything such a
|
|
3658
|
+
// wake wrote is durable already; the in-flight turn's decision is applied as usual and its suspend
|
|
3659
|
+
// rechecks what it waits on (the `reconcileMs` wake is the fallback). Only a LAPSED window (the
|
|
3660
|
+
// timer poller, `wakeAt` due) re-drives the turn below.
|
|
3661
|
+
if (run.status === 'suspended' &&
|
|
3662
|
+
run.awaitingDecisionTaskId !== undefined &&
|
|
3663
|
+
(run.wakeAt == null || run.wakeAt > this.clock())) {
|
|
3664
|
+
await this.releaseTurnLease(run.id);
|
|
3665
|
+
return { runId: run.id, status: 'suspended' };
|
|
3666
|
+
}
|
|
2963
3667
|
if (run.status === 'pending') {
|
|
2964
|
-
await this.
|
|
2965
|
-
run.status = 'running';
|
|
3668
|
+
await this.markStarted(run, registered?.singleton !== undefined);
|
|
2966
3669
|
this.emit({
|
|
2967
3670
|
type: 'run.started',
|
|
2968
3671
|
runId: run.id,
|
|
@@ -2971,21 +3674,12 @@ export class WorkflowEngine {
|
|
|
2971
3674
|
});
|
|
2972
3675
|
}
|
|
2973
3676
|
if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
2974
|
-
|
|
2975
|
-
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
2976
|
-
this.emit({
|
|
2977
|
-
type: 'run.suspended',
|
|
2978
|
-
runId: run.id,
|
|
2979
|
-
workflow: run.workflow,
|
|
2980
|
-
namespace: run.namespace,
|
|
2981
|
-
});
|
|
2982
|
-
await this.store.releaseRunLock(run.id);
|
|
2983
|
-
return { runId: run.id, status: 'suspended' };
|
|
3677
|
+
return this.singletonWaitResult(run);
|
|
2984
3678
|
}
|
|
2985
3679
|
// Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
|
|
2986
3680
|
// its group — if no live worker on that group can run it, park `blocked` before advancing rather
|
|
2987
3681
|
// than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
|
|
2988
|
-
const routePlan = await this.planRoute(remote.group, registered.requires);
|
|
3682
|
+
const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
|
|
2989
3683
|
if (routePlan?.status === 'blocked')
|
|
2990
3684
|
return this.parkBlocked(run, routePlan);
|
|
2991
3685
|
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
@@ -2995,11 +3689,39 @@ export class WorkflowEngine {
|
|
|
2995
3689
|
return this.settleRun(run, await this.followHandoff(run, handoff));
|
|
2996
3690
|
}
|
|
2997
3691
|
const history = await this.remoteHistory(run.id, checkpoints);
|
|
3692
|
+
// Broker-backed executors: dispatch the turn and suspend on it. The decision is applied by
|
|
3693
|
+
// whichever replica consumes it (completeRemoteDecision), never awaited here.
|
|
3694
|
+
if (remote.executor.dispatch)
|
|
3695
|
+
return this.dispatchRemoteTurn(run, registered, history);
|
|
3696
|
+
// INLINE advance (an executor that produces the decision in-process): await it under the lease.
|
|
3697
|
+
const advance = remote.executor.advance;
|
|
3698
|
+
if (!advance) {
|
|
3699
|
+
throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: its executor implements neither advance nor dispatch`);
|
|
3700
|
+
}
|
|
2998
3701
|
let decision;
|
|
2999
3702
|
try {
|
|
3000
|
-
|
|
3703
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3704
|
+
const pending = advance.call(remote.executor, run, history);
|
|
3705
|
+
decision =
|
|
3706
|
+
silenceMs != null
|
|
3707
|
+
? await this.awaitTurnWithSilence(run.id, pending, silenceMs)
|
|
3708
|
+
: await pending;
|
|
3001
3709
|
}
|
|
3002
3710
|
catch (err) {
|
|
3711
|
+
// A timed-out advance is not a failure: the decision may have been lost while the work completed.
|
|
3712
|
+
// Failing the run would notify its parent of a failure that never happened. Leave it `running`
|
|
3713
|
+
// with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
|
|
3714
|
+
// `maxRecoveryAttempts`).
|
|
3715
|
+
if (err instanceof RemoteWorkflowTimeout) {
|
|
3716
|
+
await this.store
|
|
3717
|
+
.updateRunIf(run.id, ['running', 'suspended'], {
|
|
3718
|
+
status: 'running',
|
|
3719
|
+
updatedAt: new Date(),
|
|
3720
|
+
})
|
|
3721
|
+
.catch(() => false);
|
|
3722
|
+
await this.releaseTurnLease(run.id);
|
|
3723
|
+
return { runId: run.id, status: 'running' };
|
|
3724
|
+
}
|
|
3003
3725
|
const error = {
|
|
3004
3726
|
message: err instanceof Error ? err.message : String(err),
|
|
3005
3727
|
stack: err instanceof Error ? err.stack : undefined,
|
|
@@ -3014,6 +3736,16 @@ export class WorkflowEngine {
|
|
|
3014
3736
|
});
|
|
3015
3737
|
return { runId: run.id, status: 'failed', error };
|
|
3016
3738
|
}
|
|
3739
|
+
return this.applyDecision(run, decision);
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then settle
|
|
3743
|
+
* (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by the
|
|
3744
|
+
* INLINE advance path ({@link runRemoteExecution}, under the execution's lease) and the dispatched
|
|
3745
|
+
* path ({@link completeRemoteDecision}, which holds the lease itself and has already checked that
|
|
3746
|
+
* this is the decision of the turn the run awaits).
|
|
3747
|
+
*/
|
|
3748
|
+
async applyDecision(run, decision) {
|
|
3017
3749
|
if (decision.status === 'completed') {
|
|
3018
3750
|
// Persist the local steps THIS turn ran before marking the run done. A workflow that runs
|
|
3019
3751
|
// straight to completion in a single turn (every step inline, never suspending — e.g. a Python
|
|
@@ -3058,6 +3790,327 @@ export class WorkflowEngine {
|
|
|
3058
3790
|
}
|
|
3059
3791
|
return suspended;
|
|
3060
3792
|
}
|
|
3793
|
+
/**
|
|
3794
|
+
* The dispatched-turn path of {@link runRemoteExecution}: SUSPEND, then ENQUEUE. The engine mints a
|
|
3795
|
+
* globally unique turn `taskId`, records it on the run as the awaited marker, and releases the lease,
|
|
3796
|
+
* all BEFORE the turn is enqueued, so a worker's decision, however fast and on whichever replica it
|
|
3797
|
+
* lands, always finds both its marker and a free lease (see {@link completeRemoteDecision}). The
|
|
3798
|
+
* deadline is the turn's liveness window ({@link decisionWindowMs}); when it lapses with no decision
|
|
3799
|
+
* and no run-scoped heartbeat, the timer poller re-drives the run, which dispatches a fresh turn.
|
|
3800
|
+
*/
|
|
3801
|
+
async dispatchRemoteTurn(run, registered, history) {
|
|
3802
|
+
const executor = registered.remote.executor;
|
|
3803
|
+
const dispatch = executor.dispatch;
|
|
3804
|
+
// Zombie fence, as settleRun's: a lost lease means another instance owns the run now.
|
|
3805
|
+
if (this.leaseLostRuns.has(run.id))
|
|
3806
|
+
return this.echoCurrentStatus(run.id);
|
|
3807
|
+
// Random, not a per-process counter: two replicas re-driving the same run must never mint the same
|
|
3808
|
+
// id (the `db` transport dedupes a turn by it, and the marker gates the decision on it).
|
|
3809
|
+
const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID()}`;
|
|
3810
|
+
const windowMs = this.decisionWindowMs(registered);
|
|
3811
|
+
const marked = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
|
|
3812
|
+
status: 'suspended',
|
|
3813
|
+
awaitingDecisionTaskId: taskId,
|
|
3814
|
+
wakeAt: windowMs != null ? this.clock() + Math.min(windowMs, MAX_BACKOFF_MS) : undefined,
|
|
3815
|
+
updatedAt: new Date(),
|
|
3816
|
+
});
|
|
3817
|
+
if (!marked) {
|
|
3818
|
+
// Cancelled / completed / dead while this turn was being prepared: nothing to dispatch.
|
|
3819
|
+
await this.releaseTurnLease(run.id);
|
|
3820
|
+
return this.echoCurrentStatus(run.id);
|
|
3821
|
+
}
|
|
3822
|
+
this.emit({
|
|
3823
|
+
type: 'run.suspended',
|
|
3824
|
+
runId: run.id,
|
|
3825
|
+
workflow: run.workflow,
|
|
3826
|
+
namespace: run.namespace,
|
|
3827
|
+
});
|
|
3828
|
+
await this.releaseTurnLease(run.id);
|
|
3829
|
+
try {
|
|
3830
|
+
await turnDispatchAls.run(run.id, () => dispatch.call(executor, run, history, taskId));
|
|
3831
|
+
}
|
|
3832
|
+
catch (err) {
|
|
3833
|
+
return this.failUndispatchedTurn(run, taskId, err);
|
|
3834
|
+
}
|
|
3835
|
+
return { runId: run.id, status: 'suspended' };
|
|
3836
|
+
}
|
|
3837
|
+
/**
|
|
3838
|
+
* The turn could not be enqueued (the transport cannot carry workflow turns, or the broker refused the
|
|
3839
|
+
* write): fail the run, as an advance that throws does — but only while it still awaits THIS turn, so
|
|
3840
|
+
* a cancel or a re-drive that landed meanwhile wins.
|
|
3841
|
+
*/
|
|
3842
|
+
async failUndispatchedTurn(run, taskId, err) {
|
|
3843
|
+
const error = {
|
|
3844
|
+
message: err instanceof Error ? err.message : String(err),
|
|
3845
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
3846
|
+
};
|
|
3847
|
+
const current = await this.store.getRun(run.id);
|
|
3848
|
+
if (current?.awaitingDecisionTaskId !== taskId)
|
|
3849
|
+
return this.echoCurrentStatus(run.id);
|
|
3850
|
+
const failed = await this.store.updateRunIf(run.id, ['suspended'], {
|
|
3851
|
+
status: 'failed',
|
|
3852
|
+
error,
|
|
3853
|
+
awaitingDecisionTaskId: undefined,
|
|
3854
|
+
wakeAt: undefined,
|
|
3855
|
+
updatedAt: new Date(),
|
|
3856
|
+
});
|
|
3857
|
+
if (!failed)
|
|
3858
|
+
return this.echoCurrentStatus(run.id);
|
|
3859
|
+
this.emit({
|
|
3860
|
+
type: 'run.failed',
|
|
3861
|
+
runId: run.id,
|
|
3862
|
+
workflow: run.workflow,
|
|
3863
|
+
namespace: run.namespace,
|
|
3864
|
+
error,
|
|
3865
|
+
});
|
|
3866
|
+
return { runId: run.id, status: 'failed', error };
|
|
3867
|
+
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Give the run lease away mid-execution on purpose: stop the execution's renew loop from renewing it
|
|
3870
|
+
* (or reading the release as a takeover), then release it. Owner-scoped, so a lease another instance
|
|
3871
|
+
* already holds is left alone.
|
|
3872
|
+
*/
|
|
3873
|
+
async releaseTurnLease(runId) {
|
|
3874
|
+
const lease = this.executionLeases.get(runId);
|
|
3875
|
+
if (lease)
|
|
3876
|
+
lease.handedOff = true;
|
|
3877
|
+
await this.store.releaseRunLock(runId, this.instanceId);
|
|
3878
|
+
}
|
|
3879
|
+
/**
|
|
3880
|
+
* The liveness window of a dispatched turn: the engine's `remoteAdvanceSilenceMs` and the executor's
|
|
3881
|
+
* own `decisionTimeoutMs` (a `RemoteWorkflowExecutor`'s `timeoutMs`), the shorter of the two when both
|
|
3882
|
+
* are set. `undefined` = no deadline: the run waits for its decision.
|
|
3883
|
+
*/
|
|
3884
|
+
decisionWindowMs(registered) {
|
|
3885
|
+
const own = registered?.remote?.executor.decisionTimeoutMs || undefined;
|
|
3886
|
+
const silence = this.remoteAdvanceSilenceMs;
|
|
3887
|
+
if (own != null && silence != null)
|
|
3888
|
+
return Math.min(own, silence);
|
|
3889
|
+
return own ?? silence;
|
|
3890
|
+
}
|
|
3891
|
+
/**
|
|
3892
|
+
* Await an INLINE advance, rejecting with {@link RemoteWorkflowTimeout} once `silenceMs` passes with
|
|
3893
|
+
* neither a decision nor a run-scoped heartbeat for `runId`. Each beat rearms the window. The beat
|
|
3894
|
+
* hook is registered before this returns, so a beat can't slip in unseen.
|
|
3895
|
+
*/
|
|
3896
|
+
awaitTurnWithSilence(runId, advance, silenceMs) {
|
|
3897
|
+
const key = turnBeatKey(runId);
|
|
3898
|
+
const windowMs = Math.min(silenceMs, MAX_BACKOFF_MS);
|
|
3899
|
+
return new Promise((resolve, reject) => {
|
|
3900
|
+
let timer;
|
|
3901
|
+
const cleanup = () => {
|
|
3902
|
+
clearTimeout(timer);
|
|
3903
|
+
if (this.heartbeatResets.get(key) === reset)
|
|
3904
|
+
this.heartbeatResets.delete(key);
|
|
3905
|
+
};
|
|
3906
|
+
const arm = () => {
|
|
3907
|
+
timer = setTimeout(() => {
|
|
3908
|
+
cleanup();
|
|
3909
|
+
reject(new RemoteWorkflowTimeout(runId, silenceMs));
|
|
3910
|
+
}, windowMs);
|
|
3911
|
+
timer.unref?.();
|
|
3912
|
+
};
|
|
3913
|
+
const reset = () => {
|
|
3914
|
+
clearTimeout(timer);
|
|
3915
|
+
arm();
|
|
3916
|
+
};
|
|
3917
|
+
this.heartbeatResets.set(key, reset);
|
|
3918
|
+
arm();
|
|
3919
|
+
advance.then((decision) => {
|
|
3920
|
+
cleanup();
|
|
3921
|
+
resolve(decision);
|
|
3922
|
+
}, (err) => {
|
|
3923
|
+
cleanup();
|
|
3924
|
+
reject(err);
|
|
3925
|
+
});
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
/** Whether `run` is awaiting exactly `decision`'s turn (and so may still apply it). */
|
|
3929
|
+
awaitsDecision(run, decision) {
|
|
3930
|
+
return (run !== null &&
|
|
3931
|
+
run.status === 'suspended' &&
|
|
3932
|
+
run.awaitingDecisionTaskId !== undefined &&
|
|
3933
|
+
run.awaitingDecisionTaskId === decision.taskId);
|
|
3934
|
+
}
|
|
3935
|
+
/**
|
|
3936
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably, on WHICHEVER replica consumes it: the
|
|
3937
|
+
* dispatching replica may be gone, or a point-to-point decisions queue handed the decision to a
|
|
3938
|
+
* sibling. The durable twin of {@link completeRemoteResult} for a workflow TURN: look the run up by
|
|
3939
|
+
* `decision.runId`, check that it still awaits this `taskId` (a stale, duplicate, foreign or
|
|
3940
|
+
* superseded decision is dropped, so a decision applies at most once), lease it, and apply it.
|
|
3941
|
+
* Serialized with this replica's own executions of the run through the run gate.
|
|
3942
|
+
*/
|
|
3943
|
+
async completeRemoteDecision(decision) {
|
|
3944
|
+
if (!decision?.runId || !decision.taskId)
|
|
3945
|
+
return;
|
|
3946
|
+
const run = await this.store.getRun(decision.runId);
|
|
3947
|
+
if (!this.awaitsDecision(run, decision))
|
|
3948
|
+
return;
|
|
3949
|
+
// A scoped engine leaves another pool's runs to that pool (an operator applies everyone's).
|
|
3950
|
+
if (this.namespace !== undefined &&
|
|
3951
|
+
run.namespace !== undefined &&
|
|
3952
|
+
run.namespace !== this.namespace) {
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
await this.serializeOnRun(decision.runId, () => this.applyRemoteDecision(decision));
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Run `fn` in the run's in-process execution gate (see {@link resumeRun}): after the execution in
|
|
3959
|
+
* flight here, before any resume that lands later. A resume already queued (not started) runs first.
|
|
3960
|
+
*/
|
|
3961
|
+
serializeOnRun(runId, fn) {
|
|
3962
|
+
let gate = this.runGates.get(runId);
|
|
3963
|
+
const prior = gate ? gate.tail.catch(() => undefined) : Promise.resolve();
|
|
3964
|
+
const done = prior.then(fn);
|
|
3965
|
+
const tail = done.then(() => ({ runId, status: 'suspended' }), () => ({ runId, status: 'suspended' }));
|
|
3966
|
+
if (!gate) {
|
|
3967
|
+
gate = { tail };
|
|
3968
|
+
this.runGates.set(runId, gate);
|
|
3969
|
+
}
|
|
3970
|
+
else {
|
|
3971
|
+
gate.tail = tail;
|
|
3972
|
+
}
|
|
3973
|
+
this.closeGateAfter(runId, gate, tail);
|
|
3974
|
+
this.trackEffect(done);
|
|
3975
|
+
return done;
|
|
3976
|
+
}
|
|
3977
|
+
/** {@link completeRemoteDecision} inside the run gate: lease, re-check, claim the turn, apply. */
|
|
3978
|
+
async applyRemoteDecision(decision) {
|
|
3979
|
+
const runId = decision.runId;
|
|
3980
|
+
if (!(await this.leaseForDecision(decision)))
|
|
3981
|
+
return;
|
|
3982
|
+
const renew = setInterval(() => {
|
|
3983
|
+
void this.store
|
|
3984
|
+
.renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs)
|
|
3985
|
+
.then((renewed) => {
|
|
3986
|
+
if (!renewed)
|
|
3987
|
+
this.leaseLostRuns.add(runId);
|
|
3988
|
+
})
|
|
3989
|
+
.catch(() => undefined);
|
|
3990
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
3991
|
+
renew.unref?.();
|
|
3992
|
+
try {
|
|
3993
|
+
const fresh = await this.store.getRun(runId);
|
|
3994
|
+
if (!this.awaitsDecision(fresh, decision))
|
|
3995
|
+
return;
|
|
3996
|
+
// Claim the turn: clear the marker and its deadline, and flip to `running` while the decision is
|
|
3997
|
+
// applied. Under the lease the marker cannot move (only a leased dispatch or re-drive rewrites
|
|
3998
|
+
// it); the status predicate loses to a lease-free cancel/requeue. If this instance dies
|
|
3999
|
+
// mid-apply, the run is a plain `running` orphan that crash recovery re-drives.
|
|
4000
|
+
const claimed = await this.store.updateRunIf(runId, ['suspended'], {
|
|
4001
|
+
status: 'running',
|
|
4002
|
+
awaitingDecisionTaskId: undefined,
|
|
4003
|
+
wakeAt: undefined,
|
|
4004
|
+
updatedAt: new Date(),
|
|
4005
|
+
});
|
|
4006
|
+
if (!claimed)
|
|
4007
|
+
return;
|
|
4008
|
+
this.decisionDeadlineRearms.delete(runId);
|
|
4009
|
+
const run = {
|
|
4010
|
+
...fresh,
|
|
4011
|
+
status: 'running',
|
|
4012
|
+
awaitingDecisionTaskId: undefined,
|
|
4013
|
+
wakeAt: undefined,
|
|
4014
|
+
};
|
|
4015
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
4016
|
+
this.latest.get(run.workflow);
|
|
4017
|
+
const result = await this.applyDecision(run, decision);
|
|
4018
|
+
// Notify-on-release, as execute does for a turn it settles.
|
|
4019
|
+
if (registered?.singleton &&
|
|
4020
|
+
(result.status === 'completed' ||
|
|
4021
|
+
result.status === 'failed' ||
|
|
4022
|
+
result.status === 'cancelled' ||
|
|
4023
|
+
result.status === 'dead')) {
|
|
4024
|
+
this.trackEffect(this.singletons.wakeNext(run));
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
finally {
|
|
4028
|
+
clearInterval(renew);
|
|
4029
|
+
this.leaseLostRuns.delete(runId);
|
|
4030
|
+
await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
/**
|
|
4034
|
+
* Acquire the run lease to apply `decision`. The dispatcher released it before enqueueing, so this
|
|
4035
|
+
* normally succeeds at once; another replica may still hold it briefly (its wake found the turn in
|
|
4036
|
+
* flight and is releasing), so retry until the lease frees, the run stops awaiting this turn, or one
|
|
4037
|
+
* lease window passes (a crashed holder's lease expires within it). A lease THIS instance holds is
|
|
4038
|
+
* reused: we are inside the run gate, so no execution of it is running here.
|
|
4039
|
+
*/
|
|
4040
|
+
async leaseForDecision(decision) {
|
|
4041
|
+
const runId = decision.runId;
|
|
4042
|
+
// Real time, not `this.clock`: a test clock may stand still.
|
|
4043
|
+
const giveUpAt = Date.now() + this.leaseMs + 1_000;
|
|
4044
|
+
let delayMs = 10;
|
|
4045
|
+
for (;;) {
|
|
4046
|
+
const run = await this.store.getRun(runId);
|
|
4047
|
+
if (!this.awaitsDecision(run, decision))
|
|
4048
|
+
return false;
|
|
4049
|
+
const now = this.clock();
|
|
4050
|
+
if (run.lockedBy === this.instanceId && (run.lockedUntil ?? 0) > now)
|
|
4051
|
+
return true;
|
|
4052
|
+
if (await this.store.tryLockRun(runId, this.instanceId, now + this.leaseMs, now))
|
|
4053
|
+
return true;
|
|
4054
|
+
if (Date.now() >= giveUpAt) {
|
|
4055
|
+
console.warn(`[adonis-durable] could not lease run ${runId} to apply the decision of turn ${decision.taskId}; the turn's liveness window re-drives it`);
|
|
4056
|
+
return false;
|
|
4057
|
+
}
|
|
4058
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
4059
|
+
delayMs = Math.min(delayMs * 2, 250);
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
/**
|
|
4063
|
+
* A run-scoped heartbeat for a run awaiting a dispatched turn's decision: push its re-drive deadline
|
|
4064
|
+
* (`wakeAt`) forward so a worker still replaying the turn is not re-driven. The durable counterpart
|
|
4065
|
+
* of an inline advance's in-memory rearm, and the run-scoped sibling of {@link rearmStepLease}.
|
|
4066
|
+
*
|
|
4067
|
+
* Throttled the same way: a renewal buys at most one window, so it is only worth a write once the
|
|
4068
|
+
* window is half spent. A beat that finds more than half of it left writes nothing and records the
|
|
4069
|
+
* half-spent mark, so later beats skip even the read until then. That bounds the cost at one read and
|
|
4070
|
+
* one write per half window per awaited turn, whatever the beat cadence. The mark is in memory only
|
|
4071
|
+
* (cold on another replica, which re-derives the same answer from the run's `wakeAt`). Best-effort:
|
|
4072
|
+
* it runs inside the transport's serial beat handler, so a store error is swallowed, and the mark only
|
|
4073
|
+
* moves after a write commits, so the next beat retries.
|
|
4074
|
+
*/
|
|
4075
|
+
async rearmDecisionDeadline(runId) {
|
|
4076
|
+
// No window anywhere: nothing ever re-drives a turn, so there is no deadline to defend.
|
|
4077
|
+
if (!runId || (this.remoteAdvanceSilenceMs == null && !this.anyDecisionTimeout))
|
|
4078
|
+
return;
|
|
4079
|
+
const now = this.clock();
|
|
4080
|
+
const lookAgainAt = this.decisionDeadlineRearms.get(runId);
|
|
4081
|
+
if (lookAgainAt !== undefined && now < lookAgainAt)
|
|
4082
|
+
return;
|
|
4083
|
+
try {
|
|
4084
|
+
const run = await this.store.getRun(runId);
|
|
4085
|
+
if (run?.status !== 'suspended' || run.awaitingDecisionTaskId === undefined) {
|
|
4086
|
+
this.decisionDeadlineRearms.delete(runId);
|
|
4087
|
+
return;
|
|
4088
|
+
}
|
|
4089
|
+
const windowMs = this.decisionWindowMs(this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
4090
|
+
this.latest.get(run.workflow));
|
|
4091
|
+
if (windowMs == null)
|
|
4092
|
+
return;
|
|
4093
|
+
const halfWindow = Math.ceil(windowMs / 2);
|
|
4094
|
+
if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
|
|
4095
|
+
this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
|
|
4096
|
+
return;
|
|
4097
|
+
}
|
|
4098
|
+
const renewed = now + Math.min(windowMs, MAX_BACKOFF_MS);
|
|
4099
|
+
// Only while the same turn is still awaited: a decision applied (or a re-drive) meanwhile wins.
|
|
4100
|
+
const applied = await this.store.updateRunIf(runId, ['suspended'], {
|
|
4101
|
+
wakeAt: renewed,
|
|
4102
|
+
updatedAt: new Date(),
|
|
4103
|
+
});
|
|
4104
|
+
if (!applied)
|
|
4105
|
+
return;
|
|
4106
|
+
if (this.decisionDeadlineRearms.size > 1024)
|
|
4107
|
+
this.decisionDeadlineRearms.clear();
|
|
4108
|
+
this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
|
|
4109
|
+
}
|
|
4110
|
+
catch {
|
|
4111
|
+
// Best-effort liveness: a store blip costs one skipped renewal, never the beat loop.
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
3061
4114
|
/**
|
|
3062
4115
|
* A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step is
|
|
3063
4116
|
* still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
|
|
@@ -3531,14 +4584,70 @@ export class WorkflowEngine {
|
|
|
3531
4584
|
}
|
|
3532
4585
|
return wakeAt;
|
|
3533
4586
|
}
|
|
4587
|
+
/**
|
|
4588
|
+
* Whether a compensating cancel is pending for this run, consuming the in-memory flag. The intent
|
|
4589
|
+
* lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same boot), and the
|
|
4590
|
+
* DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or a cross-pod handoff
|
|
4591
|
+
* mid-cancellation can't silently forget the cancel. The marker is peeked (take + re-buffer), not
|
|
4592
|
+
* consumed: it must survive a crash mid-unwind; {@link compensateAndCancel} drains it once
|
|
4593
|
+
* `cancelled` is durably recorded.
|
|
4594
|
+
*/
|
|
4595
|
+
async takeCancelIntent(runId) {
|
|
4596
|
+
if (this.cancelRequested.delete(runId))
|
|
4597
|
+
return true;
|
|
4598
|
+
const marker = await this.store.takeBufferedSignal(`cancel:${runId}`);
|
|
4599
|
+
if (!marker)
|
|
4600
|
+
return false;
|
|
4601
|
+
await this.store.bufferSignal(`cancel:${runId}`, marker.payload).catch(() => undefined);
|
|
4602
|
+
return true;
|
|
4603
|
+
}
|
|
4604
|
+
/**
|
|
4605
|
+
* Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
|
|
4606
|
+
* exit of a turn with a compensating cancel pending — the body returned, suspended, hit
|
|
4607
|
+
* continue-as-new or threw — so the cancel is never lost because the body didn't stop at a
|
|
4608
|
+
* suspension point.
|
|
4609
|
+
*/
|
|
4610
|
+
async compensateAndCancel(run, compensations) {
|
|
4611
|
+
this.cancelRequested.delete(run.id);
|
|
4612
|
+
// Zombie fence (as in settleRun): another instance owns the run now; the durable marker is still
|
|
4613
|
+
// there for it, so this stale turn must neither undo nor write.
|
|
4614
|
+
if (this.leaseLostRuns.has(run.id))
|
|
4615
|
+
return this.echoCurrentStatus(run.id);
|
|
4616
|
+
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
4617
|
+
const comp = compensations[i];
|
|
4618
|
+
if (comp)
|
|
4619
|
+
await this.runCompensation(run, comp, i);
|
|
4620
|
+
}
|
|
4621
|
+
const error = { message: 'cancelled' };
|
|
4622
|
+
await this.store.updateRun(run.id, {
|
|
4623
|
+
status: 'cancelled',
|
|
4624
|
+
error,
|
|
4625
|
+
awaitingDecisionTaskId: undefined,
|
|
4626
|
+
updatedAt: new Date(),
|
|
4627
|
+
});
|
|
4628
|
+
// The cancellation is durably recorded — consume the marker(s) so a later retry of this run id
|
|
4629
|
+
// doesn't get spuriously re-cancelled.
|
|
4630
|
+
for (;;) {
|
|
4631
|
+
const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
|
|
4632
|
+
if (!m)
|
|
4633
|
+
break;
|
|
4634
|
+
}
|
|
4635
|
+
this.emit({
|
|
4636
|
+
type: 'run.failed',
|
|
4637
|
+
runId: run.id,
|
|
4638
|
+
workflow: run.workflow,
|
|
4639
|
+
namespace: run.namespace,
|
|
4640
|
+
error,
|
|
4641
|
+
});
|
|
4642
|
+
return { runId: run.id, status: 'cancelled', error };
|
|
4643
|
+
}
|
|
3534
4644
|
/** The run body, lease held + renewed by {@link execute}. */
|
|
3535
4645
|
async runExecution(run, fn, registered) {
|
|
3536
4646
|
// First execution of an enqueued run: mark it running and announce the start, BEFORE the singleton
|
|
3537
|
-
// gate
|
|
3538
|
-
//
|
|
4647
|
+
// gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
|
|
4648
|
+
// already past `pending`, so this fires exactly once.
|
|
3539
4649
|
if (run.status === 'pending') {
|
|
3540
|
-
await this.
|
|
3541
|
-
run.status = 'running';
|
|
4650
|
+
await this.markStarted(run, registered?.singleton !== undefined);
|
|
3542
4651
|
this.emit({
|
|
3543
4652
|
type: 'run.started',
|
|
3544
4653
|
runId: run.id,
|
|
@@ -3549,16 +4658,7 @@ export class WorkflowEngine {
|
|
|
3549
4658
|
// Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
|
|
3550
4659
|
// (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
|
|
3551
4660
|
if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
3552
|
-
|
|
3553
|
-
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
3554
|
-
this.emit({
|
|
3555
|
-
type: 'run.suspended',
|
|
3556
|
-
runId: run.id,
|
|
3557
|
-
workflow: run.workflow,
|
|
3558
|
-
namespace: run.namespace,
|
|
3559
|
-
});
|
|
3560
|
-
await this.store.releaseRunLock(run.id, this.instanceId);
|
|
3561
|
-
return { runId: run.id, status: 'suspended' };
|
|
4661
|
+
return this.singletonWaitResult(run);
|
|
3562
4662
|
}
|
|
3563
4663
|
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
3564
4664
|
const compensations = [];
|
|
@@ -3590,6 +4690,11 @@ export class WorkflowEngine {
|
|
|
3590
4690
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
3591
4691
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
3592
4692
|
const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
|
|
4693
|
+
// A compensating cancel requested while this turn ran (or one whose resume replays straight to
|
|
4694
|
+
// `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
|
|
4695
|
+
if (await this.takeCancelIntent(run.id)) {
|
|
4696
|
+
return await this.compensateAndCancel(run, compensations);
|
|
4697
|
+
}
|
|
3593
4698
|
return await this.settleRun(run, { kind: 'completed', output });
|
|
3594
4699
|
}
|
|
3595
4700
|
catch (err) {
|
|
@@ -3598,6 +4703,10 @@ export class WorkflowEngine {
|
|
|
3598
4703
|
// complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
|
|
3599
4704
|
if (this.leaseLostRuns.has(run.id))
|
|
3600
4705
|
return await this.echoCurrentStatus(run.id);
|
|
4706
|
+
// A cancel in flight wins over continue-as-new: undo + cancel instead of forking the next run.
|
|
4707
|
+
if (await this.takeCancelIntent(run.id)) {
|
|
4708
|
+
return await this.compensateAndCancel(run, compensations);
|
|
4709
|
+
}
|
|
3601
4710
|
const nextId = nextContinuationId(run.id);
|
|
3602
4711
|
// Persist the continuation BEFORE the parent's terminal write — the durable ordering that
|
|
3603
4712
|
// makes the chain crash-safe. The old order (complete parent, then create the continuation
|
|
@@ -3639,45 +4748,8 @@ export class WorkflowEngine {
|
|
|
3639
4748
|
if (err instanceof WorkflowSuspended) {
|
|
3640
4749
|
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
3641
4750
|
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
// a cross-pod handoff mid-cancellation can't silently forget the cancel and settle the run
|
|
3645
|
-
// back to `suspended` as if nothing happened. Peek the marker (take + re-buffer) rather than
|
|
3646
|
-
// consume it: it must survive a crash mid-unwind; it's drained below once `cancelled` is
|
|
3647
|
-
// durably recorded.
|
|
3648
|
-
let cancelling = this.cancelRequested.delete(run.id);
|
|
3649
|
-
if (!cancelling) {
|
|
3650
|
-
const marker = await this.store.takeBufferedSignal(`cancel:${run.id}`);
|
|
3651
|
-
if (marker) {
|
|
3652
|
-
cancelling = true;
|
|
3653
|
-
await this.store
|
|
3654
|
-
.bufferSignal(`cancel:${run.id}`, marker.payload)
|
|
3655
|
-
.catch(() => undefined);
|
|
3656
|
-
}
|
|
3657
|
-
}
|
|
3658
|
-
if (cancelling) {
|
|
3659
|
-
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
3660
|
-
const comp = compensations[i];
|
|
3661
|
-
if (comp)
|
|
3662
|
-
await this.runCompensation(run, comp, i);
|
|
3663
|
-
}
|
|
3664
|
-
const error = { message: 'cancelled' };
|
|
3665
|
-
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
3666
|
-
// The cancellation is durably recorded — consume the marker(s) so a later retry of this
|
|
3667
|
-
// run id doesn't get spuriously re-cancelled.
|
|
3668
|
-
for (;;) {
|
|
3669
|
-
const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
|
|
3670
|
-
if (!m)
|
|
3671
|
-
break;
|
|
3672
|
-
}
|
|
3673
|
-
this.emit({
|
|
3674
|
-
type: 'run.failed',
|
|
3675
|
-
runId: run.id,
|
|
3676
|
-
workflow: run.workflow,
|
|
3677
|
-
namespace: run.namespace,
|
|
3678
|
-
error,
|
|
3679
|
-
});
|
|
3680
|
-
return { runId: run.id, status: 'cancelled', error };
|
|
4751
|
+
if (await this.takeCancelIntent(run.id)) {
|
|
4752
|
+
return await this.compensateAndCancel(run, compensations);
|
|
3681
4753
|
}
|
|
3682
4754
|
const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
3683
4755
|
// Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
|
|
@@ -3695,6 +4767,11 @@ export class WorkflowEngine {
|
|
|
3695
4767
|
suspendedOnFan = err.fan;
|
|
3696
4768
|
return settled;
|
|
3697
4769
|
}
|
|
4770
|
+
// A cancel in flight that surfaced as a thrown error settles `cancelled`, not `failed`: the saga
|
|
4771
|
+
// undo runs either way; this keeps the terminal status faithful to the cancel request.
|
|
4772
|
+
if (await this.takeCancelIntent(run.id)) {
|
|
4773
|
+
return await this.compensateAndCancel(run, compensations);
|
|
4774
|
+
}
|
|
3698
4775
|
const error = {
|
|
3699
4776
|
message: err instanceof Error ? err.message : String(err),
|
|
3700
4777
|
stack: err instanceof Error ? err.stack : undefined,
|
|
@@ -3840,7 +4917,9 @@ export class WorkflowEngine {
|
|
|
3840
4917
|
if (this.pool.size === 0)
|
|
3841
4918
|
throw new Error('dispatched compensation requires a Transport');
|
|
3842
4919
|
const token = stepGroup(def, run.namespace);
|
|
3843
|
-
const validInput = def.input
|
|
4920
|
+
const validInput = def.input
|
|
4921
|
+
? await validateStandard(def.input, args, `step "${def.name}" input`)
|
|
4922
|
+
: args;
|
|
3844
4923
|
const id = `${run.id}:compensate:${def.name}:${attempt}`;
|
|
3845
4924
|
const name = `compensate:${def.name}`;
|
|
3846
4925
|
const existing = await this.store.getCheckpoint(run.id, seq);
|
|
@@ -3883,7 +4962,7 @@ export class WorkflowEngine {
|
|
|
3883
4962
|
});
|
|
3884
4963
|
}
|
|
3885
4964
|
const awaited = def.timeoutMs
|
|
3886
|
-
? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
|
|
4965
|
+
? this.awaitWithHeartbeat(run.id, seq, id, resultPromise, def.timeoutMs)
|
|
3887
4966
|
: resultPromise;
|
|
3888
4967
|
await this.awaitCompensationOutcome(run.id, seq, name, id, token, attempt, awaited,
|
|
3889
4968
|
// With a heartbeat window the def's own liveness handling governs; otherwise bound the wait.
|
|
@@ -3972,6 +5051,7 @@ export class WorkflowEngine {
|
|
|
3972
5051
|
store: this.store,
|
|
3973
5052
|
replay,
|
|
3974
5053
|
clock: this.clock,
|
|
5054
|
+
eventBufferTtlMs: this.eventBufferTtlMs,
|
|
3975
5055
|
webhookUrl: this.webhookUrl,
|
|
3976
5056
|
startStep: (s) => this.startStep(s),
|
|
3977
5057
|
completeStep: (s) => this.completeStep(s),
|
|
@@ -3998,8 +5078,19 @@ export class WorkflowEngine {
|
|
|
3998
5078
|
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
3999
5079
|
upsertSearchAttributes: async (runId, attrs) => {
|
|
4000
5080
|
const run = await this.store.getRun(runId);
|
|
5081
|
+
const merged = { ...(run?.searchAttributes ?? {}), ...attrs };
|
|
5082
|
+
// A declared `searchAttributesSchema` validates the MERGED bag (the schema describes the whole
|
|
5083
|
+
// run's shape, optional keys included) — before the write, so the ctx primitive records no
|
|
5084
|
+
// checkpoint and a rejected write throws in the body (non-retryable SchemaValidationError).
|
|
5085
|
+
const schema = run
|
|
5086
|
+
? this.workflows.get(versionKey(run.workflow, run.workflowVersion))
|
|
5087
|
+
?.searchAttributesSchema
|
|
5088
|
+
: undefined;
|
|
5089
|
+
if (schema) {
|
|
5090
|
+
validateStandardSync(schema, merged, `workflow "${run?.workflow}": ctx.upsertSearchAttributes`);
|
|
5091
|
+
}
|
|
4001
5092
|
await this.store.updateRun(runId, {
|
|
4002
|
-
searchAttributes:
|
|
5093
|
+
searchAttributes: merged,
|
|
4003
5094
|
updatedAt: new Date(),
|
|
4004
5095
|
});
|
|
4005
5096
|
},
|
|
@@ -4011,6 +5102,16 @@ export class WorkflowEngine {
|
|
|
4011
5102
|
signalEntity: (name, key, op, arg, reply) => {
|
|
4012
5103
|
this.trackEffect(Promise.resolve().then(() => this.entities.dispatch(name, key, op, arg, reply)));
|
|
4013
5104
|
},
|
|
5105
|
+
// The durable half of a workflow-side entity send: awaited inside the turn (a store write
|
|
5106
|
+
// only, never an inline entity turn), so the ctx can checkpoint after it. See Entities.enqueue.
|
|
5107
|
+
enqueueEntityOp: (name, key, op, arg, id, reply) => this.entities.enqueue(name, key, op, arg, id, reply),
|
|
5108
|
+
// Start/wake the entity for what enqueueEntityOp buffered — deferred and tracked like
|
|
5109
|
+
// signalEntity above, so it never re-enters a still-executing turn and drain() waits for it.
|
|
5110
|
+
kickEntity: (name, key) => {
|
|
5111
|
+
this.trackEffect(Promise.resolve().then(() => this.entities.kick(name, key).catch((err) => {
|
|
5112
|
+
console.warn(`[durable] waking entity "${name}" key "${key}" failed (its op stays buffered for the next wake):`, err);
|
|
5113
|
+
})));
|
|
5114
|
+
},
|
|
4014
5115
|
interceptStep: (invocation, body) => this.interceptStep(invocation, body),
|
|
4015
5116
|
};
|
|
4016
5117
|
}
|
|
@@ -4316,7 +5417,9 @@ export class WorkflowEngine {
|
|
|
4316
5417
|
}
|
|
4317
5418
|
// Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
|
|
4318
5419
|
// call carries none — the engine passes the input through untouched then).
|
|
4319
|
-
const validInput = step.input
|
|
5420
|
+
const validInput = step.input
|
|
5421
|
+
? await validateStandard(step.input, input, `step "${step.name}" input`)
|
|
5422
|
+
: input;
|
|
4320
5423
|
// Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
|
|
4321
5424
|
// used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
|
|
4322
5425
|
// token the worker subscribes to per handler name serves this step.
|
|
@@ -4464,8 +5567,10 @@ export class WorkflowEngine {
|
|
|
4464
5567
|
async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
|
|
4465
5568
|
const token = stepGroup(step, runNamespace);
|
|
4466
5569
|
await this.ensureRoutable(token, step.requires);
|
|
4467
|
-
const input = (i) => step.input ? step.input
|
|
4468
|
-
const validInputs = new Map(
|
|
5570
|
+
const input = (i) => step.input ? validateStandard(step.input, inputs[i], `step "${step.name}" input`) : inputs[i];
|
|
5571
|
+
const validInputs = new Map();
|
|
5572
|
+
for (const i of batch)
|
|
5573
|
+
validInputs.set(i, await input(i));
|
|
4469
5574
|
const enqueuedAt = new Date();
|
|
4470
5575
|
for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
|
|
4471
5576
|
await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map((i) => {
|
|
@@ -4552,17 +5657,212 @@ export class WorkflowEngine {
|
|
|
4552
5657
|
await this.store.saveCheckpoint(checkpoint);
|
|
4553
5658
|
return true;
|
|
4554
5659
|
}
|
|
4555
|
-
|
|
5660
|
+
/**
|
|
5661
|
+
* Settle a durable step result and (barrier permitting) resume its run in the background.
|
|
5662
|
+
*
|
|
5663
|
+
* One run's results are settled in batches: the first starts a batch, and the ones that arrive
|
|
5664
|
+
* while it is being written (with `resultConcurrency > 1`) form the next, up to
|
|
5665
|
+
* {@link RESULT_BATCH_MAX}. A batch of several claims its checkpoints with one
|
|
5666
|
+
* {@link StateStore.saveCheckpointsIfPending} call, asks each step fan's barrier once, and resumes
|
|
5667
|
+
* the run at most once; a batch of one takes the single-result path unchanged. A result resolves
|
|
5668
|
+
* only once its own write committed, so the transport acknowledges nothing that is not durable.
|
|
5669
|
+
*/
|
|
5670
|
+
completeRemoteResult(result) {
|
|
5671
|
+
return new Promise((resolve, reject) => {
|
|
5672
|
+
const entry = { result, resolve, reject };
|
|
5673
|
+
const queued = this.resultQueues.get(result.runId);
|
|
5674
|
+
if (queued) {
|
|
5675
|
+
queued.push(entry);
|
|
5676
|
+
return;
|
|
5677
|
+
}
|
|
5678
|
+
const queue = [entry];
|
|
5679
|
+
this.resultQueues.set(result.runId, queue);
|
|
5680
|
+
void this.drainResultQueue(result.runId, queue);
|
|
5681
|
+
});
|
|
5682
|
+
}
|
|
5683
|
+
/** Settle `queue` (one run's queued results) batch by batch until it is empty, then retire it. */
|
|
5684
|
+
async drainResultQueue(runId, queue) {
|
|
5685
|
+
// Results delivered in the same tick as the first join its batch.
|
|
5686
|
+
await Promise.resolve();
|
|
5687
|
+
while (queue.length > 0) {
|
|
5688
|
+
const batch = queue.splice(0, RESULT_BATCH_MAX);
|
|
5689
|
+
let outcomes;
|
|
5690
|
+
try {
|
|
5691
|
+
outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
|
|
5692
|
+
}
|
|
5693
|
+
catch (error) {
|
|
5694
|
+
outcomes = batch.map(() => ({ error }));
|
|
5695
|
+
}
|
|
5696
|
+
batch.forEach((q, i) => {
|
|
5697
|
+
const outcome = outcomes[i] ?? true;
|
|
5698
|
+
if (outcome === true)
|
|
5699
|
+
q.resolve();
|
|
5700
|
+
else
|
|
5701
|
+
q.reject(outcome.error);
|
|
5702
|
+
});
|
|
5703
|
+
}
|
|
5704
|
+
// Same tick as the empty check: a result arriving later starts a new queue.
|
|
5705
|
+
this.resultQueues.delete(runId);
|
|
5706
|
+
}
|
|
5707
|
+
/** The single-result path: settle `result`, then resume its run unless its fan is still running. */
|
|
5708
|
+
async completeOneRemoteResult(result) {
|
|
4556
5709
|
const cp = await this.settleRemoteCheckpoint(result);
|
|
4557
5710
|
if (cp === undefined)
|
|
4558
5711
|
return;
|
|
4559
5712
|
if (await this.stepFanStillRunning(cp, result))
|
|
4560
5713
|
return;
|
|
4561
|
-
|
|
5714
|
+
this.resumeInBackground(result.runId);
|
|
5715
|
+
}
|
|
5716
|
+
resumeInBackground(runId) {
|
|
4562
5717
|
this.trackEffect(this.resumeImplicit(runId).catch((err) => {
|
|
4563
5718
|
console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
|
|
4564
5719
|
}));
|
|
4565
5720
|
}
|
|
5721
|
+
/**
|
|
5722
|
+
* Settle a batch of one run's step results; per result, `true` (settled or dropped) or the error
|
|
5723
|
+
* that kept it from settling, which the transport then redelivers. Same outcome per result as
|
|
5724
|
+
* {@link completeOneRemoteResult} on each in turn, with one resume for the batch: that resume
|
|
5725
|
+
* replays every write of the batch, so a second would only replay it again.
|
|
5726
|
+
*/
|
|
5727
|
+
async completeRemoteResults(runId, results) {
|
|
5728
|
+
const outcomes = results.map(() => true);
|
|
5729
|
+
const settleOneByOne = async (indexes) => {
|
|
5730
|
+
for (const i of indexes) {
|
|
5731
|
+
try {
|
|
5732
|
+
await this.completeOneRemoteResult(results[i]);
|
|
5733
|
+
}
|
|
5734
|
+
catch (error) {
|
|
5735
|
+
outcomes[i] = { error };
|
|
5736
|
+
}
|
|
5737
|
+
}
|
|
5738
|
+
return outcomes;
|
|
5739
|
+
};
|
|
5740
|
+
if (results.length === 1)
|
|
5741
|
+
return settleOneByOne([0]);
|
|
5742
|
+
// A second copy of a result in the same batch is a duplicate delivery: the first copy settles
|
|
5743
|
+
// the step (or re-drives the resume), so the copy is dropped like a late one.
|
|
5744
|
+
const seen = new Set();
|
|
5745
|
+
const live = results.flatMap((r, i) => {
|
|
5746
|
+
if (seen.has(r.seq))
|
|
5747
|
+
return [];
|
|
5748
|
+
seen.add(r.seq);
|
|
5749
|
+
return [i];
|
|
5750
|
+
});
|
|
5751
|
+
const run = await this.store.getRun(runId);
|
|
5752
|
+
// A finished or unknown run takes the single-result path, which salvages a late success onto a
|
|
5753
|
+
// failed run and frees the slots of a cancelled one. Rare, so it is not batched.
|
|
5754
|
+
if (run === null ||
|
|
5755
|
+
run.status === 'completed' ||
|
|
5756
|
+
run.status === 'failed' ||
|
|
5757
|
+
run.status === 'cancelled' ||
|
|
5758
|
+
run.status === 'dead') {
|
|
5759
|
+
return settleOneByOne(live);
|
|
5760
|
+
}
|
|
5761
|
+
const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
|
|
5762
|
+
// Checkpoints whose result should resume the run (barrier permitting): the ones this batch
|
|
5763
|
+
// settled, and redeliveries of ones settled before — whose resume may never have happened.
|
|
5764
|
+
const resumable = [];
|
|
5765
|
+
const pending = [];
|
|
5766
|
+
reads.forEach((read, k) => {
|
|
5767
|
+
const index = live[k];
|
|
5768
|
+
const result = results[index];
|
|
5769
|
+
if (read.status === 'rejected')
|
|
5770
|
+
outcomes[index] = { error: read.reason };
|
|
5771
|
+
else if (!read.value)
|
|
5772
|
+
return;
|
|
5773
|
+
else if (read.value.status === 'pending')
|
|
5774
|
+
pending.push({ index, cp: read.value });
|
|
5775
|
+
else
|
|
5776
|
+
resumable.push({ cp: read.value, result });
|
|
5777
|
+
});
|
|
5778
|
+
const finishedAt = new Date();
|
|
5779
|
+
const writes = pending.map(({ index, cp }) => {
|
|
5780
|
+
const result = results[index];
|
|
5781
|
+
return {
|
|
5782
|
+
...cp,
|
|
5783
|
+
status: result.status,
|
|
5784
|
+
output: result.status === 'completed' ? result.output : cp.output,
|
|
5785
|
+
error: result.error,
|
|
5786
|
+
events: result.events ?? cp.events,
|
|
5787
|
+
startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
|
|
5788
|
+
finishedAt,
|
|
5789
|
+
};
|
|
5790
|
+
});
|
|
5791
|
+
// Claimed, not blindly written: a duplicate delivery may have settled a step first.
|
|
5792
|
+
const claims = await this.savePendingCheckpoints(writes);
|
|
5793
|
+
for (const [k, claim] of claims.entries()) {
|
|
5794
|
+
const { index, cp } = pending[k];
|
|
5795
|
+
const result = results[index];
|
|
5796
|
+
if (claim === false) {
|
|
5797
|
+
resumable.push({ cp, result });
|
|
5798
|
+
continue;
|
|
5799
|
+
}
|
|
5800
|
+
if (claim !== true) {
|
|
5801
|
+
outcomes[index] = claim;
|
|
5802
|
+
continue;
|
|
5803
|
+
}
|
|
5804
|
+
try {
|
|
5805
|
+
await this.releaseQueueSlot(cp);
|
|
5806
|
+
}
|
|
5807
|
+
catch (error) {
|
|
5808
|
+
// Written but not acknowledged: the redelivery finds the step settled and re-drives.
|
|
5809
|
+
outcomes[index] = { error };
|
|
5810
|
+
}
|
|
5811
|
+
const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
|
|
5812
|
+
this.emit({
|
|
5813
|
+
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
5814
|
+
runId,
|
|
5815
|
+
seq: result.seq,
|
|
5816
|
+
name: cp.name,
|
|
5817
|
+
kind: cp.kind,
|
|
5818
|
+
output: result.output,
|
|
5819
|
+
error: result.error,
|
|
5820
|
+
queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
5821
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
5822
|
+
});
|
|
5823
|
+
resumable.push({ cp, result });
|
|
5824
|
+
}
|
|
5825
|
+
// Asked after every write of the batch committed, so the batch holding a fan's last step sees
|
|
5826
|
+
// its barrier open; once per fan. A barrier that cannot be read counts as open: a spare resume
|
|
5827
|
+
// replays and re-suspends, a lost one strands the run.
|
|
5828
|
+
const barriers = new Map();
|
|
5829
|
+
for (const { cp, result } of resumable) {
|
|
5830
|
+
const fan = parseStepFanGroup(cp.parallelGroup);
|
|
5831
|
+
if (!fan || result.status === 'failed') {
|
|
5832
|
+
this.resumeInBackground(runId);
|
|
5833
|
+
return outcomes;
|
|
5834
|
+
}
|
|
5835
|
+
let closed = barriers.get(fan.firstSeq);
|
|
5836
|
+
if (!closed) {
|
|
5837
|
+
closed = this.countPendingSteps(runId, fan.firstSeq, fan.firstSeq + fan.size - 1).then((n) => n > 0, () => false);
|
|
5838
|
+
barriers.set(fan.firstSeq, closed);
|
|
5839
|
+
}
|
|
5840
|
+
if (!(await closed)) {
|
|
5841
|
+
this.resumeInBackground(runId);
|
|
5842
|
+
return outcomes;
|
|
5843
|
+
}
|
|
5844
|
+
}
|
|
5845
|
+
return outcomes;
|
|
5846
|
+
}
|
|
5847
|
+
/**
|
|
5848
|
+
* {@link savePendingCheckpoint} for checkpoints at distinct seqs: one
|
|
5849
|
+
* {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
|
|
5850
|
+
* Never rejects as a whole: a failed write is reported as that entry's error, and the batch call
|
|
5851
|
+
* rejecting means it wrote nothing, so the entries are then written one by one.
|
|
5852
|
+
*/
|
|
5853
|
+
async savePendingCheckpoints(checkpoints) {
|
|
5854
|
+
if (checkpoints.length > 1 && this.store.saveCheckpointsIfPending) {
|
|
5855
|
+
try {
|
|
5856
|
+
const wrote = await this.store.saveCheckpointsIfPending(checkpoints);
|
|
5857
|
+
return checkpoints.map((_, i) => wrote[i] === true);
|
|
5858
|
+
}
|
|
5859
|
+
catch {
|
|
5860
|
+
// Fall through: one bad row must not fail the whole batch.
|
|
5861
|
+
}
|
|
5862
|
+
}
|
|
5863
|
+
const settled = await Promise.allSettled(checkpoints.map((cp) => this.savePendingCheckpoint(cp)));
|
|
5864
|
+
return settled.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason }));
|
|
5865
|
+
}
|
|
4566
5866
|
/**
|
|
4567
5867
|
* The join barrier of a step fan: a result for one of its steps resumes the parent only once no
|
|
4568
5868
|
* step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
|
|
@@ -4698,7 +5998,9 @@ export class WorkflowEngine {
|
|
|
4698
5998
|
async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
|
|
4699
5999
|
if (this.pool.size === 0)
|
|
4700
6000
|
throw new Error('remote steps require a Transport');
|
|
4701
|
-
const validInput = step.input
|
|
6001
|
+
const validInput = step.input
|
|
6002
|
+
? await validateStandard(step.input, input, `step "${step.name}" input`)
|
|
6003
|
+
: input;
|
|
4702
6004
|
const token = stepGroup(step, runNamespace);
|
|
4703
6005
|
// Capability/protocol guard (design §7.5) — same as the durable path, before the `step.started`
|
|
4704
6006
|
// emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
|
|
@@ -4783,7 +6085,7 @@ export class WorkflowEngine {
|
|
|
4783
6085
|
}, transport);
|
|
4784
6086
|
try {
|
|
4785
6087
|
const resolution = step.timeoutMs
|
|
4786
|
-
? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs, step.pickupTimeoutMs)
|
|
6088
|
+
? await this.awaitWithHeartbeat(runId, seq, id, resultPromise, step.timeoutMs, step.pickupTimeoutMs)
|
|
4787
6089
|
: await resultPromise;
|
|
4788
6090
|
return await this.settleRemoteAttempt(runId, seq, step, validInput, resolution, attempt, token, enqueuedAt);
|
|
4789
6091
|
}
|
|
@@ -4815,7 +6117,9 @@ export class WorkflowEngine {
|
|
|
4815
6117
|
}
|
|
4816
6118
|
/** Shared finalization for a remote resolution, however it arrived (live await or salvaged). */
|
|
4817
6119
|
async settleRemoteAttempt(runId, seq, step, input, resolution, attempt, token, enqueuedAt) {
|
|
4818
|
-
const output = (step.output
|
|
6120
|
+
const output = (step.output
|
|
6121
|
+
? await validateStandard(step.output, resolution.output, `step "${step.name}" output`)
|
|
6122
|
+
: resolution.output);
|
|
4819
6123
|
// The worker reports when it actually picked the task up; fall back to dispatch time if a
|
|
4820
6124
|
// transport doesn't carry it (queue-wait then reads as zero rather than going negative).
|
|
4821
6125
|
const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
|
|
@@ -4839,9 +6143,11 @@ export class WorkflowEngine {
|
|
|
4839
6143
|
* arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
|
|
4840
6144
|
* window, so a worker that keeps beating stays alive past `timeoutMs`.
|
|
4841
6145
|
*/
|
|
4842
|
-
awaitWithHeartbeat(id, resultPromise, timeoutMs, pickupTimeoutMs) {
|
|
6146
|
+
awaitWithHeartbeat(runId, seq, id, resultPromise, timeoutMs, pickupTimeoutMs) {
|
|
4843
6147
|
return new Promise((resolve, reject) => {
|
|
4844
6148
|
let timer;
|
|
6149
|
+
let settled = false;
|
|
6150
|
+
const dispatchedAt = this.clock();
|
|
4845
6151
|
// Two windows, one timer: until the FIRST beat arrives the job may be sitting in the broker
|
|
4846
6152
|
// queue behind long work — a stretch that produces no heartbeats by definition, so it gets
|
|
4847
6153
|
// `pickupTimeoutMs` (default: `timeoutMs`, the historical behavior). The worker's automatic
|
|
@@ -4853,20 +6159,63 @@ export class WorkflowEngine {
|
|
|
4853
6159
|
// would turn a generous liveness window into an instant RemoteStepTimeout.
|
|
4854
6160
|
const window = () => Math.min(seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs), MAX_BACKOFF_MS);
|
|
4855
6161
|
const cleanup = () => {
|
|
6162
|
+
settled = true;
|
|
4856
6163
|
clearTimeout(timer);
|
|
4857
6164
|
this.heartbeatResets.delete(id);
|
|
4858
6165
|
};
|
|
4859
|
-
const arm = () => {
|
|
4860
|
-
|
|
4861
|
-
timer = setTimeout(() => {
|
|
4862
|
-
cleanup();
|
|
4863
|
-
this.pending.delete(id);
|
|
4864
|
-
reject(new RemoteStepTimeout(id, ms));
|
|
4865
|
-
}, ms);
|
|
6166
|
+
const arm = (ms = window()) => {
|
|
6167
|
+
timer = setTimeout(() => void expire(ms), ms);
|
|
4866
6168
|
timer.unref?.();
|
|
4867
6169
|
};
|
|
4868
|
-
|
|
6170
|
+
// Worker timestamps of the beats THIS instance heard, to tell its own persisted beats from ones
|
|
6171
|
+
// persisted by another instance. Bounded: a step beating for days must not grow it forever.
|
|
6172
|
+
const localBeats = new Set();
|
|
6173
|
+
// Set once a persisted beat of this attempt turns out to come from another instance.
|
|
6174
|
+
let beatsLandElsewhere = false;
|
|
6175
|
+
// The window ran out with no beat seen HERE. With several engine instances consuming the same
|
|
6176
|
+
// heartbeat queue (the `queue` transport: point-to-point), the beats may have landed on another
|
|
6177
|
+
// instance — which persists them on the checkpoint (`persistHeartbeat`) but cannot reach this
|
|
6178
|
+
// timer. Before declaring the worker dead, read the persisted beat: a recent one (from this
|
|
6179
|
+
// attempt) means the step is alive, so re-arm instead of failing over a healthy step and
|
|
6180
|
+
// re-dispatching it while it still runs.
|
|
6181
|
+
//
|
|
6182
|
+
// "Recent" has to account for the persist throttle: the other instance writes at most one beat
|
|
6183
|
+
// per `heartbeatPersistThrottleMs`, so the newest beat it heard can be up to that much newer
|
|
6184
|
+
// than the one on the checkpoint. Once a persisted beat is known to come from elsewhere (its
|
|
6185
|
+
// timestamp is not one this instance heard), that much extra silence is allowed. A beat this
|
|
6186
|
+
// instance persisted itself gets no allowance — it knows its own beats exactly — so a lone
|
|
6187
|
+
// instance detects a dead worker as fast as before.
|
|
6188
|
+
const expire = async (ms) => {
|
|
6189
|
+
if (settled)
|
|
6190
|
+
return;
|
|
6191
|
+
const at = await this.store
|
|
6192
|
+
.getCheckpoint(runId, seq)
|
|
6193
|
+
.then((cp) => cp?.lastHeartbeatAt?.getTime())
|
|
6194
|
+
.catch(() => undefined);
|
|
6195
|
+
if (settled)
|
|
6196
|
+
return;
|
|
6197
|
+
const now = this.clock();
|
|
6198
|
+
if (at !== undefined && at >= dispatchedAt - HEARTBEAT_CLOCK_SKEW_MS) {
|
|
6199
|
+
if (!localBeats.has(at))
|
|
6200
|
+
beatsLandElsewhere = true;
|
|
6201
|
+
const allowance = timeoutMs + (beatsLandElsewhere ? this.heartbeatPersistThrottleMs : 0);
|
|
6202
|
+
if (now - at < allowance) {
|
|
6203
|
+
seenBeat = true;
|
|
6204
|
+
arm(Math.min(Math.max(allowance - (now - at), 1), MAX_BACKOFF_MS));
|
|
6205
|
+
return;
|
|
6206
|
+
}
|
|
6207
|
+
}
|
|
6208
|
+
cleanup();
|
|
6209
|
+
this.pending.delete(id);
|
|
6210
|
+
reject(new RemoteStepTimeout(id, ms));
|
|
6211
|
+
};
|
|
6212
|
+
this.heartbeatResets.set(id, (beatAt) => {
|
|
4869
6213
|
seenBeat = true;
|
|
6214
|
+
if (beatAt !== undefined) {
|
|
6215
|
+
if (localBeats.size >= 256)
|
|
6216
|
+
localBeats.clear();
|
|
6217
|
+
localBeats.add(beatAt);
|
|
6218
|
+
}
|
|
4870
6219
|
clearTimeout(timer);
|
|
4871
6220
|
arm();
|
|
4872
6221
|
});
|
|
@@ -4881,6 +6230,21 @@ export class WorkflowEngine {
|
|
|
4881
6230
|
});
|
|
4882
6231
|
}
|
|
4883
6232
|
}
|
|
6233
|
+
/** The worker group a convention-routed workflow's run dispatches to: its sanitized name, suffixed by
|
|
6234
|
+
* the run's namespace (bare for absent/`''`/`default`) — the token a tenant worker serves. */
|
|
6235
|
+
function conventionGroup(name, namespace) {
|
|
6236
|
+
return tenantGroup(sanitizeQueueToken(name), namespace);
|
|
6237
|
+
}
|
|
6238
|
+
/** Key of an inline remote turn's liveness reset in `heartbeatResets` (step keys are `runId:seq`). */
|
|
6239
|
+
function turnBeatKey(runId) {
|
|
6240
|
+
return `turn:${runId}`;
|
|
6241
|
+
}
|
|
6242
|
+
/** The run whose remote turn is being enqueued in the current async context — lets the decision
|
|
6243
|
+
* handler spot a transport delivering the decision from inside its own `dispatchWorkflowTask`. */
|
|
6244
|
+
const turnDispatchAls = new AsyncLocalStorage();
|
|
6245
|
+
/** Tolerance (ms) between the worker's clock, which stamps a persisted heartbeat, and this engine's,
|
|
6246
|
+
* when deciding whether a beat belongs to the current attempt (see `awaitWithHeartbeat`). */
|
|
6247
|
+
const HEARTBEAT_CLOCK_SKEW_MS = 1_000;
|
|
4884
6248
|
/** Raised inside the workflow when a remote worker reports a step failure. */
|
|
4885
6249
|
export class RemoteStepError extends Error {
|
|
4886
6250
|
stepError;
|