deepline 0.3.89 → 0.3.90

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.
Files changed (211) hide show
  1. package/contracts/install-commands.json +29 -0
  2. package/dist/bundling-sources/sdk/src/client.ts +120 -37
  3. package/dist/bundling-sources/sdk/src/index.ts +4 -0
  4. package/dist/bundling-sources/sdk/src/pagination.ts +25 -0
  5. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
  6. package/dist/bundling-sources/sdk/src/release.ts +16 -9
  7. package/dist/bundling-sources/sdk/src/types.ts +23 -0
  8. package/dist/bundling-sources/shared_libs/monitors/analytics-public.ts +10 -0
  9. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +3 -0
  10. package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +4 -4
  11. package/dist/bundling-sources/shared_libs/monitors/inventory-reconciliation.ts +59 -0
  12. package/dist/bundling-sources/shared_libs/monitors/lifecycle-notifications.ts +7 -9
  13. package/dist/bundling-sources/shared_libs/monitors/package.json +7 -0
  14. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +44 -2
  15. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-consumer-health.ts +2 -2
  16. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-identity-claims.ts +2 -2
  17. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-public-aliases.ts +1 -1
  18. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-dead-letter.ts +245 -0
  19. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-message.ts +38 -0
  20. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue-ack-probe.ts +94 -0
  21. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue-delivery.ts +60 -0
  22. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue.ts +60 -0
  23. package/dist/bundling-sources/shared_libs/monitors/snitcher-install-validation.ts +1 -1
  24. package/dist/bundling-sources/shared_libs/monitors/snitcher-ip2company.ts +2 -2
  25. package/dist/bundling-sources/shared_libs/monitors/snitcher-radar.ts +1 -1
  26. package/dist/bundling-sources/shared_libs/monitors/tracking-provider-integrations.ts +1 -1
  27. package/dist/bundling-sources/shared_libs/observability/build-context.ts +13 -0
  28. package/dist/bundling-sources/shared_libs/observability/log-metadata.ts +160 -0
  29. package/dist/bundling-sources/shared_libs/observability/request-context.ts +68 -0
  30. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +654 -0
  31. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +35 -0
  32. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +5 -0
  33. package/dist/bundling-sources/shared_libs/play-data-plane/neon-constants.ts +9 -0
  34. package/dist/bundling-sources/shared_libs/play-data-plane/r2.ts +272 -0
  35. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +45 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/async-operation.ts +66 -1
  37. package/dist/bundling-sources/shared_libs/play-runtime/async-play-invocation.ts +0 -1
  38. package/dist/bundling-sources/shared_libs/play-runtime/bounceban-batching.ts +137 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +779 -140
  40. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +46 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/daytona-payload-contract.ts +68 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-janitor.ts +103 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-reaper.ts +725 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +29 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +23 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-invocation-fence.ts +442 -0
  47. package/dist/bundling-sources/shared_libs/play-runtime/execution-protocol.ts +3 -3
  48. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +18 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +9 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +1 -1
  51. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +1 -1
  52. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/contracts.ts +66 -0
  53. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/job.ts +145 -0
  54. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/ports.ts +21 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/postgres-store.ts +170 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +1322 -0
  57. package/dist/bundling-sources/shared_libs/play-runtime/internal-step-ids.ts +2 -2
  58. package/dist/bundling-sources/shared_libs/play-runtime/lane-retirement-gate.ts +1 -1
  59. package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +112 -0
  60. package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +8 -0
  61. package/dist/bundling-sources/shared_libs/play-runtime/perf-trace.ts +127 -0
  62. package/dist/bundling-sources/shared_libs/play-runtime/play-artifact-runtime-contract.ts +5 -0
  63. package/dist/bundling-sources/shared_libs/play-runtime/play-run-recovery-policy.ts +24 -1
  64. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +56 -0
  65. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +13 -0
  66. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/job.ts +41 -0
  67. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/ports.ts +17 -0
  68. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +54 -0
  69. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/contracts.ts +114 -0
  70. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/engine-suspension.ts +108 -0
  71. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/index.ts +36 -0
  72. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/job.ts +97 -0
  73. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/ports.ts +104 -0
  74. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/postgres-fence-adapter.ts +263 -0
  75. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/worker-terminal-projection.ts +64 -0
  76. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/contracts.ts +29 -0
  77. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +56 -0
  78. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/index.ts +15 -0
  79. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/job.ts +65 -0
  80. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/ports.ts +17 -0
  81. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/contracts.ts +42 -0
  82. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/index.ts +17 -0
  83. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/job.ts +142 -0
  84. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/ports.ts +42 -0
  85. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/postgres-adapter.ts +102 -0
  86. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/contracts.ts +60 -0
  87. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/index.ts +16 -0
  88. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/job.ts +78 -0
  89. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/ports.ts +54 -0
  90. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/postgres-adapter.ts +71 -0
  91. package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-batching-registry.ts +4 -0
  92. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +2 -0
  93. package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +5 -8
  94. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +3 -5
  95. package/dist/bundling-sources/shared_libs/play-runtime/run-identity.ts +168 -0
  96. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +72 -9
  97. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +6 -12
  98. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +19 -0
  99. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/bundle.ts +0 -1
  100. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +14 -44
  101. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +57 -205
  102. package/dist/bundling-sources/shared_libs/play-runtime/runtime-authority.ts +201 -0
  103. package/dist/bundling-sources/shared_libs/play-runtime/runtime-env.ts +6 -0
  104. package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +67 -0
  105. package/dist/bundling-sources/shared_libs/play-runtime/runtime-release-environment.ts +2 -0
  106. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-contract.ts +68 -0
  107. package/dist/bundling-sources/shared_libs/play-runtime/runtime-scheduler-topology.ts +14 -2
  108. package/dist/bundling-sources/shared_libs/play-runtime/runtime-telemetry.ts +108 -0
  109. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/contract.ts +4 -0
  110. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/runner-backend-adapter.ts +3 -0
  111. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  112. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-deadline-reconciliation.ts +88 -0
  113. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-deploy-task.ts +124 -0
  114. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-run-task.ts +68 -0
  115. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-pool.ts +193 -0
  116. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-queue-lifecycle.ts +269 -0
  117. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-queue-suffix.ts +73 -0
  118. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +6642 -0
  119. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +897 -0
  120. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-metrics.ts +120 -0
  121. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-required-env.ts +76 -0
  122. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +476 -0
  123. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-heavy-request-admission.ts +47 -0
  124. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-progress-registry.ts +147 -0
  125. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/index.ts +27 -0
  126. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/lazy.ts +50 -0
  127. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/play-run-recovery.ts +98 -0
  128. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-budget-state.ts +260 -0
  129. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +1416 -0
  130. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +1255 -0
  131. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +942 -0
  132. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +14636 -0
  133. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +888 -0
  134. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +355 -0
  135. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-terminal-transition.ts +46 -0
  136. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runner-push.ts +60 -0
  137. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-api-origin.ts +33 -0
  138. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-receipt-gateway-health.ts +64 -0
  139. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-capacity.ts +835 -0
  140. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-ledger-reconciliation.ts +1218 -0
  141. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-operation.ts +207 -0
  142. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-scheduler-schema.ts +10 -0
  143. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-capacity.ts +123 -0
  144. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-launch-read-observability.ts +78 -0
  145. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +169 -0
  146. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/terminal-contract.ts +20 -0
  147. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-queue-metrics.ts +34 -0
  148. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-resource-metrics.ts +249 -0
  149. package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +7 -0
  150. package/dist/bundling-sources/shared_libs/play-runtime/step-progress.ts +296 -0
  151. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/native-batch.ts +3 -3
  152. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/runtime-step-receipts-adapter.ts +20 -1
  153. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +60 -15
  154. package/dist/bundling-sources/shared_libs/play-runtime/tool-execution-outcome.ts +20 -0
  155. package/dist/bundling-sources/shared_libs/play-runtime/transient-service-error.ts +12 -0
  156. package/dist/bundling-sources/shared_libs/play-runtime/vercel-protection.ts +14 -1
  157. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-storage-contract.ts +165 -0
  158. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +15 -23
  159. package/dist/bundling-sources/shared_libs/play-runtime/workflow-executor-auth.ts +169 -0
  160. package/dist/bundling-sources/shared_libs/play-runtime/zerobounce-batching.ts +122 -0
  161. package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +2 -0
  162. package/dist/bundling-sources/shared_libs/plays/contracts.ts +2 -0
  163. package/dist/bundling-sources/shared_libs/plays/deployment-status.ts +1 -1
  164. package/dist/bundling-sources/shared_libs/plays/docflow.ts +1 -1
  165. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +39 -0
  166. package/dist/bundling-sources/shared_libs/plays/share/copy.ts +271 -0
  167. package/dist/bundling-sources/shared_libs/plays/share/metrics.ts +140 -0
  168. package/dist/bundling-sources/shared_libs/plays/share/public-read-model.ts +86 -0
  169. package/dist/bundling-sources/shared_libs/plays/share/seo-quality.ts +181 -0
  170. package/dist/bundling-sources/shared_libs/plays/share/static-pipeline-summary.ts +658 -0
  171. package/dist/bundling-sources/shared_libs/plays/share/types.ts +197 -0
  172. package/dist/bundling-sources/shared_libs/plays/slack-hitl-timeout-error.ts +37 -0
  173. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +6 -1
  174. package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +1 -1
  175. package/dist/bundling-sources/shared_libs/product-notifications/configuration.ts +7 -1
  176. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +3 -2
  177. package/dist/bundling-sources/shared_libs/product-notifications/overflow.ts +18 -0
  178. package/dist/bundling-sources/shared_libs/product-notifications/rolling-limit.ts +24 -0
  179. package/dist/bundling-sources/shared_libs/product-notifications/slack-delivery.ts +624 -0
  180. package/dist/bundling-sources/shared_libs/product-notifications/slack-payload.ts +140 -0
  181. package/dist/bundling-sources/shared_libs/product-notifications/slack-transport.ts +75 -0
  182. package/dist/bundling-sources/shared_libs/runtime-env.ts +6 -0
  183. package/dist/bundling-sources/shared_libs/security/email-blacklist.ts +42 -0
  184. package/dist/bundling-sources/shared_libs/security/header-safe-env.ts +10 -3
  185. package/dist/bundling-sources/shared_libs/security/slack-sanitize.ts +133 -0
  186. package/dist/cli/getting-started.d.mts +52 -0
  187. package/dist/cli/getting-started.d.ts +52 -0
  188. package/dist/cli/getting-started.js +65 -0
  189. package/dist/cli/getting-started.mjs +36 -0
  190. package/dist/cli/index.d.mts +9 -0
  191. package/dist/cli/index.d.ts +9 -0
  192. package/dist/cli/index.js +545 -264
  193. package/dist/cli/index.mjs +543 -271
  194. package/dist/{compiler-manifest-DsLbQaew.d.mts → compiler-manifest-BaJfE-5H.d.mts} +5 -3
  195. package/dist/{compiler-manifest-DsLbQaew.d.ts → compiler-manifest-BaJfE-5H.d.ts} +5 -3
  196. package/dist/index.d.mts +81 -148
  197. package/dist/index.d.ts +81 -148
  198. package/dist/index.js +83 -26
  199. package/dist/index.mjs +83 -26
  200. package/dist/install-integrity.json +130 -2
  201. package/dist/monitor-fleet-contract.d.mts +138 -0
  202. package/dist/monitor-fleet-contract.d.ts +138 -0
  203. package/dist/monitor-fleet-contract.js +404 -0
  204. package/dist/monitor-fleet-contract.mjs +367 -0
  205. package/dist/plays/bundle-play-file.d.mts +2 -2
  206. package/dist/plays/bundle-play-file.d.ts +2 -2
  207. package/dist/release.d.mts +255 -0
  208. package/dist/release.d.ts +255 -0
  209. package/dist/release.js +215 -0
  210. package/dist/release.mjs +188 -0
  211. package/package.json +21 -4
