deepline 0.3.89 → 0.3.91
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/contracts/install-commands.json +29 -0
- package/dist/bundling-sources/sdk/src/client.ts +221 -43
- package/dist/bundling-sources/sdk/src/index.ts +4 -0
- package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
- package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
- package/dist/bundling-sources/sdk/src/pagination.ts +25 -0
- package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
- package/dist/bundling-sources/sdk/src/release.ts +11 -9
- package/dist/bundling-sources/sdk/src/types.ts +29 -1
- package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
- package/dist/bundling-sources/shared_libs/monitors/analytics-public.ts +10 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
- package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +8 -1
- package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +4 -4
- package/dist/bundling-sources/shared_libs/monitors/inventory-reconciliation.ts +59 -0
- package/dist/bundling-sources/shared_libs/monitors/lifecycle-notifications.ts +7 -9
- package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
- package/dist/bundling-sources/shared_libs/monitors/package.json +113 -4
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +56 -9
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-consumer-health.ts +2 -2
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-identity-claims.ts +2 -2
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-public-aliases.ts +1 -1
- package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-dead-letter.ts +245 -0
- package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-message.ts +38 -0
- package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue-ack-probe.ts +94 -0
- package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue-delivery.ts +60 -0
- package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue.ts +60 -0
- package/dist/bundling-sources/shared_libs/monitors/snitcher-install-validation.ts +1 -1
- package/dist/bundling-sources/shared_libs/monitors/snitcher-ip2company.ts +2 -2
- package/dist/bundling-sources/shared_libs/monitors/snitcher-radar.ts +1 -1
- package/dist/bundling-sources/shared_libs/monitors/tracking-provider-integrations.ts +1 -1
- package/dist/bundling-sources/shared_libs/observability/build-context.ts +13 -0
- package/dist/bundling-sources/shared_libs/observability/log-metadata.ts +160 -0
- package/dist/bundling-sources/shared_libs/observability/request-context.ts +68 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +690 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +35 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +5 -0
- package/dist/bundling-sources/shared_libs/play-data-plane/neon-constants.ts +9 -0
- package/dist/bundling-sources/shared_libs/play-data-plane/r2.ts +272 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +46 -1
- package/dist/bundling-sources/shared_libs/play-runtime/async-operation.ts +66 -1
- package/dist/bundling-sources/shared_libs/play-runtime/async-play-invocation.ts +0 -1
- package/dist/bundling-sources/shared_libs/play-runtime/bounceban-batching.ts +137 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +779 -140
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +46 -0
- package/dist/bundling-sources/shared_libs/play-runtime/daytona-payload-contract.ts +68 -0
- package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-janitor.ts +103 -0
- package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-reaper.ts +725 -0
- package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +29 -0
- package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +23 -0
- package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-invocation-fence.ts +442 -0
- package/dist/bundling-sources/shared_libs/play-runtime/execution-protocol.ts +3 -3
- package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +18 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +9 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/contracts.ts +66 -0
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/job.ts +145 -0
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/ports.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/postgres-store.ts +170 -0
- package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +1322 -0
- package/dist/bundling-sources/shared_libs/play-runtime/internal-step-ids.ts +2 -2
- package/dist/bundling-sources/shared_libs/play-runtime/lane-retirement-gate.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +112 -0
- package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/perf-trace.ts +127 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-artifact-runtime-contract.ts +5 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-run-recovery-policy.ts +24 -1
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +56 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +13 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/job.ts +41 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/ports.ts +17 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +54 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/contracts.ts +114 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/engine-suspension.ts +108 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/index.ts +36 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/job.ts +97 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/ports.ts +104 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/postgres-fence-adapter.ts +263 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/worker-terminal-projection.ts +64 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/contracts.ts +29 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +56 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/index.ts +15 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/job.ts +65 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/ports.ts +17 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/contracts.ts +42 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/index.ts +17 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/job.ts +142 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/ports.ts +42 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/postgres-adapter.ts +102 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/contracts.ts +60 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/index.ts +16 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/job.ts +78 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/ports.ts +54 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/postgres-adapter.ts +71 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-batching-registry.ts +4 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +5 -8
- package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +3 -5
- package/dist/bundling-sources/shared_libs/play-runtime/run-identity.ts +168 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +72 -9
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +6 -12
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +19 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/bundle.ts +0 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +14 -44
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +103 -208
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-authority.ts +201 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-env.ts +6 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +67 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-release-environment.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-contract.ts +68 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-scheduler-topology.ts +14 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-telemetry.ts +108 -0
- package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/contract.ts +4 -0
- package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/runner-backend-adapter.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-deadline-reconciliation.ts +88 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-deploy-task.ts +124 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-run-task.ts +68 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-pool.ts +193 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-queue-lifecycle.ts +269 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-queue-suffix.ts +73 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +6642 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +897 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-metrics.ts +120 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-required-env.ts +76 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +476 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-heavy-request-admission.ts +47 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-progress-registry.ts +147 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/index.ts +27 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/lazy.ts +50 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/play-run-recovery.ts +98 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-budget-state.ts +260 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +1416 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +1255 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +942 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +14636 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +888 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +355 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-terminal-transition.ts +46 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runner-push.ts +60 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-api-origin.ts +33 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-receipt-gateway-health.ts +64 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-capacity.ts +835 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-ledger-reconciliation.ts +1218 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-operation.ts +207 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-scheduler-schema.ts +10 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-capacity.ts +123 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-launch-read-observability.ts +78 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +169 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/terminal-contract.ts +20 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-queue-metrics.ts +34 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-resource-metrics.ts +249 -0
- package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/step-progress.ts +296 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-call/native-batch.ts +3 -3
- package/dist/bundling-sources/shared_libs/play-runtime/tool-call/runtime-step-receipts-adapter.ts +20 -1
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +60 -15
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execution-outcome.ts +20 -0
- package/dist/bundling-sources/shared_libs/play-runtime/transient-service-error.ts +12 -0
- package/dist/bundling-sources/shared_libs/play-runtime/vercel-protection.ts +14 -1
- package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
- package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-storage-contract.ts +165 -0
- package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +15 -23
- package/dist/bundling-sources/shared_libs/play-runtime/workflow-executor-auth.ts +169 -0
- package/dist/bundling-sources/shared_libs/play-runtime/zerobounce-batching.ts +122 -0
- package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +2 -0
- package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
- package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
- package/dist/bundling-sources/shared_libs/plays/contracts.ts +2 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
- package/dist/bundling-sources/shared_libs/plays/deployment-status.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/docflow.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
- package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
- package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
- package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +112 -0
- package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
- package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
- package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
- package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
- package/dist/bundling-sources/shared_libs/plays/share/copy.ts +271 -0
- package/dist/bundling-sources/shared_libs/plays/share/metrics.ts +140 -0
- package/dist/bundling-sources/shared_libs/plays/share/public-read-model.ts +86 -0
- package/dist/bundling-sources/shared_libs/plays/share/seo-quality.ts +181 -0
- package/dist/bundling-sources/shared_libs/plays/share/static-pipeline-summary.ts +658 -0
- package/dist/bundling-sources/shared_libs/plays/share/types.ts +197 -0
- package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
- package/dist/bundling-sources/shared_libs/plays/slack-hitl-timeout-error.ts +37 -0
- package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
- package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +6 -1
- package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
- package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
- package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
- package/dist/bundling-sources/shared_libs/product-notifications/configuration.ts +7 -1
- package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +3 -2
- package/dist/bundling-sources/shared_libs/product-notifications/overflow.ts +18 -0
- package/dist/bundling-sources/shared_libs/product-notifications/rolling-limit.ts +24 -0
- package/dist/bundling-sources/shared_libs/product-notifications/slack-delivery.ts +624 -0
- package/dist/bundling-sources/shared_libs/product-notifications/slack-payload.ts +140 -0
- package/dist/bundling-sources/shared_libs/product-notifications/slack-transport.ts +75 -0
- package/dist/bundling-sources/shared_libs/runtime-env.ts +6 -0
- package/dist/bundling-sources/shared_libs/security/email-blacklist.ts +42 -0
- package/dist/bundling-sources/shared_libs/security/header-safe-env.ts +10 -3
- package/dist/bundling-sources/shared_libs/security/slack-sanitize.ts +133 -0
- package/dist/cli/getting-started.d.mts +52 -0
- package/dist/cli/getting-started.d.ts +52 -0
- package/dist/cli/getting-started.js +65 -0
- package/dist/cli/getting-started.mjs +36 -0
- package/dist/cli/index.d.mts +9 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.js +895 -407
- package/dist/cli/index.mjs +893 -414
- package/dist/{compiler-manifest-DsLbQaew.d.mts → compiler-manifest-x14KL83E.d.mts} +4 -2
- package/dist/{compiler-manifest-DsLbQaew.d.ts → compiler-manifest-x14KL83E.d.ts} +4 -2
- package/dist/index.d.mts +124 -154
- package/dist/index.d.ts +124 -154
- package/dist/index.js +132 -28
- package/dist/index.mjs +132 -28
- package/dist/install-integrity.json +182 -2
- package/dist/monitor-fleet-contract.d.mts +131 -0
- package/dist/monitor-fleet-contract.d.ts +131 -0
- package/dist/monitor-fleet-contract.js +406 -0
- package/dist/monitor-fleet-contract.mjs +367 -0
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/release.d.mts +255 -0
- package/dist/release.d.ts +255 -0
- package/dist/release.js +210 -0
- package/dist/release.mjs +183 -0
- package/package.json +21 -4
|
@@ -0,0 +1,1416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pooled connection layer for the Postgres runtime scheduler control plane.
|
|
3
|
+
*
|
|
4
|
+
* The scheduler's hot path is short, idempotent transactions (submit, claim,
|
|
5
|
+
* complete, receipts). Three properties matter more than anything else here:
|
|
6
|
+
*
|
|
7
|
+
* 1. No connection churn. Every operation runs on a process-singleton
|
|
8
|
+
* `pg.Pool` aimed at the *pooled* (PgBouncer transaction-mode) endpoint.
|
|
9
|
+
* Opening a fresh `pg.Client` per operation — what the prototype did —
|
|
10
|
+
* costs a TLS handshake plus auth round trips per call and collapses
|
|
11
|
+
* under burst.
|
|
12
|
+
* 2. Bounded everything. Client caps, acquire timeouts, query timeouts, and
|
|
13
|
+
* an explicit saturation error once the acquire queue backs up, so
|
|
14
|
+
* callers shed load instead of stacking unbounded waiters.
|
|
15
|
+
* 3. Loud failure + fast recovery. A circuit breaker opens after repeated
|
|
16
|
+
* connection failures so a dead DB fails calls in microseconds instead
|
|
17
|
+
* of stacking 5s connect timeouts, and a half-open trial closes it as
|
|
18
|
+
* soon as the DB is back.
|
|
19
|
+
*
|
|
20
|
+
* LISTEN/NOTIFY does not work through PgBouncer transaction pooling, so the
|
|
21
|
+
* notify hub derives the direct (non-pooler) host for its single listener
|
|
22
|
+
* connection. Notifications are strictly a wakeup hint — the tables remain
|
|
23
|
+
* the source of truth and every waiter still reads the DB.
|
|
24
|
+
*/
|
|
25
|
+
import { Client, Pool } from 'pg';
|
|
26
|
+
import {
|
|
27
|
+
RuntimeSchedulerPgBouncerCapacityError,
|
|
28
|
+
RuntimeSchedulerSubmissionAdmissionError,
|
|
29
|
+
RuntimeSchedulerSaturatedError,
|
|
30
|
+
} from './scheduler-capacity';
|
|
31
|
+
import { RUNTIME_RELIABILITY_POLICY } from '../runtime-reliability-policy';
|
|
32
|
+
export {
|
|
33
|
+
isRuntimeSchedulerCapacityError,
|
|
34
|
+
parseRuntimeSchedulerCapacityError,
|
|
35
|
+
RuntimeSchedulerCapacityError,
|
|
36
|
+
RuntimeSchedulerPgBouncerCapacityError,
|
|
37
|
+
RuntimeSchedulerSubmissionAdmissionError,
|
|
38
|
+
RuntimeSchedulerSaturatedError,
|
|
39
|
+
runtimeSchedulerCapacityRetryAfterMs,
|
|
40
|
+
} from './scheduler-capacity';
|
|
41
|
+
|
|
42
|
+
const PG_SSL_FILE_PARAMS = ['sslcert', 'sslkey', 'sslrootcert'] as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* PlanetScale and Neon Postgres URLs can use `sslrootcert=system` to mean "use
|
|
46
|
+
* the host system CA bundle". `pg-connection-string` treats sslcert/sslkey/
|
|
47
|
+
* sslrootcert as file paths and tries to open a file literally named `system`,
|
|
48
|
+
* so normalize that sentinel before handing the URL to node-postgres. Node's
|
|
49
|
+
* TLS stack uses system roots by default when no custom CA is supplied.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizePostgresConnectionStringForPg(url: string): string {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = new URL(url);
|
|
54
|
+
let changed = false;
|
|
55
|
+
for (const param of PG_SSL_FILE_PARAMS) {
|
|
56
|
+
if (parsed.searchParams.get(param)?.trim().toLowerCase() === 'system') {
|
|
57
|
+
parsed.searchParams.delete(param);
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return changed ? parsed.toString() : url;
|
|
62
|
+
} catch {
|
|
63
|
+
return url;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonical env var for the workflow runtime control-plane Postgres
|
|
69
|
+
* (scheduler queue, leases, receipts, outbox). Hard-cutover runtime wiring
|
|
70
|
+
* must use this name in local dev, CI, Fly, and Vercel.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveWorkflowRuntimePostgresUrl(): string | null {
|
|
73
|
+
const url = process.env.WORKFLOW_RUNTIME_POSTGRES?.trim() || null;
|
|
74
|
+
return url ? normalizePostgresConnectionStringForPg(url) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parsePostgresUrl(url: string): URL | null {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = new URL(url);
|
|
80
|
+
const protocol = parsed.protocol.toLowerCase();
|
|
81
|
+
if (protocol !== 'postgres:' && protocol !== 'postgresql:') return null;
|
|
82
|
+
return parsed;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPlanetScalePostgresUrl(parsed: URL): boolean {
|
|
89
|
+
const host = parsed.hostname.toLowerCase();
|
|
90
|
+
return host.includes('psdb.cloud') || host.includes('planetscale');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isKnownPooledRuntimePostgresUrl(url: string): boolean {
|
|
94
|
+
const parsed = parsePostgresUrl(url);
|
|
95
|
+
if (!parsed) return false;
|
|
96
|
+
if (isPlanetScalePostgresUrl(parsed)) {
|
|
97
|
+
return parsed.port === '6432';
|
|
98
|
+
}
|
|
99
|
+
const host = parsed.hostname.toLowerCase();
|
|
100
|
+
return host.includes('-pooler') || parsed.searchParams.has('pool_timeout');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class RuntimeSchedulerDirectDatabaseUrlError extends Error {
|
|
104
|
+
constructor() {
|
|
105
|
+
super(
|
|
106
|
+
'WORKFLOW_RUNTIME_POSTGRES points at PlanetScale direct Postgres (port 5432). ' +
|
|
107
|
+
'Runtime scheduler hot paths must use the PgBouncer endpoint (port 6432); ' +
|
|
108
|
+
'reserve direct connections for explicit session-only operations.',
|
|
109
|
+
);
|
|
110
|
+
this.name = 'RuntimeSchedulerDirectDatabaseUrlError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function assertRuntimeSchedulerHotPathUrl(url: string): void {
|
|
115
|
+
const parsed = parsePostgresUrl(url);
|
|
116
|
+
if (!parsed) return;
|
|
117
|
+
if (isPlanetScalePostgresUrl(parsed) && (parsed.port || '5432') === '5432') {
|
|
118
|
+
throw new RuntimeSchedulerDirectDatabaseUrlError();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const RUNTIME_SCHEDULER_POOL_DEFAULTS = {
|
|
123
|
+
/** Per-process client cap. Workers override via options; keep small on serverless. */
|
|
124
|
+
maxClients: 8,
|
|
125
|
+
/** Refuse to construct a pool above this — protects the shared PgBouncer/Postgres. */
|
|
126
|
+
maxClientsHardCap: 32,
|
|
127
|
+
/** Max time a caller waits for a pooled connection before erroring. */
|
|
128
|
+
acquireTimeoutMs:
|
|
129
|
+
RUNTIME_RELIABILITY_POLICY.schedulerDatabase.acquireTimeoutMs,
|
|
130
|
+
/** Idle pooled connections are recycled after this. */
|
|
131
|
+
idleTimeoutMs: 30_000,
|
|
132
|
+
connectTimeoutMs: 5_000,
|
|
133
|
+
/** Client-side query timeout. Server-side SETs are unsafe through transaction pooling. */
|
|
134
|
+
queryTimeoutMs: RUNTIME_RELIABILITY_POLICY.schedulerDatabase.queryTimeoutMs,
|
|
135
|
+
/** Pending operations beyond one dense-100 burst fail fast instead of growing without bound. */
|
|
136
|
+
maxAcquireQueueDepth: 128,
|
|
137
|
+
retry: {
|
|
138
|
+
attempts: RUNTIME_RELIABILITY_POLICY.schedulerDatabase.retryAttempts,
|
|
139
|
+
baseDelayMs: RUNTIME_RELIABILITY_POLICY.schedulerDatabase.retryBaseDelayMs,
|
|
140
|
+
maxDelayMs: RUNTIME_RELIABILITY_POLICY.schedulerDatabase.retryMaxDelayMs,
|
|
141
|
+
},
|
|
142
|
+
breaker: {
|
|
143
|
+
/** Consecutive connection-category failures before the circuit opens. */
|
|
144
|
+
threshold: 5,
|
|
145
|
+
/** How long the circuit stays open before a half-open trial. */
|
|
146
|
+
openMs: RUNTIME_RELIABILITY_POLICY.schedulerDatabase.circuitOpenMs,
|
|
147
|
+
},
|
|
148
|
+
/**
|
|
149
|
+
* Keepalive ping cadence for long-lived worker processes and PgBouncer-style
|
|
150
|
+
* pooled endpoints.
|
|
151
|
+
*/
|
|
152
|
+
keepAliveIntervalMs: 60_000,
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A control-plane process only needs a few short scheduler transactions at
|
|
157
|
+
* once. This shared cap keeps horizontally scaled workers and the receipt
|
|
158
|
+
* gateway from consuming PgBouncer's global client budget independently.
|
|
159
|
+
*/
|
|
160
|
+
export const RUNTIME_SCHEDULER_CONTROL_PLANE_MAX_CLIENTS = 2;
|
|
161
|
+
|
|
162
|
+
export type RuntimeSchedulerPoolOptions = {
|
|
163
|
+
url: string;
|
|
164
|
+
applicationName?: string;
|
|
165
|
+
maxClients?: number;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type RuntimeSchedulerQueryClient = {
|
|
169
|
+
query<Row extends Record<string, unknown> = Record<string, unknown>>(
|
|
170
|
+
sql: string,
|
|
171
|
+
params?: unknown[],
|
|
172
|
+
): Promise<{ rows: Row[] }>;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export class RuntimeSchedulerCircuitOpenError extends Error {
|
|
176
|
+
constructor(cause: string) {
|
|
177
|
+
super(
|
|
178
|
+
`Runtime scheduler DB circuit breaker is open after repeated connection failures: ${cause}`,
|
|
179
|
+
);
|
|
180
|
+
this.name = 'RuntimeSchedulerCircuitOpenError';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type LatencyRing = {
|
|
185
|
+
samples: number[];
|
|
186
|
+
next: number;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const LATENCY_RING_SIZE = 512;
|
|
190
|
+
|
|
191
|
+
function pushSample(ring: LatencyRing, value: number): void {
|
|
192
|
+
if (ring.samples.length < LATENCY_RING_SIZE) {
|
|
193
|
+
ring.samples.push(value);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
ring.samples[ring.next % LATENCY_RING_SIZE] = value;
|
|
197
|
+
ring.next = (ring.next + 1) % LATENCY_RING_SIZE;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ringQuantile(ring: LatencyRing, q: number): number | null {
|
|
201
|
+
if (ring.samples.length === 0) return null;
|
|
202
|
+
const sorted = [...ring.samples].sort((a, b) => a - b);
|
|
203
|
+
const idx = Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1);
|
|
204
|
+
return sorted[Math.max(0, idx)] ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type BreakerState = {
|
|
208
|
+
consecutiveFailures: number;
|
|
209
|
+
openedAt: number | null;
|
|
210
|
+
lastFailureMessage: string;
|
|
211
|
+
halfOpenInFlight: boolean;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export type RuntimeSchedulerPoolPriority = 'ordinary' | 'critical';
|
|
215
|
+
|
|
216
|
+
type PoolAdmissionWaiter = {
|
|
217
|
+
priority: RuntimeSchedulerPoolPriority;
|
|
218
|
+
resolve: (release: () => void) => void;
|
|
219
|
+
reject: (error: Error) => void;
|
|
220
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
221
|
+
signal?: AbortSignal;
|
|
222
|
+
onAbort?: () => void;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Critical work may skip one queued launch, but never an unbounded launch
|
|
227
|
+
* backlog. Alternation keeps already-running Plays moving without turning a
|
|
228
|
+
* burst of executor-token lookups into starvation for new durable launches.
|
|
229
|
+
*/
|
|
230
|
+
const MAX_CONSECUTIVE_CRITICAL_ADMISSIONS = 1;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Keep priority outside `pg.Pool`: once pg-pool owns a checkout request it is
|
|
234
|
+
* FIFO and an already-running Play's control-plane request can sit behind an
|
|
235
|
+
* entire launch burst. This gate admits no more work than the existing client
|
|
236
|
+
* cap and only changes which bounded waiter receives the next free client.
|
|
237
|
+
*/
|
|
238
|
+
export class RuntimeSchedulerPoolAdmission {
|
|
239
|
+
readonly #waiting: Record<
|
|
240
|
+
RuntimeSchedulerPoolPriority,
|
|
241
|
+
PoolAdmissionWaiter[]
|
|
242
|
+
> = {
|
|
243
|
+
ordinary: [],
|
|
244
|
+
critical: [],
|
|
245
|
+
};
|
|
246
|
+
#active = 0;
|
|
247
|
+
#consecutiveCritical = 0;
|
|
248
|
+
|
|
249
|
+
constructor(
|
|
250
|
+
readonly maxActive: number,
|
|
251
|
+
readonly maxWaiting: number,
|
|
252
|
+
readonly maxWaitMs: number = RUNTIME_SCHEDULER_POOL_DEFAULTS.acquireTimeoutMs,
|
|
253
|
+
) {}
|
|
254
|
+
|
|
255
|
+
acquire(
|
|
256
|
+
priority: RuntimeSchedulerPoolPriority,
|
|
257
|
+
signal?: AbortSignal,
|
|
258
|
+
): Promise<() => void> {
|
|
259
|
+
throwIfSchedulerAborted(signal);
|
|
260
|
+
if (this.#active < this.maxActive && this.waitingCount === 0) {
|
|
261
|
+
return Promise.resolve(this.#grant());
|
|
262
|
+
}
|
|
263
|
+
if (this.waitingCount >= this.maxWaiting) {
|
|
264
|
+
throw new RuntimeSchedulerSaturatedError(this.waitingCount);
|
|
265
|
+
}
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const waiter: PoolAdmissionWaiter = {
|
|
268
|
+
priority,
|
|
269
|
+
resolve,
|
|
270
|
+
reject,
|
|
271
|
+
signal,
|
|
272
|
+
timeout: setTimeout(() => {
|
|
273
|
+
const index = this.#waiting[priority].indexOf(waiter);
|
|
274
|
+
if (index < 0) return;
|
|
275
|
+
this.#waiting[priority].splice(index, 1);
|
|
276
|
+
signal?.removeEventListener('abort', waiter.onAbort!);
|
|
277
|
+
reject(new RuntimeSchedulerSaturatedError(this.waitingCount));
|
|
278
|
+
}, this.maxWaitMs),
|
|
279
|
+
};
|
|
280
|
+
waiter.onAbort = () => {
|
|
281
|
+
const index = this.#waiting[priority].indexOf(waiter);
|
|
282
|
+
if (index < 0) return;
|
|
283
|
+
this.#waiting[priority].splice(index, 1);
|
|
284
|
+
clearTimeout(waiter.timeout);
|
|
285
|
+
reject(schedulerAbortReason(signal!));
|
|
286
|
+
};
|
|
287
|
+
signal?.addEventListener('abort', waiter.onAbort, { once: true });
|
|
288
|
+
this.#waiting[priority].push(waiter);
|
|
289
|
+
this.#drain();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
get waitingCount(): number {
|
|
294
|
+
return this.#waiting.ordinary.length + this.#waiting.critical.length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#grant(): () => void {
|
|
298
|
+
this.#active += 1;
|
|
299
|
+
let released = false;
|
|
300
|
+
return () => {
|
|
301
|
+
if (released) return;
|
|
302
|
+
released = true;
|
|
303
|
+
this.#active -= 1;
|
|
304
|
+
this.#drain();
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#drain(): void {
|
|
309
|
+
while (this.#active < this.maxActive) {
|
|
310
|
+
const ordinary =
|
|
311
|
+
this.#consecutiveCritical >= MAX_CONSECUTIVE_CRITICAL_ADMISSIONS
|
|
312
|
+
? this.#waiting.ordinary.shift()
|
|
313
|
+
: undefined;
|
|
314
|
+
const waiter =
|
|
315
|
+
ordinary ??
|
|
316
|
+
this.#waiting.critical.shift() ??
|
|
317
|
+
this.#waiting.ordinary.shift();
|
|
318
|
+
if (!waiter) return;
|
|
319
|
+
clearTimeout(waiter.timeout);
|
|
320
|
+
waiter.signal?.removeEventListener('abort', waiter.onAbort!);
|
|
321
|
+
this.#consecutiveCritical =
|
|
322
|
+
waiter.priority === 'critical' ? this.#consecutiveCritical + 1 : 0;
|
|
323
|
+
waiter.resolve(this.#grant());
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
type PoolEntry = {
|
|
329
|
+
pool: Pool;
|
|
330
|
+
maxClients: number;
|
|
331
|
+
admission: RuntimeSchedulerPoolAdmission;
|
|
332
|
+
breaker: BreakerState;
|
|
333
|
+
stats: {
|
|
334
|
+
acquires: number;
|
|
335
|
+
acquireWait: LatencyRing;
|
|
336
|
+
saturationRejections: number;
|
|
337
|
+
capacityRejections: number;
|
|
338
|
+
breakerRejections: number;
|
|
339
|
+
connectionErrors: number;
|
|
340
|
+
queryErrors: number;
|
|
341
|
+
retries: number;
|
|
342
|
+
peakTotalClients: number;
|
|
343
|
+
peakWaitingClients: number;
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const poolRegistryKey = Symbol.for(
|
|
348
|
+
'deepline.runtime-scheduler.postgres-pool.registry',
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
function getPoolRegistry(): Map<string, PoolEntry> {
|
|
352
|
+
const globalWithRegistry = globalThis as typeof globalThis & {
|
|
353
|
+
[poolRegistryKey]?: Map<string, PoolEntry>;
|
|
354
|
+
};
|
|
355
|
+
globalWithRegistry[poolRegistryKey] ??= new Map();
|
|
356
|
+
return globalWithRegistry[poolRegistryKey];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Connection-category errors are safe to retry at the operation level because
|
|
361
|
+
* every scheduler operation is idempotent (ON CONFLICT guards, lease tokens).
|
|
362
|
+
*/
|
|
363
|
+
export function isConnectionCategoryError(error: unknown): boolean {
|
|
364
|
+
const seen = new Set<object>();
|
|
365
|
+
const inspect = (candidate: unknown): boolean => {
|
|
366
|
+
if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
seen.add(candidate);
|
|
370
|
+
|
|
371
|
+
if (
|
|
372
|
+
candidate instanceof AggregateError &&
|
|
373
|
+
[...candidate.errors].some(inspect)
|
|
374
|
+
) {
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
const cause = (candidate as { cause?: unknown }).cause;
|
|
378
|
+
if (inspect(cause)) return true;
|
|
379
|
+
|
|
380
|
+
const code = (candidate as { code?: unknown }).code;
|
|
381
|
+
if (typeof code === 'string') {
|
|
382
|
+
// 08* = connection exceptions; 57P01-57P03 = server shutdown/crash.
|
|
383
|
+
// 53300 is deliberately NOT here: PgBouncer admission pressure is a
|
|
384
|
+
// capacity signal, so durable work must defer instead of opening the
|
|
385
|
+
// connection-failure circuit and burning execution attempts.
|
|
386
|
+
if (code.startsWith('08')) return true;
|
|
387
|
+
if (code === '57P01' || code === '57P02' || code === '57P03') {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (
|
|
391
|
+
code === 'ECONNRESET' ||
|
|
392
|
+
code === 'ECONNREFUSED' ||
|
|
393
|
+
code === 'ETIMEDOUT' ||
|
|
394
|
+
code === 'EPIPE' ||
|
|
395
|
+
code === 'ENOTFOUND'
|
|
396
|
+
) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const message = (candidate as { message?: unknown }).message;
|
|
401
|
+
if (typeof message === 'string') {
|
|
402
|
+
return (
|
|
403
|
+
message.includes('Connection terminated') ||
|
|
404
|
+
/\bterminating connection\b/i.test(message) ||
|
|
405
|
+
message.includes('Client has encountered a connection error') ||
|
|
406
|
+
message.includes('connection timeout') ||
|
|
407
|
+
message.includes('Query read timeout') ||
|
|
408
|
+
// This is the scheduler pooler's own upstream-connect failure. It is
|
|
409
|
+
// observed before a PostgreSQL session exists, so retrying this
|
|
410
|
+
// idempotent scheduler operation cannot replay customer code. Keep the
|
|
411
|
+
// normalization at the DB adapter boundary rather than teaching launch
|
|
412
|
+
// callers to parse an infrastructure string.
|
|
413
|
+
/\bfailed to connect to upstream\b/i.test(message)
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
};
|
|
418
|
+
return inspect(error);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* These PostgreSQL errors abort only the current transaction. Scheduler
|
|
423
|
+
* operations already carry stable operation identities, so re-running the
|
|
424
|
+
* whole operation is safe and is materially better than turning a one-off
|
|
425
|
+
* lock conflict into a failed Play or an unhealthy Machine.
|
|
426
|
+
*/
|
|
427
|
+
export function isRuntimeSchedulerRetryableTransactionError(
|
|
428
|
+
error: unknown,
|
|
429
|
+
): boolean {
|
|
430
|
+
if (!error || typeof error !== 'object') return false;
|
|
431
|
+
const code = (error as { code?: unknown }).code;
|
|
432
|
+
return code === '40001' || code === '40P01' || code === '55P03';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* node-postgres' pool emits this exact lowercase message only when a caller
|
|
437
|
+
* times out waiting in the local checkout queue. A new physical connection
|
|
438
|
+
* timing out is rewritten to `Connection terminated due to connection
|
|
439
|
+
* timeout`, so it must remain a breaker-eligible connection failure.
|
|
440
|
+
*/
|
|
441
|
+
export function isRuntimeSchedulerLocalAcquireTimeout(error: unknown): boolean {
|
|
442
|
+
return (
|
|
443
|
+
error instanceof Error &&
|
|
444
|
+
error.message === 'timeout exceeded when trying to connect'
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Connection-capacity exhaustion is a *retryable saturation* signal, not a
|
|
450
|
+
* broken connection: callers that own durable work must defer instead of
|
|
451
|
+
* burning an execution attempt or opening the connection-failure circuit.
|
|
452
|
+
*
|
|
453
|
+
* Two distinct sources land here:
|
|
454
|
+
* - PgBouncer admission pressure (`max_client_conn`) — SQLSTATE 53300, or a
|
|
455
|
+
* message mentioning `max_client_conn`.
|
|
456
|
+
* - Raw Postgres `max_connections` exhaustion when the runtime DB is reached
|
|
457
|
+
* directly (no pooler): also SQLSTATE 53300 (`too_many_connections`), but
|
|
458
|
+
* at *connect* time node-postgres does not always preserve `.code`, so the
|
|
459
|
+
* Postgres-core message forms are matched too. The canonical form is
|
|
460
|
+
* "remaining connection slots are reserved for roles with the SUPERUSER
|
|
461
|
+
* attribute"; `too many clients already` is the pgbouncer-side twin.
|
|
462
|
+
* Kept function name for call-site stability; it now covers both pooler and
|
|
463
|
+
* direct-Postgres capacity exhaustion.
|
|
464
|
+
*/
|
|
465
|
+
export function isPgBouncerCapacityError(error: unknown): boolean {
|
|
466
|
+
if (!error || typeof error !== 'object') return false;
|
|
467
|
+
if ((error as { code?: unknown }).code === '53300') return true;
|
|
468
|
+
const message = (error as { message?: unknown }).message;
|
|
469
|
+
return (
|
|
470
|
+
typeof message === 'string' &&
|
|
471
|
+
/(?:max_client_conn|too many connections|too many clients|connection limit|remaining connection slots|reserved for roles with the superuser|sorry, too many clients)/i.test(
|
|
472
|
+
message,
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function recordCapacityRejection(entry: PoolEntry, error: unknown): void {
|
|
478
|
+
entry.stats.capacityRejections += 1;
|
|
479
|
+
console.warn('[runtime-scheduler-pool.capacity_rejected]', {
|
|
480
|
+
error: error instanceof Error ? error.message : String(error),
|
|
481
|
+
maxClients: entry.maxClients,
|
|
482
|
+
totalClients: entry.pool.totalCount,
|
|
483
|
+
idleClients: entry.pool.idleCount,
|
|
484
|
+
waitingClients: entry.pool.waitingCount,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
|
|
489
|
+
assertRuntimeSchedulerHotPathUrl(options.url);
|
|
490
|
+
const maxClients =
|
|
491
|
+
options.maxClients ?? RUNTIME_SCHEDULER_POOL_DEFAULTS.maxClients;
|
|
492
|
+
if (maxClients > RUNTIME_SCHEDULER_POOL_DEFAULTS.maxClientsHardCap) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`Runtime scheduler pool maxClients=${maxClients} exceeds the hard cap of ` +
|
|
495
|
+
`${RUNTIME_SCHEDULER_POOL_DEFAULTS.maxClientsHardCap}. Scale worker replicas ` +
|
|
496
|
+
'instead of widening a single process pool.',
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const registry = getPoolRegistry();
|
|
500
|
+
const connectionString = normalizePostgresConnectionStringForPg(options.url);
|
|
501
|
+
const key = `${connectionString}::${maxClients}`;
|
|
502
|
+
const existing = registry.get(key);
|
|
503
|
+
if (existing) {
|
|
504
|
+
// Next dev can retain the process-global pool across a module hot reload.
|
|
505
|
+
// Upgrade that live entry in place when this admission layer first lands.
|
|
506
|
+
existing.admission ??= new RuntimeSchedulerPoolAdmission(
|
|
507
|
+
maxClients,
|
|
508
|
+
RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth,
|
|
509
|
+
);
|
|
510
|
+
return existing;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const pool = new Pool({
|
|
514
|
+
connectionString,
|
|
515
|
+
application_name:
|
|
516
|
+
options.applicationName ?? 'deepline-runtime-scheduler-pool',
|
|
517
|
+
max: maxClients,
|
|
518
|
+
idleTimeoutMillis: RUNTIME_SCHEDULER_POOL_DEFAULTS.idleTimeoutMs,
|
|
519
|
+
connectionTimeoutMillis: RUNTIME_SCHEDULER_POOL_DEFAULTS.acquireTimeoutMs,
|
|
520
|
+
query_timeout: RUNTIME_SCHEDULER_POOL_DEFAULTS.queryTimeoutMs,
|
|
521
|
+
keepAlive: true,
|
|
522
|
+
});
|
|
523
|
+
const entry: PoolEntry = {
|
|
524
|
+
pool,
|
|
525
|
+
maxClients,
|
|
526
|
+
admission: new RuntimeSchedulerPoolAdmission(
|
|
527
|
+
maxClients,
|
|
528
|
+
RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth,
|
|
529
|
+
),
|
|
530
|
+
breaker: {
|
|
531
|
+
consecutiveFailures: 0,
|
|
532
|
+
openedAt: null,
|
|
533
|
+
lastFailureMessage: '',
|
|
534
|
+
halfOpenInFlight: false,
|
|
535
|
+
},
|
|
536
|
+
stats: {
|
|
537
|
+
acquires: 0,
|
|
538
|
+
acquireWait: { samples: [], next: 0 },
|
|
539
|
+
saturationRejections: 0,
|
|
540
|
+
capacityRejections: 0,
|
|
541
|
+
breakerRejections: 0,
|
|
542
|
+
connectionErrors: 0,
|
|
543
|
+
queryErrors: 0,
|
|
544
|
+
retries: 0,
|
|
545
|
+
peakTotalClients: 0,
|
|
546
|
+
peakWaitingClients: 0,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
pool.on('error', (error) => {
|
|
550
|
+
// Idle clients dropped by the server (failover, suspend) surface here.
|
|
551
|
+
// The pool replaces them on next acquire; record and move on.
|
|
552
|
+
entry.stats.connectionErrors += 1;
|
|
553
|
+
console.warn('[runtime-scheduler-pool.idle_client_error]', {
|
|
554
|
+
error: error instanceof Error ? error.message : String(error),
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
registry.set(key, entry);
|
|
558
|
+
return entry;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function breakerGate(entry: PoolEntry): void {
|
|
562
|
+
const { breaker } = entry;
|
|
563
|
+
if (breaker.openedAt === null) return;
|
|
564
|
+
const elapsed = Date.now() - breaker.openedAt;
|
|
565
|
+
if (elapsed < RUNTIME_SCHEDULER_POOL_DEFAULTS.breaker.openMs) {
|
|
566
|
+
entry.stats.breakerRejections += 1;
|
|
567
|
+
throw new RuntimeSchedulerCircuitOpenError(breaker.lastFailureMessage);
|
|
568
|
+
}
|
|
569
|
+
// Half-open: let exactly one caller through as the trial.
|
|
570
|
+
if (breaker.halfOpenInFlight) {
|
|
571
|
+
entry.stats.breakerRejections += 1;
|
|
572
|
+
throw new RuntimeSchedulerCircuitOpenError(breaker.lastFailureMessage);
|
|
573
|
+
}
|
|
574
|
+
breaker.halfOpenInFlight = true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function recordBreakerSuccess(entry: PoolEntry): void {
|
|
578
|
+
entry.breaker.consecutiveFailures = 0;
|
|
579
|
+
entry.breaker.openedAt = null;
|
|
580
|
+
entry.breaker.halfOpenInFlight = false;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function recordBreakerFailure(entry: PoolEntry, error: unknown): void {
|
|
584
|
+
const { breaker } = entry;
|
|
585
|
+
breaker.halfOpenInFlight = false;
|
|
586
|
+
breaker.consecutiveFailures += 1;
|
|
587
|
+
breaker.lastFailureMessage =
|
|
588
|
+
error instanceof Error ? error.message : String(error);
|
|
589
|
+
if (
|
|
590
|
+
breaker.consecutiveFailures >=
|
|
591
|
+
RUNTIME_SCHEDULER_POOL_DEFAULTS.breaker.threshold
|
|
592
|
+
) {
|
|
593
|
+
breaker.openedAt = Date.now();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function jitteredBackoffMs(attempt: number): number {
|
|
598
|
+
const base = Math.min(
|
|
599
|
+
RUNTIME_SCHEDULER_POOL_DEFAULTS.retry.maxDelayMs,
|
|
600
|
+
RUNTIME_SCHEDULER_POOL_DEFAULTS.retry.baseDelayMs * 2 ** attempt,
|
|
601
|
+
);
|
|
602
|
+
return Math.round(base / 2 + Math.random() * (base / 2));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function sleep(ms: number): Promise<void> {
|
|
606
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function schedulerAbortReason(signal: AbortSignal): Error {
|
|
610
|
+
return signal.reason instanceof Error
|
|
611
|
+
? signal.reason
|
|
612
|
+
: new Error('Runtime scheduler operation was cancelled.');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function throwIfSchedulerAborted(signal: AbortSignal | undefined): void {
|
|
616
|
+
if (signal?.aborted) throw schedulerAbortReason(signal);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Run `fn` with a dedicated pooled connection. The connection is held for the
|
|
621
|
+
* duration of `fn` so multi-statement transactions (BEGIN..COMMIT) stay on one
|
|
622
|
+
* session. Connection-category failures destroy the bad connection and retry
|
|
623
|
+
* the whole operation with jittered backoff — safe because scheduler
|
|
624
|
+
* operations are idempotent by construction.
|
|
625
|
+
*/
|
|
626
|
+
export async function withRuntimeSchedulerClient<T>(
|
|
627
|
+
options: RuntimeSchedulerPoolOptions,
|
|
628
|
+
fn: (client: RuntimeSchedulerQueryClient) => Promise<T>,
|
|
629
|
+
trace?: {
|
|
630
|
+
runId: string;
|
|
631
|
+
phase: string;
|
|
632
|
+
priority?: RuntimeSchedulerPoolPriority;
|
|
633
|
+
},
|
|
634
|
+
operationOptions: { signal?: AbortSignal } = {},
|
|
635
|
+
): Promise<T> {
|
|
636
|
+
const signal = operationOptions.signal;
|
|
637
|
+
throwIfSchedulerAborted(signal);
|
|
638
|
+
const entry = getPoolEntry(options);
|
|
639
|
+
const operationStartedAt = performance.now();
|
|
640
|
+
const admissionStartedAt = performance.now();
|
|
641
|
+
const releaseAdmission = await entry.admission.acquire(
|
|
642
|
+
trace?.priority ?? 'ordinary',
|
|
643
|
+
signal,
|
|
644
|
+
);
|
|
645
|
+
const admissionWaitMs = performance.now() - admissionStartedAt;
|
|
646
|
+
let lastError: unknown = null;
|
|
647
|
+
try {
|
|
648
|
+
throwIfSchedulerAborted(signal);
|
|
649
|
+
for (
|
|
650
|
+
let attempt = 0;
|
|
651
|
+
attempt < RUNTIME_SCHEDULER_POOL_DEFAULTS.retry.attempts;
|
|
652
|
+
attempt += 1
|
|
653
|
+
) {
|
|
654
|
+
throwIfSchedulerAborted(signal);
|
|
655
|
+
breakerGate(entry);
|
|
656
|
+
if (
|
|
657
|
+
entry.pool.waitingCount >=
|
|
658
|
+
RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth
|
|
659
|
+
) {
|
|
660
|
+
entry.stats.saturationRejections += 1;
|
|
661
|
+
throw new RuntimeSchedulerSaturatedError(entry.pool.waitingCount);
|
|
662
|
+
}
|
|
663
|
+
const acquireStart = performance.now();
|
|
664
|
+
entry.stats.peakTotalClients = Math.max(
|
|
665
|
+
entry.stats.peakTotalClients,
|
|
666
|
+
entry.pool.totalCount,
|
|
667
|
+
);
|
|
668
|
+
entry.stats.peakWaitingClients = Math.max(
|
|
669
|
+
entry.stats.peakWaitingClients,
|
|
670
|
+
entry.pool.waitingCount,
|
|
671
|
+
);
|
|
672
|
+
let poolClient;
|
|
673
|
+
try {
|
|
674
|
+
poolClient = await entry.pool.connect();
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (isRuntimeSchedulerLocalAcquireTimeout(error)) {
|
|
677
|
+
entry.stats.saturationRejections += 1;
|
|
678
|
+
console.warn('[runtime-scheduler-pool.local_acquire_saturated]', {
|
|
679
|
+
maxClients: entry.maxClients,
|
|
680
|
+
totalClients: entry.pool.totalCount,
|
|
681
|
+
idleClients: entry.pool.idleCount,
|
|
682
|
+
waitingClients: entry.pool.waitingCount,
|
|
683
|
+
});
|
|
684
|
+
throw new RuntimeSchedulerSaturatedError(entry.pool.waitingCount);
|
|
685
|
+
}
|
|
686
|
+
if (isPgBouncerCapacityError(error)) {
|
|
687
|
+
recordCapacityRejection(entry, error);
|
|
688
|
+
throw new RuntimeSchedulerPgBouncerCapacityError(
|
|
689
|
+
error instanceof Error ? error.message : String(error),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
recordBreakerFailure(entry, error);
|
|
693
|
+
entry.stats.connectionErrors += 1;
|
|
694
|
+
lastError = error;
|
|
695
|
+
if (attempt + 1 < RUNTIME_SCHEDULER_POOL_DEFAULTS.retry.attempts) {
|
|
696
|
+
entry.stats.retries += 1;
|
|
697
|
+
await sleep(jitteredBackoffMs(attempt));
|
|
698
|
+
throwIfSchedulerAborted(signal);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
entry.stats.acquires += 1;
|
|
704
|
+
const acquireWaitMs = performance.now() - acquireStart;
|
|
705
|
+
entry.stats.peakTotalClients = Math.max(
|
|
706
|
+
entry.stats.peakTotalClients,
|
|
707
|
+
entry.pool.totalCount,
|
|
708
|
+
);
|
|
709
|
+
pushSample(entry.stats.acquireWait, acquireWaitMs);
|
|
710
|
+
|
|
711
|
+
// A checked-out pg client emits 'error' (not via the pool) when its TCP
|
|
712
|
+
// connection dies while idle — e.g. a worker holding a client across a
|
|
713
|
+
// long play execution. With no listener that is an uncaught exception
|
|
714
|
+
// that kills the process (observed: worker crash mid-150k-row run).
|
|
715
|
+
// Swallow it here; the next query on the dead client fails normally and
|
|
716
|
+
// flows through the connection-error retry/poison path below.
|
|
717
|
+
let checkedOutError: unknown = null;
|
|
718
|
+
const onCheckedOutError = (error: Error) => {
|
|
719
|
+
checkedOutError = error;
|
|
720
|
+
entry.stats.connectionErrors += 1;
|
|
721
|
+
console.warn('[runtime-scheduler-pool.checked_out_client_error]', {
|
|
722
|
+
error: error.message,
|
|
723
|
+
});
|
|
724
|
+
};
|
|
725
|
+
poolClient.on('error', onCheckedOutError);
|
|
726
|
+
let clientReleased = false;
|
|
727
|
+
const releasePoolClient = (error?: Error) => {
|
|
728
|
+
if (clientReleased) return;
|
|
729
|
+
clientReleased = true;
|
|
730
|
+
poolClient.release(error);
|
|
731
|
+
};
|
|
732
|
+
const onAbort = () => releasePoolClient(schedulerAbortReason(signal!));
|
|
733
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
734
|
+
|
|
735
|
+
let queryCount = 0;
|
|
736
|
+
let queryMs = 0;
|
|
737
|
+
try {
|
|
738
|
+
throwIfSchedulerAborted(signal);
|
|
739
|
+
const result = await fn({
|
|
740
|
+
query: async (sql, params) => {
|
|
741
|
+
throwIfSchedulerAborted(signal);
|
|
742
|
+
const queryStartedAt = performance.now();
|
|
743
|
+
queryCount += 1;
|
|
744
|
+
try {
|
|
745
|
+
const queryResult = await poolClient.query(sql, params);
|
|
746
|
+
throwIfSchedulerAborted(signal);
|
|
747
|
+
return { rows: queryResult.rows };
|
|
748
|
+
} finally {
|
|
749
|
+
queryMs += performance.now() - queryStartedAt;
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
throwIfSchedulerAborted(signal);
|
|
754
|
+
recordBreakerSuccess(entry);
|
|
755
|
+
signal?.removeEventListener('abort', onAbort);
|
|
756
|
+
poolClient.removeListener('error', onCheckedOutError);
|
|
757
|
+
// The connection died mid-fn but fn happened to finish without another
|
|
758
|
+
// query — destroy the dead connection rather than pooling it.
|
|
759
|
+
releasePoolClient(
|
|
760
|
+
checkedOutError instanceof Error ? checkedOutError : undefined,
|
|
761
|
+
);
|
|
762
|
+
if (trace) {
|
|
763
|
+
console.info(
|
|
764
|
+
'[runtime-scheduler-pool.operation] ' +
|
|
765
|
+
JSON.stringify({
|
|
766
|
+
runId: trace.runId,
|
|
767
|
+
phase: trace.phase,
|
|
768
|
+
outcome: 'success',
|
|
769
|
+
attempt: attempt + 1,
|
|
770
|
+
priority: trace.priority ?? 'ordinary',
|
|
771
|
+
admissionWaitMs,
|
|
772
|
+
acquireWaitMs,
|
|
773
|
+
queryCount,
|
|
774
|
+
queryMs,
|
|
775
|
+
totalMs: performance.now() - operationStartedAt,
|
|
776
|
+
}),
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return result;
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (trace) {
|
|
782
|
+
console.info(
|
|
783
|
+
'[runtime-scheduler-pool.operation] ' +
|
|
784
|
+
JSON.stringify({
|
|
785
|
+
runId: trace.runId,
|
|
786
|
+
phase: trace.phase,
|
|
787
|
+
outcome: 'failed',
|
|
788
|
+
attempt: attempt + 1,
|
|
789
|
+
priority: trace.priority ?? 'ordinary',
|
|
790
|
+
admissionWaitMs,
|
|
791
|
+
acquireWaitMs,
|
|
792
|
+
queryCount,
|
|
793
|
+
queryMs,
|
|
794
|
+
totalMs: performance.now() - operationStartedAt,
|
|
795
|
+
errorName: error instanceof Error ? error.name : 'UnknownError',
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
signal?.removeEventListener('abort', onAbort);
|
|
800
|
+
poolClient.removeListener('error', onCheckedOutError);
|
|
801
|
+
if (signal?.aborted) {
|
|
802
|
+
const cancellation = schedulerAbortReason(signal);
|
|
803
|
+
releasePoolClient(cancellation);
|
|
804
|
+
throw cancellation;
|
|
805
|
+
}
|
|
806
|
+
if (isPgBouncerCapacityError(error)) {
|
|
807
|
+
recordCapacityRejection(entry, error);
|
|
808
|
+
releasePoolClient();
|
|
809
|
+
throw new RuntimeSchedulerPgBouncerCapacityError(
|
|
810
|
+
error instanceof Error ? error.message : String(error),
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (isConnectionCategoryError(error)) {
|
|
814
|
+
recordBreakerFailure(entry, error);
|
|
815
|
+
entry.stats.connectionErrors += 1;
|
|
816
|
+
lastError = error;
|
|
817
|
+
// Destroy the broken connection instead of returning it to the pool.
|
|
818
|
+
releasePoolClient(
|
|
819
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
820
|
+
);
|
|
821
|
+
if (attempt + 1 < RUNTIME_SCHEDULER_POOL_DEFAULTS.retry.attempts) {
|
|
822
|
+
entry.stats.retries += 1;
|
|
823
|
+
await sleep(jitteredBackoffMs(attempt));
|
|
824
|
+
throwIfSchedulerAborted(signal);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
if (isRuntimeSchedulerRetryableTransactionError(error)) {
|
|
830
|
+
// PostgreSQL has aborted the transaction, so never return this
|
|
831
|
+
// client to the pool. Re-run the idempotent scheduler operation on a
|
|
832
|
+
// new session without opening the connection-failure breaker.
|
|
833
|
+
entry.stats.queryErrors += 1;
|
|
834
|
+
recordBreakerSuccess(entry);
|
|
835
|
+
lastError = error;
|
|
836
|
+
releasePoolClient(
|
|
837
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
838
|
+
);
|
|
839
|
+
if (attempt + 1 < RUNTIME_SCHEDULER_POOL_DEFAULTS.retry.attempts) {
|
|
840
|
+
entry.stats.retries += 1;
|
|
841
|
+
await sleep(jitteredBackoffMs(attempt));
|
|
842
|
+
throwIfSchedulerAborted(signal);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
throw error;
|
|
846
|
+
}
|
|
847
|
+
// Query-level failure on a healthy connection: count it, return the
|
|
848
|
+
// connection to the pool, surface the error without retry.
|
|
849
|
+
entry.stats.queryErrors += 1;
|
|
850
|
+
recordBreakerSuccess(entry);
|
|
851
|
+
releasePoolClient();
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
throw lastError instanceof Error
|
|
856
|
+
? lastError
|
|
857
|
+
: new Error('Runtime scheduler pool operation failed after retries.');
|
|
858
|
+
} finally {
|
|
859
|
+
releaseAdmission();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export type RuntimeSchedulerPoolStats = {
|
|
864
|
+
url: string;
|
|
865
|
+
maxClients: number;
|
|
866
|
+
totalCount: number;
|
|
867
|
+
idleCount: number;
|
|
868
|
+
waitingCount: number;
|
|
869
|
+
acquires: number;
|
|
870
|
+
acquireWaitP50Ms: number | null;
|
|
871
|
+
acquireWaitP95Ms: number | null;
|
|
872
|
+
saturationRejections: number;
|
|
873
|
+
capacityRejections: number;
|
|
874
|
+
breakerRejections: number;
|
|
875
|
+
breakerOpen: boolean;
|
|
876
|
+
connectionErrors: number;
|
|
877
|
+
queryErrors: number;
|
|
878
|
+
retries: number;
|
|
879
|
+
peakTotalClients: number;
|
|
880
|
+
peakWaitingClients: number;
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
export function getRuntimeSchedulerPoolStats(): RuntimeSchedulerPoolStats[] {
|
|
884
|
+
const registry = getPoolRegistry();
|
|
885
|
+
return [...registry.entries()].map(([key, entry]) => ({
|
|
886
|
+
url: key.split('::')[0]!.replace(/:\/\/[^@]*@/, '://***@'),
|
|
887
|
+
maxClients: entry.maxClients,
|
|
888
|
+
totalCount: entry.pool.totalCount,
|
|
889
|
+
idleCount: entry.pool.idleCount,
|
|
890
|
+
waitingCount: entry.admission.waitingCount,
|
|
891
|
+
acquires: entry.stats.acquires,
|
|
892
|
+
acquireWaitP50Ms: ringQuantile(entry.stats.acquireWait, 0.5),
|
|
893
|
+
acquireWaitP95Ms: ringQuantile(entry.stats.acquireWait, 0.95),
|
|
894
|
+
saturationRejections: entry.stats.saturationRejections,
|
|
895
|
+
capacityRejections: entry.stats.capacityRejections,
|
|
896
|
+
breakerRejections: entry.stats.breakerRejections,
|
|
897
|
+
breakerOpen: entry.breaker.openedAt !== null,
|
|
898
|
+
connectionErrors: entry.stats.connectionErrors,
|
|
899
|
+
queryErrors: entry.stats.queryErrors,
|
|
900
|
+
retries: entry.stats.retries,
|
|
901
|
+
peakTotalClients: entry.stats.peakTotalClients,
|
|
902
|
+
peakWaitingClients: entry.stats.peakWaitingClients,
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export async function closeRuntimeSchedulerPools(): Promise<void> {
|
|
907
|
+
const registry = getPoolRegistry();
|
|
908
|
+
const entries = [...registry.values()];
|
|
909
|
+
registry.clear();
|
|
910
|
+
await Promise.all([
|
|
911
|
+
...entries.map((entry) => entry.pool.end()),
|
|
912
|
+
closePgNotifyHubs(),
|
|
913
|
+
]);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Warm one pooled connection (TLS + auth + first query) so the first real
|
|
918
|
+
* operation does not pay the cold-connect cost. Also wakes a suspended Neon
|
|
919
|
+
* compute before user-visible traffic arrives.
|
|
920
|
+
*/
|
|
921
|
+
export async function warmRuntimeSchedulerPool(
|
|
922
|
+
options: RuntimeSchedulerPoolOptions,
|
|
923
|
+
): Promise<void> {
|
|
924
|
+
await withRuntimeSchedulerClient(options, async (client) => {
|
|
925
|
+
await client.query('SELECT 1');
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Periodic SELECT 1 to keep long-lived worker connections healthy. Returns a
|
|
931
|
+
* stop function.
|
|
932
|
+
*/
|
|
933
|
+
export function startRuntimeSchedulerKeepalive(
|
|
934
|
+
options: RuntimeSchedulerPoolOptions,
|
|
935
|
+
): () => void {
|
|
936
|
+
const interval = setInterval(() => {
|
|
937
|
+
warmRuntimeSchedulerPool(options).catch((error) => {
|
|
938
|
+
console.warn('[runtime-scheduler-pool.keepalive_failed]', {
|
|
939
|
+
error: error instanceof Error ? error.message : String(error),
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
}, RUNTIME_SCHEDULER_POOL_DEFAULTS.keepAliveIntervalMs);
|
|
943
|
+
interval.unref?.();
|
|
944
|
+
return () => clearInterval(interval);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* LISTEN/NOTIFY is unavailable through PgBouncer transaction pooling, so the
|
|
949
|
+
* hub connects to the direct host when one is configured. Neon-style `-pooler`
|
|
950
|
+
* hosts are normalized for the legacy scheduler path; other managed Postgres
|
|
951
|
+
* URLs are returned unchanged.
|
|
952
|
+
*/
|
|
953
|
+
export function deriveDirectDatabaseUrl(url: string): string {
|
|
954
|
+
const explicit =
|
|
955
|
+
process.env.WORKFLOW_RUNTIME_POSTGRES_DIRECT?.trim() ||
|
|
956
|
+
process.env.DEEPLINE_RUNTIME_DATABASE_DIRECT_URL?.trim();
|
|
957
|
+
if (explicit) return normalizePostgresConnectionStringForPg(explicit);
|
|
958
|
+
try {
|
|
959
|
+
const parsed = new URL(url);
|
|
960
|
+
if (isPlanetScalePostgresUrl(parsed) && parsed.port === '6432') {
|
|
961
|
+
parsed.port = '5432';
|
|
962
|
+
}
|
|
963
|
+
parsed.hostname = parsed.hostname.replace('-pooler.', '.');
|
|
964
|
+
return normalizePostgresConnectionStringForPg(parsed.toString());
|
|
965
|
+
} catch {
|
|
966
|
+
return normalizePostgresConnectionStringForPg(url);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The runtime worker needs one direct Postgres session for its optional
|
|
972
|
+
* LISTEN wake hint. Preview gates must prove that this separate session budget
|
|
973
|
+
* is available before submitting a workload: the pooled scheduler probe alone
|
|
974
|
+
* cannot observe direct-session exhaustion.
|
|
975
|
+
*/
|
|
976
|
+
export class RuntimeSchedulerDirectSessionCapacityError extends Error {
|
|
977
|
+
constructor(reason: string) {
|
|
978
|
+
super(
|
|
979
|
+
'Runtime scheduler direct-session capacity is unavailable. ' +
|
|
980
|
+
'Reclaim stale Preview worker listener owners or use an isolated direct-session endpoint. ' +
|
|
981
|
+
`Database error: ${reason}`,
|
|
982
|
+
);
|
|
983
|
+
this.name = 'RuntimeSchedulerDirectSessionCapacityError';
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
export type RuntimeSchedulerDirectSessionClient = Pick<
|
|
988
|
+
Client,
|
|
989
|
+
'connect' | 'query' | 'end'
|
|
990
|
+
>;
|
|
991
|
+
|
|
992
|
+
export async function probeRuntimeSchedulerDirectSession(input: {
|
|
993
|
+
url: string;
|
|
994
|
+
applicationName?: string;
|
|
995
|
+
createClient?: (
|
|
996
|
+
config: ConstructorParameters<typeof Client>[0],
|
|
997
|
+
) => RuntimeSchedulerDirectSessionClient;
|
|
998
|
+
}): Promise<void> {
|
|
999
|
+
const client = (input.createClient ?? ((config) => new Client(config)))({
|
|
1000
|
+
connectionString: deriveDirectDatabaseUrl(input.url),
|
|
1001
|
+
application_name:
|
|
1002
|
+
input.applicationName ?? 'deepline-runtime-direct-session-preflight',
|
|
1003
|
+
});
|
|
1004
|
+
try {
|
|
1005
|
+
await client.connect();
|
|
1006
|
+
await client.query('select 1');
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
if (isPgBouncerCapacityError(error)) {
|
|
1009
|
+
throw new RuntimeSchedulerDirectSessionCapacityError(
|
|
1010
|
+
error instanceof Error ? error.message : String(error),
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
throw error;
|
|
1014
|
+
} finally {
|
|
1015
|
+
await client.end().catch(() => undefined);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* `standby` means this process lost an optional session advisory-lock election
|
|
1021
|
+
* and deliberately closed its direct connection. It will retry the election
|
|
1022
|
+
* later; callers continue with their durable polling fallback meanwhile.
|
|
1023
|
+
*/
|
|
1024
|
+
export type PgNotifyHubState = 'connecting' | 'listening' | 'standby' | 'down';
|
|
1025
|
+
|
|
1026
|
+
export type PgNotifyHub = {
|
|
1027
|
+
state: () => PgNotifyHubState;
|
|
1028
|
+
subscribe: (handler: (payload: string) => void) => () => void;
|
|
1029
|
+
subscribeState: (handler: (state: PgNotifyHubState) => void) => () => void;
|
|
1030
|
+
stop: () => Promise<void>;
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
type PgNotifyHubEntry = {
|
|
1034
|
+
hub: PgNotifyHub;
|
|
1035
|
+
refs: number;
|
|
1036
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
1037
|
+
lastUsedAt: number;
|
|
1038
|
+
closing: Promise<boolean> | null;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
export type PgNotifyHubLease = {
|
|
1042
|
+
hub: PgNotifyHub;
|
|
1043
|
+
release: () => void;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
const pgNotifyHubsKey = Symbol.for('deepline.runtime-scheduler.pg-notify-hubs');
|
|
1047
|
+
const PG_NOTIFY_HUB_IDLE_MS = 30_000;
|
|
1048
|
+
const PG_NOTIFY_HUB_MAX_PER_PROCESS = 4;
|
|
1049
|
+
|
|
1050
|
+
function getPgNotifyHubs(): Map<string, PgNotifyHubEntry> {
|
|
1051
|
+
const target = globalThis as typeof globalThis & {
|
|
1052
|
+
[pgNotifyHubsKey]?: Map<string, PgNotifyHubEntry>;
|
|
1053
|
+
};
|
|
1054
|
+
target[pgNotifyHubsKey] ??= new Map();
|
|
1055
|
+
return target[pgNotifyHubsKey];
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function beginPgNotifyHubClose(
|
|
1059
|
+
hubs: Map<string, PgNotifyHubEntry>,
|
|
1060
|
+
key: string,
|
|
1061
|
+
entry: PgNotifyHubEntry,
|
|
1062
|
+
): void {
|
|
1063
|
+
if (entry.closing) return;
|
|
1064
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1065
|
+
entry.idleTimer = null;
|
|
1066
|
+
entry.closing = entry.hub
|
|
1067
|
+
.stop()
|
|
1068
|
+
.then(() => {
|
|
1069
|
+
if (hubs.get(key) === entry) hubs.delete(key);
|
|
1070
|
+
return true;
|
|
1071
|
+
})
|
|
1072
|
+
.catch((error) => {
|
|
1073
|
+
// Keep the failed close occupying its slot. Polling remains available,
|
|
1074
|
+
// and opening a replacement could exceed the physical session budget.
|
|
1075
|
+
console.warn('[runtime-scheduler-notify-hub.close_failed]', {
|
|
1076
|
+
channel: key.split('::').at(-1) ?? key,
|
|
1077
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1078
|
+
});
|
|
1079
|
+
return false;
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Lease a bounded direct LISTEN connection. When every slot is actively leased,
|
|
1085
|
+
* additional callers receive null and safely use table polling instead.
|
|
1086
|
+
*/
|
|
1087
|
+
export function acquirePgNotifyHub(
|
|
1088
|
+
input: {
|
|
1089
|
+
url: string;
|
|
1090
|
+
channel: string;
|
|
1091
|
+
applicationName?: string;
|
|
1092
|
+
leaderLockKey?: string;
|
|
1093
|
+
},
|
|
1094
|
+
lifecycle: {
|
|
1095
|
+
idleMs?: number;
|
|
1096
|
+
create?: (input: {
|
|
1097
|
+
url: string;
|
|
1098
|
+
channel: string;
|
|
1099
|
+
applicationName?: string;
|
|
1100
|
+
leaderLockKey?: string;
|
|
1101
|
+
}) => PgNotifyHub;
|
|
1102
|
+
} = {},
|
|
1103
|
+
): PgNotifyHubLease | null {
|
|
1104
|
+
const hubs = getPgNotifyHubs();
|
|
1105
|
+
const key = `${input.url}::${input.channel}::${input.leaderLockKey ?? ''}`;
|
|
1106
|
+
let entry = hubs.get(key);
|
|
1107
|
+
if (entry?.closing) return null;
|
|
1108
|
+
if (!entry && hubs.size >= PG_NOTIFY_HUB_MAX_PER_PROCESS) {
|
|
1109
|
+
const idle = [...hubs.entries()]
|
|
1110
|
+
.filter(([, candidate]) => candidate.refs === 0 && !candidate.closing)
|
|
1111
|
+
.sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0];
|
|
1112
|
+
if (!idle) return null;
|
|
1113
|
+
const [idleKey, idleEntry] = idle;
|
|
1114
|
+
beginPgNotifyHubClose(hubs, idleKey, idleEntry);
|
|
1115
|
+
// Keep the closing entry in the registry until its physical connection is
|
|
1116
|
+
// gone. This caller polls and may acquire the freed slot on its next pass.
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
if (!entry) {
|
|
1120
|
+
entry = {
|
|
1121
|
+
hub: (lifecycle.create ?? createPgNotifyHub)(input),
|
|
1122
|
+
refs: 0,
|
|
1123
|
+
idleTimer: null,
|
|
1124
|
+
lastUsedAt: Date.now(),
|
|
1125
|
+
closing: null,
|
|
1126
|
+
};
|
|
1127
|
+
hubs.set(key, entry);
|
|
1128
|
+
}
|
|
1129
|
+
if (entry.idleTimer) {
|
|
1130
|
+
clearTimeout(entry.idleTimer);
|
|
1131
|
+
entry.idleTimer = null;
|
|
1132
|
+
}
|
|
1133
|
+
entry.refs += 1;
|
|
1134
|
+
let released = false;
|
|
1135
|
+
return {
|
|
1136
|
+
hub: entry.hub,
|
|
1137
|
+
release: () => {
|
|
1138
|
+
if (released) return;
|
|
1139
|
+
released = true;
|
|
1140
|
+
entry!.refs = Math.max(0, entry!.refs - 1);
|
|
1141
|
+
entry!.lastUsedAt = Date.now();
|
|
1142
|
+
if (entry!.refs > 0) return;
|
|
1143
|
+
entry!.idleTimer = setTimeout(() => {
|
|
1144
|
+
if (entry!.refs > 0 || hubs.get(key) !== entry) return;
|
|
1145
|
+
beginPgNotifyHubClose(hubs, key, entry!);
|
|
1146
|
+
}, lifecycle.idleMs ?? PG_NOTIFY_HUB_IDLE_MS);
|
|
1147
|
+
entry!.idleTimer.unref?.();
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
export async function closePgNotifyHubs(): Promise<void> {
|
|
1153
|
+
const hubs = getPgNotifyHubs();
|
|
1154
|
+
const entries = [...hubs.entries()];
|
|
1155
|
+
for (const [, entry] of entries) {
|
|
1156
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1157
|
+
}
|
|
1158
|
+
for (const [key, entry] of entries) {
|
|
1159
|
+
beginPgNotifyHubClose(hubs, key, entry);
|
|
1160
|
+
}
|
|
1161
|
+
const firstResults = await Promise.all(
|
|
1162
|
+
entries.map(([, entry]) => entry.closing ?? Promise.resolve(false)),
|
|
1163
|
+
);
|
|
1164
|
+
const retryErrors: unknown[] = [];
|
|
1165
|
+
await Promise.all(
|
|
1166
|
+
entries.map(async ([key, entry], index) => {
|
|
1167
|
+
if (firstResults[index]) return;
|
|
1168
|
+
try {
|
|
1169
|
+
await entry.hub.stop();
|
|
1170
|
+
if (hubs.get(key) === entry) hubs.delete(key);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
retryErrors.push(error);
|
|
1173
|
+
}
|
|
1174
|
+
}),
|
|
1175
|
+
);
|
|
1176
|
+
if (retryErrors.length > 0) {
|
|
1177
|
+
throw new AggregateError(
|
|
1178
|
+
retryErrors,
|
|
1179
|
+
'Failed to close one or more runtime notification hubs.',
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const NOTIFY_HUB_RECONNECT_BASE_MS = 250;
|
|
1185
|
+
const NOTIFY_HUB_CAPACITY_RECONNECT_BASE_MS = 30_000;
|
|
1186
|
+
const NOTIFY_HUB_RECONNECT_MAX_MS = 300_000;
|
|
1187
|
+
const NOTIFY_HUB_STANDBY_RETRY_MS = 30_000;
|
|
1188
|
+
|
|
1189
|
+
export function runtimeSchedulerNotifyReconnectDelayMs(input: {
|
|
1190
|
+
error: unknown;
|
|
1191
|
+
attempt: number;
|
|
1192
|
+
random?: number;
|
|
1193
|
+
}): number {
|
|
1194
|
+
const baseMs = isPgBouncerCapacityError(input.error)
|
|
1195
|
+
? NOTIFY_HUB_CAPACITY_RECONNECT_BASE_MS
|
|
1196
|
+
: NOTIFY_HUB_RECONNECT_BASE_MS;
|
|
1197
|
+
const ceilingMs = Math.min(
|
|
1198
|
+
NOTIFY_HUB_RECONNECT_MAX_MS,
|
|
1199
|
+
baseMs * 2 ** Math.max(0, input.attempt),
|
|
1200
|
+
);
|
|
1201
|
+
const random = Math.min(1, Math.max(0, input.random ?? Math.random()));
|
|
1202
|
+
return Math.max(1, Math.round(ceilingMs / 2 + random * (ceilingMs / 2)));
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* One long-lived LISTEN connection per (url, channel) with auto-reconnect.
|
|
1207
|
+
* Strictly a wakeup hint: consumers must still read the tables for truth, so
|
|
1208
|
+
* a down hub degrades to polling latency, never to wrong answers.
|
|
1209
|
+
*/
|
|
1210
|
+
export function createPgNotifyHub(input: {
|
|
1211
|
+
url: string;
|
|
1212
|
+
channel: string;
|
|
1213
|
+
applicationName?: string;
|
|
1214
|
+
/**
|
|
1215
|
+
* When set, only the session holding this advisory lock LISTENs. Losing
|
|
1216
|
+
* candidates close their direct connection immediately and retry later.
|
|
1217
|
+
* This keeps a horizontally scaled worker lane to one steady direct session.
|
|
1218
|
+
*/
|
|
1219
|
+
leaderLockKey?: string;
|
|
1220
|
+
/** Test seam for exercising listener lifecycle failures without a real DB. */
|
|
1221
|
+
createClient?: (config: ConstructorParameters<typeof Client>[0]) => Client;
|
|
1222
|
+
}): PgNotifyHub {
|
|
1223
|
+
const directUrl = deriveDirectDatabaseUrl(input.url);
|
|
1224
|
+
const handlers = new Set<(payload: string) => void>();
|
|
1225
|
+
const stateHandlers = new Set<(state: PgNotifyHubState) => void>();
|
|
1226
|
+
let state: PgNotifyHubState = 'connecting';
|
|
1227
|
+
let stopped = false;
|
|
1228
|
+
let client: Client | null = null;
|
|
1229
|
+
let clientClosePromise: Promise<void> | null = null;
|
|
1230
|
+
let connectPromise: Promise<void> | null = null;
|
|
1231
|
+
let reconnectAttempt = 0;
|
|
1232
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1233
|
+
const setState = (next: PgNotifyHubState): void => {
|
|
1234
|
+
state = next;
|
|
1235
|
+
for (const handler of stateHandlers) handler(next);
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const closeClient = (current: Client): Promise<void> => {
|
|
1239
|
+
if (client !== current) return Promise.resolve();
|
|
1240
|
+
if (clientClosePromise) return clientClosePromise;
|
|
1241
|
+
// Defer end() by one microtask so the shared promise is installed before
|
|
1242
|
+
// node-postgres can emit an end event. The slot remains occupied until the
|
|
1243
|
+
// physical close promise succeeds; a rejection retains the client so a
|
|
1244
|
+
// later stop can retry rather than opening a replacement session.
|
|
1245
|
+
const pending = Promise.resolve()
|
|
1246
|
+
.then(() => current.end())
|
|
1247
|
+
.then(() => {
|
|
1248
|
+
if (client === current) client = null;
|
|
1249
|
+
})
|
|
1250
|
+
.finally(() => {
|
|
1251
|
+
if (clientClosePromise === pending) clientClosePromise = null;
|
|
1252
|
+
});
|
|
1253
|
+
clientClosePromise = pending;
|
|
1254
|
+
return pending;
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
const connect = async (): Promise<void> => {
|
|
1258
|
+
if (stopped) return;
|
|
1259
|
+
setState('connecting');
|
|
1260
|
+
const next = (input.createClient ?? ((config) => new Client(config)))({
|
|
1261
|
+
connectionString: directUrl,
|
|
1262
|
+
application_name: input.applicationName ?? 'deepline-runtime-notify-hub',
|
|
1263
|
+
});
|
|
1264
|
+
try {
|
|
1265
|
+
await next.connect();
|
|
1266
|
+
if (input.leaderLockKey) {
|
|
1267
|
+
const election = await next.query<{ acquired: boolean }>(
|
|
1268
|
+
`SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired`,
|
|
1269
|
+
[input.leaderLockKey],
|
|
1270
|
+
);
|
|
1271
|
+
if (election.rows[0]?.acquired === false) {
|
|
1272
|
+
// Advisory locks are session-scoped. Do not leave a standby process
|
|
1273
|
+
// holding a scarce direct Postgres connection while the elected
|
|
1274
|
+
// worker owns the listener.
|
|
1275
|
+
client = next;
|
|
1276
|
+
setState('standby');
|
|
1277
|
+
await closeClient(next);
|
|
1278
|
+
scheduleStandbyRetry();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
await next.query(
|
|
1283
|
+
`LISTEN ${'"' + input.channel.replace(/"/g, '""') + '"'}`,
|
|
1284
|
+
);
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
client = next;
|
|
1287
|
+
setState('down');
|
|
1288
|
+
try {
|
|
1289
|
+
await closeClient(next);
|
|
1290
|
+
} catch (closeError) {
|
|
1291
|
+
throw new AggregateError(
|
|
1292
|
+
[error, closeError],
|
|
1293
|
+
'Listener setup failed and its database session could not be closed.',
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
scheduleReconnect(error);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (stopped) {
|
|
1300
|
+
client = next;
|
|
1301
|
+
await closeClient(next);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
client = next;
|
|
1305
|
+
setState('listening');
|
|
1306
|
+
reconnectAttempt = 0;
|
|
1307
|
+
next.on('notification', (message) => {
|
|
1308
|
+
if (typeof message.payload === 'string') {
|
|
1309
|
+
for (const handler of handlers) handler(message.payload);
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
next.on('error', (error) => {
|
|
1313
|
+
if (stopped || client !== next) return;
|
|
1314
|
+
setState('down');
|
|
1315
|
+
void closeClient(next).then(
|
|
1316
|
+
() => scheduleReconnect(error),
|
|
1317
|
+
(closeError) => {
|
|
1318
|
+
console.warn('[runtime-scheduler-notify-hub.close_failed]', {
|
|
1319
|
+
channel: input.channel,
|
|
1320
|
+
error:
|
|
1321
|
+
closeError instanceof Error
|
|
1322
|
+
? closeError.message
|
|
1323
|
+
: String(closeError),
|
|
1324
|
+
});
|
|
1325
|
+
},
|
|
1326
|
+
);
|
|
1327
|
+
});
|
|
1328
|
+
next.on('end', () => {
|
|
1329
|
+
if (stopped || client !== next || clientClosePromise) return;
|
|
1330
|
+
client = null;
|
|
1331
|
+
if (state === 'listening')
|
|
1332
|
+
scheduleReconnect(new Error('listener connection ended'));
|
|
1333
|
+
});
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
const scheduleReconnect = (error: unknown): void => {
|
|
1337
|
+
if (stopped) return;
|
|
1338
|
+
setState('down');
|
|
1339
|
+
if (reconnectTimer) return;
|
|
1340
|
+
// A listener is only a wakeup optimization; durable polling remains the
|
|
1341
|
+
// correctness path. When Postgres is at connection capacity, retrying one
|
|
1342
|
+
// direct LISTEN session per worker every ten seconds creates a thundering
|
|
1343
|
+
// herd precisely when the database needs fewer connection attempts. Use a
|
|
1344
|
+
// slower exponential series for capacity and full jitter for every class.
|
|
1345
|
+
const delay = runtimeSchedulerNotifyReconnectDelayMs({
|
|
1346
|
+
error,
|
|
1347
|
+
attempt: reconnectAttempt,
|
|
1348
|
+
});
|
|
1349
|
+
reconnectAttempt += 1;
|
|
1350
|
+
console.warn('[runtime-scheduler-notify-hub.reconnecting]', {
|
|
1351
|
+
channel: input.channel,
|
|
1352
|
+
delayMs: delay,
|
|
1353
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1354
|
+
});
|
|
1355
|
+
reconnectTimer = setTimeout(() => {
|
|
1356
|
+
reconnectTimer = null;
|
|
1357
|
+
startConnect();
|
|
1358
|
+
}, delay);
|
|
1359
|
+
reconnectTimer.unref?.();
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
const scheduleStandbyRetry = (): void => {
|
|
1363
|
+
if (stopped || reconnectTimer) return;
|
|
1364
|
+
reconnectTimer = setTimeout(() => {
|
|
1365
|
+
reconnectTimer = null;
|
|
1366
|
+
startConnect();
|
|
1367
|
+
}, NOTIFY_HUB_STANDBY_RETRY_MS);
|
|
1368
|
+
reconnectTimer.unref?.();
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
const startConnect = (): void => {
|
|
1372
|
+
if (stopped || connectPromise) return;
|
|
1373
|
+
const pending = connect();
|
|
1374
|
+
connectPromise = pending;
|
|
1375
|
+
void pending.then(
|
|
1376
|
+
() => {
|
|
1377
|
+
if (connectPromise === pending) connectPromise = null;
|
|
1378
|
+
},
|
|
1379
|
+
(error) => {
|
|
1380
|
+
if (connectPromise === pending) connectPromise = null;
|
|
1381
|
+
console.warn('[runtime-scheduler-notify-hub.connect_failed]', {
|
|
1382
|
+
channel: input.channel,
|
|
1383
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1384
|
+
});
|
|
1385
|
+
},
|
|
1386
|
+
);
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
startConnect();
|
|
1390
|
+
|
|
1391
|
+
return {
|
|
1392
|
+
state: () => state,
|
|
1393
|
+
subscribe: (handler) => {
|
|
1394
|
+
handlers.add(handler);
|
|
1395
|
+
return () => handlers.delete(handler);
|
|
1396
|
+
},
|
|
1397
|
+
subscribeState: (handler) => {
|
|
1398
|
+
stateHandlers.add(handler);
|
|
1399
|
+
handler(state);
|
|
1400
|
+
return () => stateHandlers.delete(handler);
|
|
1401
|
+
},
|
|
1402
|
+
stop: async () => {
|
|
1403
|
+
stopped = true;
|
|
1404
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1405
|
+
handlers.clear();
|
|
1406
|
+
setState('down');
|
|
1407
|
+
stateHandlers.clear();
|
|
1408
|
+
const pendingConnect = connectPromise;
|
|
1409
|
+
if (pendingConnect) await pendingConnect;
|
|
1410
|
+
if (client) {
|
|
1411
|
+
const current = client;
|
|
1412
|
+
await closeClient(current);
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
};
|
|
1416
|
+
}
|