@adonis-agora/durable 0.49.1 → 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 +251 -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 -165
- package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
- package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -351
- 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 +128 -18
- 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,7 +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 { isWorkflowTask } from '../workflow-turn.js';
|
|
6
|
+
import { AdaptiveController } from './adaptive-concurrency.js';
|
|
7
|
+
import { workerHeartbeatKey } from './bullmq/naming.js';
|
|
8
|
+
import { heartbeatKeyValue, parseHeartbeatValue, WORKER_HEARTBEAT_INTERVAL_MS, WORKER_HEARTBEAT_TTL_SECONDS, } from './bullmq/serialization.js';
|
|
9
|
+
import { DEFAULT_INBOX_SWEEP_INTERVAL_MS, DEFAULT_INBOX_TTL_MS, DEFAULT_TENANT_SUBSCRIBERS_CACHE_MS, InboxRegistry, inboxRegistrationsQueue, runRepliesQueue, tenantEventsQueue, } from './queue-remote.js';
|
|
10
|
+
import { failedTaskIdentity, fanOutDecision, infrastructureFailure } from './task-failure.js';
|
|
5
11
|
import { cachedLiveKeys, descriptorsOn, liveGroupsOf, NO_WORKER_KEYS, workerKeyWriter, } from './worker-keys.js';
|
|
6
12
|
/**
|
|
7
13
|
* The wire payloads carried as a queue job's `payload`. Everything that crosses the queue is plain
|
|
@@ -25,16 +31,31 @@ export function fromJson(value) {
|
|
|
25
31
|
}
|
|
26
32
|
/** How often the engine-side / worker-side poll loops ask the adapter for the next job. */
|
|
27
33
|
const DEFAULT_POLL_INTERVAL_MS = 200;
|
|
34
|
+
/** Idle backoff for the task, heartbeat and control loops: after consecutive empty polls the sleep
|
|
35
|
+
* doubles up to this many poll intervals (800ms at the default interval), and the first job found
|
|
36
|
+
* resets it. Each handler name has its own task loop, so an idle worker serving many step names
|
|
37
|
+
* would otherwise hit the backend `names × 5` times a second. The results loop is exempt — see
|
|
38
|
+
* {@link QueueTransportOptions.idleBackoffMax}. */
|
|
39
|
+
const DEFAULT_IDLE_BACKOFF_MAX = 4;
|
|
28
40
|
/** How often (ms) the stalled-job reclaim sweep runs — a coarse, low-frequency check, NOT per poll
|
|
29
41
|
* tick. Mirrors `DbTransport`'s crash-recovery cadence (its stale-lease default is also 30s). */
|
|
30
42
|
const DEFAULT_STALLED_CHECK_INTERVAL_MS = 30_000;
|
|
31
|
-
/** How old (ms) a claim must be before the sweep presumes its worker dead and re-delivers the job
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
43
|
+
/** How old (ms) a claim must be before the sweep presumes its worker dead and re-delivers the job, on
|
|
44
|
+
* an adapter that CANNOT renew claims (one without `renewJobs` — every `@adonisjs/queue` driver has it,
|
|
45
|
+
* so in practice a custom adapter). There the claim's `acquiredAt` is never
|
|
46
|
+
* refreshed while the worker processes — a long step holds one claim for its whole duration — so the
|
|
47
|
+
* threshold must comfortably exceed the longest legitimate step: re-delivery double-runs a step whose
|
|
48
|
+
* worker is merely slow (safe under the durable idempotency contract, see the reclaim doc block, but
|
|
49
|
+
* not free). A step routinely longer than this should raise it. */
|
|
37
50
|
const DEFAULT_STALLED_THRESHOLD_MS = 1_800_000;
|
|
51
|
+
/** The threshold on an adapter that renews claims (`adapter.renewJobs`, on both `@adonisjs/queue` 0.6
|
|
52
|
+
* and 0.7). Every
|
|
53
|
+
* in-flight claim is renewed every half threshold (see {@link QueueTransport.#renewOpenClaims}), so a
|
|
54
|
+
* claim's age measures how long its worker has been SILENT, not how long the step has run: a dead
|
|
55
|
+
* worker's job is re-delivered within about this long, and a healthy long step never is. Larger than
|
|
56
|
+
* `@boringnode/queue`'s own 30s worker default so a step that blocks the event loop for a while (a
|
|
57
|
+
* CPU-heavy body starves the renewal timer) is not mistaken for a dead worker. */
|
|
58
|
+
const DEFAULT_RENEWING_STALLED_THRESHOLD_MS = 120_000;
|
|
38
59
|
/** How many times a single job may be reclaimed before the adapter fails it permanently instead of
|
|
39
60
|
* re-delivering — bounds a poison job that stalls every worker it touches. */
|
|
40
61
|
const DEFAULT_MAX_STALLED_COUNT = 3;
|
|
@@ -87,6 +108,12 @@ export function toBrokerPriority(priority) {
|
|
|
87
108
|
* is single-consumer (point-to-point), so it is correct for a single engine instance but does NOT
|
|
88
109
|
* fan out to every pod the way a real pub/sub does.
|
|
89
110
|
*
|
|
111
|
+
* It also carries remote workflow turns (`dispatchWorkflowTask` / `handleWorkflow` / `onDecision`,
|
|
112
|
+
* streamed step events) and the store-less tenant-pod protocol (start-run, run-request, run replies,
|
|
113
|
+
* tenant events), so a `tenant` pod can reach a `control-plane` over `@adonisjs/queue` alone. Run
|
|
114
|
+
* replies and tenant events, which BullMQ broadcasts on Redis PUB/SUB, ride per-instance inbox queues
|
|
115
|
+
* here — see `queue-remote.ts`.
|
|
116
|
+
*
|
|
90
117
|
* Run one instance engine-side (`onResult` + `dispatch`) and one per worker process (`handle()`).
|
|
91
118
|
* The wire payloads are the documented `RemoteTask`/`StepResult` JSON, so non-Node workers on the
|
|
92
119
|
* same queues interoperate.
|
|
@@ -104,6 +131,26 @@ export class QueueTransport {
|
|
|
104
131
|
#namespace;
|
|
105
132
|
#explicitNamespace;
|
|
106
133
|
#pollIntervalMs;
|
|
134
|
+
#closeTimeoutMs;
|
|
135
|
+
/** Fixed or adaptive task concurrency, plus the in-flight/latency/RSS/CPU telemetry the liveness
|
|
136
|
+
* heartbeat carries. One controller for every task loop of this instance, as on BullMQ. */
|
|
137
|
+
#controller;
|
|
138
|
+
/** Task loops parked on "every slot busy", woken when an adaptive move raises the limit. */
|
|
139
|
+
#limitWaiters = new Set();
|
|
140
|
+
/** The task queues this instance consumes — the reclaim sweep bridges a poison job only on these. */
|
|
141
|
+
#taskQueues = new Set();
|
|
142
|
+
/** Routing tokens this instance beats a liveness key for (one per handled step name). */
|
|
143
|
+
#heartbeatTokens = new Set();
|
|
144
|
+
#workerHeartbeatIntervalMs;
|
|
145
|
+
#workerHeartbeatTimer;
|
|
146
|
+
/** Liveness-key writes still in flight — `close()` lets them land before withdrawing the keys. */
|
|
147
|
+
#livenessWrites = new Set();
|
|
148
|
+
/** Routing tokens whose liveness-key write is still in flight (see `#startWorkerHeartbeat`). */
|
|
149
|
+
#beatsInFlight = new Set();
|
|
150
|
+
#resultConcurrency;
|
|
151
|
+
#idleBackoffMax;
|
|
152
|
+
/** Every job handler still running (any queue) — `close()` waits for these, bounded. */
|
|
153
|
+
#runningJobs = new Set();
|
|
107
154
|
#stalledThresholdMs;
|
|
108
155
|
#maxStalledCount;
|
|
109
156
|
#instanceId;
|
|
@@ -124,13 +171,54 @@ export class QueueTransport {
|
|
|
124
171
|
* (`stalledCheckIntervalMs <= 0`) or the adapter can't `recoverStalledJobs`. Separate from
|
|
125
172
|
* {@link #pollers} because the sweep cadence is unrelated to the fast job-poll cadence. */
|
|
126
173
|
#reclaimPollers;
|
|
174
|
+
/** Renews the claims in {@link #openJobs} every half threshold. Absent when the adapter can't
|
|
175
|
+
* `renewJobs`. */
|
|
176
|
+
#renewTimer;
|
|
177
|
+
/** The renewal round in flight, so a slow adapter never stacks rounds and close() can await it. */
|
|
178
|
+
#renewal;
|
|
127
179
|
/** When set (see {@link deferConsumers}), {@link QueueTransport.#startLoop} parks its loop here
|
|
128
180
|
* instead of polling — {@link startConsumers} flushes the parked starts. Dispatching (`pushOn`)
|
|
129
181
|
* is never deferred; only consumption is. */
|
|
130
182
|
#consumersDeferred = false;
|
|
131
183
|
#deferredStarts = [];
|
|
132
184
|
#workerKeys;
|
|
185
|
+
/** Whether a real key store was configured — without one there is no liveness to report. */
|
|
186
|
+
#hasWorkerKeys;
|
|
133
187
|
#liveWorkerKeys;
|
|
188
|
+
/** Workflow turn consumers by workflow name — a `workflow`-shaped task job routes here. */
|
|
189
|
+
#workflowTurns = new Map();
|
|
190
|
+
/** Every `onDecision` handler (an engine subscribes one; it applies a decision by run id). */
|
|
191
|
+
#decisionHandlers = new Set();
|
|
192
|
+
#decisionsLoop = false;
|
|
193
|
+
#stepEventsLoop = false;
|
|
194
|
+
#startRunLoop = false;
|
|
195
|
+
#runRequestLoop = false;
|
|
196
|
+
#runRepliesLoop = false;
|
|
197
|
+
#runReplyHandler;
|
|
198
|
+
#tenantEventsLoop = false;
|
|
199
|
+
#registrationsLoop = false;
|
|
200
|
+
#tenantRole = false;
|
|
201
|
+
/** Control-plane side: correlation id (requestId, or a start-run's runId) → the requester's instance
|
|
202
|
+
* id, held while the request's handler runs so {@link publishRunReply} knows whose inbox to target. */
|
|
203
|
+
#replyRoutes = new Map();
|
|
204
|
+
#tenantEventHandlers = new Map();
|
|
205
|
+
/** Serializes {@link publishTenantEvent} so events reach every inbox in the order they were published. */
|
|
206
|
+
#tenantPublishChain = Promise.resolve();
|
|
207
|
+
#inboxes;
|
|
208
|
+
/**
|
|
209
|
+
* Per-group worker health, for `engine.workerHealth()` and the dashboard/Telescope worker panels.
|
|
210
|
+
* Present only when a {@link QueueTransportOptions.workerKeys} store is configured (the
|
|
211
|
+
* `transports.queue()` default) — without one no worker can advertise liveness, and reporting
|
|
212
|
+
* "backlog, zero workers" for every group would be a false alarm.
|
|
213
|
+
*
|
|
214
|
+
* - `depth` is the task queue's PENDING count (`adapter.sizeOf`), the jobs waiting for a worker.
|
|
215
|
+
* Unlike BullMQ's waiting+active+delayed+prioritized it leaves out jobs in flight and delayed
|
|
216
|
+
* retries: the `@adonisjs/queue` adapter contract has no count for those. Priorities are included
|
|
217
|
+
* (they are pending jobs). An adapter without `sizeOf` reports `0`.
|
|
218
|
+
* - `liveWorkers` are the unexpired `${P}-worker-heartbeat:<group>:<instance>` keys, each with the
|
|
219
|
+
* worker's {@link WorkerStatus} when it carries one (this transport's own beats do).
|
|
220
|
+
*/
|
|
221
|
+
groupHealth;
|
|
134
222
|
constructor(options) {
|
|
135
223
|
// Typed through CompatAdapter so the same code drives the 0.6 (job-id) and 0.7 (lease) contracts.
|
|
136
224
|
this.#adapter = options.adapter();
|
|
@@ -139,7 +227,25 @@ export class QueueTransport {
|
|
|
139
227
|
this.#namespace = options.namespace;
|
|
140
228
|
this.#explicitNamespace = options.namespace !== undefined;
|
|
141
229
|
this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
142
|
-
this.#
|
|
230
|
+
this.#closeTimeoutMs =
|
|
231
|
+
options.closeTimeoutMs !== undefined &&
|
|
232
|
+
Number.isFinite(options.closeTimeoutMs) &&
|
|
233
|
+
options.closeTimeoutMs >= 0
|
|
234
|
+
? options.closeTimeoutMs
|
|
235
|
+
: 5_000;
|
|
236
|
+
this.#controller = new AdaptiveController({
|
|
237
|
+
...(options.concurrency !== undefined ? { concurrency: options.concurrency } : {}),
|
|
238
|
+
apply: () => {
|
|
239
|
+
for (const wake of this.#limitWaiters)
|
|
240
|
+
wake();
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
this.#resultConcurrency = atLeastOne(options.resultConcurrency, 1);
|
|
244
|
+
this.#idleBackoffMax = atLeastOne(options.idleBackoffMax, DEFAULT_IDLE_BACKOFF_MAX);
|
|
245
|
+
const canRenew = typeof this.#adapter.renewJobs === 'function';
|
|
246
|
+
this.#stalledThresholdMs =
|
|
247
|
+
options.stalledThresholdMs ??
|
|
248
|
+
(canRenew ? DEFAULT_RENEWING_STALLED_THRESHOLD_MS : DEFAULT_STALLED_THRESHOLD_MS);
|
|
143
249
|
this.#maxStalledCount = options.maxStalledCount ?? DEFAULT_MAX_STALLED_COUNT;
|
|
144
250
|
this.#instanceId = options.instanceId ?? randomUUID();
|
|
145
251
|
this.#onError = options.onError ?? DEFAULT_LOG;
|
|
@@ -147,13 +253,44 @@ export class QueueTransport {
|
|
|
147
253
|
this.#adapter.setWorkerId(this.#instanceId);
|
|
148
254
|
this.#pollers = new Pollers(this.#pollIntervalMs, this.#onError);
|
|
149
255
|
this.#reclaimPollers = this.#startReclaimSweep(options.stalledCheckIntervalMs ?? DEFAULT_STALLED_CHECK_INTERVAL_MS);
|
|
256
|
+
if (canRenew)
|
|
257
|
+
this.#startClaimRenewal();
|
|
258
|
+
this.#hasWorkerKeys = options.workerKeys !== undefined;
|
|
259
|
+
if (this.#hasWorkerKeys)
|
|
260
|
+
this.groupHealth = (group) => this.#groupHealth(group);
|
|
261
|
+
this.#workerHeartbeatIntervalMs = atLeastOne(options.workerHeartbeatIntervalMs, WORKER_HEARTBEAT_INTERVAL_MS);
|
|
150
262
|
this.#workerKeys = options.workerKeys ?? NO_WORKER_KEYS;
|
|
151
263
|
this.#liveWorkerKeys = cachedLiveKeys(this.#workerKeys, options.workerKeysCacheMs ?? 5_000, Date.now);
|
|
264
|
+
this.#inboxes = new InboxRegistry({
|
|
265
|
+
store: this.#workerKeys,
|
|
266
|
+
prefix: () => this.#effectivePrefix(),
|
|
267
|
+
instanceId: this.#instanceId,
|
|
268
|
+
ttlMs: atLeastOne(options.inboxTtlMs, DEFAULT_INBOX_TTL_MS),
|
|
269
|
+
subscribersCacheMs: options.tenantSubscribersCacheMs ?? DEFAULT_TENANT_SUBSCRIBERS_CACHE_MS,
|
|
270
|
+
sweepIntervalMs: options.inboxSweepIntervalMs ?? DEFAULT_INBOX_SWEEP_INTERVAL_MS,
|
|
271
|
+
announce: (registration) => this.#adapter
|
|
272
|
+
.pushOn(inboxRegistrationsQueue(this.#effectivePrefix()), this.#job('inboxRegistration', registration))
|
|
273
|
+
.then(() => undefined),
|
|
274
|
+
drain: (queue, max) => this.#drainQueue(queue, max),
|
|
275
|
+
onError: this.#onError,
|
|
276
|
+
});
|
|
152
277
|
}
|
|
153
|
-
/** The writer a worker runtime's registry advertises its descriptor and heartbeat through
|
|
278
|
+
/** The writer a worker runtime's registry advertises its descriptor and heartbeat through: the key
|
|
279
|
+
* store when this instance has one. A store-less tenant pod (see {@link useTenantRole}) with no key
|
|
280
|
+
* store forwards it to the control plane instead, which records it in its own — so the control plane
|
|
281
|
+
* still sees the pod's workers. Otherwise, with no store, nothing is advertised. */
|
|
154
282
|
createDescriptorRedis() {
|
|
283
|
+
if (this.#workerKeys === NO_WORKER_KEYS && this.#tenantRole) {
|
|
284
|
+
return this.#inboxes.remoteKeyWriter();
|
|
285
|
+
}
|
|
155
286
|
return workerKeyWriter(this.#workerKeys, Date.now);
|
|
156
287
|
}
|
|
288
|
+
/** Declare that this transport backs a store-less `tenant` pod (the provider calls it): worker
|
|
289
|
+
* advertisement is then forwarded to the control plane when this instance has no key store. Only a
|
|
290
|
+
* tenant pod is guaranteed a control plane that records the forwarded keys. */
|
|
291
|
+
useTenantRole() {
|
|
292
|
+
this.#tenantRole = true;
|
|
293
|
+
}
|
|
157
294
|
/** The live worker descriptors advertised on `token`. */
|
|
158
295
|
async listWorkerDescriptors(token) {
|
|
159
296
|
return descriptorsOn(await this.#liveWorkerKeys(), token);
|
|
@@ -229,6 +366,18 @@ export class QueueTransport {
|
|
|
229
366
|
#controlQueue() {
|
|
230
367
|
return `${this.#effectivePrefix()}:control`;
|
|
231
368
|
}
|
|
369
|
+
#decisionsQueue() {
|
|
370
|
+
return `${this.#effectivePrefix()}:decisions`;
|
|
371
|
+
}
|
|
372
|
+
#stepEventsQueue() {
|
|
373
|
+
return `${this.#effectivePrefix()}:step-events`;
|
|
374
|
+
}
|
|
375
|
+
#startRunQueue() {
|
|
376
|
+
return `${this.#effectivePrefix()}:start-run`;
|
|
377
|
+
}
|
|
378
|
+
#runRequestQueue() {
|
|
379
|
+
return `${this.#effectivePrefix()}:run-request`;
|
|
380
|
+
}
|
|
232
381
|
/**
|
|
233
382
|
* Build the adapter `JobData` envelope for a JSON-safe payload. A translated `priority` is added
|
|
234
383
|
* only when set, so the default FIFO path is untouched for unprioritised dispatches.
|
|
@@ -261,14 +410,167 @@ export class QueueTransport {
|
|
|
261
410
|
*/
|
|
262
411
|
handle(name, fn) {
|
|
263
412
|
this.#handlers.set(name, fn);
|
|
264
|
-
|
|
413
|
+
this.#ensureTaskLoop(tenantGroup(sanitizeQueueToken(name), this.#partition));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Register a WORKFLOW turn consumer (worker side). Shares the step task loop of the workflow name's
|
|
417
|
+
* routing token — the same queue {@link dispatchWorkflowTask} targets — and tells the two job kinds
|
|
418
|
+
* apart BY SHAPE: a `workflow`-shaped job runs `turn(task)` and its {@link WorkflowDecision} is pushed
|
|
419
|
+
* onto `${P}:decisions`. Re-registering swaps the turn fn.
|
|
420
|
+
*/
|
|
421
|
+
handleWorkflow(name, turn) {
|
|
422
|
+
this.#workflowTurns.set(name, turn);
|
|
423
|
+
this.#ensureTaskLoop(tenantGroup(sanitizeQueueToken(name), this.#partition));
|
|
424
|
+
}
|
|
425
|
+
/** Start (once per routing token) the task loop shared by {@link handle} and {@link handleWorkflow}. */
|
|
426
|
+
#ensureTaskLoop(token) {
|
|
265
427
|
if (this.#taskLoops.has(token))
|
|
266
428
|
return;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
429
|
+
const queue = this.#tasksQueue(token);
|
|
430
|
+
this.#taskQueues.add(queue);
|
|
431
|
+
const controller = this.#controller;
|
|
432
|
+
this.#taskLoops.set(token, this.#startLoop(queue, async (job) => {
|
|
433
|
+
const payload = fromJson(job.payload);
|
|
434
|
+
const startedAt = Date.now();
|
|
435
|
+
controller.onStart();
|
|
436
|
+
// Only step durations feed the latency gradient; a workflow turn still counts as in-flight
|
|
437
|
+
// work and as forward progress for stall detection (as on bullmq).
|
|
438
|
+
if (isWorkflowTask(payload)) {
|
|
439
|
+
try {
|
|
440
|
+
await this.#runWorkflowTask(payload);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
controller.onSettle(Date.now() - startedAt, true, 'workflow');
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const task = payload;
|
|
448
|
+
let ok = false;
|
|
449
|
+
try {
|
|
450
|
+
const result = await runStepHandler(task, this.#handlers.get(task.name), (beat) => this.heartbeat(beat));
|
|
451
|
+
await this.#adapter.pushOn(this.#resultsQueue(), this.#job('result', result));
|
|
452
|
+
ok = result.status === 'completed';
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
controller.onSettle(Date.now() - startedAt, ok, 'step');
|
|
456
|
+
}
|
|
457
|
+
}, {
|
|
458
|
+
// Adaptive: every tick reads the controller's live limit. Fixed: the plain number, so the
|
|
459
|
+
// one-slot loop keeps its historical inline shape.
|
|
460
|
+
concurrency: controller.isAdaptive ? () => controller.limit : controller.limit,
|
|
461
|
+
idleBackoffMax: this.#idleBackoffMax,
|
|
271
462
|
}));
|
|
463
|
+
// Advertise liveness only once the loop really consumes: a deferred (producer-only) process must
|
|
464
|
+
// not look like a live worker.
|
|
465
|
+
if (this.#consumersDeferred)
|
|
466
|
+
this.#deferredStarts.push(() => this.#beginServing(token));
|
|
467
|
+
else
|
|
468
|
+
this.#beginServing(token);
|
|
469
|
+
}
|
|
470
|
+
/** A task loop for `token` is consuming: run the concurrency controller and beat its liveness key. */
|
|
471
|
+
#beginServing(token) {
|
|
472
|
+
this.#controller.start();
|
|
473
|
+
this.#startWorkerHeartbeat(token);
|
|
474
|
+
}
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// worker liveness + health — `${P}-worker-heartbeat:<token>:<instanceId>` in the worker-key store
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
/**
|
|
479
|
+
* Refresh every served routing token's liveness key on one shared interval (default 10s, live 35s),
|
|
480
|
+
* stamped with the controller's {@link WorkerStatus} snapshot (concurrency mode and live limit,
|
|
481
|
+
* in-flight, RSS/CPU, throughput, p95, last adjustment) — the same `{ ts, status }` value and key
|
|
482
|
+
* the BullMQ transport writes, so the dashboard's worker cards read both alike. A new token beats
|
|
483
|
+
* at once. Best-effort: a failed write goes to `onError` and the key simply expires. A no-op without
|
|
484
|
+
* a configured {@link QueueTransportOptions.workerKeys} store.
|
|
485
|
+
*/
|
|
486
|
+
#startWorkerHeartbeat(token) {
|
|
487
|
+
if (!this.#hasWorkerKeys)
|
|
488
|
+
return;
|
|
489
|
+
const ttlMs = Math.max(WORKER_HEARTBEAT_TTL_SECONDS * 1000, this.#workerHeartbeatIntervalMs * 3);
|
|
490
|
+
const beatOne = (t) => {
|
|
491
|
+
// One write per token at a time: a beat due while the last one is still in flight is skipped.
|
|
492
|
+
// Beats are not queued behind a slow store — every interval used to start a write regardless,
|
|
493
|
+
// so a store slower than the interval piled up writes without bound, each holding a pool
|
|
494
|
+
// connection the reads (groupHealth, the claim loop) then waited behind.
|
|
495
|
+
if (this.#beatsInFlight.has(t))
|
|
496
|
+
return;
|
|
497
|
+
this.#beatsInFlight.add(t);
|
|
498
|
+
const now = Date.now();
|
|
499
|
+
const key = workerHeartbeatKey(this.#effectivePrefix(), t, this.#instanceId);
|
|
500
|
+
const value = heartbeatKeyValue(now, this.#controller.snapshot());
|
|
501
|
+
const write = Promise.resolve()
|
|
502
|
+
.then(() => this.#workerKeys.put(key, value, now + ttlMs))
|
|
503
|
+
.catch((err) => this.#onError(err))
|
|
504
|
+
.finally(() => this.#beatsInFlight.delete(t));
|
|
505
|
+
this.#livenessWrites.add(write);
|
|
506
|
+
void write.finally(() => this.#livenessWrites.delete(write));
|
|
507
|
+
};
|
|
508
|
+
const isNew = !this.#heartbeatTokens.has(token);
|
|
509
|
+
this.#heartbeatTokens.add(token);
|
|
510
|
+
if (isNew)
|
|
511
|
+
beatOne(token);
|
|
512
|
+
if (this.#workerHeartbeatTimer)
|
|
513
|
+
return;
|
|
514
|
+
this.#workerHeartbeatTimer = setInterval(() => {
|
|
515
|
+
for (const t of this.#heartbeatTokens)
|
|
516
|
+
beatOne(t);
|
|
517
|
+
}, this.#workerHeartbeatIntervalMs);
|
|
518
|
+
this.#workerHeartbeatTimer.unref?.();
|
|
519
|
+
}
|
|
520
|
+
async #groupHealth(group) {
|
|
521
|
+
const [depth, entries] = await Promise.all([
|
|
522
|
+
this.#adapter.sizeOf ? this.#adapter.sizeOf(this.#tasksQueue(group)) : Promise.resolve(0),
|
|
523
|
+
this.#liveWorkerKeys(),
|
|
524
|
+
]);
|
|
525
|
+
const marker = workerHeartbeatKey(this.#effectivePrefix(), group, '');
|
|
526
|
+
const liveWorkers = [];
|
|
527
|
+
for (const { key, value } of entries) {
|
|
528
|
+
if (!key.startsWith(marker))
|
|
529
|
+
continue;
|
|
530
|
+
const parsed = parseHeartbeatValue(value);
|
|
531
|
+
liveWorkers.push({
|
|
532
|
+
group,
|
|
533
|
+
instanceId: key.slice(marker.length),
|
|
534
|
+
lastBeatAt: parsed.lastBeatAt,
|
|
535
|
+
// Passed through as-is (worker-authored); a worker runtime's compact `{ ts, status: 'up' }`
|
|
536
|
+
// beat carries no status object and reads as liveness only.
|
|
537
|
+
...(parsed.status !== undefined
|
|
538
|
+
? { status: parsed.status }
|
|
539
|
+
: {}),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return { group, depth: Number(depth) || 0, liveWorkers };
|
|
543
|
+
}
|
|
544
|
+
/** Replay one workflow turn and push its decision. An unknown workflow or a throwing turn still
|
|
545
|
+
* yields a `failed` decision so the run settles; a failed push throws, so the job is redelivered. */
|
|
546
|
+
async #runWorkflowTask(task) {
|
|
547
|
+
let decision;
|
|
548
|
+
const turn = this.#workflowTurns.get(task.workflow);
|
|
549
|
+
try {
|
|
550
|
+
decision = turn
|
|
551
|
+
? await withTurnHeartbeat(task, (beat) => this.heartbeat(beat), () => turn(task))
|
|
552
|
+
: {
|
|
553
|
+
taskId: task.taskId,
|
|
554
|
+
runId: task.runId,
|
|
555
|
+
status: 'failed',
|
|
556
|
+
commands: [],
|
|
557
|
+
error: {
|
|
558
|
+
message: `no workflow registered for '${task.workflow}'`,
|
|
559
|
+
code: 'no_workflow',
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
this.#onError(err);
|
|
565
|
+
decision = {
|
|
566
|
+
taskId: task.taskId,
|
|
567
|
+
runId: task.runId,
|
|
568
|
+
status: 'failed',
|
|
569
|
+
commands: [],
|
|
570
|
+
error: { message: err instanceof Error ? err.message : String(err) },
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
await this.#adapter.pushOn(this.#decisionsQueue(), this.#job('decision', decision));
|
|
272
574
|
}
|
|
273
575
|
/** Worker side: publish a liveness heartbeat for an in-flight long step. */
|
|
274
576
|
async heartbeat(beat) {
|
|
@@ -280,12 +582,12 @@ export class QueueTransport {
|
|
|
280
582
|
onResult(handler) {
|
|
281
583
|
this.#startLoop(this.#resultsQueue(), async (job) => {
|
|
282
584
|
await handler(fromJson(job.payload));
|
|
283
|
-
});
|
|
585
|
+
}, { concurrency: this.#resultConcurrency, idleBackoffMax: 1 });
|
|
284
586
|
}
|
|
285
587
|
onHeartbeat(handler) {
|
|
286
588
|
this.#startLoop(this.#heartbeatsQueue(), async (job) => {
|
|
287
589
|
await handler(fromJson(job.payload));
|
|
288
|
-
});
|
|
590
|
+
}, { idleBackoffMax: this.#idleBackoffMax });
|
|
289
591
|
}
|
|
290
592
|
// ---------------------------------------------------------------------------
|
|
291
593
|
// control plane (best-effort, point-to-point)
|
|
@@ -297,7 +599,243 @@ export class QueueTransport {
|
|
|
297
599
|
onControl(handler) {
|
|
298
600
|
this.#startLoop(this.#controlQueue(), async (job) => {
|
|
299
601
|
handler(fromJson(job.payload));
|
|
602
|
+
}, { idleBackoffMax: this.#idleBackoffMax });
|
|
603
|
+
}
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// remote workflows — engine ⇄ workflow worker (point-to-point, like the step queues)
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
/** engine → workflow worker: a {@link WorkflowTask} on the workflow's routing-token task queue (job
|
|
608
|
+
* `workflow`), the queue a {@link handleWorkflow} consumer polls. */
|
|
609
|
+
async dispatchWorkflowTask(task) {
|
|
610
|
+
await this.#adapter.pushOn(this.#tasksQueue(task.group), this.#job('workflow', task, task.priority));
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* engine ← workflow worker: consume {@link WorkflowDecision}s from `${P}:decisions`. ONE loop per
|
|
614
|
+
* transport; every registered handler sees every decision (the engine applies one by run id and drops
|
|
615
|
+
* any its run no longer awaits), so several remote workflows can share the transport.
|
|
616
|
+
*/
|
|
617
|
+
onDecision(handler) {
|
|
618
|
+
this.#decisionHandlers.add(handler);
|
|
619
|
+
if (this.#decisionsLoop)
|
|
620
|
+
return;
|
|
621
|
+
this.#decisionsLoop = true;
|
|
622
|
+
this.#startLoop(this.#decisionsQueue(), async (job) => {
|
|
623
|
+
const decision = fromJson(job.payload);
|
|
624
|
+
// Every handler, concurrently; all settle before a failure redelivers the decision.
|
|
625
|
+
await fanOutDecision(this.#decisionHandlers, decision, this.#onError);
|
|
626
|
+
}, { idleBackoffMax: 1 });
|
|
627
|
+
}
|
|
628
|
+
/** workflow worker → engine: a local step's lifecycle on `${P}:step-events` (job `stepEvent`),
|
|
629
|
+
* point-to-point so ONE engine instance checkpoints each event once. */
|
|
630
|
+
async dispatchStepEvent(event) {
|
|
631
|
+
await this.#adapter.pushOn(this.#stepEventsQueue(), this.#job('stepEvent', event));
|
|
632
|
+
}
|
|
633
|
+
/** engine ← workflow worker: consume streamed step events. Idempotent — a second call is a no-op. */
|
|
634
|
+
onStepEvent(handler) {
|
|
635
|
+
if (this.#stepEventsLoop)
|
|
636
|
+
return;
|
|
637
|
+
this.#stepEventsLoop = true;
|
|
638
|
+
this.#startLoop(this.#stepEventsQueue(), async (job) => {
|
|
639
|
+
await handler(fromJson(job.payload));
|
|
640
|
+
}, { idleBackoffMax: this.#idleBackoffMax });
|
|
641
|
+
}
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// store-less protocol (P4) — tenant pod ⇄ control plane
|
|
644
|
+
//
|
|
645
|
+
// start-run and run-request are point-to-point queues consumed by the control plane, exactly as on
|
|
646
|
+
// BullMQ. Run replies and tenant events — Redis PUB/SUB on BullMQ — go to per-instance inbox queues
|
|
647
|
+
// instead, since a queue job has one consumer: a reply to the requester's inbox (its id rides the
|
|
648
|
+
// request as `replyTo`), a tenant event to the inbox of each live subscriber of that tenant (read from
|
|
649
|
+
// the worker-key store). See `queue-remote.ts` for the design and its limits.
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
/** tenant → control plane: enqueue a {@link StartRunMessage} on `${P}:start-run` (job `startRun`),
|
|
652
|
+
* stamped with this instance as the reply target. */
|
|
653
|
+
async dispatchStartRun(msg) {
|
|
654
|
+
this.#ensureRunRepliesLoop();
|
|
655
|
+
const payload = { ...msg, replyTo: this.#instanceId };
|
|
656
|
+
await this.#adapter.pushOn(this.#startRunQueue(), this.#job('startRun', payload));
|
|
657
|
+
}
|
|
658
|
+
/** control plane ← tenant: consume {@link StartRunMessage}s. Idempotent — a second call is a no-op. */
|
|
659
|
+
onStartRun(handler) {
|
|
660
|
+
this.#ensureRegistrationsLoop();
|
|
661
|
+
if (this.#startRunLoop)
|
|
662
|
+
return;
|
|
663
|
+
this.#startRunLoop = true;
|
|
664
|
+
this.#startLoop(this.#startRunQueue(), async (job) => {
|
|
665
|
+
const { replyTo, ...msg } = fromJson(job.payload);
|
|
666
|
+
await this.#withReplyRoute(msg.runId, replyTo, () => handler(msg));
|
|
667
|
+
}, { idleBackoffMax: this.#idleBackoffMax });
|
|
668
|
+
}
|
|
669
|
+
/** tenant → control plane: enqueue a {@link RunRequest} on `${P}:run-request` (job `runRequest`),
|
|
670
|
+
* stamped with this instance as the reply target. */
|
|
671
|
+
async dispatchRunRequest(msg) {
|
|
672
|
+
this.#ensureRunRepliesLoop();
|
|
673
|
+
const payload = { ...msg, replyTo: this.#instanceId };
|
|
674
|
+
await this.#adapter.pushOn(this.#runRequestQueue(), this.#job('runRequest', payload));
|
|
675
|
+
}
|
|
676
|
+
/** control plane ← tenant: consume {@link RunRequest}s. Idempotent — a second call is a no-op. */
|
|
677
|
+
onRunRequest(handler) {
|
|
678
|
+
this.#ensureRegistrationsLoop();
|
|
679
|
+
if (this.#runRequestLoop)
|
|
680
|
+
return;
|
|
681
|
+
this.#runRequestLoop = true;
|
|
682
|
+
this.#startLoop(this.#runRequestQueue(), async (job) => {
|
|
683
|
+
const { replyTo, ...msg } = fromJson(job.payload);
|
|
684
|
+
await this.#withReplyRoute(msg.requestId, replyTo, () => handler(msg));
|
|
685
|
+
}, { idleBackoffMax: this.#idleBackoffMax });
|
|
686
|
+
}
|
|
687
|
+
/** Remember who asked for `correlationId` while `work` (the responder, which replies inside it) runs. */
|
|
688
|
+
async #withReplyRoute(correlationId, replyTo, work) {
|
|
689
|
+
if (correlationId === undefined || replyTo === undefined)
|
|
690
|
+
return work();
|
|
691
|
+
this.#replyRoutes.set(correlationId, replyTo);
|
|
692
|
+
try {
|
|
693
|
+
await work();
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
if (this.#replyRoutes.get(correlationId) === replyTo)
|
|
697
|
+
this.#replyRoutes.delete(correlationId);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* control plane → tenant: push a correlated {@link RunReply} onto the inbox of the instance that sent
|
|
702
|
+
* the request (`${P}:run-replies:<instance>`). Only the instance that consumed the request knows the
|
|
703
|
+
* requester, so this must run while that request is being handled — which is what the responder does.
|
|
704
|
+
* A reply with no known requester (a request that carried no `replyTo`) is dropped and reported.
|
|
705
|
+
*/
|
|
706
|
+
async publishRunReply(reply) {
|
|
707
|
+
const owner = this.#replyRoutes.get(reply.requestId);
|
|
708
|
+
if (owner === undefined) {
|
|
709
|
+
this.#onError(new Error(`[QueueTransport] dropped the run reply for "${reply.requestId}": no requester is known for it on this instance`));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const queue = runRepliesQueue(this.#effectivePrefix(), owner);
|
|
713
|
+
await this.#adapter.pushOn(queue, this.#job('runReply', reply));
|
|
714
|
+
this.#inboxes.notePush(queue, owner);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* tenant ← control plane: consume this instance's reply inbox. Idempotent — a second call is a no-op
|
|
718
|
+
* (one gateway per transport matches every reply by id). The inbox is private to this instance, so it
|
|
719
|
+
* is consumed even when consumers are deferred: no other process could take its jobs.
|
|
720
|
+
*/
|
|
721
|
+
onRunReply(handler) {
|
|
722
|
+
if (this.#runReplyHandler)
|
|
723
|
+
return;
|
|
724
|
+
this.#runReplyHandler = handler;
|
|
725
|
+
this.#ensureRunRepliesLoop();
|
|
726
|
+
}
|
|
727
|
+
/** Start consuming the reply inbox (once). Also started on the first request, so a reply is never
|
|
728
|
+
* left for a dead queue just because the gateway subscribed late. */
|
|
729
|
+
#ensureRunRepliesLoop() {
|
|
730
|
+
if (this.#runRepliesLoop)
|
|
731
|
+
return;
|
|
732
|
+
this.#runRepliesLoop = true;
|
|
733
|
+
this.#inboxes.activateInbox();
|
|
734
|
+
// On the critical path of every proxied call: never back off (like the results loop).
|
|
735
|
+
this.#reallyStartLoop(runRepliesQueue(this.#effectivePrefix(), this.#instanceId), async (job) => {
|
|
736
|
+
const reply = fromJson(job.payload);
|
|
737
|
+
try {
|
|
738
|
+
this.#runReplyHandler?.(reply);
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
this.#onError(err); // a throwing listener must not redeliver the reply
|
|
742
|
+
}
|
|
743
|
+
}, { idleBackoffMax: 1 });
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* control plane → tenant: push a {@link TenantEvent} onto the inbox of every instance with a live
|
|
747
|
+
* subscription to `evt.tenant` (none: nothing is pushed). Publishes are serialized, so each
|
|
748
|
+
* subscriber receives one publisher's events in publish order.
|
|
749
|
+
*/
|
|
750
|
+
publishTenantEvent(evt) {
|
|
751
|
+
this.#ensureRegistrationsLoop();
|
|
752
|
+
const run = this.#tenantPublishChain.then(async () => {
|
|
753
|
+
const owners = await this.#inboxes.subscribersOf(evt.tenant);
|
|
754
|
+
for (const owner of owners) {
|
|
755
|
+
const queue = tenantEventsQueue(this.#effectivePrefix(), owner);
|
|
756
|
+
await this.#adapter.pushOn(queue, this.#job('tenantEvent', evt));
|
|
757
|
+
this.#inboxes.notePush(queue, owner);
|
|
758
|
+
}
|
|
300
759
|
});
|
|
760
|
+
this.#tenantPublishChain = run.catch(() => undefined);
|
|
761
|
+
return run;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* control plane: record the inbox registrations instances announce (their subscriptions and inbox
|
|
765
|
+
* liveness) in this instance's worker-key store, where {@link publishTenantEvent} and the janitor read
|
|
766
|
+
* them. Started by whichever control-plane surface comes up first (the responder's consumers, or the
|
|
767
|
+
* first publish). Point-to-point: control-plane replicas share the store, so one recording suffices.
|
|
768
|
+
*/
|
|
769
|
+
#ensureRegistrationsLoop() {
|
|
770
|
+
if (this.#registrationsLoop)
|
|
771
|
+
return;
|
|
772
|
+
this.#registrationsLoop = true;
|
|
773
|
+
this.#startLoop(inboxRegistrationsQueue(this.#effectivePrefix()), (job) => this.#inboxes.record(fromJson(job.payload)), { idleBackoffMax: this.#idleBackoffMax });
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* tenant ← control plane: subscribe to `tenant`'s events. Announces the subscription to the control
|
|
777
|
+
* plane (re-announced every third of {@link QueueTransportOptions.inboxTtlMs} while it lasts) and
|
|
778
|
+
* consumes this instance's event inbox. Needs no store on this side. Returns an unsubscribe fn that
|
|
779
|
+
* detaches only this handler; the last one withdraws the subscription.
|
|
780
|
+
*/
|
|
781
|
+
onTenantEvent(tenant, handler) {
|
|
782
|
+
let handlers = this.#tenantEventHandlers.get(tenant);
|
|
783
|
+
if (!handlers) {
|
|
784
|
+
handlers = new Set();
|
|
785
|
+
this.#tenantEventHandlers.set(tenant, handlers);
|
|
786
|
+
}
|
|
787
|
+
handlers.add(handler);
|
|
788
|
+
this.#inboxes.subscribe(tenant);
|
|
789
|
+
this.#ensureTenantEventsLoop();
|
|
790
|
+
return () => {
|
|
791
|
+
const set = this.#tenantEventHandlers.get(tenant);
|
|
792
|
+
if (!set?.delete(handler) || set.size > 0)
|
|
793
|
+
return;
|
|
794
|
+
this.#tenantEventHandlers.delete(tenant);
|
|
795
|
+
this.#inboxes.unsubscribe(tenant);
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
/** Consume the tenant-event inbox (once; private to this instance, so never deferred). Keeps running
|
|
799
|
+
* after the last unsubscribe so events pushed before the withdrawal are drained, not left behind. */
|
|
800
|
+
#ensureTenantEventsLoop() {
|
|
801
|
+
if (this.#tenantEventsLoop)
|
|
802
|
+
return;
|
|
803
|
+
this.#tenantEventsLoop = true;
|
|
804
|
+
this.#reallyStartLoop(tenantEventsQueue(this.#effectivePrefix(), this.#instanceId), async (job) => {
|
|
805
|
+
const evt = fromJson(job.payload);
|
|
806
|
+
for (const h of this.#tenantEventHandlers.get(evt.tenant) ?? []) {
|
|
807
|
+
try {
|
|
808
|
+
h(evt);
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
this.#onError(err); // a throwing listener must not redeliver (and reorder) the event
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}, { idleBackoffMax: this.#idleBackoffMax });
|
|
815
|
+
}
|
|
816
|
+
/** Withdraw this instance's advertisements, then drop what is left in its own inboxes: replies and
|
|
817
|
+
* events addressed to a process that is going away have no one else to go to. */
|
|
818
|
+
async #closeInboxes() {
|
|
819
|
+
await this.#inboxes.close();
|
|
820
|
+
const prefix = this.#effectivePrefix();
|
|
821
|
+
const own = [
|
|
822
|
+
...(this.#runRepliesLoop ? [runRepliesQueue(prefix, this.#instanceId)] : []),
|
|
823
|
+
...(this.#tenantEventsLoop ? [tenantEventsQueue(prefix, this.#instanceId)] : []),
|
|
824
|
+
];
|
|
825
|
+
for (const queue of own) {
|
|
826
|
+
await this.#drainQueue(queue, 1_000).catch((err) => this.#onError(err));
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/** Pop and finalize up to `max` jobs of `queue` without running them (the janitor, and close()).
|
|
830
|
+
* Resolves `true` once the queue came back empty. */
|
|
831
|
+
async #drainQueue(queue, max) {
|
|
832
|
+
for (let i = 0; i < max; i += 1) {
|
|
833
|
+
const job = await this.#adapter.popFrom(queue);
|
|
834
|
+
if (!job)
|
|
835
|
+
return true;
|
|
836
|
+
await this.#adapter.completeJob(jobHandle(job), queue);
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
301
839
|
}
|
|
302
840
|
// ---------------------------------------------------------------------------
|
|
303
841
|
// poll loop
|
|
@@ -317,7 +855,7 @@ export class QueueTransport {
|
|
|
317
855
|
* reconcile recovery instead of wedging or losing the result.) A job nobody can settle loops at
|
|
318
856
|
* the poll rate and says so on every attempt, which is the failure we want — loud, not invisible.
|
|
319
857
|
*/
|
|
320
|
-
#startLoop(queue, onJob) {
|
|
858
|
+
#startLoop(queue, onJob, opts = {}) {
|
|
321
859
|
// Deferred consumption (see deferConsumers): park the start; startConsumers() flushes it. The
|
|
322
860
|
// handle returned now still stops the loop whether it is flushed later or never.
|
|
323
861
|
if (this.#consumersDeferred) {
|
|
@@ -325,7 +863,7 @@ export class QueueTransport {
|
|
|
325
863
|
let live;
|
|
326
864
|
this.#deferredStarts.push(() => {
|
|
327
865
|
if (!cancelled)
|
|
328
|
-
live = this.#reallyStartLoop(queue, onJob);
|
|
866
|
+
live = this.#reallyStartLoop(queue, onJob, opts);
|
|
329
867
|
});
|
|
330
868
|
return {
|
|
331
869
|
stop: () => {
|
|
@@ -334,12 +872,23 @@ export class QueueTransport {
|
|
|
334
872
|
},
|
|
335
873
|
};
|
|
336
874
|
}
|
|
337
|
-
return this.#reallyStartLoop(queue, onJob);
|
|
875
|
+
return this.#reallyStartLoop(queue, onJob, opts);
|
|
338
876
|
}
|
|
339
|
-
#reallyStartLoop(queue, onJob) {
|
|
877
|
+
#reallyStartLoop(queue, onJob, { concurrency = 1, idleBackoffMax }) {
|
|
340
878
|
// Record every queue we consume from so the reclaim sweep can walk exactly this set.
|
|
341
879
|
this.#poppedQueues.add(queue);
|
|
880
|
+
// This loop's jobs still in a handler. With one fixed slot the tick awaits its job (the historical
|
|
881
|
+
// shape); otherwise it hands each job off and keeps popping until every slot is taken, so N
|
|
882
|
+
// slots cost ONE poll loop, not N. A live (adaptive) limit is re-read on every tick.
|
|
883
|
+
const limit = typeof concurrency === 'function' ? concurrency : () => concurrency;
|
|
884
|
+
const inFlight = new Set();
|
|
342
885
|
return this.#pollers.start(async () => {
|
|
886
|
+
if (inFlight.size >= limit()) {
|
|
887
|
+
// Every slot busy: wait for one to free (or the limit to move), then report work so the
|
|
888
|
+
// loop re-ticks at once.
|
|
889
|
+
await this.#slotFreed(inFlight);
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
343
892
|
let job;
|
|
344
893
|
try {
|
|
345
894
|
job = await this.#adapter.popFrom(queue);
|
|
@@ -350,11 +899,42 @@ export class QueueTransport {
|
|
|
350
899
|
}
|
|
351
900
|
if (!job)
|
|
352
901
|
return false;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
902
|
+
const run = this.#runJob(queue, job, onJob);
|
|
903
|
+
if (concurrency === 1) {
|
|
904
|
+
await run;
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
inFlight.add(run);
|
|
908
|
+
void run.finally(() => inFlight.delete(run));
|
|
909
|
+
return true;
|
|
910
|
+
}, idleBackoffMax === undefined ? undefined : { idleBackoffMax });
|
|
911
|
+
}
|
|
912
|
+
/** Resolve when one of `inFlight` settles or the adaptive controller moves the limit. */
|
|
913
|
+
async #slotFreed(inFlight) {
|
|
914
|
+
let wake = () => { };
|
|
915
|
+
const moved = new Promise((resolve) => {
|
|
916
|
+
wake = resolve;
|
|
917
|
+
});
|
|
918
|
+
this.#limitWaiters.add(wake);
|
|
919
|
+
try {
|
|
920
|
+
await Promise.race([...inFlight, moved]);
|
|
921
|
+
}
|
|
922
|
+
finally {
|
|
923
|
+
this.#limitWaiters.delete(wake);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Run one popped job and finalize it: complete on success, hand back for redelivery on a throw.
|
|
928
|
+
* Tracked in {@link #openJobs} (renewed, and handed back by `close()` if unfinished) and in
|
|
929
|
+
* {@link #runningJobs} (waited for by `close()`, bounded).
|
|
930
|
+
*/
|
|
931
|
+
#runJob(queue, job, onJob) {
|
|
932
|
+
// Track the popped-but-unfinished job so close() can hand it back to the broker instead of
|
|
933
|
+
// destroying the adapter under it (which stranded it `active` until the stalled sweep —
|
|
934
|
+
// every deploy of a busy fleet parked its mid-flight jobs for up to the stalled threshold).
|
|
935
|
+
const handle = jobHandle(job);
|
|
936
|
+
this.#openJobs.set(job.id, { queue, handle });
|
|
937
|
+
const run = (async () => {
|
|
358
938
|
try {
|
|
359
939
|
await onJob(job);
|
|
360
940
|
// A lease-based adapter answers `false` when the lease was lost (the reclaim sweep re-delivered
|
|
@@ -372,8 +952,10 @@ export class QueueTransport {
|
|
|
372
952
|
finally {
|
|
373
953
|
this.#openJobs.delete(job.id);
|
|
374
954
|
}
|
|
375
|
-
|
|
376
|
-
|
|
955
|
+
})();
|
|
956
|
+
this.#runningJobs.add(run);
|
|
957
|
+
void run.finally(() => this.#runningJobs.delete(run));
|
|
958
|
+
return run;
|
|
377
959
|
}
|
|
378
960
|
// ---------------------------------------------------------------------------
|
|
379
961
|
// stalled-job reclaim
|
|
@@ -395,8 +977,9 @@ export class QueueTransport {
|
|
|
395
977
|
* engine ignores results for a checkpoint that already completed — so re-running a reclaimed step, or
|
|
396
978
|
* re-delivering the two production shapes (result published but `completeJob` never reached; or claimed
|
|
397
979
|
* and died before any result), cannot corrupt a run. The only cost is wasted work when a merely-slow
|
|
398
|
-
* worker's job is re-delivered
|
|
399
|
-
*
|
|
980
|
+
* worker's job is re-delivered — which claim renewal ({@link #startClaimRenewal}) rules out on an
|
|
981
|
+
* adapter that supports it, and which is why {@link DEFAULT_STALLED_THRESHOLD_MS} is deliberately
|
|
982
|
+
* generous on one that doesn't. A permanently lost job is a worse failure than a rare double-run of an idempotent step, so
|
|
400
983
|
* this defaults ON; set `stalledCheckIntervalMs: 0` to opt out.
|
|
401
984
|
*
|
|
402
985
|
* Feature-detected: an adapter that doesn't implement `recoverStalledJobs` (not every driver does)
|
|
@@ -415,13 +998,10 @@ export class QueueTransport {
|
|
|
415
998
|
// `@adonisjs/queue` 0.7 (`@boringnode/queue` >= 0.8) no longer fails a job that stalled too
|
|
416
999
|
// many times: it keeps it `active`, re-leases it to us and returns it in `exceeded`, so the
|
|
417
1000
|
// caller fails it. Left alone it would stay `active` forever. 0.6 returns a count and has
|
|
418
|
-
// already failed them — nothing to do.
|
|
1001
|
+
// already failed (deleted) them — nothing to do, and nothing left to bridge.
|
|
419
1002
|
if (outcome && typeof outcome === 'object') {
|
|
420
|
-
for (const job of outcome.exceeded)
|
|
421
|
-
await this.#
|
|
422
|
-
.failJob(jobHandle(job), queue, new Error(`stalled more than ${this.#maxStalledCount} times`))
|
|
423
|
-
.catch((err) => this.#onError(err));
|
|
424
|
-
}
|
|
1003
|
+
for (const job of outcome.exceeded)
|
|
1004
|
+
await this.#failPoisonJob(queue, job);
|
|
425
1005
|
}
|
|
426
1006
|
}
|
|
427
1007
|
catch (err) {
|
|
@@ -432,6 +1012,100 @@ export class QueueTransport {
|
|
|
432
1012
|
});
|
|
433
1013
|
return pollers;
|
|
434
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Fail a job that stalled more than `maxStalledCount` times and, for a STEP task, publish a failed
|
|
1017
|
+
* {@link StepResult} for it — the queue twin of the BullMQ transport's `worker.on('failed')` bridge.
|
|
1018
|
+
* No handler produced a result for that step (its workers kept dying), so without one the run would
|
|
1019
|
+
* wait on its `pending` checkpoint forever. The result is RETRYABLE: the engine re-dispatches while
|
|
1020
|
+
* the step has attempts left, then fails it into the workflow's error path. Only after `failJob`
|
|
1021
|
+
* succeeded (a lost lease means another sweep owns the job), and only for task queues: a stalled
|
|
1022
|
+
* result/heartbeat/control job carries no step to fail, and a workflow-turn payload has no step
|
|
1023
|
+
* identity, so both are just failed.
|
|
1024
|
+
*/
|
|
1025
|
+
async #failPoisonJob(queue, job) {
|
|
1026
|
+
const reason = `stalled more than ${this.#maxStalledCount} times`;
|
|
1027
|
+
let failed;
|
|
1028
|
+
try {
|
|
1029
|
+
failed = await this.#adapter.failJob(jobHandle(job), queue, new Error(reason));
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
this.#onError(err);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (failed === false || !this.#taskQueues.has(queue))
|
|
1036
|
+
return;
|
|
1037
|
+
let identity;
|
|
1038
|
+
try {
|
|
1039
|
+
identity = failedTaskIdentity(fromJson(job.payload));
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
return; // an unparseable payload: nothing safe to publish
|
|
1043
|
+
}
|
|
1044
|
+
if (!identity)
|
|
1045
|
+
return;
|
|
1046
|
+
try {
|
|
1047
|
+
await this.#adapter.pushOn(this.#resultsQueue(), this.#job('result', infrastructureFailure(identity, reason)));
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
this.#onError(err);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
// claim renewal
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
/**
|
|
1057
|
+
* Renew every claim this instance is still working on, each half {@link #stalledThresholdMs} —
|
|
1058
|
+
* the heartbeat `@boringnode/queue`'s own `Worker` runs, which this transport bypasses (see the
|
|
1059
|
+
* class header) exactly as it bypasses that class's stalled recovery.
|
|
1060
|
+
*
|
|
1061
|
+
* WHY: without it, a claim's age is the step's elapsed runtime, so the threshold had to exceed the
|
|
1062
|
+
* longest step — a 30min default that also left a dead worker's job unrecovered for 30min, while a
|
|
1063
|
+
* step that legitimately ran longer was re-delivered and ran twice. Renewing makes the age measure
|
|
1064
|
+
* the worker's silence instead, so a short threshold recovers crashes fast without ever re-running a
|
|
1065
|
+
* healthy long step.
|
|
1066
|
+
*
|
|
1067
|
+
* Each claim is renewed with the handle it is finalized with (lease on 0.9, job id on 0.6). The timer is `unref`'d, and a round still in flight skips the next tick instead of stacking.
|
|
1068
|
+
*/
|
|
1069
|
+
#startClaimRenewal() {
|
|
1070
|
+
const intervalMs = Math.max(Math.floor(this.#stalledThresholdMs / 2), 1);
|
|
1071
|
+
this.#renewTimer = setInterval(() => {
|
|
1072
|
+
if (this.#renewal)
|
|
1073
|
+
return;
|
|
1074
|
+
const round = this.#renewOpenClaims().finally(() => {
|
|
1075
|
+
if (this.#renewal === round)
|
|
1076
|
+
this.#renewal = undefined;
|
|
1077
|
+
});
|
|
1078
|
+
this.#renewal = round;
|
|
1079
|
+
}, intervalMs);
|
|
1080
|
+
this.#renewTimer.unref?.();
|
|
1081
|
+
}
|
|
1082
|
+
async #renewOpenClaims() {
|
|
1083
|
+
if (this.#openJobs.size === 0)
|
|
1084
|
+
return;
|
|
1085
|
+
const byQueue = new Map();
|
|
1086
|
+
for (const { queue, handle } of this.#openJobs.values()) {
|
|
1087
|
+
const leases = byQueue.get(queue) ?? [];
|
|
1088
|
+
leases.push(handle);
|
|
1089
|
+
byQueue.set(queue, leases);
|
|
1090
|
+
}
|
|
1091
|
+
for (const [queue, leases] of byQueue) {
|
|
1092
|
+
try {
|
|
1093
|
+
const renewed = await this.#adapter.renewJobs?.(queue, leases);
|
|
1094
|
+
// A job that finished normally during the round-trip was finalized, not lost: count only the
|
|
1095
|
+
// claims still open under the same handle, or a healthy completion would raise a false alarm.
|
|
1096
|
+
const stillHeld = leases.filter((lease) => this.#openJobs.get(typeof lease === 'string' ? lease : lease.id)?.handle === lease).length;
|
|
1097
|
+
// Fewer renewed than still held: the sweep already re-delivered those claims (this worker went
|
|
1098
|
+
// silent past the threshold), so another worker may be running the same step right now. The
|
|
1099
|
+
// handler can't be aborted — durable steps are idempotent — but the double run must be loud.
|
|
1100
|
+
if (typeof renewed === 'number' && renewed < stillHeld) {
|
|
1101
|
+
this.#onError(new Error(`[QueueTransport] lost ${stillHeld - renewed} of ${stillHeld} claim(s) on "${queue}" before renewing them — the jobs were re-delivered and may run twice; raise stalledThresholdMs if this worker blocks its event loop`));
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
catch (err) {
|
|
1105
|
+
this.#onError(err);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
435
1109
|
/**
|
|
436
1110
|
* Stop every poll loop, WAIT (bounded) for the job already in a handler's hands, and hand any
|
|
437
1111
|
* still-unfinished job back to the broker before destroying the adapter. Without the wait, a
|
|
@@ -443,13 +1117,49 @@ export class QueueTransport {
|
|
|
443
1117
|
this.#pollers.stopAll();
|
|
444
1118
|
this.#reclaimPollers?.stopAll();
|
|
445
1119
|
this.#taskLoops.clear();
|
|
446
|
-
|
|
1120
|
+
this.#controller.stop();
|
|
1121
|
+
if (this.#workerHeartbeatTimer)
|
|
1122
|
+
clearInterval(this.#workerHeartbeatTimer);
|
|
1123
|
+
// A clean stop withdraws this instance's liveness keys at once, so the dashboard and
|
|
1124
|
+
// `listWorkerGroups` drop it now instead of after the 35s TTL. In-flight beats land first, or one
|
|
1125
|
+
// could re-create a key after its removal.
|
|
1126
|
+
if (this.#heartbeatTokens.size > 0) {
|
|
1127
|
+
await Promise.allSettled([...this.#livenessWrites]);
|
|
1128
|
+
const keys = [...this.#heartbeatTokens].map((t) => workerHeartbeatKey(this.#effectivePrefix(), t, this.#instanceId));
|
|
1129
|
+
this.#heartbeatTokens.clear();
|
|
1130
|
+
await Promise.resolve()
|
|
1131
|
+
.then(() => this.#workerKeys.remove(keys))
|
|
1132
|
+
.catch((err) => this.#onError(err));
|
|
1133
|
+
}
|
|
1134
|
+
this.#workerHeartbeatTimer = undefined;
|
|
1135
|
+
const drainDeadline = Date.now() + this.#closeTimeoutMs;
|
|
1136
|
+
await this.#pollers.drain(this.#closeTimeoutMs);
|
|
1137
|
+
// Jobs handed off by a multi-slot loop are not part of a poll tick: wait for them too, within the
|
|
1138
|
+
// same budget.
|
|
1139
|
+
if (this.#runningJobs.size > 0) {
|
|
1140
|
+
const left = Math.max(drainDeadline - Date.now(), 0);
|
|
1141
|
+
let timer;
|
|
1142
|
+
await Promise.race([
|
|
1143
|
+
Promise.allSettled([...this.#runningJobs]),
|
|
1144
|
+
new Promise((r) => {
|
|
1145
|
+
timer = setTimeout(r, left);
|
|
1146
|
+
}),
|
|
1147
|
+
]);
|
|
1148
|
+
clearTimeout(timer);
|
|
1149
|
+
}
|
|
1150
|
+
// Renewal keeps running through the drain (a job finishing inside it must keep its claim), then
|
|
1151
|
+
// stops before the leftovers are handed back.
|
|
1152
|
+
if (this.#renewTimer)
|
|
1153
|
+
clearInterval(this.#renewTimer);
|
|
1154
|
+
this.#renewTimer = undefined;
|
|
1155
|
+
await this.#renewal;
|
|
447
1156
|
// Anything still open after the bounded drain (a long step): re-deliver it now so another
|
|
448
1157
|
// worker picks it up immediately instead of waiting out the stalled threshold.
|
|
449
1158
|
for (const [jobId, { queue, handle }] of [...this.#openJobs]) {
|
|
450
1159
|
this.#openJobs.delete(jobId);
|
|
451
1160
|
await this.#adapter.retryJob(handle, queue, new Date()).catch((err) => this.#onError(err));
|
|
452
1161
|
}
|
|
1162
|
+
await this.#closeInboxes();
|
|
453
1163
|
await this.#adapter.destroy();
|
|
454
1164
|
}
|
|
455
1165
|
}
|