@@ -0,0 +1,942 @@
1
+ import type {
2
+ PlayRunnerRateStateAcquireInput,
3
+ PlayRunnerRateStateAcquireResult,
4
+ PlayRunnerRateStatePenalizeInput,
5
+ PlayRunnerRateStateReleaseInput,
6
+ } from '../protocol';
7
+ import type { PacingRule } from '../governor/rate-state-backend';
8
+ import type { PostgresSchedulerQueryClient } from './postgres';
9
+ import { resolveRuntimeSchedulerSchemaEnv } from './runtime-scheduler-schema';
10
+ import { stringifyPostgresJson } from '../postgres-json';
11
+
12
+ const DEFAULT_SCHEMA = 'runtime_scheduler';
13
+ const RATE_BUCKET_TABLE = 'rate_bucket';
14
+ const RATE_CONCURRENCY_LEASE_TABLE = 'rate_concurrency_leases';
15
+ const MIN_CONCURRENCY_WAIT_MS = 10;
16
+ const ACTIVE_LEASE_TTL_MS = 5 * 60 * 1000;
17
+ const MIN_ADAPTIVE_RPS_FLOOR = 0.01;
18
+
19
+ /**
20
+ * Internal cooldown cap. A 429 report (penalize) can carry an arbitrarily large
21
+ * server Retry-After; we store that verbatim in `claimed_retry_at_ms` for
22
+ * observability but cap the pacer's own cooldown at 45 minutes so a stuck
23
+ * provider is re-probed at least this often instead of freezing the bucket for
24
+ * the whole (possibly hours-long) Retry-After.
25
+ */
26
+ const MAX_INTERNAL_COOLDOWN_MS = 45 * 60 * 1000;
27
+
28
+ /**
29
+ * Additive-ramp multiplier applied to undeclared default-pacing buckets.
30
+ * Declared rules stay pinned at base unless they carry an explicit adaptive
31
+ * ceiling.
32
+ */
33
+ const DEFAULT_PACING_MAX_RPS_MULTIPLIER = 25;
34
+
35
+ /**
36
+ * Low-rate provider requests cross the sandbox -> gateway -> app boundary after
37
+ * their permit wakes. Small network-latency differences can compress two
38
+ * otherwise exact 100ms/200ms intervals at the provider and turn a mathematically
39
+ * valid edge into a rolling-window 429. Keep a 20% arrival margin below 100
40
+ * RPS. High-rate providers retain their microburst schedule and full rate.
41
+ */
42
+ const LOW_RATE_DISPATCH_HEADROOM_FACTOR = 0.8;
43
+ const LOW_RATE_DISPATCH_HEADROOM_THRESHOLD_RPS = 100;
44
+
45
+ function quoteIdent(value: string): string {
46
+ return `"${value.replace(/"/g, '""')}"`;
47
+ }
48
+
49
+ function schemaName(options?: {
50
+ schema?: string | null;
51
+ schedulerSchema?: string | null;
52
+ }): string {
53
+ return (
54
+ options?.schema?.trim() ||
55
+ options?.schedulerSchema?.trim() ||
56
+ resolveRuntimeSchedulerSchemaEnv() ||
57
+ DEFAULT_SCHEMA
58
+ );
59
+ }
60
+
61
+ function bucketTableName(options?: {
62
+ schema?: string | null;
63
+ schedulerSchema?: string | null;
64
+ }): string {
65
+ return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_BUCKET_TABLE)}`;
66
+ }
67
+
68
+ function concurrencyLeaseTableName(options?: {
69
+ schema?: string | null;
70
+ schedulerSchema?: string | null;
71
+ }): string {
72
+ return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_CONCURRENCY_LEASE_TABLE)}`;
73
+ }
74
+
75
+ /**
76
+ * Acquire input rules may carry an optional `adaptiveMaxRps` the client can send
77
+ * to widen a bucket's recovery ceiling above its declared base rate. When
78
+ * absent, `default:*` rules get base * 25 and declared providers pin at base.
79
+ */
80
+ type AcquirePacingRule = PacingRule & {
81
+ adaptiveMaxRps?: number | null;
82
+ adaptiveMaxConfigured?: boolean;
83
+ };
84
+
85
+ function normalizeRules(
86
+ rules: readonly AcquirePacingRule[],
87
+ ): AcquirePacingRule[] {
88
+ return [...rules]
89
+ .map((rule) => {
90
+ const isDefaultPacing = rule.ruleId.startsWith('default:');
91
+ return {
92
+ ruleId: rule.ruleId,
93
+ // Rates are fractional; only dispatched requests must be whole.
94
+ requestsPerWindow: rule.requestsPerWindow,
95
+ windowMs: Math.max(0, Math.trunc(rule.windowMs)),
96
+ maxConcurrency:
97
+ rule.maxConcurrency == null
98
+ ? null
99
+ : Math.max(0, Math.trunc(rule.maxConcurrency)),
100
+ adaptiveMaxRps:
101
+ rule.adaptiveMaxRps == null || !Number.isFinite(rule.adaptiveMaxRps)
102
+ ? null
103
+ : Math.max(0, rule.adaptiveMaxRps),
104
+ adaptiveMaxConfigured:
105
+ isDefaultPacing ||
106
+ Object.prototype.hasOwnProperty.call(rule, 'adaptiveMaxRps'),
107
+ };
108
+ })
109
+ .filter(
110
+ (rule) =>
111
+ rule.ruleId &&
112
+ Number.isFinite(rule.requestsPerWindow) &&
113
+ rule.requestsPerWindow > 0 &&
114
+ rule.windowMs > 0,
115
+ )
116
+ .sort((a, b) => a.ruleId.localeCompare(b.ruleId));
117
+ }
118
+
119
+ /**
120
+ * Seed parameters for a rule row. `base_refill` is the declared rate in tokens
121
+ * per second; `capacity` is one window of burst. The adaptive `[min,max]` range
122
+ * is derived value-first so the same wire shape drives both declared and
123
+ * default-pacing buckets:
124
+ * - min_rps = min(base, max(0.01, base/8))
125
+ * (low-RPM rules must still be able to back down below their base)
126
+ * - max_rps = base (declared: recovery climbs back TO base only)
127
+ * - max_rps = base * 25 (undeclared default:* buckets, or explicit
128
+ * adaptiveMaxRps override)
129
+ */
130
+ function seedForRule(rule: AcquirePacingRule): {
131
+ rule_id: string;
132
+ capacity: number;
133
+ base_refill_per_sec: number;
134
+ min_rps: number;
135
+ max_rps: number;
136
+ adaptive_max_configured: boolean;
137
+ } {
138
+ // Even a sub-one-request window must accumulate one complete permit.
139
+ // Preserve its fractional refill rate, with dispatch times resolved in ms.
140
+ const capacity = Math.max(1, rule.requestsPerWindow);
141
+ const base = (rule.requestsPerWindow / rule.windowMs) * 1000;
142
+ const minRps = Math.min(base, Math.max(MIN_ADAPTIVE_RPS_FLOOR, base / 8));
143
+ const isDefaultPacing = rule.ruleId.startsWith('default:');
144
+ const maxRps =
145
+ rule.adaptiveMaxRps != null && rule.adaptiveMaxRps > base
146
+ ? rule.adaptiveMaxRps
147
+ : isDefaultPacing
148
+ ? base * DEFAULT_PACING_MAX_RPS_MULTIPLIER
149
+ : base;
150
+ return {
151
+ rule_id: rule.ruleId,
152
+ capacity,
153
+ base_refill_per_sec: base,
154
+ min_rps: minRps,
155
+ max_rps: Math.max(maxRps, base),
156
+ adaptive_max_configured: rule.adaptiveMaxConfigured === true,
157
+ };
158
+ }
159
+
160
+ function positiveInt(value: number, label: string): number {
161
+ if (!Number.isFinite(value) || value <= 0) {
162
+ throw new Error(`${label} must be a positive finite number.`);
163
+ }
164
+ return Math.trunc(value);
165
+ }
166
+
167
+ export async function ensurePostgresRateStateSchema(
168
+ client: PostgresSchedulerQueryClient,
169
+ options?: { schema?: string | null },
170
+ ): Promise<void> {
171
+ // Token-bucket + AIMD-in-the-row state. One row per (bucket_id, rule_id). The
172
+ // hot path is a single autocommit statement. It locks only the selected
173
+ // bucket rows, in rule-id order, while it refills, grants, schedules, deducts,
174
+ // and folds the ramp into the same UPDATE.
175
+ await client.query(`
176
+ CREATE TABLE IF NOT EXISTS ${bucketTableName(options)} (
177
+ bucket_id text NOT NULL,
178
+ rule_id text NOT NULL,
179
+ tokens float8 NOT NULL,
180
+ capacity float8 NOT NULL,
181
+ refill_per_sec float8 NOT NULL,
182
+ base_refill_per_sec float8 NOT NULL,
183
+ min_rps float8 NOT NULL,
184
+ max_rps float8 NOT NULL,
185
+ seen_backpressure boolean NOT NULL DEFAULT false,
186
+ cool_until_ms bigint NOT NULL DEFAULT 0,
187
+ claimed_retry_at_ms bigint NOT NULL DEFAULT 0,
188
+ last_ramp_at_ms bigint NOT NULL DEFAULT 0,
189
+ refilled_at_ms bigint NOT NULL,
190
+ PRIMARY KEY (bucket_id, rule_id)
191
+ )
192
+ `);
193
+ await client.query(`
194
+ ALTER TABLE ${bucketTableName(options)}
195
+ ADD COLUMN IF NOT EXISTS next_dispatch_at_ms bigint NOT NULL DEFAULT 0
196
+ `);
197
+ await client.query(`
198
+ ALTER TABLE ${bucketTableName(options)}
199
+ ADD COLUMN IF NOT EXISTS adaptive_max_configured boolean NOT NULL DEFAULT false
200
+ `);
201
+ await client.query(`
202
+ CREATE TABLE IF NOT EXISTS ${concurrencyLeaseTableName(options)} (
203
+ bucket_id text NOT NULL,
204
+ rule_id text NOT NULL,
205
+ lease_id text NOT NULL,
206
+ permits integer NOT NULL CHECK (permits > 0),
207
+ expires_at_ms bigint NOT NULL,
208
+ created_at timestamptz NOT NULL DEFAULT now(),
209
+ updated_at timestamptz NOT NULL DEFAULT now(),
210
+ PRIMARY KEY (bucket_id, rule_id, lease_id)
211
+ )
212
+ `);
213
+ await client.query(`
214
+ CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_concurrency_expiry_idx
215
+ ON ${concurrencyLeaseTableName(options)} (expires_at_ms)
216
+ `);
217
+ await client.query(`
218
+ CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_bucket_bucket_idx
219
+ ON ${bucketTableName(options)} (bucket_id)
220
+ `);
221
+ }
222
+
223
+ /**
224
+ * Seed missing rule rows and refresh changed configuration without erasing
225
+ * learned AIMD state. The conflict WHERE keeps the warm path a no-op, while a
226
+ * newly deployed adaptive ceiling reaches existing production buckets.
227
+ */
228
+ async function seedBucketRows(
229
+ client: PostgresSchedulerQueryClient,
230
+ input: { bucketId: string } & {
231
+ schema?: string | null;
232
+ schedulerSchema?: string | null;
233
+ },
234
+ rules: AcquirePacingRule[],
235
+ nowMs: number,
236
+ ): Promise<void> {
237
+ const seeds = rules.map(seedForRule);
238
+ await client.query(
239
+ `
240
+ INSERT INTO ${bucketTableName(input)} AS existing (
241
+ bucket_id,
242
+ rule_id,
243
+ tokens,
244
+ capacity,
245
+ refill_per_sec,
246
+ base_refill_per_sec,
247
+ min_rps,
248
+ max_rps,
249
+ adaptive_max_configured,
250
+ seen_backpressure,
251
+ cool_until_ms,
252
+ claimed_retry_at_ms,
253
+ last_ramp_at_ms,
254
+ refilled_at_ms,
255
+ next_dispatch_at_ms
256
+ )
257
+ SELECT
258
+ $1,
259
+ seed.rule_id,
260
+ seed.capacity,
261
+ seed.capacity,
262
+ seed.base_refill_per_sec,
263
+ seed.base_refill_per_sec,
264
+ seed.min_rps,
265
+ seed.max_rps,
266
+ seed.adaptive_max_configured,
267
+ false,
268
+ 0,
269
+ 0,
270
+ 0,
271
+ $3::bigint,
272
+ $3::bigint
273
+ FROM jsonb_to_recordset($2::jsonb) AS seed(
274
+ rule_id text,
275
+ capacity float8,
276
+ base_refill_per_sec float8,
277
+ min_rps float8,
278
+ max_rps float8,
279
+ adaptive_max_configured boolean
280
+ )
281
+ ON CONFLICT (bucket_id, rule_id) DO UPDATE SET
282
+ tokens = LEAST(existing.tokens, EXCLUDED.capacity),
283
+ capacity = EXCLUDED.capacity,
284
+ refill_per_sec = LEAST(
285
+ EXCLUDED.max_rps,
286
+ GREATEST(EXCLUDED.min_rps, existing.refill_per_sec)
287
+ ),
288
+ base_refill_per_sec = EXCLUDED.base_refill_per_sec,
289
+ min_rps = EXCLUDED.min_rps,
290
+ max_rps = CASE
291
+ WHEN EXCLUDED.adaptive_max_configured THEN EXCLUDED.max_rps
292
+ ELSE existing.max_rps
293
+ END,
294
+ adaptive_max_configured =
295
+ existing.adaptive_max_configured OR EXCLUDED.adaptive_max_configured
296
+ WHERE existing.capacity IS DISTINCT FROM EXCLUDED.capacity
297
+ OR existing.base_refill_per_sec IS DISTINCT FROM EXCLUDED.base_refill_per_sec
298
+ OR existing.min_rps IS DISTINCT FROM EXCLUDED.min_rps
299
+ OR (
300
+ EXCLUDED.adaptive_max_configured
301
+ AND existing.max_rps IS DISTINCT FROM EXCLUDED.max_rps
302
+ )
303
+ `,
304
+ [input.bucketId, stringifyPostgresJson(seeds), nowMs],
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Pure-rate (no maxConcurrency) acquire. A deterministic row lock inside one
310
+ * autocommit statement makes every contender reserve from the latest committed
311
+ * token and dispatch cursor instead of reusing a stale CTE snapshot. It refills
312
+ * every rule row against clock_timestamp(), grants the integer floor of the min
313
+ * available tokens across the bucket's rules (multi-rule = most constrained),
314
+ * deducts, and folds the AIMD ramp in. Returns the granted count and, when
315
+ * granted is 0, the wait until one more token accrues on the binding rule (or
316
+ * the cooldown clears).
317
+ */
318
+ async function acquirePureRate(
319
+ client: PostgresSchedulerQueryClient,
320
+ input: PlayRunnerRateStateAcquireInput & { nowMs?: number },
321
+ rules: AcquirePacingRule[],
322
+ requested: number,
323
+ ): Promise<PlayRunnerRateStateAcquireResult> {
324
+ const ruleIds = rules.map((rule) => rule.ruleId);
325
+ const result = await client.query<{
326
+ granted: string | number;
327
+ wait_ms: string | number;
328
+ server_now_ms: string | number;
329
+ cool_until_ms: string | number;
330
+ claimed_retry_at_ms: string | number;
331
+ scheduled_at_ms: Array<string | number> | null;
332
+ effective_rps: string | number;
333
+ }>(
334
+ `
335
+ WITH now_value AS (
336
+ SELECT COALESCE(
337
+ $3::bigint,
338
+ FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
339
+ ) AS now_ms
340
+ ),
341
+ refreshed AS (
342
+ SELECT
343
+ b.bucket_id,
344
+ b.rule_id,
345
+ b.capacity,
346
+ b.min_rps,
347
+ b.max_rps,
348
+ b.seen_backpressure,
349
+ b.base_refill_per_sec,
350
+ b.refill_per_sec,
351
+ b.cool_until_ms,
352
+ b.last_ramp_at_ms,
353
+ b.next_dispatch_at_ms,
354
+ nv.now_ms AS now_ms,
355
+ LEAST(
356
+ b.capacity,
357
+ b.tokens + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0 * b.refill_per_sec
358
+ ) AS tokens_after_refill
359
+ FROM ${bucketTableName(input)} b
360
+ CROSS JOIN now_value nv
361
+ WHERE b.bucket_id = $1
362
+ AND b.rule_id = ANY($2::text[])
363
+ ORDER BY b.rule_id
364
+ FOR UPDATE OF b
365
+ ),
366
+ cooling AS (
367
+ SELECT COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms
368
+ FROM refreshed
369
+ ),
370
+ grant_calc AS (
371
+ SELECT
372
+ -- Cooling buckets grant nothing; otherwise the integer floor of the
373
+ -- minimum post-refill tokens across every rule row (most constrained).
374
+ CASE
375
+ WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
376
+ THEN 0
377
+ ELSE GREATEST(0, LEAST($4::int, FLOOR(MIN(tokens_after_refill))::int))
378
+ END AS granted
379
+ FROM refreshed
380
+ ),
381
+ schedule_calc AS (
382
+ SELECT
383
+ GREATEST(
384
+ MAX(now_ms),
385
+ MAX(cool_until_ms),
386
+ MAX(next_dispatch_at_ms)
387
+ )::bigint AS first_at_ms,
388
+ CEIL(MAX(
389
+ 1000.0 / GREATEST(
390
+ refill_per_sec * CASE
391
+ WHEN refill_per_sec < ${LOW_RATE_DISPATCH_HEADROOM_THRESHOLD_RPS}
392
+ THEN ${LOW_RATE_DISPATCH_HEADROOM_FACTOR}
393
+ ELSE 1.0
394
+ END,
395
+ 0.0001
396
+ )
397
+ ))::bigint AS spacing_ms,
398
+ GREATEST(1, FLOOR(MIN(refill_per_sec) / 100.0))::bigint AS burst_size
399
+ FROM refreshed
400
+ ),
401
+ updated AS (
402
+ UPDATE ${bucketTableName(input)} b
403
+ SET
404
+ tokens = r.tokens_after_refill - (SELECT granted FROM grant_calc),
405
+ refilled_at_ms = r.now_ms,
406
+ next_dispatch_at_ms = CASE
407
+ WHEN (SELECT granted FROM grant_calc) > 0
408
+ THEN (SELECT first_at_ms FROM schedule_calc)
409
+ + CEIL(
410
+ (SELECT granted FROM grant_calc)::numeric
411
+ / (SELECT burst_size FROM schedule_calc)
412
+ )::bigint
413
+ * (SELECT spacing_ms FROM schedule_calc)
414
+ * (SELECT burst_size FROM schedule_calc)
415
+ ELSE b.next_dispatch_at_ms
416
+ END,
417
+ -- AIMD ramp, folded in. Only after confirmed provider success,
418
+ -- under real demand, not cooling, and at least 1s since the last
419
+ -- ramp. Slow-start doubles until the first 429, then adds
420
+ -- max(1, base/10) per step. Never exceeds max_rps.
421
+ refill_per_sec = CASE
422
+ WHEN $5::int > 0
423
+ AND r.now_ms - r.last_ramp_at_ms >= 1000
424
+ AND r.cool_until_ms <= r.now_ms
425
+ AND r.tokens_after_refill < r.capacity / 2
426
+ THEN LEAST(
427
+ r.max_rps,
428
+ CASE
429
+ WHEN r.seen_backpressure
430
+ THEN r.refill_per_sec + GREATEST(1, r.base_refill_per_sec / 10)
431
+ ELSE r.refill_per_sec * 2
432
+ END
433
+ )
434
+ ELSE r.refill_per_sec
435
+ END,
436
+ last_ramp_at_ms = CASE
437
+ WHEN $5::int > 0
438
+ AND r.now_ms - r.last_ramp_at_ms >= 1000
439
+ AND r.cool_until_ms <= r.now_ms
440
+ AND r.tokens_after_refill < r.capacity / 2
441
+ THEN r.now_ms
442
+ ELSE r.last_ramp_at_ms
443
+ END
444
+ FROM refreshed r
445
+ WHERE b.bucket_id = r.bucket_id
446
+ AND b.rule_id = r.rule_id
447
+ RETURNING
448
+ b.rule_id,
449
+ r.tokens_after_refill,
450
+ b.refill_per_sec AS new_refill_per_sec,
451
+ r.now_ms AS now_ms
452
+ )
453
+ SELECT
454
+ (SELECT granted FROM grant_calc) AS granted,
455
+ (SELECT now_ms FROM refreshed LIMIT 1) AS server_now_ms,
456
+ (SELECT cool_until_ms FROM cooling) AS cool_until_ms,
457
+ (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
458
+ WHERE bucket_id = $1 AND rule_id = ANY($2::text[])) AS claimed_retry_at_ms,
459
+ (SELECT COALESCE(MIN(new_refill_per_sec), 0) FROM updated) AS effective_rps,
460
+ CASE
461
+ WHEN (SELECT granted FROM grant_calc) > 0 THEN ARRAY(
462
+ SELECT
463
+ (SELECT first_at_ms FROM schedule_calc)
464
+ + FLOOR(
465
+ (permit_index - 1)::numeric
466
+ / (SELECT burst_size FROM schedule_calc)
467
+ )::bigint
468
+ * (SELECT spacing_ms FROM schedule_calc)
469
+ * (SELECT burst_size FROM schedule_calc)
470
+ FROM generate_series(1, (SELECT granted FROM grant_calc)) AS permit_index
471
+ )
472
+ ELSE '{}'::bigint[]
473
+ END AS scheduled_at_ms,
474
+ -- Wait until either the cooldown clears or one token accrues on the
475
+ -- most-constrained rule (whichever a granted=0 caller must sleep for).
476
+ CASE
477
+ WHEN (SELECT granted FROM grant_calc) > 0 THEN 0
478
+ WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
479
+ THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)
480
+ ELSE (
481
+ SELECT CEIL(
482
+ MAX((1.0 - tokens_after_refill) / GREATEST(new_refill_per_sec, 0.0001) * 1000)
483
+ )::bigint
484
+ FROM updated
485
+ )
486
+ END AS wait_ms
487
+ `,
488
+ [
489
+ input.bucketId,
490
+ ruleIds,
491
+ input.nowMs ?? null,
492
+ requested,
493
+ Math.max(0, Math.trunc(input.observedSuccesses ?? 0)),
494
+ ],
495
+ );
496
+ const row = result.rows[0];
497
+ if (!row) {
498
+ // Seeding raced away or rows vanished; treat as a short retryable wait.
499
+ return { granted: 0, waitMs: MIN_CONCURRENCY_WAIT_MS, leaseIds: [] };
500
+ }
501
+ const granted = Math.max(0, Math.trunc(Number(row.granted)));
502
+ const serverNowMs = Math.max(0, Number(row.server_now_ms));
503
+ const coolUntilMs = Math.max(0, Number(row.cool_until_ms));
504
+ const claimedRetryAtMs = Math.max(0, Number(row.claimed_retry_at_ms));
505
+ const scheduledAtMs = Array.isArray(row.scheduled_at_ms)
506
+ ? row.scheduled_at_ms.map(Number)
507
+ : [];
508
+ const effectiveRequestsPerSecond = Math.max(0, Number(row.effective_rps));
509
+ const waitMs = granted > 0 ? 0 : Math.max(1, Math.trunc(Number(row.wait_ms)));
510
+ return {
511
+ granted,
512
+ waitMs,
513
+ serverNowMs,
514
+ leaseIds: [],
515
+ scheduledAtMs,
516
+ effectiveRequestsPerSecond,
517
+ coolUntilMs,
518
+ claimedRetryAtMs,
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Mixed / concurrency-bearing acquire. The token bucket still governs the
524
+ * request rate; the durable lease table governs the simultaneous-in-flight cap.
525
+ * Expired-lease reclaim + active count + rate refill + grant + token deduction +
526
+ * lease minting all execute in ONE multi-CTE statement (one implicit
527
+ * transaction), so a crash can never deduct tokens without minting the matching
528
+ * lease rows. Lease ids are generated in SQL (gen_random_uuid) and returned to
529
+ * the client, one per granted permit, releasable independently.
530
+ */
531
+ async function acquireWithConcurrency(
532
+ client: PostgresSchedulerQueryClient,
533
+ input: PlayRunnerRateStateAcquireInput & { nowMs?: number },
534
+ rules: AcquirePacingRule[],
535
+ requested: number,
536
+ ): Promise<PlayRunnerRateStateAcquireResult> {
537
+ const ruleIds = rules.map((rule) => rule.ruleId);
538
+ const concurrencyRules = rules.filter((rule) => rule.maxConcurrency != null);
539
+ const concurrencyRuleIds = concurrencyRules.map((rule) => rule.ruleId);
540
+ // Per-rule limits, NOT collapsed to a single min. When a bucket carries more
541
+ // than one concurrency rule, each rule mints its own lease row per permit, so
542
+ // SUM(permits) over all rules double-counts a single in-flight permit. The
543
+ // grant must instead be bounded by the MIN free slots computed PER RULE
544
+ // (limit_r - active_r), matching the per-rule enforcement on origin/main.
545
+ const concurrencyLimits = concurrencyRules.map((rule) => ({
546
+ rule_id: rule.ruleId,
547
+ max_concurrency: rule.maxConcurrency as number,
548
+ }));
549
+
550
+ // One multi-CTE statement: reclaim expired leases, refill the rate rows,
551
+ // count live leases, compute the grant bounded by rate AND free concurrency
552
+ // slots, deduct the tokens, AND mint the lease rows — atomically. Grant +
553
+ // deduction + lease minting share one implicit transaction, so a crash can
554
+ // never deduct tokens without the matching lease rows (which would otherwise
555
+ // over-admit the provider until the phantom leases "expired"). PgBouncer-safe:
556
+ // autocommit, deterministic row locking, no lock_timeout, no session state.
557
+ const result = await client.query<{
558
+ granted: string | number;
559
+ wait_ms: string | number;
560
+ server_now_ms: string | number;
561
+ cool_until_ms: string | number;
562
+ claimed_retry_at_ms: string | number;
563
+ lease_ids: string[] | null;
564
+ scheduled_at_ms: Array<string | number> | null;
565
+ effective_rps: string | number;
566
+ }>(
567
+ `
568
+ WITH now_value AS (
569
+ SELECT COALESCE(
570
+ $3::bigint,
571
+ FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
572
+ ) AS now_ms
573
+ ),
574
+ reclaim AS (
575
+ DELETE FROM ${concurrencyLeaseTableName(input)}
576
+ WHERE bucket_id = $1
577
+ AND rule_id = ANY($5::text[])
578
+ AND expires_at_ms <= (SELECT now_ms FROM now_value)
579
+ RETURNING 1
580
+ ),
581
+ limits AS (
582
+ -- One row per concurrency rule with its declared cap.
583
+ SELECT rule_id, max_concurrency
584
+ FROM jsonb_to_recordset($9::jsonb) AS lim(
585
+ rule_id text,
586
+ max_concurrency int
587
+ )
588
+ ),
589
+ active_by_rule AS (
590
+ -- Live in-flight permits PER RULE. A CTE SELECT sees the pre-statement
591
+ -- snapshot, so expired rows the reclaim DELETE removes are still visible
592
+ -- here; filter them out explicitly by expiry so the free-slot count
593
+ -- matches the post-reclaim table. Grouping by rule_id (not SUM across
594
+ -- all rules) is what keeps a single permit — which mints one lease row
595
+ -- per concurrency rule — from being counted once per rule.
596
+ SELECT l.rule_id,
597
+ COALESCE((
598
+ SELECT SUM(cl.permits)
599
+ FROM ${concurrencyLeaseTableName(input)} cl
600
+ WHERE cl.bucket_id = $1
601
+ AND cl.rule_id = l.rule_id
602
+ AND cl.expires_at_ms > (SELECT now_ms FROM now_value)
603
+ ), 0)::int AS active_count
604
+ FROM limits l
605
+ ),
606
+ free_slots AS (
607
+ -- Most-constrained free concurrency across all rules of the bucket.
608
+ SELECT COALESCE(
609
+ MIN(l.max_concurrency - a.active_count),
610
+ $6::int
611
+ )::int AS free
612
+ FROM limits l
613
+ JOIN active_by_rule a ON a.rule_id = l.rule_id
614
+ ),
615
+ refreshed AS (
616
+ SELECT
617
+ b.rule_id,
618
+ b.capacity,
619
+ b.max_rps,
620
+ b.seen_backpressure,
621
+ b.base_refill_per_sec,
622
+ b.cool_until_ms,
623
+ b.last_ramp_at_ms,
624
+ b.next_dispatch_at_ms,
625
+ nv.now_ms AS now_ms,
626
+ LEAST(
627
+ b.capacity,
628
+ b.tokens + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0 * b.refill_per_sec
629
+ ) AS tokens_after_refill,
630
+ b.refill_per_sec
631
+ FROM ${bucketTableName(input)} b
632
+ CROSS JOIN now_value nv
633
+ WHERE b.bucket_id = $1
634
+ AND b.rule_id = ANY($2::text[])
635
+ ORDER BY b.rule_id
636
+ FOR UPDATE OF b
637
+ ),
638
+ cooling AS (
639
+ SELECT COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms FROM refreshed
640
+ ),
641
+ grant_calc AS (
642
+ SELECT
643
+ CASE
644
+ WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
645
+ THEN 0
646
+ ELSE GREATEST(
647
+ 0,
648
+ LEAST(
649
+ $4::int,
650
+ FLOOR(MIN(tokens_after_refill))::int,
651
+ (SELECT free FROM free_slots)
652
+ )
653
+ )
654
+ END AS granted
655
+ FROM refreshed
656
+ ),
657
+ schedule_calc AS (
658
+ SELECT
659
+ GREATEST(
660
+ MAX(now_ms),
661
+ MAX(cool_until_ms),
662
+ MAX(next_dispatch_at_ms)
663
+ )::bigint AS first_at_ms,
664
+ CEIL(MAX(
665
+ 1000.0 / GREATEST(
666
+ refill_per_sec * CASE
667
+ WHEN refill_per_sec < ${LOW_RATE_DISPATCH_HEADROOM_THRESHOLD_RPS}
668
+ THEN ${LOW_RATE_DISPATCH_HEADROOM_FACTOR}
669
+ ELSE 1.0
670
+ END,
671
+ 0.0001
672
+ )
673
+ ))::bigint AS spacing_ms,
674
+ GREATEST(1, FLOOR(MIN(refill_per_sec) / 100.0))::bigint AS burst_size
675
+ FROM refreshed
676
+ ),
677
+ updated AS (
678
+ UPDATE ${bucketTableName(input)} b
679
+ SET
680
+ tokens = r.tokens_after_refill - (SELECT granted FROM grant_calc),
681
+ refilled_at_ms = r.now_ms,
682
+ next_dispatch_at_ms = CASE
683
+ WHEN (SELECT granted FROM grant_calc) > 0
684
+ THEN (SELECT first_at_ms FROM schedule_calc)
685
+ + CEIL(
686
+ (SELECT granted FROM grant_calc)::numeric
687
+ / (SELECT burst_size FROM schedule_calc)
688
+ )::bigint
689
+ * (SELECT spacing_ms FROM schedule_calc)
690
+ * (SELECT burst_size FROM schedule_calc)
691
+ ELSE b.next_dispatch_at_ms
692
+ END,
693
+ -- Match the pure-rate path: concurrency leases change admission
694
+ -- accounting, not the AIMD learning contract.
695
+ refill_per_sec = CASE
696
+ WHEN $10::int > 0
697
+ AND r.now_ms - r.last_ramp_at_ms >= 1000
698
+ AND r.cool_until_ms <= r.now_ms
699
+ AND r.tokens_after_refill < r.capacity / 2
700
+ THEN LEAST(
701
+ r.max_rps,
702
+ CASE
703
+ WHEN r.seen_backpressure
704
+ THEN r.refill_per_sec + GREATEST(1, r.base_refill_per_sec / 10)
705
+ ELSE r.refill_per_sec * 2
706
+ END
707
+ )
708
+ ELSE r.refill_per_sec
709
+ END,
710
+ last_ramp_at_ms = CASE
711
+ WHEN $10::int > 0
712
+ AND r.now_ms - r.last_ramp_at_ms >= 1000
713
+ AND r.cool_until_ms <= r.now_ms
714
+ AND r.tokens_after_refill < r.capacity / 2
715
+ THEN r.now_ms
716
+ ELSE r.last_ramp_at_ms
717
+ END
718
+ FROM refreshed r
719
+ WHERE b.bucket_id = $1
720
+ AND b.rule_id = r.rule_id
721
+ RETURNING
722
+ r.tokens_after_refill,
723
+ b.refill_per_sec AS new_refill_per_sec
724
+ ),
725
+ lease_tokens AS (
726
+ -- One uuid per granted permit. A WITH query is evaluated once (volatile
727
+ -- functions included), so each lease_id below stays stable across the
728
+ -- per-rule cross join in minted — every concurrency rule row shares the
729
+ -- same lease_id for a given permit, matching release semantics.
730
+ SELECT gen_random_uuid()::text AS lease_id
731
+ FROM generate_series(1, (SELECT granted FROM grant_calc))
732
+ ),
733
+ minted AS (
734
+ -- Mint the lease rows from grant_calc (NOT by re-reading the lease
735
+ -- table: all CTEs share the pre-statement snapshot). Same-statement =
736
+ -- same transaction as the token deduction above.
737
+ INSERT INTO ${concurrencyLeaseTableName(input)} (
738
+ bucket_id,
739
+ rule_id,
740
+ lease_id,
741
+ permits,
742
+ expires_at_ms
743
+ )
744
+ SELECT
745
+ $1,
746
+ rule_id,
747
+ lt.lease_id,
748
+ 1,
749
+ (SELECT now_ms FROM now_value) + $8::bigint
750
+ FROM unnest($5::text[]) AS rule_id
751
+ CROSS JOIN lease_tokens lt
752
+ RETURNING lease_id
753
+ )
754
+ SELECT
755
+ (SELECT granted FROM grant_calc) AS granted,
756
+ (SELECT now_ms FROM refreshed LIMIT 1) AS server_now_ms,
757
+ (SELECT cool_until_ms FROM cooling) AS cool_until_ms,
758
+ (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
759
+ WHERE bucket_id = $1 AND rule_id = ANY($2::text[])) AS claimed_retry_at_ms,
760
+ (SELECT COALESCE(MIN(new_refill_per_sec), 0) FROM updated) AS effective_rps,
761
+ CASE
762
+ WHEN (SELECT granted FROM grant_calc) > 0 THEN ARRAY(
763
+ SELECT
764
+ (SELECT first_at_ms FROM schedule_calc)
765
+ + FLOOR(
766
+ (permit_index - 1)::numeric
767
+ / (SELECT burst_size FROM schedule_calc)
768
+ )::bigint
769
+ * (SELECT spacing_ms FROM schedule_calc)
770
+ * (SELECT burst_size FROM schedule_calc)
771
+ FROM generate_series(1, (SELECT granted FROM grant_calc)) AS permit_index
772
+ )
773
+ ELSE '{}'::bigint[]
774
+ END AS scheduled_at_ms,
775
+ (SELECT COALESCE(ARRAY_AGG(DISTINCT lease_id), '{}') FROM minted) AS lease_ids,
776
+ CASE
777
+ WHEN (SELECT granted FROM grant_calc) > 0 THEN 0
778
+ WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
779
+ THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)
780
+ WHEN (SELECT free FROM free_slots) <= 0 THEN $7::bigint
781
+ ELSE (
782
+ SELECT CEIL(
783
+ MAX((1.0 - tokens_after_refill) / GREATEST(new_refill_per_sec, 0.0001) * 1000)
784
+ )::bigint
785
+ FROM updated
786
+ )
787
+ END AS wait_ms
788
+ `,
789
+ [
790
+ input.bucketId,
791
+ ruleIds,
792
+ input.nowMs ?? null,
793
+ requested,
794
+ concurrencyRuleIds,
795
+ // $6 is only the free_slots COALESCE fallback (no concurrency rows). Use
796
+ // the tightest declared cap so it can never widen the grant.
797
+ Math.min(...concurrencyLimits.map((limit) => limit.max_concurrency)),
798
+ MIN_CONCURRENCY_WAIT_MS,
799
+ ACTIVE_LEASE_TTL_MS,
800
+ stringifyPostgresJson(concurrencyLimits),
801
+ Math.max(0, Math.trunc(input.observedSuccesses ?? 0)),
802
+ ],
803
+ );
804
+ const row = result.rows[0];
805
+ if (!row) {
806
+ return { granted: 0, waitMs: MIN_CONCURRENCY_WAIT_MS, leaseIds: [] };
807
+ }
808
+ const granted = Math.max(0, Math.trunc(Number(row.granted)));
809
+ const serverNowMs = Math.max(0, Number(row.server_now_ms));
810
+ const coolUntilMs = Math.max(0, Number(row.cool_until_ms));
811
+ const claimedRetryAtMs = Math.max(0, Number(row.claimed_retry_at_ms));
812
+ const scheduledAtMs = Array.isArray(row.scheduled_at_ms)
813
+ ? row.scheduled_at_ms.map(Number)
814
+ : [];
815
+ const effectiveRequestsPerSecond = Math.max(0, Number(row.effective_rps));
816
+ const waitMs = granted > 0 ? 0 : Math.max(1, Math.trunc(Number(row.wait_ms)));
817
+ const leaseIds = Array.isArray(row.lease_ids)
818
+ ? row.lease_ids.filter(
819
+ (leaseId): leaseId is string =>
820
+ typeof leaseId === 'string' && leaseId.length > 0,
821
+ )
822
+ : [];
823
+ if (granted > 0 && leaseIds.length !== granted) {
824
+ throw new Error(
825
+ `Rate-state lease minting drift for ${input.bucketId}: granted ${granted} with ${leaseIds.length} lease tokens.`,
826
+ );
827
+ }
828
+ return {
829
+ granted,
830
+ waitMs,
831
+ serverNowMs,
832
+ leaseIds: granted > 0 ? leaseIds : [],
833
+ scheduledAtMs,
834
+ effectiveRequestsPerSecond,
835
+ coolUntilMs,
836
+ claimedRetryAtMs,
837
+ };
838
+ }
839
+
840
+ export async function acquirePostgresRateState(
841
+ client: PostgresSchedulerQueryClient,
842
+ input: PlayRunnerRateStateAcquireInput & { nowMs?: number },
843
+ ): Promise<PlayRunnerRateStateAcquireResult> {
844
+ const rules = normalizeRules(input.rules as AcquirePacingRule[]);
845
+ if (rules.length === 0) return { granted: 0, waitMs: 0, leaseIds: [] };
846
+ const requested = positiveInt(input.requested, 'requested permits');
847
+ const nowMs =
848
+ input.nowMs ??
849
+ Number(
850
+ (
851
+ await client.query<{ now_ms: string | number }>(
852
+ `SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint AS now_ms`,
853
+ )
854
+ ).rows[0]?.now_ms ?? Date.now(),
855
+ );
856
+ // Cold path: seed missing rows (ON CONFLICT DO NOTHING, no-op when warm).
857
+ await seedBucketRows(client, input, rules, nowMs);
858
+ const hasConcurrency = rules.some((rule) => rule.maxConcurrency != null);
859
+ if (!hasConcurrency) {
860
+ return acquirePureRate(client, { ...input, nowMs }, rules, requested);
861
+ }
862
+ return acquireWithConcurrency(client, { ...input, nowMs }, rules, requested);
863
+ }
864
+
865
+ export async function releasePostgresRateState(
866
+ client: PostgresSchedulerQueryClient,
867
+ input: PlayRunnerRateStateReleaseInput & { nowMs?: number },
868
+ ): Promise<void> {
869
+ const rules = normalizeRules(input.rules as AcquirePacingRule[]).filter(
870
+ (rule) => rule.maxConcurrency != null,
871
+ );
872
+ if (rules.length === 0) return;
873
+ const leaseIds = [
874
+ ...new Set(
875
+ input.leaseIds
876
+ .filter((leaseId): leaseId is string => typeof leaseId === 'string')
877
+ .map((leaseId) => leaseId.trim())
878
+ .filter(Boolean),
879
+ ),
880
+ ];
881
+ if (leaseIds.length === 0) return;
882
+ // DELETE by holder token is naturally idempotent, including HTTP retries.
883
+ await client.query(
884
+ `
885
+ DELETE FROM ${concurrencyLeaseTableName(input)}
886
+ WHERE bucket_id = $1
887
+ AND rule_id = ANY($2::text[])
888
+ AND lease_id = ANY($3::text[])
889
+ `,
890
+ [input.bucketId, rules.map((rule) => rule.ruleId), leaseIds],
891
+ );
892
+ }
893
+
894
+ export async function penalizePostgresRateState(
895
+ client: PostgresSchedulerQueryClient,
896
+ input: PlayRunnerRateStatePenalizeInput & { nowMs?: number },
897
+ ): Promise<void> {
898
+ if (!Number.isFinite(input.cooldownMs) || input.cooldownMs <= 0) return;
899
+ // Single autocommit statement applied to every rule row of the bucket:
900
+ // - claimed_retry_at_ms: server Retry-After stored verbatim (observability).
901
+ // - cool_until_ms: extended by the cooldown, internally capped at 45min.
902
+ // - refill_per_sec: multiplicative decrease, but ONLY once per active
903
+ // cooldown window (halve-once guard: skip if already cooling), floored at
904
+ // min_rps.
905
+ // - seen_backpressure flips the ramp from slow-start (x2) to additive.
906
+ // - tokens drained: no more grants until refill re-accrues past the cooldown.
907
+ await client.query(
908
+ `
909
+ WITH now_value AS (
910
+ SELECT COALESCE(
911
+ $3::bigint,
912
+ FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
913
+ ) AS now_ms
914
+ )
915
+ UPDATE ${bucketTableName(input)} b
916
+ SET
917
+ claimed_retry_at_ms = (SELECT now_ms FROM now_value) + $2::bigint,
918
+ refill_per_sec = CASE
919
+ WHEN (SELECT now_ms FROM now_value) >= b.cool_until_ms
920
+ THEN GREATEST(b.min_rps, b.refill_per_sec / 2)
921
+ ELSE b.refill_per_sec
922
+ END,
923
+ cool_until_ms = GREATEST(
924
+ b.cool_until_ms,
925
+ (SELECT now_ms FROM now_value) + LEAST($2::bigint, $4::bigint)
926
+ ),
927
+ seen_backpressure = true,
928
+ tokens = 0,
929
+ next_dispatch_at_ms = GREATEST(
930
+ b.next_dispatch_at_ms,
931
+ (SELECT now_ms FROM now_value) + LEAST($2::bigint, $4::bigint)
932
+ )
933
+ WHERE b.bucket_id = $1
934
+ `,
935
+ [
936
+ input.bucketId,
937
+ Math.ceil(input.cooldownMs),
938
+ input.nowMs ?? null,
939
+ MAX_INTERNAL_COOLDOWN_MS,
940
+ ],
941
+ );
942
+ }