@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
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { Pollers } from '../pollers.js';
|
|
3
|
-
import { runStepHandler } from '../protocol.js';
|
|
2
|
+
import { atLeastOne, Pollers } from '../pollers.js';
|
|
3
|
+
import { runStepHandler, withTurnHeartbeat } from '../protocol.js';
|
|
4
4
|
import { sanitizeQueueToken, tenantGroup } from '../tenant-group.js';
|
|
5
|
-
import {
|
|
5
|
+
import { AdaptiveController } from './adaptive-concurrency.js';
|
|
6
|
+
import { effectivePrefix, workerHeartbeatKey } from './bullmq/naming.js';
|
|
7
|
+
import { heartbeatKeyValue, parseHeartbeatValue, WORKER_HEARTBEAT_INTERVAL_MS, WORKER_HEARTBEAT_TTL_SECONDS, } from './bullmq/serialization.js';
|
|
8
|
+
import { DbInboxRegistry, DEFAULT_DB_INBOX_SWEEP_INTERVAL_MS, DEFAULT_DB_INBOX_TTL_MS, DEFAULT_DB_TENANT_SUBSCRIBERS_CACHE_MS, } from './db-remote.js';
|
|
9
|
+
import { createDurableTransportTables, MESSAGE_KINDS, TRANSPORT_TABLES, } from './db-schema.js';
|
|
10
|
+
import { fanOutDecision, infrastructureFailure } from './task-failure.js';
|
|
6
11
|
import { cachedLiveKeys, descriptorsOn, liveGroupsOf, workerKeyWriter, } from './worker-keys.js';
|
|
7
12
|
import { lucidWorkerKeys } from './worker-keys-db.js';
|
|
8
13
|
/**
|
|
@@ -25,8 +30,48 @@ function fromJson(value) {
|
|
|
25
30
|
const DEFAULT_POLL_INTERVAL_MS = 200;
|
|
26
31
|
/** Default lease window (ms): a claimed-but-not-deleted row is reclaimable after this (crash recovery). */
|
|
27
32
|
const DEFAULT_LEASE_MS = 30_000;
|
|
28
|
-
/** Default max rows claimed per poll
|
|
33
|
+
/** Default max rows claimed per poll (results / heartbeats / control; the task loop claims only as
|
|
34
|
+
* many rows as it has free slots, never more than this). */
|
|
29
35
|
const DEFAULT_BATCH_SIZE = 20;
|
|
36
|
+
/** Idle backoff for the task, heartbeat and control loops (the results loop is exempt): after
|
|
37
|
+
* consecutive empty polls the sleep doubles up to this many poll intervals. Same default as the
|
|
38
|
+
* queue transport. */
|
|
39
|
+
const DEFAULT_IDLE_BACKOFF_MAX = 4;
|
|
40
|
+
/** How many times a task row's lapsed claim may be taken over before the row is treated as poison
|
|
41
|
+
* (its workers keep dying on it) and failed instead of re-run. Same default as the queue transport. */
|
|
42
|
+
const DEFAULT_MAX_STALLED_COUNT = 3;
|
|
43
|
+
/** Key prefix of the worker liveness keys — the one a `WorkerRuntime` uses by default, so both kinds of
|
|
44
|
+
* beat land in the same keyspace and `groupHealth` reads them alike. */
|
|
45
|
+
const WORKER_KEY_PREFIX = 'durable';
|
|
46
|
+
/** Default budget (ms) `close()` waits for in-flight work before handing the rest back. */
|
|
47
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 5_000;
|
|
48
|
+
/** The `priority` column is a 32-bit integer on every dialect. */
|
|
49
|
+
const PRIORITY_MIN = -2_147_483_648;
|
|
50
|
+
const PRIORITY_MAX = 2_147_483_647;
|
|
51
|
+
/**
|
|
52
|
+
* The `priority` column value for the engine's dispatch `priority` (higher = more urgent): rounded and
|
|
53
|
+
* clamped to the column's range, and `0` — "normal" — when absent, which is where the queue
|
|
54
|
+
* transport's broker mapping puts an unprioritised job too.
|
|
55
|
+
*/
|
|
56
|
+
export function toDbPriority(priority) {
|
|
57
|
+
if (priority == null || Number.isNaN(priority))
|
|
58
|
+
return 0;
|
|
59
|
+
return Math.min(PRIORITY_MAX, Math.max(PRIORITY_MIN, Math.round(priority)));
|
|
60
|
+
}
|
|
61
|
+
const MISSING_COLUMN = {
|
|
62
|
+
reclaims: {
|
|
63
|
+
effect: 'poison tasks are not bounded (maxStalledCount is off)',
|
|
64
|
+
ddl: 'integer, not null, default 0',
|
|
65
|
+
},
|
|
66
|
+
priority: {
|
|
67
|
+
effect: 'dispatch priority is ignored and tasks are claimed oldest first',
|
|
68
|
+
ddl: 'integer, not null, default 0',
|
|
69
|
+
},
|
|
70
|
+
kind: {
|
|
71
|
+
effect: 'remote workflow turns cannot be dispatched on it',
|
|
72
|
+
ddl: "string, not null, default 'step'",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
30
75
|
const DEFAULT_LOG = (err) => console.error('[DbTransport] poll failed', err);
|
|
31
76
|
/**
|
|
32
77
|
* A poll-based, DB-table-backed {@link Transport} (and best-effort {@link ControlPlane}) over
|
|
@@ -45,6 +90,12 @@ const DEFAULT_LOG = (err) => console.error('[DbTransport] poll failed', err);
|
|
|
45
90
|
* {@link ControlPlane} here is single-consumer (one row → one poller), correct for a single engine
|
|
46
91
|
* instance but NOT a true broadcast.
|
|
47
92
|
*
|
|
93
|
+
* It also carries remote workflow turns (`dispatchWorkflowTask` / `handleWorkflow` / `onDecision`,
|
|
94
|
+
* streamed step events) and the store-less tenant-pod protocol (start-run, run-request, run replies,
|
|
95
|
+
* tenant events), so a `tenant` pod can reach a `control-plane` over the database alone. Those ride the
|
|
96
|
+
* `durable_transport_messages` table: run replies go to the requester only, tenant events to every
|
|
97
|
+
* live subscriber of the tenant, decisions to the engine that dispatched the turn — see `db-remote.ts`.
|
|
98
|
+
*
|
|
48
99
|
* Run one instance engine-side (`onResult` + `dispatch`) and one per worker process (`handle()` for
|
|
49
100
|
* its group). Call {@link start} to begin the engine-side pollers; `handle()` auto-starts the task
|
|
50
101
|
* loop. The wire payloads are the documented `RemoteTask`/`StepResult` JSON.
|
|
@@ -80,8 +131,63 @@ export class DbTransport {
|
|
|
80
131
|
#readWorkerKeys;
|
|
81
132
|
#handlers = new Map();
|
|
82
133
|
#pollers;
|
|
134
|
+
/** When set (see {@link deferConsumers}), {@link #startLoop} parks its loop until
|
|
135
|
+
* {@link startConsumers}. Dispatching is never deferred; only consumption is. */
|
|
136
|
+
#consumersDeferred = false;
|
|
137
|
+
#deferredStarts = [];
|
|
83
138
|
#schemaReady;
|
|
84
139
|
#taskLoop;
|
|
140
|
+
/** Fixed or adaptive task concurrency, plus the in-flight/latency/RSS/CPU telemetry the liveness
|
|
141
|
+
* key carries. One controller for the task loop of this instance, as on the queue transport. */
|
|
142
|
+
#controller;
|
|
143
|
+
/** A task loop parked on "every slot busy", woken when an adaptive move raises the limit. */
|
|
144
|
+
#limitWaiters = new Set();
|
|
145
|
+
/** Every task row still in a handler — the slots in use, and what `stop()` waits for (bounded). */
|
|
146
|
+
#runningTasks = new Set();
|
|
147
|
+
#resultConcurrency;
|
|
148
|
+
#idleBackoffMax;
|
|
149
|
+
#maxStalledCount;
|
|
150
|
+
#onError;
|
|
151
|
+
/** Claims this instance holds, per table: claim token → the row ids still held under it. Every one
|
|
152
|
+
* is renewed on {@link #renewTimer}, so no held row ages into another instance's reclaim. */
|
|
153
|
+
#held = new Map();
|
|
154
|
+
#renewTimer;
|
|
155
|
+
/** The renewal round in flight, so a slow database never stacks rounds. */
|
|
156
|
+
#renewal;
|
|
157
|
+
/** Whether the tasks table has each adaptive column (resolved once per column). */
|
|
158
|
+
#taskColumns = new Map();
|
|
159
|
+
/** Claim tokens `close()` handed back — a handler still running under one learns why it lost it. */
|
|
160
|
+
#handedBack = new Set();
|
|
161
|
+
#closeTimeoutMs;
|
|
162
|
+
/** Routing tokens this instance beats a liveness key for (one per handled step name). */
|
|
163
|
+
#heartbeatTokens = new Set();
|
|
164
|
+
#workerHeartbeatIntervalMs;
|
|
165
|
+
#workerHeartbeatTimer;
|
|
166
|
+
/** Liveness-key writes still in flight — `close()` lets them land before withdrawing the keys. */
|
|
167
|
+
#livenessWrites = new Set();
|
|
168
|
+
/** Routing tokens whose liveness-key write is still in flight (see `#startWorkerHeartbeat`). */
|
|
169
|
+
#beatsInFlight = new Set();
|
|
170
|
+
/** Workflow turn consumers by workflow name — a `workflow` task row routes here. */
|
|
171
|
+
#workflowTurns = new Map();
|
|
172
|
+
/** Every `onDecision` handler (an engine subscribes one; it applies a decision by run id). */
|
|
173
|
+
#decisionHandlers = new Set();
|
|
174
|
+
/** Which message loops run (each starts once). */
|
|
175
|
+
#messageLoops = new Set();
|
|
176
|
+
#runReplyHandler;
|
|
177
|
+
/** Control-plane side: correlation id (requestId, or a start-run's runId) → the requester's instance
|
|
178
|
+
* id, held while the request's handler runs so {@link publishRunReply} knows whom to address. */
|
|
179
|
+
#replyRoutes = new Map();
|
|
180
|
+
#tenantEventHandlers = new Map();
|
|
181
|
+
/** Serializes {@link publishTenantEvent} so each subscriber gets one publisher's events in order. */
|
|
182
|
+
#tenantPublishChain = Promise.resolve();
|
|
183
|
+
#inboxes;
|
|
184
|
+
#inboxTtlMs;
|
|
185
|
+
#inboxSweepIntervalMs;
|
|
186
|
+
#sweepTimer;
|
|
187
|
+
#sweeping;
|
|
188
|
+
/** Whether the messages table exists (resolved once), and whether its absence was reported. */
|
|
189
|
+
#messagesTable;
|
|
190
|
+
#messagesTableReported = false;
|
|
85
191
|
constructor(options) {
|
|
86
192
|
this.#db = options.db;
|
|
87
193
|
this.#partition = options.partition;
|
|
@@ -94,7 +200,41 @@ export class DbTransport {
|
|
|
94
200
|
this.#autoCreate = options.autoCreate ?? true;
|
|
95
201
|
this.#instanceId = options.instanceId ?? randomUUID();
|
|
96
202
|
this.#workerKeysCacheMs = options.workerKeysCacheMs ?? 5_000;
|
|
97
|
-
this.#
|
|
203
|
+
this.#onError = options.onError ?? DEFAULT_LOG;
|
|
204
|
+
this.#closeTimeoutMs =
|
|
205
|
+
options.closeTimeoutMs !== undefined && options.closeTimeoutMs >= 0
|
|
206
|
+
? options.closeTimeoutMs
|
|
207
|
+
: DEFAULT_CLOSE_TIMEOUT_MS;
|
|
208
|
+
this.#pollers = new Pollers(this.#pollIntervalMs, this.#onError);
|
|
209
|
+
this.#controller = new AdaptiveController({
|
|
210
|
+
...(options.concurrency !== undefined ? { concurrency: options.concurrency } : {}),
|
|
211
|
+
apply: () => {
|
|
212
|
+
for (const wake of this.#limitWaiters)
|
|
213
|
+
wake();
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
this.#resultConcurrency = atLeastOne(options.resultConcurrency, 1);
|
|
217
|
+
this.#idleBackoffMax = atLeastOne(options.idleBackoffMax, DEFAULT_IDLE_BACKOFF_MAX);
|
|
218
|
+
const maxStalled = options.maxStalledCount;
|
|
219
|
+
this.#maxStalledCount =
|
|
220
|
+
maxStalled !== undefined && !Number.isNaN(maxStalled) && maxStalled >= 0
|
|
221
|
+
? Math.floor(maxStalled)
|
|
222
|
+
: DEFAULT_MAX_STALLED_COUNT;
|
|
223
|
+
this.#workerHeartbeatIntervalMs = atLeastOne(options.workerHeartbeatIntervalMs, WORKER_HEARTBEAT_INTERVAL_MS);
|
|
224
|
+
this.#inboxTtlMs = atLeastOne(options.inboxTtlMs, DEFAULT_DB_INBOX_TTL_MS);
|
|
225
|
+
this.#inboxSweepIntervalMs =
|
|
226
|
+
options.inboxSweepIntervalMs !== undefined && Number.isFinite(options.inboxSweepIntervalMs)
|
|
227
|
+
? Math.max(0, options.inboxSweepIntervalMs)
|
|
228
|
+
: DEFAULT_DB_INBOX_SWEEP_INTERVAL_MS;
|
|
229
|
+
this.#inboxes = new DbInboxRegistry({
|
|
230
|
+
store: () => this.#workerKeys(),
|
|
231
|
+
prefix: () => this.#keyPrefix(),
|
|
232
|
+
instanceId: this.#instanceId,
|
|
233
|
+
ttlMs: this.#inboxTtlMs,
|
|
234
|
+
subscribersCacheMs: options.tenantSubscribersCacheMs ?? DEFAULT_DB_TENANT_SUBSCRIBERS_CACHE_MS,
|
|
235
|
+
onError: this.#onError,
|
|
236
|
+
now: () => this.#now(),
|
|
237
|
+
});
|
|
98
238
|
}
|
|
99
239
|
/** Stable id stamped on heartbeats, control `from`, and `claimed_by`. */
|
|
100
240
|
get instanceId() {
|
|
@@ -133,6 +273,7 @@ export class DbTransport {
|
|
|
133
273
|
// ───────────────────────────────────────────────────────────────────────────
|
|
134
274
|
async dispatch(task) {
|
|
135
275
|
await this.#ensureSchema();
|
|
276
|
+
const withPriority = await this.#hasTaskColumn('priority');
|
|
136
277
|
// Idempotent: a redelivered dispatch for the same step_id is ignored (the PK already exists).
|
|
137
278
|
// `onConflict().ignore()` is a Knex feature not surfaced on Lucid's insert contract, so we use
|
|
138
279
|
// the underlying Knex builder (`.knexQuery`).
|
|
@@ -151,6 +292,7 @@ export class DbTransport {
|
|
|
151
292
|
context: toJson(task.context),
|
|
152
293
|
transport: task.transport ?? null,
|
|
153
294
|
attempt: task.attempt,
|
|
295
|
+
...(withPriority ? { priority: toDbPriority(task.priority) } : {}),
|
|
154
296
|
claimed_by: null,
|
|
155
297
|
claimed_at: null,
|
|
156
298
|
created_at: this.#now(),
|
|
@@ -161,26 +303,100 @@ export class DbTransport {
|
|
|
161
303
|
// ───────────────────────────────────────────────────────────────────────────
|
|
162
304
|
// worker side — register a handler, claim + run tasks, write results
|
|
163
305
|
// ───────────────────────────────────────────────────────────────────────────
|
|
306
|
+
/**
|
|
307
|
+
* Park every consumer loop registered from now on ({@link handle}, {@link onResult},
|
|
308
|
+
* {@link onHeartbeat}, {@link onControl}) until {@link startConsumers}. Task, result, heartbeat and
|
|
309
|
+
* control rows are claimed by ONE consumer each, so a process not built to be a worker — a
|
|
310
|
+
* `node ace` command, a REPL — would otherwise claim rows it then dies holding (re-delivered only
|
|
311
|
+
* after the lease expires) and steal results addressed to the long-lived engine. The same gate as
|
|
312
|
+
* the `queue` and `bullmq` transports; the provider calls it per the `consumers` config.
|
|
313
|
+
*/
|
|
314
|
+
deferConsumers() {
|
|
315
|
+
this.#consumersDeferred = true;
|
|
316
|
+
}
|
|
317
|
+
/** Start every loop parked by {@link deferConsumers} and stop deferring (`durable:work` calls it via
|
|
318
|
+
* `engine.startConsumers()`). Idempotent; a no-op when consumption was never deferred. */
|
|
319
|
+
startConsumers() {
|
|
320
|
+
if (!this.#consumersDeferred)
|
|
321
|
+
return;
|
|
322
|
+
this.#consumersDeferred = false;
|
|
323
|
+
for (const start of this.#deferredStarts.splice(0))
|
|
324
|
+
start();
|
|
325
|
+
}
|
|
326
|
+
/** `#pollers.start`, unless consumption is deferred: then park it, returning a handle that still
|
|
327
|
+
* stops the loop whether it is started later or never. */
|
|
328
|
+
#startLoop(tick, idleBackoffMax) {
|
|
329
|
+
if (!this.#consumersDeferred)
|
|
330
|
+
return this.#pollers.start(tick, { idleBackoffMax });
|
|
331
|
+
let cancelled = false;
|
|
332
|
+
let live;
|
|
333
|
+
this.#deferredStarts.push(() => {
|
|
334
|
+
if (!cancelled)
|
|
335
|
+
live = this.#pollers.start(tick, { idleBackoffMax });
|
|
336
|
+
});
|
|
337
|
+
return {
|
|
338
|
+
stop: () => {
|
|
339
|
+
cancelled = true;
|
|
340
|
+
live?.stop();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** Run `start` once this instance really consumes: now, or at {@link startConsumers} when deferred. */
|
|
345
|
+
#whenConsuming(start) {
|
|
346
|
+
if (this.#consumersDeferred)
|
|
347
|
+
this.#deferredStarts.push(start);
|
|
348
|
+
else
|
|
349
|
+
start();
|
|
350
|
+
}
|
|
164
351
|
/**
|
|
165
352
|
* Register a step handler (worker side). Records this name's routing token
|
|
166
353
|
* (`tenantGroup(sanitizeQueueToken(name), partition)`) as served and starts the shared task poll
|
|
167
354
|
* loop on the first call — it claims task rows for EVERY served token (matching the token the
|
|
168
|
-
* engine dispatches by), runs each through {@link runStepHandler}, and writes the result.
|
|
355
|
+
* engine dispatches by), runs each through {@link runStepHandler}, and writes the result. The loop
|
|
356
|
+
* keeps up to {@link DbTransportOptions.concurrency} rows in flight, and each served token gets a
|
|
357
|
+
* liveness key once the loop consumes (a deferred, producer-only process advertises nothing).
|
|
169
358
|
*/
|
|
170
359
|
handle(name, fn) {
|
|
171
360
|
this.#handlers.set(name, fn);
|
|
172
|
-
this.#
|
|
361
|
+
this.#serve(name);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Register a WORKFLOW turn consumer (worker side). Shares the task loop — and the routing token —
|
|
365
|
+
* of a step of the same name: {@link dispatchWorkflowTask} writes a task row of kind `workflow`, which
|
|
366
|
+
* runs `turn(task)` in a slot like any step, and its {@link WorkflowDecision} is written to the
|
|
367
|
+
* messages table, addressed to the engine instance that dispatched the turn. Re-registering swaps
|
|
368
|
+
* the turn fn.
|
|
369
|
+
*/
|
|
370
|
+
handleWorkflow(name, turn) {
|
|
371
|
+
this.#workflowTurns.set(name, turn);
|
|
372
|
+
this.#serve(name);
|
|
373
|
+
}
|
|
374
|
+
/** Serve `name`'s routing token on the shared task loop (started on the first call). */
|
|
375
|
+
#serve(name) {
|
|
376
|
+
const token = tenantGroup(sanitizeQueueToken(name), this.#partition);
|
|
377
|
+
const isNew = !this.#servedTokens.has(token);
|
|
378
|
+
this.#servedTokens.add(token);
|
|
173
379
|
if (!this.#taskLoop) {
|
|
174
|
-
this.#taskLoop = this.#
|
|
380
|
+
this.#taskLoop = this.#startLoop(() => this.#taskTick(), this.#idleBackoffMax);
|
|
381
|
+
this.#whenConsuming(() => {
|
|
382
|
+
for (const served of this.#servedTokens)
|
|
383
|
+
this.#beginServing(served);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
else if (isNew) {
|
|
387
|
+
this.#whenConsuming(() => this.#beginServing(token));
|
|
175
388
|
}
|
|
176
389
|
}
|
|
177
390
|
/** Worker side: publish a liveness heartbeat for an in-flight long step. */
|
|
178
391
|
async heartbeat(beat) {
|
|
179
392
|
await this.#ensureSchema();
|
|
180
|
-
await this.#client()
|
|
393
|
+
await this.#client()
|
|
394
|
+
.table(TRANSPORT_TABLES.heartbeats)
|
|
395
|
+
.insert({
|
|
181
396
|
run_id: beat.runId,
|
|
182
397
|
seq: beat.seq,
|
|
183
|
-
|
|
398
|
+
// '' for a run-scoped (workflow-turn) beat: the column is NOT NULL.
|
|
399
|
+
step_id: beat.stepId ?? '',
|
|
184
400
|
grp: beat.group,
|
|
185
401
|
namespace: this.#namespaceValue(),
|
|
186
402
|
claimed_by: null,
|
|
@@ -188,56 +404,279 @@ export class DbTransport {
|
|
|
188
404
|
created_at: this.#now(),
|
|
189
405
|
});
|
|
190
406
|
}
|
|
191
|
-
|
|
407
|
+
/**
|
|
408
|
+
* One task-loop round: claim as many rows as there are free slots (never more — a row claimed but
|
|
409
|
+
* not running is a row an idle sibling can't run, and one whose lease nobody is using), hand each
|
|
410
|
+
* to {@link #runTask}, and report work so the loop re-ticks at once. With every slot busy it waits
|
|
411
|
+
* for one to free (or the adaptive limit to move) instead of polling.
|
|
412
|
+
*/
|
|
413
|
+
async #taskTick() {
|
|
192
414
|
if (this.#servedTokens.size === 0)
|
|
193
|
-
return
|
|
194
|
-
const
|
|
195
|
-
|
|
415
|
+
return false;
|
|
416
|
+
const free = this.#controller.limit - this.#runningTasks.size;
|
|
417
|
+
if (free <= 0) {
|
|
418
|
+
await this.#slotFreed();
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
const [countReclaims, byPriority] = await Promise.all([
|
|
422
|
+
this.#hasTaskColumn('reclaims'),
|
|
423
|
+
this.#hasTaskColumn('priority'),
|
|
196
424
|
]);
|
|
425
|
+
const { rows, token } = await this.#claim(TRANSPORT_TABLES.tasks, Math.min(free, this.#batchSize), [...this.#servedTokens], 'step_id', { countReclaims, byPriority });
|
|
426
|
+
if (rows.length === 0)
|
|
427
|
+
return false;
|
|
428
|
+
if (this.#pollers.closed) {
|
|
429
|
+
// close() began while this round was claiming: don't start work it would only abandon.
|
|
430
|
+
await this.#handBack(TRANSPORT_TABLES.tasks, [token]);
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
// `free` was read before the claim's round trips. The adaptive limit may have dropped meanwhile
|
|
434
|
+
// (the RAM brake fires on its own timer), and a round sized for the old limit would run past the
|
|
435
|
+
// new one. Un-claim what no longer fits, so the slot loop never runs more than the LIVE limit.
|
|
436
|
+
// (A lapsed row handed back this way keeps the reclaim its takeover counted: rare — a limit drop
|
|
437
|
+
// inside one claim round — and only ever early toward the poison bound, never past it unnoticed.)
|
|
438
|
+
const fits = Math.max(0, this.#controller.limit - this.#runningTasks.size);
|
|
439
|
+
if (rows.length > fits) {
|
|
440
|
+
const excess = rows.splice(fits);
|
|
441
|
+
await this.#client()
|
|
442
|
+
.knexQuery()
|
|
443
|
+
.from(TRANSPORT_TABLES.tasks)
|
|
444
|
+
.where('claimed_by', token)
|
|
445
|
+
.whereIn('step_id', excess.map((row) => row.step_id))
|
|
446
|
+
.update({ claimed_by: null, claimed_at: null });
|
|
447
|
+
if (rows.length === 0)
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
this.#hold(TRANSPORT_TABLES.tasks, token, rows.map((row) => row.step_id));
|
|
197
451
|
for (const row of rows) {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
452
|
+
const run = this.#runTask(row, token);
|
|
453
|
+
this.#runningTasks.add(run);
|
|
454
|
+
void run.finally(() => this.#runningTasks.delete(run));
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
/** Resolve when a running task settles or the adaptive controller moves the limit. */
|
|
459
|
+
async #slotFreed() {
|
|
460
|
+
let wake = () => { };
|
|
461
|
+
const moved = new Promise((resolve) => {
|
|
462
|
+
wake = resolve;
|
|
463
|
+
});
|
|
464
|
+
this.#limitWaiters.add(wake);
|
|
465
|
+
try {
|
|
466
|
+
await Promise.race([...this.#runningTasks, moved]);
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
this.#limitWaiters.delete(wake);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Run one claimed task row and settle it. A row whose claim lapsed more than
|
|
474
|
+
* {@link DbTransportOptions.maxStalledCount} times is bridged to a failed result instead of run.
|
|
475
|
+
* Otherwise the claim is re-checked first (a row is run only while this instance holds it), and the
|
|
476
|
+
* result is published only by the instance still holding the claim (see {@link #settleTask}).
|
|
477
|
+
* The claim is renewed for the whole run with every other held claim (see {@link #renewHeld}).
|
|
478
|
+
*/
|
|
479
|
+
async #runTask(row, token) {
|
|
480
|
+
if (row.kind === 'workflow')
|
|
481
|
+
return this.#runWorkflowRow(row, token);
|
|
482
|
+
const task = {
|
|
483
|
+
runId: row.run_id,
|
|
484
|
+
seq: Number(row.seq),
|
|
485
|
+
stepId: row.step_id,
|
|
486
|
+
name: row.name,
|
|
487
|
+
group: row.grp,
|
|
488
|
+
input: fromJson(row.input),
|
|
489
|
+
attempt: Number(row.attempt),
|
|
490
|
+
...(row.traceparent != null ? { traceparent: row.traceparent } : {}),
|
|
491
|
+
...(row.context != null ? { context: fromJson(row.context) } : {}),
|
|
492
|
+
...(row.transport != null ? { transport: row.transport } : {}),
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
if (row.reclaims != null && Number(row.reclaims) > this.#maxStalledCount) {
|
|
496
|
+
// Its workers keep dying on it (or blocking past the lease): running it again would just
|
|
497
|
+
// stall the next one. Fail it RETRYABLE so the engine's durable retry decides.
|
|
498
|
+
this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
|
|
499
|
+
await this.#settleTask(task.stepId, token, infrastructureFailure(task, `claim lapsed more than ${this.#maxStalledCount} times`));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!(await this.#stillHolds(task.stepId, token))) {
|
|
503
|
+
this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
|
|
504
|
+
this.#onError(new Error(`[DbTransport] lost the claim on task ${task.stepId} before running it — another worker took it over; not running it here`));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const startedAt = Date.now();
|
|
508
|
+
this.#controller.onStart();
|
|
509
|
+
let ok = false;
|
|
228
510
|
try {
|
|
229
511
|
const result = await runStepHandler(task, this.#handlers.get(task.name), (beat) => this.heartbeat(beat));
|
|
230
|
-
|
|
512
|
+
ok = result.status === 'completed';
|
|
513
|
+
// Stop renewing before settling: the settle deletes the row, and a renewal racing it would
|
|
514
|
+
// count the row as lost.
|
|
515
|
+
this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
|
|
516
|
+
if (!(await this.#settleTask(task.stepId, token, result))) {
|
|
517
|
+
if (this.#handedBack.has(token)) {
|
|
518
|
+
this.#onError(new Error(`[DbTransport] task ${task.stepId} outlived close()'s ${this.#closeTimeoutMs}ms drain and was handed back, so another worker may have run it too; this run's result was dropped`));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
this.#onError(new Error(`[DbTransport] lost the claim on task ${task.stepId} while running it (this worker went silent past leaseMs) — another worker took it over and may run it too; this run's result was dropped. Raise leaseMs if steps block the event loop`));
|
|
522
|
+
}
|
|
231
523
|
}
|
|
232
524
|
finally {
|
|
233
|
-
|
|
525
|
+
this.#controller.onSettle(Date.now() - startedAt, ok, 'step');
|
|
234
526
|
}
|
|
235
|
-
await this.#client().from(TRANSPORT_TABLES.tasks).where('step_id', task.stepId).delete();
|
|
236
527
|
}
|
|
237
|
-
|
|
528
|
+
catch (err) {
|
|
529
|
+
// A database error settling the row: it stays claimed, unrenewed, and is reclaimed after the
|
|
530
|
+
// lease — at-least-once, like a crash.
|
|
531
|
+
this.#onError(err);
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Run one claimed `workflow` task row: replay the turn and settle it — delete the row and write its
|
|
539
|
+
* {@link WorkflowDecision}, addressed to the engine that dispatched it, in ONE transaction and only
|
|
540
|
+
* while this round still holds the claim (as a step's result). An unknown workflow or a throwing turn
|
|
541
|
+
* still yields a `failed` decision so the run settles. A row past the poison bound is deleted without
|
|
542
|
+
* a decision, as on the other transports: a turn carries no step to fail, and the turn's liveness
|
|
543
|
+
* window (`remoteAdvanceSilenceMs` / the executor's `timeoutMs`) re-drives it.
|
|
544
|
+
*/
|
|
545
|
+
async #runWorkflowRow(row, token) {
|
|
546
|
+
const id = row.step_id;
|
|
547
|
+
try {
|
|
548
|
+
let task;
|
|
549
|
+
let replyTo;
|
|
550
|
+
try {
|
|
551
|
+
const payload = fromJson(row.input);
|
|
552
|
+
if (!payload)
|
|
553
|
+
throw new Error('empty payload');
|
|
554
|
+
({ replyTo, ...task } = payload);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
this.#release(TRANSPORT_TABLES.tasks, token, id);
|
|
558
|
+
await this.#deleteClaimed(TRANSPORT_TABLES.tasks, 'step_id', id, token);
|
|
559
|
+
this.#onError(new Error(`[DbTransport] dropped workflow task ${id}: unreadable payload (${String(err)})`));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (row.reclaims != null && Number(row.reclaims) > this.#maxStalledCount) {
|
|
563
|
+
this.#release(TRANSPORT_TABLES.tasks, token, id);
|
|
564
|
+
await this.#deleteClaimed(TRANSPORT_TABLES.tasks, 'step_id', id, token);
|
|
565
|
+
this.#onError(new Error(`[DbTransport] dropped workflow task ${id} (run ${task.runId}): its claim lapsed more than ${this.#maxStalledCount} times. No decision is written: as on bullmq and queue, the turn is re-driven by the executor's timeoutMs or by recovery`));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!(await this.#stillHolds(id, token))) {
|
|
569
|
+
this.#release(TRANSPORT_TABLES.tasks, token, id);
|
|
570
|
+
this.#onError(new Error(`[DbTransport] lost the claim on workflow task ${id} before running it — another worker took it over; not running it here`));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const startedAt = Date.now();
|
|
574
|
+
this.#controller.onStart();
|
|
575
|
+
try {
|
|
576
|
+
const decision = await this.#replayTurn(task);
|
|
577
|
+
this.#release(TRANSPORT_TABLES.tasks, token, id);
|
|
578
|
+
if (!(await this.#settleWorkflow(id, token, decision, replyTo))) {
|
|
579
|
+
this.#onError(new Error(this.#handedBack.has(token)
|
|
580
|
+
? `[DbTransport] workflow task ${id} outlived close()'s ${this.#closeTimeoutMs}ms drain and was handed back; this turn's decision was dropped`
|
|
581
|
+
: `[DbTransport] lost the claim on workflow task ${id} while running it — another worker took it over; this turn's decision was dropped`));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
// Only step durations feed the latency gradient; a turn still counts as in-flight work.
|
|
586
|
+
this.#controller.onSettle(Date.now() - startedAt, true, 'workflow');
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
this.#onError(err);
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
this.#release(TRANSPORT_TABLES.tasks, token, id);
|
|
594
|
+
}
|
|
238
595
|
}
|
|
239
|
-
|
|
596
|
+
/** Replay one turn. Never throws: an unknown workflow or a throwing turn is a `failed` decision. */
|
|
597
|
+
async #replayTurn(task) {
|
|
598
|
+
const turn = this.#workflowTurns.get(task.workflow);
|
|
599
|
+
if (!turn) {
|
|
600
|
+
return {
|
|
601
|
+
taskId: task.taskId,
|
|
602
|
+
runId: task.runId,
|
|
603
|
+
status: 'failed',
|
|
604
|
+
commands: [],
|
|
605
|
+
error: { message: `no workflow registered for '${task.workflow}'`, code: 'no_workflow' },
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
return await withTurnHeartbeat(task, (beat) => this.heartbeat(beat), () => turn(task));
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
this.#onError(err);
|
|
613
|
+
return {
|
|
614
|
+
taskId: task.taskId,
|
|
615
|
+
runId: task.runId,
|
|
616
|
+
status: 'failed',
|
|
617
|
+
commands: [],
|
|
618
|
+
error: { message: err instanceof Error ? err.message : String(err) },
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** Delete the workflow row and write its decision in ONE transaction, only while `token` holds it. */
|
|
623
|
+
async #settleWorkflow(id, token, decision, replyTo) {
|
|
624
|
+
const settled = await this.#client().transaction(async (trx) => {
|
|
625
|
+
const deleted = await trx
|
|
626
|
+
.knexQuery()
|
|
627
|
+
.from(TRANSPORT_TABLES.tasks)
|
|
628
|
+
.where('step_id', id)
|
|
629
|
+
.where('claimed_by', token)
|
|
630
|
+
.delete();
|
|
631
|
+
if (Number(deleted) === 0)
|
|
632
|
+
return false;
|
|
633
|
+
await this.#insertMessages(MESSAGE_KINDS.decision, [{ recipient: replyTo ?? '', payload: decision }], trx);
|
|
634
|
+
return true;
|
|
635
|
+
});
|
|
636
|
+
if (settled && replyTo)
|
|
637
|
+
this.#noteAddressed();
|
|
638
|
+
return settled;
|
|
639
|
+
}
|
|
640
|
+
/** Delete one row of `table` while `token` still holds its claim. */
|
|
641
|
+
async #deleteClaimed(table, idCol, id, token) {
|
|
240
642
|
await this.#client()
|
|
643
|
+
.knexQuery()
|
|
644
|
+
.from(table)
|
|
645
|
+
.where(idCol, id)
|
|
646
|
+
.where('claimed_by', token)
|
|
647
|
+
.delete();
|
|
648
|
+
}
|
|
649
|
+
/** Re-stamp the row's claim if this round still holds it; `false` once another instance took it. */
|
|
650
|
+
async #stillHolds(stepId, token) {
|
|
651
|
+
const updated = await this.#client()
|
|
652
|
+
.knexQuery()
|
|
653
|
+
.from(TRANSPORT_TABLES.tasks)
|
|
654
|
+
.where('step_id', stepId)
|
|
655
|
+
.where('claimed_by', token)
|
|
656
|
+
.update({ claimed_at: this.#now() });
|
|
657
|
+
return Number(updated) > 0;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Delete the task row and publish its result in ONE transaction, and only while `token` still holds
|
|
661
|
+
* the claim: an instance whose claim was taken over neither deletes the new holder's row nor writes
|
|
662
|
+
* a second result. Resolves whether this instance settled it.
|
|
663
|
+
*/
|
|
664
|
+
async #settleTask(stepId, token, result) {
|
|
665
|
+
return this.#client().transaction(async (trx) => {
|
|
666
|
+
const deleted = await trx
|
|
667
|
+
.knexQuery()
|
|
668
|
+
.from(TRANSPORT_TABLES.tasks)
|
|
669
|
+
.where('step_id', stepId)
|
|
670
|
+
.where('claimed_by', token)
|
|
671
|
+
.delete();
|
|
672
|
+
if (Number(deleted) === 0)
|
|
673
|
+
return false;
|
|
674
|
+
await this.#insertResult(result, trx);
|
|
675
|
+
return true;
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
async #insertResult(result, client = this.#client()) {
|
|
679
|
+
await client
|
|
241
680
|
.insertQuery()
|
|
242
681
|
.table(TRANSPORT_TABLES.results)
|
|
243
682
|
.knexQuery.insert({
|
|
@@ -260,57 +699,63 @@ export class DbTransport {
|
|
|
260
699
|
// ───────────────────────────────────────────────────────────────────────────
|
|
261
700
|
// worker → engine — the engine polls results + heartbeats
|
|
262
701
|
// ───────────────────────────────────────────────────────────────────────────
|
|
702
|
+
/** Engine side: settle step results, up to {@link DbTransportOptions.resultConcurrency} at once. The
|
|
703
|
+
* results loop never backs off when idle — a result is on the critical path of every step. */
|
|
263
704
|
onResult(handler) {
|
|
264
|
-
this.#
|
|
705
|
+
this.#startLoop(async () => (await this.#drain(TRANSPORT_TABLES.results, 'step_id', this.#resultConcurrency, async (row) => {
|
|
706
|
+
await handler({
|
|
707
|
+
runId: row.run_id,
|
|
708
|
+
seq: Number(row.seq),
|
|
709
|
+
stepId: row.step_id,
|
|
710
|
+
status: row.status,
|
|
711
|
+
output: fromJson(row.output),
|
|
712
|
+
error: fromJson(row.error),
|
|
713
|
+
startedAt: row.started_at == null ? undefined : Number(row.started_at),
|
|
714
|
+
events: fromJson(row.events),
|
|
715
|
+
});
|
|
716
|
+
})) > 0, 1);
|
|
265
717
|
}
|
|
266
718
|
onHeartbeat(handler) {
|
|
267
|
-
this.#
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
for (const row of rows) {
|
|
276
|
-
const result = {
|
|
277
|
-
runId: row.run_id,
|
|
278
|
-
seq: Number(row.seq),
|
|
279
|
-
stepId: row.step_id,
|
|
280
|
-
status: row.status,
|
|
281
|
-
output: fromJson(row.output),
|
|
282
|
-
error: fromJson(row.error),
|
|
283
|
-
startedAt: row.started_at == null ? undefined : Number(row.started_at),
|
|
284
|
-
events: fromJson(row.events),
|
|
285
|
-
};
|
|
286
|
-
await handler(result);
|
|
287
|
-
done.push(result.stepId);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
finally {
|
|
291
|
-
if (done.length) {
|
|
292
|
-
await this.#client().from(TRANSPORT_TABLES.results).whereIn('step_id', done).delete();
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return rows.length;
|
|
719
|
+
this.#startLoop(async () => (await this.#drain(TRANSPORT_TABLES.heartbeats, 'id', 1, async (row) => {
|
|
720
|
+
await handler({
|
|
721
|
+
runId: row.run_id,
|
|
722
|
+
seq: Number(row.seq),
|
|
723
|
+
...(row.step_id ? { stepId: row.step_id } : {}),
|
|
724
|
+
group: row.grp,
|
|
725
|
+
});
|
|
726
|
+
})) > 0, this.#idleBackoffMax);
|
|
296
727
|
}
|
|
297
|
-
|
|
298
|
-
|
|
728
|
+
/**
|
|
729
|
+
* Claim a batch of `table` rows, hand each to `each` (up to `concurrency` at once), then delete the
|
|
730
|
+
* handled rows in one `WHERE IN` — only AFTER their handler ran (at-least-once), and only while this
|
|
731
|
+
* round still holds their claim. The batch's claims are renewed until it is done, so a slow handler
|
|
732
|
+
* never lets the rows behind it age into another instance's reclaim. A throwing `each` stops the
|
|
733
|
+
* batch: the rows not handled stay claimed, unrenewed, and are re-delivered after the lease.
|
|
734
|
+
*/
|
|
735
|
+
async #drain(table, idCol, concurrency, each, scope) {
|
|
736
|
+
const { rows, token } = await this.#claim(table, this.#batchSize, undefined, idCol, {
|
|
737
|
+
scope,
|
|
738
|
+
});
|
|
739
|
+
if (rows.length === 0)
|
|
740
|
+
return 0;
|
|
741
|
+
const idOf = (row) => row[idCol];
|
|
742
|
+
this.#hold(table, token, rows.map(idOf));
|
|
299
743
|
const done = [];
|
|
300
744
|
try {
|
|
301
|
-
|
|
302
|
-
await
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
stepId: row.step_id,
|
|
306
|
-
group: row.grp,
|
|
307
|
-
});
|
|
308
|
-
done.push(row.id);
|
|
309
|
-
}
|
|
745
|
+
await eachLimited(rows, concurrency, async (row) => {
|
|
746
|
+
await each(row);
|
|
747
|
+
done.push(idOf(row));
|
|
748
|
+
});
|
|
310
749
|
}
|
|
311
750
|
finally {
|
|
751
|
+
this.#releaseToken(table, token);
|
|
312
752
|
if (done.length) {
|
|
313
|
-
await this.#client()
|
|
753
|
+
await this.#client()
|
|
754
|
+
.knexQuery()
|
|
755
|
+
.from(table)
|
|
756
|
+
.whereIn(idCol, done)
|
|
757
|
+
.where('claimed_by', token)
|
|
758
|
+
.delete();
|
|
314
759
|
}
|
|
315
760
|
}
|
|
316
761
|
return rows.length;
|
|
@@ -332,45 +777,434 @@ export class DbTransport {
|
|
|
332
777
|
});
|
|
333
778
|
}
|
|
334
779
|
onControl(handler) {
|
|
335
|
-
this.#
|
|
780
|
+
this.#startLoop(async () => (await this.#drain(TRANSPORT_TABLES.control, 'id', 1, async (row) => {
|
|
781
|
+
const msg = fromJson(row.payload);
|
|
782
|
+
if (msg)
|
|
783
|
+
handler(msg);
|
|
784
|
+
})) > 0, this.#idleBackoffMax);
|
|
336
785
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
786
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
787
|
+
// remote workflows + the store-less (tenant pod) protocol — the messages table
|
|
788
|
+
//
|
|
789
|
+
// Workflow turns ride the task table (kind `workflow`, next to the steps of the same token). Every
|
|
790
|
+
// other message is a row of `durable_transport_messages`, claimed and deleted like the other
|
|
791
|
+
// channels and told apart by `kind`. A row is either for ANY consumer of its kind (`recipient = ''`:
|
|
792
|
+
// step events, start-run, run-request) or addressed to ONE instance (`recipient = <instanceId>`: the
|
|
793
|
+
// reply to the requester, a tenant event to each live subscriber, a decision to the engine that
|
|
794
|
+
// dispatched the turn). See `db-remote.ts` for the subscriber registry and the janitor.
|
|
795
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
796
|
+
/**
|
|
797
|
+
* Whether the messages table exists — `autoCreate` creates it; a deployment that runs its own
|
|
798
|
+
* migrations needs `create_durable_transport_messages`. Resolved once. With `report`, its absence is
|
|
799
|
+
* said ONCE through `onError` (the consumer loops ask silently, so a deployment that never uses the
|
|
800
|
+
* protocol hears nothing).
|
|
801
|
+
*/
|
|
802
|
+
async #messagesReady(report) {
|
|
803
|
+
let known = this.#messagesTable;
|
|
804
|
+
if (!known) {
|
|
805
|
+
known = this.#ensureSchema()
|
|
806
|
+
.then(() => this.#client().schema.hasTable(TRANSPORT_TABLES.messages))
|
|
807
|
+
.catch((err) => {
|
|
808
|
+
this.#messagesTable = undefined; // unknown yet (a transient error): ask again next time
|
|
809
|
+
this.#onError(err);
|
|
810
|
+
return false;
|
|
811
|
+
});
|
|
812
|
+
this.#messagesTable = known;
|
|
813
|
+
}
|
|
814
|
+
const has = await known;
|
|
815
|
+
if (!has && report && !this.#messagesTableReported) {
|
|
816
|
+
this.#messagesTableReported = true;
|
|
817
|
+
this.#onError(new Error(this.#messagesMissing()));
|
|
818
|
+
}
|
|
819
|
+
return has;
|
|
820
|
+
}
|
|
821
|
+
#messagesMissing() {
|
|
822
|
+
return `[DbTransport] the ${TRANSPORT_TABLES.messages} table does not exist, so remote workflows and store-less tenant pods do not work on this transport. Run the create_durable_transport_messages migration (node ace configure @adonis-agora/durable publishes it), or enable autoCreate`;
|
|
823
|
+
}
|
|
824
|
+
/** Insert `rows` of `kind` in one statement. */
|
|
825
|
+
async #insertMessages(kind, rows, client = this.#client()) {
|
|
826
|
+
if (rows.length === 0)
|
|
827
|
+
return;
|
|
828
|
+
const at = this.#now();
|
|
829
|
+
await client
|
|
830
|
+
.insertQuery()
|
|
831
|
+
.table(TRANSPORT_TABLES.messages)
|
|
832
|
+
.knexQuery.insert(rows.map((row) => ({
|
|
833
|
+
kind,
|
|
834
|
+
namespace: this.#namespaceValue(),
|
|
835
|
+
recipient: row.recipient ?? '',
|
|
836
|
+
reply_to: row.replyTo ?? null,
|
|
837
|
+
payload: JSON.stringify(row.payload),
|
|
838
|
+
claimed_by: null,
|
|
839
|
+
claimed_at: null,
|
|
840
|
+
created_at: at,
|
|
841
|
+
})));
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Start the consumer loop of `kind` (once). `recipients` is re-read every poll. A loop over rows
|
|
845
|
+
* anyone may take is `shared`: parked while consumers are deferred, like every other consumer. One
|
|
846
|
+
* over rows addressed to this instance is not — nobody else could take them. A payload that does not
|
|
847
|
+
* parse is reported and dropped (never re-delivered forever).
|
|
848
|
+
*/
|
|
849
|
+
#startMessageLoop(kind, { shared, recipients, idleBackoffMax, each, }) {
|
|
850
|
+
if (this.#messageLoops.has(kind))
|
|
851
|
+
return;
|
|
852
|
+
this.#messageLoops.add(kind);
|
|
853
|
+
const tick = async () => {
|
|
854
|
+
if (!(await this.#messagesReady(false)))
|
|
855
|
+
return false;
|
|
856
|
+
const handled = await this.#drain(TRANSPORT_TABLES.messages, 'id', 1, async (row) => {
|
|
857
|
+
let payload;
|
|
858
|
+
try {
|
|
859
|
+
payload = JSON.parse(row.payload);
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
this.#onError(new Error(`[DbTransport] dropped ${kind} message ${row.id}: ${String(err)}`));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
await each(payload, row);
|
|
866
|
+
}, { kind, recipients: recipients() });
|
|
867
|
+
return handled > 0;
|
|
868
|
+
};
|
|
869
|
+
if (shared)
|
|
870
|
+
this.#startLoop(tick, idleBackoffMax);
|
|
871
|
+
else
|
|
872
|
+
this.#pollers.start(tick, { idleBackoffMax });
|
|
873
|
+
}
|
|
874
|
+
/** This instance consumes rows addressed to it: keep its inbox key live (and delete what is left
|
|
875
|
+
* addressed to it on close). */
|
|
876
|
+
#consumeInbox() {
|
|
877
|
+
void this.#inboxes.activateInbox();
|
|
878
|
+
}
|
|
879
|
+
/** This instance addressed a row to another one: run the janitor (once). */
|
|
880
|
+
#noteAddressed() {
|
|
881
|
+
if (this.#inboxSweepIntervalMs <= 0 || this.#sweepTimer)
|
|
882
|
+
return;
|
|
883
|
+
this.#sweepTimer = setInterval(() => {
|
|
884
|
+
if (this.#sweeping)
|
|
885
|
+
return;
|
|
886
|
+
const round = this.sweepInboxes()
|
|
887
|
+
.catch((err) => this.#onError(err))
|
|
888
|
+
.finally(() => {
|
|
889
|
+
if (this.#sweeping === round)
|
|
890
|
+
this.#sweeping = undefined;
|
|
891
|
+
});
|
|
892
|
+
this.#sweeping = round;
|
|
893
|
+
}, this.#inboxSweepIntervalMs);
|
|
894
|
+
this.#sweepTimer.unref?.();
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* One janitor round: delete the addressed rows older than the inbox TTL whose recipient has no live
|
|
898
|
+
* inbox key — a requester or subscriber that died, an engine that crashed awaiting a decision. Rows
|
|
899
|
+
* younger than the TTL are never touched, so an instance that is just starting (or whose key write is
|
|
900
|
+
* a refresh late) keeps its rows. Reads everything from the database, so it needs no memory of what
|
|
901
|
+
* was addressed — any instance that addressed a row runs it, and a restart forgets nothing.
|
|
902
|
+
*/
|
|
903
|
+
async sweepInboxes() {
|
|
904
|
+
if (!(await this.#messagesReady(false)))
|
|
905
|
+
return;
|
|
906
|
+
const cutoff = this.#now() - this.#inboxTtlMs;
|
|
907
|
+
const namespace = this.#namespaceValue();
|
|
908
|
+
const recipients = (await this.#client()
|
|
909
|
+
.knexQuery()
|
|
910
|
+
.from(TRANSPORT_TABLES.messages)
|
|
911
|
+
.distinct('recipient')
|
|
912
|
+
.where('namespace', namespace)
|
|
913
|
+
.whereNot('recipient', '')
|
|
914
|
+
.where('created_at', '<', cutoff)).map((r) => r.recipient);
|
|
915
|
+
if (recipients.length === 0)
|
|
916
|
+
return;
|
|
917
|
+
const alive = await this.#inboxes.liveInboxOwners();
|
|
918
|
+
const dead = recipients.filter((r) => !alive.has(r));
|
|
919
|
+
if (dead.length === 0)
|
|
920
|
+
return;
|
|
921
|
+
await this.#client()
|
|
922
|
+
.knexQuery()
|
|
923
|
+
.from(TRANSPORT_TABLES.messages)
|
|
924
|
+
.where('namespace', namespace)
|
|
925
|
+
.whereIn('recipient', dead)
|
|
926
|
+
.where('created_at', '<', cutoff)
|
|
927
|
+
.delete();
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* engine → workflow worker: a {@link WorkflowTask} as a task row of kind `workflow` on the workflow's
|
|
931
|
+
* routing token, where a {@link handleWorkflow} consumer claims it (with the token's steps, by
|
|
932
|
+
* priority). Stamped with this instance as the decision's recipient when it consumes decisions, so
|
|
933
|
+
* the decision comes back to the engine that dispatched the turn (any replica could apply it — the
|
|
934
|
+
* engine applies decisions durably by run id — but the dispatcher is the one known to be live).
|
|
935
|
+
* Idempotent per `taskId`. Throws when the schema lacks the tasks `kind` column or the messages table.
|
|
936
|
+
*/
|
|
937
|
+
async dispatchWorkflowTask(task) {
|
|
938
|
+
await this.#ensureSchema();
|
|
939
|
+
const [hasKind, hasMessages, withPriority] = await Promise.all([
|
|
940
|
+
this.#hasTaskColumn('kind'),
|
|
941
|
+
this.#messagesReady(true),
|
|
942
|
+
this.#hasTaskColumn('priority'),
|
|
943
|
+
]);
|
|
944
|
+
if (!hasMessages)
|
|
945
|
+
throw new Error(this.#messagesMissing());
|
|
946
|
+
if (!hasKind) {
|
|
947
|
+
throw new Error(`[DbTransport] ${TRANSPORT_TABLES.tasks} has no "kind" column, so remote workflow turns cannot be dispatched on it. Run the create_durable_transport_messages migration, or enable autoCreate`);
|
|
948
|
+
}
|
|
949
|
+
const payload = {
|
|
950
|
+
...task,
|
|
951
|
+
...(this.#decisionHandlers.size > 0 ? { replyTo: this.#instanceId } : {}),
|
|
952
|
+
};
|
|
953
|
+
await this.#client()
|
|
954
|
+
.insertQuery()
|
|
955
|
+
.table(TRANSPORT_TABLES.tasks)
|
|
956
|
+
.knexQuery.insert({
|
|
957
|
+
step_id: task.taskId,
|
|
958
|
+
run_id: task.runId,
|
|
959
|
+
seq: 0,
|
|
960
|
+
name: task.workflow,
|
|
961
|
+
grp: task.group,
|
|
962
|
+
namespace: this.#namespaceValue(),
|
|
963
|
+
input: toJson(payload),
|
|
964
|
+
traceparent: task.traceparent ?? null,
|
|
965
|
+
context: null,
|
|
966
|
+
transport: task.transport ?? null,
|
|
967
|
+
attempt: task.attempt,
|
|
968
|
+
kind: 'workflow',
|
|
969
|
+
...(withPriority ? { priority: toDbPriority(task.priority) } : {}),
|
|
970
|
+
claimed_by: null,
|
|
971
|
+
claimed_at: null,
|
|
972
|
+
created_at: this.#now(),
|
|
973
|
+
})
|
|
974
|
+
.onConflict('step_id')
|
|
975
|
+
.ignore();
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* engine ← workflow worker: consume {@link WorkflowDecision}s. ONE loop per transport; every handler
|
|
979
|
+
* sees every decision (the engine applies one by run id, dropping any its run no longer awaits). It claims the
|
|
980
|
+
* decisions addressed to this instance — consumed even while consumers are deferred, as nobody else
|
|
981
|
+
* may take them — and, once consuming, the unaddressed ones (a worker that did not know whom to
|
|
982
|
+
* reply to). Never backs off: a decision is on the critical path of every remote workflow turn.
|
|
983
|
+
*/
|
|
984
|
+
onDecision(handler) {
|
|
985
|
+
this.#decisionHandlers.add(handler);
|
|
986
|
+
if (this.#messageLoops.has(MESSAGE_KINDS.decision))
|
|
987
|
+
return;
|
|
988
|
+
this.#consumeInbox();
|
|
989
|
+
this.#startMessageLoop(MESSAGE_KINDS.decision, {
|
|
990
|
+
shared: false,
|
|
991
|
+
recipients: () => (this.#consumersDeferred ? [this.#instanceId] : [this.#instanceId, '']),
|
|
992
|
+
idleBackoffMax: 1,
|
|
993
|
+
each: async (decision) => {
|
|
994
|
+
// Every handler, concurrently; all settle before a failure redelivers the decision.
|
|
995
|
+
await fanOutDecision(this.#decisionHandlers, decision, this.#onError);
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/** workflow worker → engine: a local step's lifecycle, for ONE engine instance to checkpoint. A
|
|
1000
|
+
* deployment without the messages table drops it (reported once): it is observability only. */
|
|
1001
|
+
async dispatchStepEvent(event) {
|
|
1002
|
+
if (!(await this.#messagesReady(true)))
|
|
1003
|
+
return;
|
|
1004
|
+
await this.#insertMessages(MESSAGE_KINDS.stepEvent, [{ payload: event }]);
|
|
1005
|
+
}
|
|
1006
|
+
/** engine ← workflow worker: consume streamed step events. Idempotent — a second call is a no-op. */
|
|
1007
|
+
onStepEvent(handler) {
|
|
1008
|
+
this.#startMessageLoop(MESSAGE_KINDS.stepEvent, {
|
|
1009
|
+
shared: true,
|
|
1010
|
+
recipients: () => [''],
|
|
1011
|
+
idleBackoffMax: this.#idleBackoffMax,
|
|
1012
|
+
each: (event) => handler(event),
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
/** tenant → control plane: a {@link StartRunMessage} for any control plane, with this instance as
|
|
1016
|
+
* the reply's recipient. Throws when the messages table is missing. */
|
|
1017
|
+
async dispatchStartRun(msg) {
|
|
1018
|
+
this.#ensureRunRepliesLoop();
|
|
1019
|
+
if (!(await this.#messagesReady(true)))
|
|
1020
|
+
throw new Error(this.#messagesMissing());
|
|
1021
|
+
await this.#insertMessages(MESSAGE_KINDS.startRun, [
|
|
1022
|
+
{ replyTo: this.#instanceId, payload: msg },
|
|
1023
|
+
]);
|
|
1024
|
+
}
|
|
1025
|
+
/** control plane ← tenant: consume {@link StartRunMessage}s. Idempotent — a second call is a no-op. */
|
|
1026
|
+
onStartRun(handler) {
|
|
1027
|
+
this.#startMessageLoop(MESSAGE_KINDS.startRun, {
|
|
1028
|
+
shared: true,
|
|
1029
|
+
recipients: () => [''],
|
|
1030
|
+
idleBackoffMax: this.#idleBackoffMax,
|
|
1031
|
+
each: (msg, row) => this.#withReplyRoute(msg.runId, row.reply_to, () => handler(msg)),
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
/** tenant → control plane: a {@link RunRequest} for any control plane, with this instance as the
|
|
1035
|
+
* reply's recipient. Throws when the messages table is missing. */
|
|
1036
|
+
async dispatchRunRequest(msg) {
|
|
1037
|
+
this.#ensureRunRepliesLoop();
|
|
1038
|
+
if (!(await this.#messagesReady(true)))
|
|
1039
|
+
throw new Error(this.#messagesMissing());
|
|
1040
|
+
await this.#insertMessages(MESSAGE_KINDS.runRequest, [
|
|
1041
|
+
{ replyTo: this.#instanceId, payload: msg },
|
|
1042
|
+
]);
|
|
1043
|
+
}
|
|
1044
|
+
/** control plane ← tenant: consume {@link RunRequest}s. Idempotent — a second call is a no-op. */
|
|
1045
|
+
onRunRequest(handler) {
|
|
1046
|
+
this.#startMessageLoop(MESSAGE_KINDS.runRequest, {
|
|
1047
|
+
shared: true,
|
|
1048
|
+
recipients: () => [''],
|
|
1049
|
+
idleBackoffMax: this.#idleBackoffMax,
|
|
1050
|
+
each: (msg, row) => this.#withReplyRoute(msg.requestId, row.reply_to, () => handler(msg)),
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
/** Remember who asked for `correlationId` while `work` (the responder, which replies inside it) runs. */
|
|
1054
|
+
async #withReplyRoute(correlationId, replyTo, work) {
|
|
1055
|
+
if (correlationId === undefined || !replyTo)
|
|
1056
|
+
return work();
|
|
1057
|
+
this.#replyRoutes.set(correlationId, replyTo);
|
|
340
1058
|
try {
|
|
341
|
-
|
|
342
|
-
const msg = fromJson(row.payload);
|
|
343
|
-
if (msg)
|
|
344
|
-
handler(msg);
|
|
345
|
-
done.push(row.id);
|
|
346
|
-
}
|
|
1059
|
+
await work();
|
|
347
1060
|
}
|
|
348
1061
|
finally {
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
}
|
|
1062
|
+
if (this.#replyRoutes.get(correlationId) === replyTo)
|
|
1063
|
+
this.#replyRoutes.delete(correlationId);
|
|
352
1064
|
}
|
|
353
|
-
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* control plane → tenant: a correlated {@link RunReply}, addressed to the instance that sent the
|
|
1068
|
+
* request. Only the instance handling that request knows the requester, so this must run while it
|
|
1069
|
+
* is handled — which is what the responder does. A reply with no known requester is dropped and
|
|
1070
|
+
* reported, never broadcast.
|
|
1071
|
+
*/
|
|
1072
|
+
async publishRunReply(reply) {
|
|
1073
|
+
const owner = this.#replyRoutes.get(reply.requestId);
|
|
1074
|
+
if (owner === undefined) {
|
|
1075
|
+
this.#onError(new Error(`[DbTransport] dropped the run reply for "${reply.requestId}": no requester is known for it on this instance`));
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (!(await this.#messagesReady(true)))
|
|
1079
|
+
return;
|
|
1080
|
+
await this.#insertMessages(MESSAGE_KINDS.runReply, [{ recipient: owner, payload: reply }]);
|
|
1081
|
+
this.#noteAddressed();
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* tenant ← control plane: consume the replies addressed to this instance. Idempotent — a second call
|
|
1085
|
+
* is a no-op (one gateway per transport matches every reply by id). Consumed even while consumers are
|
|
1086
|
+
* deferred: nobody else may take them.
|
|
1087
|
+
*/
|
|
1088
|
+
onRunReply(handler) {
|
|
1089
|
+
if (this.#runReplyHandler)
|
|
1090
|
+
return;
|
|
1091
|
+
this.#runReplyHandler = handler;
|
|
1092
|
+
this.#ensureRunRepliesLoop();
|
|
1093
|
+
}
|
|
1094
|
+
/** Start consuming this instance's replies (once) — also on the first request, so a reply is never
|
|
1095
|
+
* left behind because the gateway subscribed late. Never backs off: a reply is on the critical path
|
|
1096
|
+
* of every proxied call. */
|
|
1097
|
+
#ensureRunRepliesLoop() {
|
|
1098
|
+
if (this.#messageLoops.has(MESSAGE_KINDS.runReply))
|
|
1099
|
+
return;
|
|
1100
|
+
this.#consumeInbox();
|
|
1101
|
+
this.#startMessageLoop(MESSAGE_KINDS.runReply, {
|
|
1102
|
+
shared: false,
|
|
1103
|
+
recipients: () => [this.#instanceId],
|
|
1104
|
+
idleBackoffMax: 1,
|
|
1105
|
+
each: async (reply) => {
|
|
1106
|
+
try {
|
|
1107
|
+
this.#runReplyHandler?.(reply);
|
|
1108
|
+
}
|
|
1109
|
+
catch (err) {
|
|
1110
|
+
this.#onError(err); // a throwing listener must not re-deliver the reply
|
|
1111
|
+
}
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* control plane → tenant: one row of the {@link TenantEvent} per instance with a live subscription to
|
|
1117
|
+
* `evt.tenant` (none: nothing is written), in one INSERT. Publishes are serialized, so each subscriber
|
|
1118
|
+
* receives one publisher's events in publish order.
|
|
1119
|
+
*/
|
|
1120
|
+
publishTenantEvent(evt) {
|
|
1121
|
+
const run = this.#tenantPublishChain.then(async () => {
|
|
1122
|
+
const owners = await this.#inboxes.subscribersOf(evt.tenant);
|
|
1123
|
+
if (owners.length === 0)
|
|
1124
|
+
return;
|
|
1125
|
+
if (!(await this.#messagesReady(true)))
|
|
1126
|
+
return;
|
|
1127
|
+
await this.#insertMessages(MESSAGE_KINDS.tenantEvent, owners.map((recipient) => ({ recipient, payload: evt })));
|
|
1128
|
+
this.#noteAddressed();
|
|
1129
|
+
});
|
|
1130
|
+
this.#tenantPublishChain = run.catch(() => undefined);
|
|
1131
|
+
return run;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* tenant ← control plane: subscribe to `tenant`'s events. Writes this instance's subscriber key
|
|
1135
|
+
* (kept live every third of {@link DbTransportOptions.inboxTtlMs}) and consumes the events addressed
|
|
1136
|
+
* to it — never deferred, nobody else may take them. Returns an unsubscribe fn that detaches only
|
|
1137
|
+
* this handler; the last one removes the subscription.
|
|
1138
|
+
*/
|
|
1139
|
+
onTenantEvent(tenant, handler) {
|
|
1140
|
+
let handlers = this.#tenantEventHandlers.get(tenant);
|
|
1141
|
+
if (!handlers) {
|
|
1142
|
+
handlers = new Set();
|
|
1143
|
+
this.#tenantEventHandlers.set(tenant, handlers);
|
|
1144
|
+
}
|
|
1145
|
+
handlers.add(handler);
|
|
1146
|
+
void this.#inboxes.subscribe(tenant);
|
|
1147
|
+
// Keeps running after the last unsubscribe, so events written before the key went away are
|
|
1148
|
+
// consumed rather than left behind.
|
|
1149
|
+
this.#startMessageLoop(MESSAGE_KINDS.tenantEvent, {
|
|
1150
|
+
shared: false,
|
|
1151
|
+
recipients: () => [this.#instanceId],
|
|
1152
|
+
idleBackoffMax: this.#idleBackoffMax,
|
|
1153
|
+
each: async (evt) => {
|
|
1154
|
+
for (const h of this.#tenantEventHandlers.get(evt.tenant) ?? []) {
|
|
1155
|
+
try {
|
|
1156
|
+
h(evt);
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
this.#onError(err); // a throwing listener must not re-deliver (and reorder) the event
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
});
|
|
1164
|
+
return () => {
|
|
1165
|
+
const set = this.#tenantEventHandlers.get(tenant);
|
|
1166
|
+
if (!set?.delete(handler) || set.size > 0)
|
|
1167
|
+
return;
|
|
1168
|
+
this.#tenantEventHandlers.delete(tenant);
|
|
1169
|
+
this.#inboxes.unsubscribe(tenant);
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/** On close: remove this instance's inbox and subscriber keys, then delete what is still addressed
|
|
1173
|
+
* to it — replies, events and decisions for a process going away have no one else to go to. */
|
|
1174
|
+
async #closeInboxes() {
|
|
1175
|
+
await this.#inboxes.close();
|
|
1176
|
+
const own = [MESSAGE_KINDS.decision, MESSAGE_KINDS.runReply, MESSAGE_KINDS.tenantEvent].filter((kind) => this.#messageLoops.has(kind));
|
|
1177
|
+
if (own.length === 0)
|
|
1178
|
+
return;
|
|
1179
|
+
await this.#client()
|
|
1180
|
+
.knexQuery()
|
|
1181
|
+
.from(TRANSPORT_TABLES.messages)
|
|
1182
|
+
.where('namespace', this.#namespaceValue())
|
|
1183
|
+
.where('recipient', this.#instanceId)
|
|
1184
|
+
.whereIn('kind', own)
|
|
1185
|
+
.delete()
|
|
1186
|
+
.catch((err) => this.#onError(err));
|
|
354
1187
|
}
|
|
355
1188
|
// ───────────────────────────────────────────────────────────────────────────
|
|
356
1189
|
// atomic claim — portable compare-and-set (no FOR UPDATE SKIP LOCKED)
|
|
357
1190
|
// ───────────────────────────────────────────────────────────────────────────
|
|
358
1191
|
/**
|
|
359
|
-
* Atomically claim up to `
|
|
360
|
-
* with this instance's id + a fresh per-round claim timestamp, then SELECT exactly the
|
|
361
|
-
* round stamped. The
|
|
362
|
-
* is the concurrency guard: two instances racing the same rows can't
|
|
363
|
-
* UPDATE matches nothing for those rows). A crashed claimer's rows are
|
|
364
|
-
* expires (`claimed_at < now - leaseMs`)
|
|
1192
|
+
* Atomically claim up to `limit` un-leased rows (optionally narrowed to the routing tokens `groups`)
|
|
1193
|
+
* and stamp them with this instance's id + a fresh per-round claim timestamp, then SELECT exactly the
|
|
1194
|
+
* rows this round stamped. The conditional `UPDATE … WHERE claimed_at IS NULL` /
|
|
1195
|
+
* `… WHERE claimed_at < stale` is the concurrency guard: two instances racing the same rows can't
|
|
1196
|
+
* both stamp them (the loser's UPDATE matches nothing for those rows). A crashed claimer's rows are
|
|
1197
|
+
* reclaimed once their lease expires (`claimed_at < now - leaseMs`) — and with `countReclaims`, each
|
|
1198
|
+
* such takeover bumps the row's `reclaims` count (the poison-task bound).
|
|
365
1199
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
1200
|
+
* Fresh and stale candidates are stamped by separate UPDATEs so the increment never depends on how a
|
|
1201
|
+
* dialect orders the `SET` list (MySQL evaluates it left to right against the NEW values).
|
|
368
1202
|
*/
|
|
369
|
-
async #claim(table, groups, idCol = 'step_id') {
|
|
1203
|
+
async #claim(table, limit, groups, idCol = 'step_id', { countReclaims = false, byPriority = false, scope, } = {}) {
|
|
370
1204
|
await this.#ensureSchema();
|
|
371
1205
|
// The raw Knex builder — `onConflict`, the `where(cb)` grouping and `whereIn` are cleaner there
|
|
372
1206
|
// than through Lucid's stricter query-builder typings, and we only need plain SQL here.
|
|
373
|
-
const
|
|
1207
|
+
const client = this.#client();
|
|
374
1208
|
const stale = this.#now() - this.#leaseMs;
|
|
375
1209
|
// Every claim is namespace-scoped: this instance only ever sees rows stamped with its own
|
|
376
1210
|
// namespace, so two engines/workers on different namespaces sharing one table set never
|
|
@@ -381,32 +1215,61 @@ export class DbTransport {
|
|
|
381
1215
|
// the same instance).
|
|
382
1216
|
const token = `${this.#instanceId}:${process.hrtime.bigint().toString(36)}`;
|
|
383
1217
|
const at = this.#now();
|
|
384
|
-
// 1) Pick candidate ids (unclaimed or stale-lease)
|
|
385
|
-
|
|
1218
|
+
// 1) Pick candidate ids (unclaimed or stale-lease) — highest priority first when the table has
|
|
1219
|
+
// one, then oldest — bounded by `limit`.
|
|
1220
|
+
const candidates = (await client
|
|
1221
|
+
.knexQuery()
|
|
386
1222
|
.from(table)
|
|
387
|
-
.select(idCol)
|
|
1223
|
+
.select(idCol, 'claimed_at')
|
|
388
1224
|
.where('namespace', namespace)
|
|
389
1225
|
.modify((q) => {
|
|
390
1226
|
if (groups !== undefined)
|
|
391
1227
|
q.whereIn('grp', groups);
|
|
1228
|
+
if (scope !== undefined)
|
|
1229
|
+
q.where('kind', scope.kind).whereIn('recipient', scope.recipients);
|
|
392
1230
|
})
|
|
393
1231
|
.where((q) => q.whereNull('claimed_at').orWhere('claimed_at', '<', stale))
|
|
1232
|
+
.modify((q) => {
|
|
1233
|
+
if (byPriority)
|
|
1234
|
+
q.orderBy('priority', 'desc');
|
|
1235
|
+
})
|
|
394
1236
|
.orderBy(idCol === 'id' ? 'id' : 'created_at', 'asc')
|
|
395
|
-
.limit(
|
|
1237
|
+
.limit(limit));
|
|
396
1238
|
if (candidates.length === 0)
|
|
397
1239
|
return { rows: [], token };
|
|
398
|
-
const
|
|
1240
|
+
const fresh = [];
|
|
1241
|
+
const lapsed = [];
|
|
1242
|
+
for (const c of candidates) {
|
|
1243
|
+
(c.claimed_at == null ? fresh : lapsed).push(c[idCol]);
|
|
1244
|
+
}
|
|
399
1245
|
// 2) Conditionally claim them — only rows STILL un-leased flip to this round's token. A racing
|
|
400
1246
|
// instance's UPDATE matches nothing for the rows it lost, so no row is claimed twice.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1247
|
+
if (fresh.length) {
|
|
1248
|
+
await client
|
|
1249
|
+
.knexQuery()
|
|
1250
|
+
.from(table)
|
|
1251
|
+
.where('namespace', namespace)
|
|
1252
|
+
.whereIn(idCol, fresh)
|
|
1253
|
+
.whereNull('claimed_at')
|
|
1254
|
+
.update({ claimed_by: token, claimed_at: at });
|
|
1255
|
+
}
|
|
1256
|
+
if (lapsed.length) {
|
|
1257
|
+
await client
|
|
1258
|
+
.knexQuery()
|
|
1259
|
+
.from(table)
|
|
1260
|
+
.where('namespace', namespace)
|
|
1261
|
+
.whereIn(idCol, lapsed)
|
|
1262
|
+
.where('claimed_at', '<', stale)
|
|
1263
|
+
.update({
|
|
1264
|
+
claimed_by: token,
|
|
1265
|
+
claimed_at: at,
|
|
1266
|
+
...(countReclaims
|
|
1267
|
+
? { reclaims: client.getWriteClient().raw('?? + 1', ['reclaims']) }
|
|
1268
|
+
: {}),
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
408
1271
|
// 3) SELECT back exactly the rows this round won.
|
|
409
|
-
const rows = (await
|
|
1272
|
+
const rows = (await client
|
|
410
1273
|
.knexQuery()
|
|
411
1274
|
.from(table)
|
|
412
1275
|
.select('*')
|
|
@@ -414,15 +1277,238 @@ export class DbTransport {
|
|
|
414
1277
|
.where('claimed_at', at));
|
|
415
1278
|
return { rows, token };
|
|
416
1279
|
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Whether the tasks table has `column` — `autoCreate` adds both, but a table from an older migration
|
|
1282
|
+
* run with `autoCreate: false` may lack them. Without `reclaims` the poison bound is off; without
|
|
1283
|
+
* `priority` dispatch priority is ignored and rows are claimed oldest first. Either is said once
|
|
1284
|
+
* through `onError`, and everything else behaves as before.
|
|
1285
|
+
*/
|
|
1286
|
+
#hasTaskColumn(column) {
|
|
1287
|
+
let known = this.#taskColumns.get(column);
|
|
1288
|
+
if (!known) {
|
|
1289
|
+
known = this.#ensureSchema()
|
|
1290
|
+
.then(() => this.#client().schema.hasColumn(TRANSPORT_TABLES.tasks, column))
|
|
1291
|
+
.then((has) => {
|
|
1292
|
+
if (!has) {
|
|
1293
|
+
this.#onError(new Error(`[DbTransport] ${TRANSPORT_TABLES.tasks} has no "${column}" column, so ${MISSING_COLUMN[column].effect}. Add it — ${MISSING_COLUMN[column].ddl} — or enable autoCreate`));
|
|
1294
|
+
}
|
|
1295
|
+
return has;
|
|
1296
|
+
})
|
|
1297
|
+
.catch((err) => {
|
|
1298
|
+
// Unknown yet (a transient error): go without it this time, ask again next time.
|
|
1299
|
+
this.#taskColumns.delete(column);
|
|
1300
|
+
this.#onError(err);
|
|
1301
|
+
return false;
|
|
1302
|
+
});
|
|
1303
|
+
this.#taskColumns.set(column, known);
|
|
1304
|
+
}
|
|
1305
|
+
return known;
|
|
1306
|
+
}
|
|
1307
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1308
|
+
// claim renewal — every claim held, not only the row being run
|
|
1309
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1310
|
+
/** Start renewing `ids` of `table`, claimed under `token`. */
|
|
1311
|
+
#hold(table, token, ids) {
|
|
1312
|
+
let byToken = this.#held.get(table);
|
|
1313
|
+
if (!byToken) {
|
|
1314
|
+
byToken = new Map();
|
|
1315
|
+
this.#held.set(table, byToken);
|
|
1316
|
+
}
|
|
1317
|
+
byToken.set(token, new Set(ids));
|
|
1318
|
+
if (this.#renewTimer)
|
|
1319
|
+
return;
|
|
1320
|
+
const intervalMs = Math.max(10, Math.floor(this.#leaseMs / 3));
|
|
1321
|
+
this.#renewTimer = setInterval(() => {
|
|
1322
|
+
if (this.#renewal)
|
|
1323
|
+
return;
|
|
1324
|
+
const round = this.#renewHeld()
|
|
1325
|
+
.catch((err) => this.#onError(err))
|
|
1326
|
+
.finally(() => {
|
|
1327
|
+
if (this.#renewal === round)
|
|
1328
|
+
this.#renewal = undefined;
|
|
1329
|
+
});
|
|
1330
|
+
this.#renewal = round;
|
|
1331
|
+
}, intervalMs);
|
|
1332
|
+
this.#renewTimer.unref?.();
|
|
1333
|
+
}
|
|
1334
|
+
/** Stop renewing one row (settled, or no longer ours). Idempotent. */
|
|
1335
|
+
#release(table, token, id) {
|
|
1336
|
+
const ids = this.#held.get(table)?.get(token);
|
|
1337
|
+
if (!ids)
|
|
1338
|
+
return;
|
|
1339
|
+
ids.delete(id);
|
|
1340
|
+
if (ids.size === 0)
|
|
1341
|
+
this.#releaseToken(table, token);
|
|
1342
|
+
}
|
|
1343
|
+
/** Stop renewing every row of a claim round. Stops the renewal timer once nothing is held. */
|
|
1344
|
+
#releaseToken(table, token) {
|
|
1345
|
+
const byToken = this.#held.get(table);
|
|
1346
|
+
byToken?.delete(token);
|
|
1347
|
+
if (byToken?.size === 0)
|
|
1348
|
+
this.#held.delete(table);
|
|
1349
|
+
if (this.#held.size === 0 && this.#renewTimer) {
|
|
1350
|
+
clearInterval(this.#renewTimer);
|
|
1351
|
+
this.#renewTimer = undefined;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Hand `tokens`' rows of `table` back: clear `claimed_by`/`claimed_at` where the claim is still ours,
|
|
1356
|
+
* so the next poll anywhere claims them as fresh rows — NOT as lapsed claims, so a hand-back never
|
|
1357
|
+
* counts toward the poison bound — and stop renewing them.
|
|
1358
|
+
*/
|
|
1359
|
+
async #handBack(table, tokens) {
|
|
1360
|
+
for (const token of tokens) {
|
|
1361
|
+
this.#handedBack.add(token);
|
|
1362
|
+
this.#releaseToken(table, token);
|
|
1363
|
+
}
|
|
1364
|
+
await this.#client()
|
|
1365
|
+
.knexQuery()
|
|
1366
|
+
.from(table)
|
|
1367
|
+
.whereIn('claimed_by', tokens)
|
|
1368
|
+
.update({ claimed_by: null, claimed_at: null });
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Re-stamp `claimed_at` on every row this instance holds — one UPDATE per table, matched on the
|
|
1372
|
+
* rounds' claim tokens, every `leaseMs / 3`. A slow but alive instance therefore keeps every row it
|
|
1373
|
+
* claimed (running or queued behind a slow one); only one that stopped renewing (crashed, lost the
|
|
1374
|
+
* database, or blocked its event loop past the lease) has its rows reclaimed. Fewer rows renewed
|
|
1375
|
+
* than still held means exactly that happened — reported, since the step may now run twice.
|
|
1376
|
+
*/
|
|
1377
|
+
async #renewHeld() {
|
|
1378
|
+
for (const [table, byToken] of [...this.#held]) {
|
|
1379
|
+
const tokens = [...byToken.keys()];
|
|
1380
|
+
if (tokens.length === 0)
|
|
1381
|
+
continue;
|
|
1382
|
+
const renewed = await this.#client()
|
|
1383
|
+
.knexQuery()
|
|
1384
|
+
.from(table)
|
|
1385
|
+
.whereIn('claimed_by', tokens)
|
|
1386
|
+
.update({ claimed_at: this.#now() });
|
|
1387
|
+
// Rows settled during the round-trip were released, not lost: count only what is still held.
|
|
1388
|
+
let stillHeld = 0;
|
|
1389
|
+
for (const token of tokens)
|
|
1390
|
+
stillHeld += this.#held.get(table)?.get(token)?.size ?? 0;
|
|
1391
|
+
if (Number(renewed) < stillHeld) {
|
|
1392
|
+
this.#onError(new Error(`[DbTransport] lost ${stillHeld - Number(renewed)} of ${stillHeld} claim(s) on ${table} before renewing them — another instance reclaimed them and may process them too; raise leaseMs if this process blocks its event loop`));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1397
|
+
// worker liveness + health — `${P}-worker-heartbeat:<token>:<instanceId>` in the worker-key table
|
|
1398
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1399
|
+
/** The prefix the liveness keys are built from (`durable`, `durable-<namespace>`). */
|
|
1400
|
+
#keyPrefix() {
|
|
1401
|
+
return effectivePrefix(WORKER_KEY_PREFIX, this.#namespace);
|
|
1402
|
+
}
|
|
1403
|
+
/** The task loop serves `token`: run the concurrency controller and beat its liveness key. */
|
|
1404
|
+
#beginServing(token) {
|
|
1405
|
+
this.#controller.start();
|
|
1406
|
+
this.#startWorkerHeartbeat(token);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Refresh every served routing token's liveness key on one shared interval (default 10s, live 35s),
|
|
1410
|
+
* stamped with the controller's {@link WorkerStatus} snapshot — the same `{ ts, status }` value and
|
|
1411
|
+
* key the BullMQ and queue transports write, so the dashboard's worker cards read all three alike.
|
|
1412
|
+
* A new token beats at once. Best-effort: a failed write goes to `onError` and the key expires.
|
|
1413
|
+
*/
|
|
1414
|
+
#startWorkerHeartbeat(token) {
|
|
1415
|
+
const ttlMs = Math.max(WORKER_HEARTBEAT_TTL_SECONDS * 1000, this.#workerHeartbeatIntervalMs * 3);
|
|
1416
|
+
const beatOne = (t) => {
|
|
1417
|
+
// One write per token at a time: a beat due while the last one is still in flight is skipped.
|
|
1418
|
+
// Beats are not queued behind a slow store — every interval used to start a write regardless,
|
|
1419
|
+
// so a store slower than the interval piled up writes without bound, each holding a pool
|
|
1420
|
+
// connection the reads (groupHealth, the claim loop) then waited behind.
|
|
1421
|
+
if (this.#beatsInFlight.has(t))
|
|
1422
|
+
return;
|
|
1423
|
+
this.#beatsInFlight.add(t);
|
|
1424
|
+
const now = this.#now();
|
|
1425
|
+
const key = workerHeartbeatKey(this.#keyPrefix(), t, this.#instanceId);
|
|
1426
|
+
const value = heartbeatKeyValue(now, this.#controller.snapshot());
|
|
1427
|
+
const write = Promise.resolve()
|
|
1428
|
+
.then(() => this.#workerKeys().put(key, value, now + ttlMs))
|
|
1429
|
+
.catch((err) => this.#onError(err))
|
|
1430
|
+
.finally(() => this.#beatsInFlight.delete(t));
|
|
1431
|
+
this.#livenessWrites.add(write);
|
|
1432
|
+
void write.finally(() => this.#livenessWrites.delete(write));
|
|
1433
|
+
};
|
|
1434
|
+
if (!this.#heartbeatTokens.has(token)) {
|
|
1435
|
+
this.#heartbeatTokens.add(token);
|
|
1436
|
+
beatOne(token);
|
|
1437
|
+
}
|
|
1438
|
+
if (this.#workerHeartbeatTimer)
|
|
1439
|
+
return;
|
|
1440
|
+
this.#workerHeartbeatTimer = setInterval(() => {
|
|
1441
|
+
for (const t of this.#heartbeatTokens)
|
|
1442
|
+
beatOne(t);
|
|
1443
|
+
}, this.#workerHeartbeatIntervalMs);
|
|
1444
|
+
this.#workerHeartbeatTimer.unref?.();
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Per-group worker health, for `engine.workerHealth()` and the dashboard/Telescope worker panels.
|
|
1448
|
+
*
|
|
1449
|
+
* - `depth` is every task row of `group` in this namespace — pending AND claimed (running, or held
|
|
1450
|
+
* by a worker that died and not yet reclaimed): the DB can count both, so it is closer to BullMQ's
|
|
1451
|
+
* waiting+active than the queue transport's pending-only count.
|
|
1452
|
+
* - `liveWorkers` are the unexpired `${P}-worker-heartbeat:<group>:<instance>` keys in the
|
|
1453
|
+
* worker-key table, each with the worker's {@link WorkerStatus} when it carries one (this
|
|
1454
|
+
* transport's own beats do; a `WorkerRuntime`'s compact beat reads as liveness only).
|
|
1455
|
+
*/
|
|
1456
|
+
async groupHealth(group) {
|
|
1457
|
+
await this.#ensureSchema();
|
|
1458
|
+
const [counted, entries] = await Promise.all([
|
|
1459
|
+
this.#client()
|
|
1460
|
+
.knexQuery()
|
|
1461
|
+
.from(TRANSPORT_TABLES.tasks)
|
|
1462
|
+
.where('namespace', this.#namespaceValue())
|
|
1463
|
+
.where('grp', group)
|
|
1464
|
+
.count({ n: '*' })
|
|
1465
|
+
.first(),
|
|
1466
|
+
this.#liveWorkerKeys(),
|
|
1467
|
+
]);
|
|
1468
|
+
const marker = workerHeartbeatKey(this.#keyPrefix(), group, '');
|
|
1469
|
+
const liveWorkers = [];
|
|
1470
|
+
for (const { key, value } of entries) {
|
|
1471
|
+
if (!key.startsWith(marker))
|
|
1472
|
+
continue;
|
|
1473
|
+
const parsed = parseHeartbeatValue(value);
|
|
1474
|
+
liveWorkers.push({
|
|
1475
|
+
group,
|
|
1476
|
+
instanceId: key.slice(marker.length),
|
|
1477
|
+
lastBeatAt: parsed.lastBeatAt,
|
|
1478
|
+
...(parsed.status !== undefined
|
|
1479
|
+
? { status: parsed.status }
|
|
1480
|
+
: {}),
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
return { group, depth: Number(counted?.n) || 0, liveWorkers };
|
|
1484
|
+
}
|
|
417
1485
|
// ───────────────────────────────────────────────────────────────────────────
|
|
418
1486
|
// worker advertisement (design §7.2) — a key/value table with an expiry per key
|
|
419
1487
|
// ───────────────────────────────────────────────────────────────────────────
|
|
420
1488
|
#workerKeys() {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1489
|
+
if (!this.#workerKeyStore) {
|
|
1490
|
+
// The table is created with the others by #ensureSchema — waited for first, so a key written
|
|
1491
|
+
// while the schema is still being created never races its CREATE.
|
|
1492
|
+
const keys = lucidWorkerKeys(this.#db, {
|
|
1493
|
+
connectionName: this.#connectionName,
|
|
1494
|
+
autoCreate: false,
|
|
1495
|
+
now: () => this.#now(),
|
|
1496
|
+
});
|
|
1497
|
+
this.#workerKeyStore = {
|
|
1498
|
+
put: async (key, value, expiresAt) => {
|
|
1499
|
+
await this.#ensureSchema();
|
|
1500
|
+
await keys.put(key, value, expiresAt);
|
|
1501
|
+
},
|
|
1502
|
+
remove: async (removed) => {
|
|
1503
|
+
await this.#ensureSchema();
|
|
1504
|
+
await keys.remove(removed);
|
|
1505
|
+
},
|
|
1506
|
+
live: async (at) => {
|
|
1507
|
+
await this.#ensureSchema();
|
|
1508
|
+
return keys.live(at);
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
426
1512
|
return this.#workerKeyStore;
|
|
427
1513
|
}
|
|
428
1514
|
/** The writer a worker runtime's registry advertises its descriptor and heartbeat through. */
|
|
@@ -455,17 +1541,85 @@ export class DbTransport {
|
|
|
455
1541
|
this.#pollers.reopen();
|
|
456
1542
|
await this.#ensureSchema();
|
|
457
1543
|
}
|
|
458
|
-
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
1544
|
+
/**
|
|
1545
|
+
* Stop every poll loop and wait — bounded by {@link DbTransportOptions.closeTimeoutMs}, 5s by
|
|
1546
|
+
* default — for in-flight work: the tasks in a handler get to finish and write their result, and a
|
|
1547
|
+
* result batch being settled gets to finish. Whatever is still unfinished then is HANDED BACK: its
|
|
1548
|
+
* claims are cleared (only where still ours) so another worker picks the rows up at once, instead of
|
|
1549
|
+
* after the lease — and uncounted, so a deploy never pushes a long step toward the poison bound. A
|
|
1550
|
+
* handler still running here may finish after another worker re-ran its step; its result is
|
|
1551
|
+
* dropped. Does not close the shared `Database`.
|
|
1552
|
+
*/
|
|
461
1553
|
async stop() {
|
|
462
1554
|
this.#pollers.stopAll();
|
|
463
1555
|
this.#taskLoop = undefined;
|
|
464
|
-
|
|
1556
|
+
this.#controller.stop();
|
|
1557
|
+
if (this.#sweepTimer)
|
|
1558
|
+
clearInterval(this.#sweepTimer);
|
|
1559
|
+
this.#sweepTimer = undefined;
|
|
1560
|
+
if (this.#workerHeartbeatTimer)
|
|
1561
|
+
clearInterval(this.#workerHeartbeatTimer);
|
|
1562
|
+
// A clean stop withdraws this instance's liveness keys at once, so the dashboard and
|
|
1563
|
+
// `listWorkerGroups` drop it now instead of after the 35s TTL. In-flight beats land first, or one
|
|
1564
|
+
// could re-create a key after its removal.
|
|
1565
|
+
if (this.#heartbeatTokens.size > 0) {
|
|
1566
|
+
await Promise.allSettled([...this.#livenessWrites]);
|
|
1567
|
+
const keys = [...this.#heartbeatTokens].map((t) => workerHeartbeatKey(this.#keyPrefix(), t, this.#instanceId));
|
|
1568
|
+
this.#heartbeatTokens.clear();
|
|
1569
|
+
await Promise.resolve()
|
|
1570
|
+
.then(() => this.#workerKeys().remove(keys))
|
|
1571
|
+
.catch((err) => this.#onError(err));
|
|
1572
|
+
}
|
|
1573
|
+
this.#workerHeartbeatTimer = undefined;
|
|
1574
|
+
this.#heartbeatTokens.clear();
|
|
1575
|
+
const deadline = Date.now() + this.#closeTimeoutMs;
|
|
1576
|
+
await this.#pollers.drain(this.#closeTimeoutMs);
|
|
1577
|
+
if (this.#runningTasks.size > 0) {
|
|
1578
|
+
let timer;
|
|
1579
|
+
await Promise.race([
|
|
1580
|
+
Promise.allSettled([...this.#runningTasks]),
|
|
1581
|
+
new Promise((resolve) => {
|
|
1582
|
+
timer = setTimeout(resolve, Math.max(deadline - Date.now(), 0));
|
|
1583
|
+
}),
|
|
1584
|
+
]);
|
|
1585
|
+
clearTimeout(timer);
|
|
1586
|
+
}
|
|
1587
|
+
// A renewal round in flight would re-stamp what we are about to hand back.
|
|
1588
|
+
await this.#renewal;
|
|
1589
|
+
for (const [table, byToken] of [...this.#held]) {
|
|
1590
|
+
const tokens = [...byToken.keys()];
|
|
1591
|
+
if (tokens.length === 0)
|
|
1592
|
+
continue;
|
|
1593
|
+
await this.#handBack(table, tokens).catch((err) => this.#onError(err));
|
|
1594
|
+
}
|
|
1595
|
+
await this.#sweeping;
|
|
1596
|
+
await this.#closeInboxes();
|
|
465
1597
|
}
|
|
466
1598
|
/** Stop the pollers (alias of {@link stop} for the `Transport.close` contract). */
|
|
467
1599
|
async close() {
|
|
468
1600
|
await this.stop();
|
|
469
1601
|
}
|
|
470
1602
|
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Run `fn` over `items` with at most `limit` in flight, in order of start. The first failure stops
|
|
1605
|
+
* starting new items; the ones already running settle, then it rethrows.
|
|
1606
|
+
*/
|
|
1607
|
+
async function eachLimited(items, limit, fn) {
|
|
1608
|
+
let next = 0;
|
|
1609
|
+
let failure;
|
|
1610
|
+
const lane = async () => {
|
|
1611
|
+
while (!failure && next < items.length) {
|
|
1612
|
+
const item = items[next++];
|
|
1613
|
+
try {
|
|
1614
|
+
await fn(item);
|
|
1615
|
+
}
|
|
1616
|
+
catch (err) {
|
|
1617
|
+
failure ??= { err };
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
1622
|
+
if (failure)
|
|
1623
|
+
throw failure.err;
|
|
1624
|
+
}
|
|
471
1625
|
//# sourceMappingURL=db.js.map
|