deepline 0.3.128 → 0.3.129

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 (128) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +34 -9
  2. package/dist/bundling-sources/sdk/src/http.ts +22 -0
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/shared_libs/play-data-plane/r2.ts +22 -0
  5. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +265 -2
  6. package/dist/bundling-sources/shared_libs/play-runtime/bounded-batch-function.ts +682 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/child-execution-strategy.ts +21 -5
  8. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +5390 -1732
  9. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +107 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +7 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +229 -7
  12. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +25 -4
  13. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-capacity.ts +71 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +1 -1
  15. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +4 -1
  16. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +694 -28
  17. package/dist/bundling-sources/shared_libs/play-runtime/egress-bandwidth-probe.ts +167 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/execute-server-timing.ts +103 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-receipt-output-reference.ts +34 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-response-size.ts +27 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/gateway-postgres-admission.ts +66 -1
  22. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +425 -71
  23. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +210 -32
  24. package/dist/bundling-sources/shared_libs/play-runtime/governor/in-memory-rate-state-backend.ts +27 -13
  25. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +19 -6
  26. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +61 -2
  27. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/contracts.ts +33 -1
  28. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/job.ts +42 -1
  29. package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +11 -11
  30. package/dist/bundling-sources/shared_libs/play-runtime/ledger-safe-payload.ts +49 -16
  31. package/dist/bundling-sources/shared_libs/play-runtime/map-memory-limits.ts +1653 -98
  32. package/dist/bundling-sources/shared_libs/play-runtime/memory-ledger.ts +807 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/memory-pressure-signal.ts +138 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +85 -3
  35. package/dist/bundling-sources/shared_libs/play-runtime/persistence-latch.ts +60 -2
  36. package/dist/bundling-sources/shared_libs/play-runtime/play-run-recovery-policy.ts +46 -1
  37. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/contracts.ts +2 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/job.ts +1 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/ports.ts +1 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/postgres-adapter.ts +1 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-semantics.ts +70 -27
  42. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +56 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/adaptive-provider-capacity-controller.ts +192 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/epoch-aimd-rate-controller.ts +194 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/fixture-capacity-simulator.ts +220 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/gradient2-concurrency-controller.ts +214 -0
  47. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-capacity-policy.ts +43 -0
  48. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-concurrency-policy.ts +30 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-rate-policy.ts +95 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-throttle-classifier.ts +40 -0
  51. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +1535 -11
  52. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +49 -2
  53. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +6 -1
  54. package/dist/bundling-sources/shared_libs/play-runtime/run-log-buffer.ts +55 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/egress-probe.ts +221 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +339 -29
  57. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +28 -1
  58. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +192 -165
  59. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +6 -0
  60. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +90 -5
  61. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +281 -6
  62. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +27 -1
  63. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +19 -0
  64. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/modal.ts +108 -0
  65. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +40 -0
  66. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +11 -5
  67. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +30 -0
  68. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api-compression.ts +276 -0
  69. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +723 -115
  70. package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +98 -2
  71. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +3 -2
  72. package/dist/bundling-sources/shared_libs/play-runtime/runtime-environment.ts +41 -4
  73. package/dist/bundling-sources/shared_libs/play-runtime/runtime-flow.ts +448 -0
  74. package/dist/bundling-sources/shared_libs/play-runtime/runtime-map-row-feed.ts +215 -0
  75. package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +11 -0
  76. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-store-adapter.ts +161 -14
  77. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-value.ts +184 -0
  78. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-writer.ts +607 -121
  79. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-placement-policy.ts +4 -0
  80. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-writer.ts +21 -0
  81. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/contract.ts +15 -2
  82. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/runner-backend-adapter.ts +71 -14
  83. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-deploy-task.ts +2 -2
  84. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +288 -119
  85. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +8 -6
  86. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-required-env.ts +28 -3
  87. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/canonical-integration-operation-store.ts +179 -0
  88. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-heavy-request-admission.ts +80 -2
  89. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-progress-registry.ts +31 -2
  90. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-store-contract.ts +104 -0
  91. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-value-reference.ts +47 -0
  92. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-value-retirement.ts +193 -0
  93. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-value-storage.ts +420 -0
  94. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +41 -0
  95. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +134 -3
  96. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +1657 -95
  97. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +885 -73
  98. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-completion-coalescer.ts +452 -0
  99. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +601 -42
  100. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-terminal-transition.ts +1 -0
  101. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-receipt-value-storage.ts +371 -0
  102. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-resource-metrics.ts +86 -7
  103. package/dist/bundling-sources/shared_libs/play-runtime/secret-redaction.ts +153 -22
  104. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/index.ts +1 -0
  105. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/receipt-cohort.ts +188 -38
  106. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/runtime-step-receipts-adapter.ts +28 -13
  107. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +44 -1
  108. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +56 -4
  109. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +2 -0
  110. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +16 -10
  111. package/dist/bundling-sources/shared_libs/plays/dataset.ts +4 -1
  112. package/dist/cli/index.js +250 -38
  113. package/dist/cli/index.mjs +250 -38
  114. package/dist/{compiler-manifest-DfSnbCTW.d.mts → compiler-manifest-BIqRyj5m.d.mts} +1 -1
  115. package/dist/{compiler-manifest-DfSnbCTW.d.ts → compiler-manifest-BIqRyj5m.d.ts} +1 -1
  116. package/dist/index.d.mts +4 -2
  117. package/dist/index.d.ts +4 -2
  118. package/dist/index.js +231 -35
  119. package/dist/index.mjs +231 -35
  120. package/dist/install-integrity.json +31 -2
  121. package/dist/plays/bundle-play-file.d.mts +2 -2
  122. package/dist/plays/bundle-play-file.d.ts +2 -2
  123. package/dist/plays/bundle-play-file.mjs +65 -1
  124. package/dist/release.d.mts +1 -1
  125. package/dist/release.d.ts +1 -1
  126. package/dist/release.js +1 -1
  127. package/dist/release.mjs +1 -1
  128. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  PlayRunnerRateStateAcquireInput,
3
3
  PlayRunnerRateStateAcquireResult,
4
+ PlayRunnerRateStateConcurrencyGrant,
4
5
  PlayRunnerRateStatePenalizeInput,
5
6
  PlayRunnerRateStateReleaseInput,
6
7
  } from '../protocol';
@@ -8,16 +9,90 @@ import {
8
9
  DEFAULT_ADVISORY_PROVIDER_MAX_RPS,
9
10
  type PacingRule,
10
11
  } from '../governor/rate-state-backend';
11
- import type { PostgresSchedulerQueryClient } from './postgres';
12
+ import { type PostgresSchedulerQueryClient } from './postgres';
12
13
  import { resolveRuntimeSchedulerSchemaEnv } from './runtime-scheduler-schema';
13
14
  import { stringifyPostgresJson } from '../postgres-json';
15
+ import {
16
+ Gradient2ConcurrencyController,
17
+ type Gradient2Observation,
18
+ } from '../provider-capacity/gradient2-concurrency-controller';
19
+ import { providerConcurrencyControllerOptions } from '../provider-capacity/provider-concurrency-policy';
20
+ import { classifyProviderThrottle } from '../provider-capacity/provider-throttle-classifier';
14
21
 
15
22
  const DEFAULT_SCHEMA = 'runtime_scheduler';
16
23
  const RATE_BUCKET_TABLE = 'rate_bucket';
17
24
  const RATE_CONCURRENCY_LEASE_TABLE = 'rate_concurrency_leases';
25
+ const RATE_CONCURRENCY_OBSERVER_TABLE = 'rate_concurrency_observers';
26
+ const RATE_RESERVATION_TABLE = 'rate_reservations';
27
+ const RATE_REQUESTER_TABLE = 'rate_requesters';
18
28
  const MIN_CONCURRENCY_WAIT_MS = 10;
29
+ /**
30
+ * Longest a requester held back by the fair-share cap sleeps before it re-asks.
31
+ * The natural wait is the rest of the current fair-share window (a sibling's
32
+ * reserved slice frees at the boundary), but a slow rule's window can be a
33
+ * minute or more, and the runtime treats any hold above its 60 s exhaustion
34
+ * threshold as a provider outage. A capped requester therefore re-asks at most
35
+ * this often; a healthy-but-busy provider must never read as exhausted.
36
+ */
37
+ export const FAIR_SHARE_MAX_WAIT_MS = 30_000;
38
+ /** Re-ask cadence when a sibling's reserved concurrency slots are the limit. */
39
+ const FAIR_SHARE_SLOT_WAIT_MS = 100;
40
+ /** Idle fair-share windows after which a requester stops counting as active. */
41
+ const FAIR_SHARE_REQUESTER_IDLE_WINDOWS = 2;
42
+ /** Opportunistically reclaim idle requester rows without a whole-table lock. */
43
+ const FAIR_SHARE_REQUESTER_CLEANUP_BATCH_SIZE = 64;
44
+ /** Conservative warm-start width for a previously unobserved provider scope. */
45
+ const INITIAL_CONCURRENCY_DISCOVERY_LIMIT = 8;
46
+ /** A transport batch must stay bounded even when a runner completes huge maps. */
47
+ const MAX_CONCURRENCY_OBSERVATIONS_PER_ACQUIRE = 4_096;
48
+ /** Keep retry identities long enough for failed runtime work to recover. */
49
+ const CONCURRENCY_OBSERVATION_DEDUP_RETENTION_MS = 24 * 60 * 60 * 1000;
50
+ /** Opportunistically reclaim stale runner identities without a whole-table lock. */
51
+ const CONCURRENCY_OBSERVER_CLEANUP_BATCH_SIZE = 128;
19
52
  const ACTIVE_LEASE_TTL_MS = 5 * 60 * 1000;
53
+ const RATE_RESERVATION_TTL_MS = 5 * 60 * 1000;
20
54
  const MIN_ADAPTIVE_RPS_FLOOR = 0.01;
55
+ /**
56
+ * Horizon a granted=0 caller is allowed to sleep toward a WHOLE block instead of
57
+ * a single token. Waiting for one token means one permit per store round trip,
58
+ * so a lane's real ceiling becomes 1/(1000/rps + RTT) — at 10 rps that is ~6/s
59
+ * against a 10/s budget, and raising the rate does not help because the round
60
+ * trip dominates. Waiting for a block amortizes the round trip instead. Bounded
61
+ * so a slow lane (a 15 RPM rule, or one floored at min_rps after a 429) still
62
+ * reports the same single-token wait it does today and can never be mistaken for
63
+ * PROVIDER_EXHAUSTED.
64
+ */
65
+ const BLOCK_WAIT_HORIZON_MS = 1_000;
66
+
67
+ /**
68
+ * Burst horizon of the adaptive token bucket, in milliseconds of the rate
69
+ * ACTUALLY in force.
70
+ *
71
+ * The stored `capacity` column is the DECLARED window burst and must stay at
72
+ * its seed: config-drift detection compares it against the seed verbatim, so
73
+ * persisting a learned capacity would make every warm acquire look like drift
74
+ * and re-seed the row. The bucket the grant actually spends is therefore
75
+ * derived, not stored: one second of `refill_per_sec`, floored at the declared
76
+ * burst.
77
+ *
78
+ * Without this the AIMD ramp is invisible to throughput. A grant is bounded by
79
+ * the bucket's tokens, tokens are clamped to `capacity`, and every grant costs
80
+ * one app-runtime round trip — so a lane's real ceiling is
81
+ * `capacity / round-trip`, not `refill_per_sec`. A default-pacing lane
82
+ * (capacity 10) stayed at ~7.7 calls/s and a declared 25 rps lane at ~13
83
+ * calls/s no matter how high `refill_per_sec` climbed.
84
+ *
85
+ * The burst stays bounded: one second of the learned rate, and the learned rate
86
+ * is itself capped at `max_rps`. The dispatch cursor still spaces every permit
87
+ * at 1/`refill_per_sec`, so provider arrivals never exceed the rate in force.
88
+ */
89
+ const ADAPTIVE_BURST_HORIZON_MS = 1_000;
90
+
91
+ /**
92
+ * Effective (spendable) bucket capacity for rate rows aliased `b`. Derived per
93
+ * statement from the learned rate; see {@link ADAPTIVE_BURST_HORIZON_MS}.
94
+ */
95
+ const EFFECTIVE_CAPACITY_SQL = `GREATEST(b.capacity, b.refill_per_sec * ${ADAPTIVE_BURST_HORIZON_MS} / 1000.0)`;
21
96
 
22
97
  /**
23
98
  * Internal cooldown cap. A 429 report (penalize) can carry an arbitrarily large
@@ -28,6 +103,29 @@ const MIN_ADAPTIVE_RPS_FLOOR = 0.01;
28
103
  */
29
104
  const MAX_INTERNAL_COOLDOWN_MS = 45 * 60 * 1000;
30
105
 
106
+ /**
107
+ * Buckets this process has already seeded, keyed by schema + bucket id + the
108
+ * exact seed payload. Seeding is a pure upsert of declared configuration, so
109
+ * once this process has written a key the rule rows are known present AND
110
+ * current; replaying that upsert on every acquire buys nothing but a round
111
+ * trip. Changed configuration hashes to a different key, so a newly deployed
112
+ * adaptive ceiling still reaches an existing bucket on its next acquire.
113
+ * Bounded FIFO: eviction costs one extra seed, never correctness, and a row
114
+ * that disappears behind a warm mark is detected by the grant statement itself
115
+ * (see `acquirePostgresRateState`) rather than silently granting nothing.
116
+ */
117
+ const WARM_RATE_BUCKET_LIMIT = 4096;
118
+ const warmRateBuckets = new Set<string>();
119
+
120
+ function markRateBucketWarm(key: string): void {
121
+ if (warmRateBuckets.has(key)) return;
122
+ if (warmRateBuckets.size >= WARM_RATE_BUCKET_LIMIT) {
123
+ const oldest = warmRateBuckets.values().next();
124
+ if (!oldest.done) warmRateBuckets.delete(oldest.value);
125
+ }
126
+ warmRateBuckets.add(key);
127
+ }
128
+
31
129
  function quoteIdent(value: string): string {
32
130
  return `"${value.replace(/"/g, '""')}"`;
33
131
  }
@@ -58,6 +156,127 @@ function concurrencyLeaseTableName(options?: {
58
156
  return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_CONCURRENCY_LEASE_TABLE)}`;
59
157
  }
60
158
 
159
+ function concurrencyObserverTableName(options?: {
160
+ schema?: string | null;
161
+ schedulerSchema?: string | null;
162
+ }): string {
163
+ return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_CONCURRENCY_OBSERVER_TABLE)}`;
164
+ }
165
+
166
+ function reservationTableName(options?: {
167
+ schema?: string | null;
168
+ schedulerSchema?: string | null;
169
+ }): string {
170
+ return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_RESERVATION_TABLE)}`;
171
+ }
172
+
173
+ function requesterTableName(options?: {
174
+ schema?: string | null;
175
+ schedulerSchema?: string | null;
176
+ }): string {
177
+ return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_REQUESTER_TABLE)}`;
178
+ }
179
+
180
+ /**
181
+ * v43 added `rate_requesters` and the lease `requester_id` column. Only an
182
+ * acquire that carries a requester identity touches them, so during the
183
+ * expansion window a v43 process serving a v42 database converts exactly that
184
+ * missing relation/column into the retryable deployment-skew error, mirroring
185
+ * {@link isMissingRateReservationTableError}.
186
+ */
187
+ function isMissingRateRequesterSchemaError(error: unknown): boolean {
188
+ if (!error || typeof error !== 'object') return false;
189
+ const candidate = error as { code?: unknown; message?: unknown };
190
+ if (typeof candidate.message !== 'string') return false;
191
+ return (
192
+ (candidate.code === '42P01' &&
193
+ new RegExp(`\\b${RATE_REQUESTER_TABLE}\\b`).test(candidate.message)) ||
194
+ (candidate.code === '42703' && /\brequester_id\b/.test(candidate.message))
195
+ );
196
+ }
197
+
198
+ /**
199
+ * `rate_reservations` arrived with schema v35. The deploy-owned migration owns
200
+ * its creation (`ensurePostgresRateStateSchema`); no hot path runs DDL. During
201
+ * the expansion window a v35 process can therefore be serving a v34 database,
202
+ * and every reservation statement below fails with 42P01 `undefined_table`.
203
+ *
204
+ * Raw, that surfaces to the runner as an ordinary query error and fails the
205
+ * call. Recognizing exactly this relation — and only this relation — converts
206
+ * it into the typed, retryable deployment-skew error the runtime already
207
+ * understands, so the runner waits out the migration instead. Any other
208
+ * missing relation is a real defect and propagates untouched.
209
+ */
210
+ function isMissingRateReservationTableError(error: unknown): boolean {
211
+ if (!error || typeof error !== 'object') return false;
212
+ const candidate = error as { code?: unknown; message?: unknown };
213
+ if (candidate.code !== '42P01') return false;
214
+ if (typeof candidate.message !== 'string') return false;
215
+ return new RegExp(`\\b${RATE_RESERVATION_TABLE}\\b`).test(candidate.message);
216
+ }
217
+
218
+ function isMissingConcurrencyCheckpointError(error: unknown): boolean {
219
+ if (!error || typeof error !== 'object') return false;
220
+ const candidate = error as { code?: unknown; message?: unknown };
221
+ return (
222
+ candidate.code === '42703' &&
223
+ typeof candidate.message === 'string' &&
224
+ /\b(learned_concurrency_limit|in_flight_at_start|target_requests_per_second|minimum_rtt_ms)\b/.test(
225
+ candidate.message,
226
+ )
227
+ );
228
+ }
229
+
230
+ function isMissingConcurrencyObserverTableError(error: unknown): boolean {
231
+ if (!error || typeof error !== 'object') return false;
232
+ const candidate = error as { code?: unknown; message?: unknown };
233
+ if (typeof candidate.message !== 'string') return false;
234
+ return (
235
+ (candidate.code === '42P01' &&
236
+ new RegExp(`\\b${RATE_CONCURRENCY_OBSERVER_TABLE}\\b`).test(
237
+ candidate.message,
238
+ )) ||
239
+ (candidate.code === '42703' && /\bexpires_at_ms\b/.test(candidate.message))
240
+ );
241
+ }
242
+
243
+ /**
244
+ * An incomplete deploy can leave the provider-rate tables absent temporarily.
245
+ * This is an operation-local, retryable store failure—not a scheduler-version
246
+ * admission gate—so unrelated runtime work continues through its own stores.
247
+ */
248
+ export class RuntimeRateStateUnavailableError extends Error {
249
+ readonly code = 'RUNTIME_RATE_STATE_UNAVAILABLE';
250
+
251
+ constructor(input: { schema: string }) {
252
+ super(
253
+ `Runtime rate state in schema ${input.schema} is unavailable; retry after the deploy migration completes.`,
254
+ );
255
+ this.name = 'RuntimeRateStateUnavailableError';
256
+ }
257
+ }
258
+
259
+ async function withRateStateAvailabilityGuard<T>(
260
+ options: { schema?: string | null; schedulerSchema?: string | null },
261
+ run: () => Promise<T>,
262
+ ): Promise<T> {
263
+ try {
264
+ return await run();
265
+ } catch (error) {
266
+ if (
267
+ !isMissingRateReservationTableError(error) &&
268
+ !isMissingConcurrencyCheckpointError(error) &&
269
+ !isMissingConcurrencyObserverTableError(error) &&
270
+ !isMissingRateRequesterSchemaError(error)
271
+ ) {
272
+ throw error;
273
+ }
274
+ throw new RuntimeRateStateUnavailableError({
275
+ schema: schemaName(options),
276
+ });
277
+ }
278
+ }
279
+
61
280
  /**
62
281
  * Acquire input rules may carry an optional `adaptiveMaxRps` the client can send
63
282
  * to override a bucket's recovery ceiling. When absent, a catalogued provider
@@ -107,20 +326,22 @@ function normalizeRules(
107
326
  * Seed parameters for a rule row. `base_refill` is the declared rate in tokens
108
327
  * per second; `capacity` is one window of burst. The adaptive `[min,max]` range
109
328
  * is derived value-first so the same wire shape drives both declared and
110
- * catalogued-rate buckets:
329
+ * default-pacing buckets:
111
330
  * - min_rps = min(base, max(0.01, base/8))
112
331
  * (low-RPM rules must still be able to back down below their base)
113
332
  * - max_rps = max(base, 100) (catalogued-rate recommendation)
114
333
  * - max_rps = explicit adaptiveMaxRps when supplied
115
334
  */
116
- function seedForRule(rule: AcquirePacingRule): {
335
+ type RateBucketSeed = {
117
336
  rule_id: string;
118
337
  capacity: number;
119
338
  base_refill_per_sec: number;
120
339
  min_rps: number;
121
340
  max_rps: number;
122
341
  adaptive_max_configured: boolean;
123
- } {
342
+ };
343
+
344
+ function seedForRule(rule: AcquirePacingRule): RateBucketSeed {
124
345
  // Even a sub-one-request window must accumulate one complete permit.
125
346
  // Preserve its fractional refill rate, with dispatch times resolved in ms.
126
347
  const capacity = Math.max(1, rule.requestsPerWindow);
@@ -181,6 +402,37 @@ export async function ensurePostgresRateStateSchema(
181
402
  ALTER TABLE ${bucketTableName(options)}
182
403
  ADD COLUMN IF NOT EXISTS adaptive_max_configured boolean NOT NULL DEFAULT false
183
404
  `);
405
+ // v37: durable Gradient2 checkpoint. Null limit marks a pre-v37 bucket and
406
+ // starts its next concurrency-bearing acquire at the conservative discovery
407
+ // width; rate-only rules leave these fields unused.
408
+ await client.query(`
409
+ ALTER TABLE ${bucketTableName(options)}
410
+ ADD COLUMN IF NOT EXISTS learned_concurrency_limit float8
411
+ `);
412
+ await client.query(`
413
+ ALTER TABLE ${bucketTableName(options)}
414
+ ADD COLUMN IF NOT EXISTS concurrency_short_rtt_ms float8
415
+ `);
416
+ await client.query(`
417
+ ALTER TABLE ${bucketTableName(options)}
418
+ ADD COLUMN IF NOT EXISTS concurrency_long_rtt_ms float8
419
+ `);
420
+ await client.query(`
421
+ ALTER TABLE ${bucketTableName(options)}
422
+ ADD COLUMN IF NOT EXISTS concurrency_minimum_rtt_ms float8
423
+ `);
424
+ await client.query(`
425
+ ALTER TABLE ${bucketTableName(options)}
426
+ ADD COLUMN IF NOT EXISTS concurrency_successful_samples integer NOT NULL DEFAULT 0
427
+ `);
428
+ await client.query(`
429
+ ALTER TABLE ${bucketTableName(options)}
430
+ ADD COLUMN IF NOT EXISTS concurrency_window_limit float8
431
+ `);
432
+ await client.query(`
433
+ ALTER TABLE ${bucketTableName(options)}
434
+ ADD COLUMN IF NOT EXISTS concurrency_has_observed_throttle boolean NOT NULL DEFAULT false
435
+ `);
184
436
  await client.query(`
185
437
  CREATE TABLE IF NOT EXISTS ${concurrencyLeaseTableName(options)} (
186
438
  bucket_id text NOT NULL,
@@ -197,10 +449,84 @@ export async function ensurePostgresRateStateSchema(
197
449
  CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_concurrency_expiry_idx
198
450
  ON ${concurrencyLeaseTableName(options)} (expires_at_ms)
199
451
  `);
452
+ // v38: each server-minted lease also retains the admission facts required to
453
+ // classify a later 429. The runner returns the lease id, but the gateway
454
+ // reloads these values instead of trusting request-body attribution.
455
+ await client.query(`
456
+ ALTER TABLE ${concurrencyLeaseTableName(options)}
457
+ ADD COLUMN IF NOT EXISTS in_flight_at_start integer
458
+ `);
459
+ await client.query(`
460
+ ALTER TABLE ${concurrencyLeaseTableName(options)}
461
+ ADD COLUMN IF NOT EXISTS target_requests_per_second float8
462
+ `);
463
+ await client.query(`
464
+ ALTER TABLE ${concurrencyLeaseTableName(options)}
465
+ ADD COLUMN IF NOT EXISTS minimum_rtt_ms float8
466
+ `);
467
+ // One row per runner/rule lane remembers the latest locally buffered
468
+ // observation sequence. It prevents an acquire whose response was lost from
469
+ // applying the same Gradient2 samples twice, without persisting each result.
470
+ // The bounded retry identity expires so completed sandbox runs cannot grow
471
+ // the durable control plane forever.
472
+ await client.query(`
473
+ CREATE TABLE IF NOT EXISTS ${concurrencyObserverTableName(options)} (
474
+ bucket_id text NOT NULL,
475
+ observer_id text NOT NULL,
476
+ lane_id text NOT NULL,
477
+ last_sequence bigint NOT NULL CHECK (last_sequence > 0),
478
+ updated_at_ms bigint NOT NULL,
479
+ expires_at_ms bigint NOT NULL,
480
+ PRIMARY KEY (bucket_id, observer_id, lane_id)
481
+ )
482
+ `);
483
+ await client.query(`
484
+ CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_concurrency_observer_expiry_idx
485
+ ON ${concurrencyObserverTableName(options)} (expires_at_ms)
486
+ `);
487
+ await client.query(`
488
+ CREATE TABLE IF NOT EXISTS ${reservationTableName(options)} (
489
+ bucket_id text NOT NULL,
490
+ reservation_id text NOT NULL,
491
+ expires_at_ms bigint NOT NULL,
492
+ PRIMARY KEY (bucket_id, reservation_id)
493
+ )
494
+ `);
495
+ await client.query(`
496
+ CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_reservation_expiry_idx
497
+ ON ${reservationTableName(options)} (expires_at_ms)
498
+ `);
200
499
  await client.query(`
201
500
  CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_bucket_bucket_idx
202
501
  ON ${bucketTableName(options)} (bucket_id)
203
502
  `);
503
+ // v43: one row per (bucket, run) remembers how much of the current
504
+ // fair-share window that run has drawn, so several runs contending for one
505
+ // org's provider window each keep a max-min share instead of first-come
506
+ // draining every window. Rows expire after a short idle; a lone requester
507
+ // never has peers and is granted exactly as before.
508
+ await client.query(`
509
+ CREATE TABLE IF NOT EXISTS ${requesterTableName(options)} (
510
+ bucket_id text NOT NULL,
511
+ requester_id text NOT NULL,
512
+ window_started_at_ms bigint NOT NULL,
513
+ granted_in_window integer NOT NULL DEFAULT 0,
514
+ demand_unmet boolean NOT NULL DEFAULT false,
515
+ last_seen_ms bigint NOT NULL,
516
+ expires_at_ms bigint NOT NULL,
517
+ PRIMARY KEY (bucket_id, requester_id)
518
+ )
519
+ `);
520
+ await client.query(`
521
+ CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_requester_expiry_idx
522
+ ON ${requesterTableName(options)} (expires_at_ms)
523
+ `);
524
+ // The same share applies to in-flight concurrency slots, which needs each
525
+ // live lease attributed to the run that holds it.
526
+ await client.query(`
527
+ ALTER TABLE ${concurrencyLeaseTableName(options)}
528
+ ADD COLUMN IF NOT EXISTS requester_id text
529
+ `);
204
530
  }
205
531
 
206
532
  /**
@@ -214,12 +540,17 @@ async function seedBucketRows(
214
540
  schema?: string | null;
215
541
  schedulerSchema?: string | null;
216
542
  },
217
- rules: AcquirePacingRule[],
218
- nowMs: number,
543
+ seeds: RateBucketSeed[],
544
+ nowMs: number | null,
219
545
  ): Promise<void> {
220
- const seeds = rules.map(seedForRule);
221
546
  await client.query(
222
547
  `
548
+ WITH seed_now AS (
549
+ SELECT COALESCE(
550
+ $3::bigint,
551
+ FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
552
+ ) AS now_ms
553
+ )
223
554
  INSERT INTO ${bucketTableName(input)} AS existing (
224
555
  bucket_id,
225
556
  rule_id,
@@ -251,8 +582,8 @@ async function seedBucketRows(
251
582
  0,
252
583
  0,
253
584
  0,
254
- $3::bigint,
255
- $3::bigint
585
+ sn.now_ms,
586
+ sn.now_ms
256
587
  FROM jsonb_to_recordset($2::jsonb) AS seed(
257
588
  rule_id text,
258
589
  capacity float8,
@@ -261,6 +592,7 @@ async function seedBucketRows(
261
592
  max_rps float8,
262
593
  adaptive_max_configured boolean
263
594
  )
595
+ CROSS JOIN seed_now sn
264
596
  ON CONFLICT (bucket_id, rule_id) DO UPDATE SET
265
597
  tokens = LEAST(existing.tokens, EXCLUDED.capacity),
266
598
  capacity = EXCLUDED.capacity,
@@ -288,30 +620,399 @@ async function seedBucketRows(
288
620
  );
289
621
  }
290
622
 
623
+ /**
624
+ * Grant result plus the number of rule rows the statement actually locked.
625
+ * Fewer rows than declared rules means the bucket rows are missing; the caller
626
+ * turns that into a re-seed (and then a loud failure), never a silent
627
+ * granted=0 spin.
628
+ */
629
+ type InternalAcquireOutcome = PlayRunnerRateStateAcquireResult & {
630
+ observedRuleRows: number;
631
+ /** True when a seed upsert would still change a stored row (config drift). */
632
+ configDrift: boolean;
633
+ };
634
+
635
+ function acquireResultOf(
636
+ outcome: InternalAcquireOutcome,
637
+ ): PlayRunnerRateStateAcquireResult {
638
+ const result: PlayRunnerRateStateAcquireResult & {
639
+ observedRuleRows?: number;
640
+ configDrift?: boolean;
641
+ } = { ...outcome };
642
+ delete result.observedRuleRows;
643
+ delete result.configDrift;
644
+ return result;
645
+ }
646
+
647
+ type RateStateAcquireInput = PlayRunnerRateStateAcquireInput & {
648
+ nowMs?: number;
649
+ /**
650
+ * Server-resolved identity of the run asking (see
651
+ * {@link rateStateRequesterId}); never taken from the request body. Without
652
+ * it the acquire is not fair-share governed and touches none of the v43
653
+ * requester objects.
654
+ */
655
+ requesterId?: string | null;
656
+ };
657
+
658
+ /**
659
+ * Fair-share identity for a rate-state acquire: the root (billing) run when the
660
+ * executor token carries one, else the run itself, so a parent and the child
661
+ * plays it launches draw from one slice instead of multiplying their count.
662
+ */
663
+ export function rateStateRequesterId(auth: {
664
+ internalExecutorRunId?: string | null;
665
+ internalExecutorBillingRunId?: string | null;
666
+ }): string | null {
667
+ const requesterId =
668
+ auth.internalExecutorBillingRunId?.trim() ||
669
+ auth.internalExecutorRunId?.trim() ||
670
+ '';
671
+ return requesterId || null;
672
+ }
673
+
674
+ function normalizeRequesterId(input: {
675
+ requesterId?: string | null;
676
+ }): string | null {
677
+ const requesterId =
678
+ typeof input.requesterId === 'string' ? input.requesterId.trim() : '';
679
+ return requesterId || null;
680
+ }
681
+
682
+ type FairShareSql = {
683
+ /** CTEs (each comma-terminated) that need `refreshed`, `cooling`, and — on the concurrency path — `limits` and `free_slots`. */
684
+ ctes: string;
685
+ /** Extra `LEAST()` term for the grant. */
686
+ capTerm: string;
687
+ /** `wait_ms` branch for a requester held back while capacity remains. */
688
+ waitBranch: string;
689
+ /** Comma-prefixed data-modifying CTE recording this acquire; needs `grant_calc`. */
690
+ rememberCte: string;
691
+ };
692
+
693
+ /**
694
+ * Max-min fair share across the runs contending for one bucket, folded into
695
+ * the single grant statement.
696
+ *
697
+ * A fair-share window is the time the binding rule (slowest refill) takes to
698
+ * refill its spendable burst: one second for an adaptive lane, the declared
699
+ * window for a slow rule. Windows are aligned to the clock so every requester
700
+ * agrees on the current one. Each requester's grant inside a window is capped
701
+ * at `ceil(capacity / N)` (N = requesters seen within the last
702
+ * {@link FAIR_SHARE_REQUESTER_IDLE_WINDOWS} windows) plus whatever the bucket
703
+ * holds beyond the slices still reserved for peers with unmet demand. Peers
704
+ * whose last ask was fully granted reserve nothing, and an idle peer drops
705
+ * out of N, so the window stays fully usable; a lone requester is never
706
+ * capped. The token bucket remains the source of truth for what exists, so a
707
+ * late starter that finds tokens while its greedy sibling is over share is
708
+ * granted immediately, and the sibling's wait is the rest of the window.
709
+ *
710
+ * On the concurrency path the same share bounds in-flight leases per
711
+ * requester, otherwise a run holding every slot re-takes each one it releases.
712
+ *
713
+ * A run alone on a bucket writes its ledger row only when its window rolls or
714
+ * its lease nears expiry, not on every acquire, so the solo path pays reads
715
+ * and no extra writes. The cost of that is one window: a peer arriving
716
+ * mid-window sees the incumbent's draw from when it last wrote, so the
717
+ * incumbent can exceed its share until the window rolls, after which both
718
+ * write on every acquire and the share is exact.
719
+ */
720
+ function fairShareSql(
721
+ input: { schema?: string | null; schedulerSchema?: string | null },
722
+ options: {
723
+ requesterParam: string;
724
+ requestedParam: string;
725
+ concurrency: { ruleIdsParam: string } | null;
726
+ },
727
+ ): FairShareSql {
728
+ const table = requesterTableName(input);
729
+ const requester = `${options.requesterParam}::text`;
730
+ const requested = `${options.requestedParam}::int`;
731
+ const rawAvailable = options.concurrency
732
+ ? `GREATEST(0, LEAST(${requested}, FLOOR(MIN(tokens_after_refill))::int, (SELECT free FROM free_slots)))`
733
+ : `GREATEST(0, LEAST(${requested}, FLOOR(MIN(tokens_after_refill))::int))`;
734
+ const liveLeasesHeldBy = (holder: string): string =>
735
+ options.concurrency
736
+ ? `(
737
+ SELECT COUNT(DISTINCT cl.lease_id)
738
+ FROM ${concurrencyLeaseTableName(input)} cl
739
+ WHERE cl.bucket_id = $1
740
+ AND cl.rule_id = ANY(${options.concurrency.ruleIdsParam}::text[])
741
+ AND cl.requester_id = ${holder}
742
+ AND cl.expires_at_ms > (SELECT now_ms FROM fair_epoch)
743
+ )`
744
+ : '0';
745
+ const slotCtes = options.concurrency
746
+ ? `
747
+ fair_slots AS (
748
+ SELECT
749
+ CEIL(
750
+ (SELECT MIN(max_concurrency) FROM limits)::float8
751
+ / (SELECT requesters FROM fair_share)
752
+ ) AS slot_share,
753
+ ${liveLeasesHeldBy(requester)} AS own_active
754
+ ),
755
+ fair_peer_slots AS (
756
+ SELECT
757
+ GREATEST(
758
+ 0,
759
+ (SELECT slot_share FROM fair_slots) - ${liveLeasesHeldBy('p.requester_id')}
760
+ ) AS reserved
761
+ FROM peers p
762
+ WHERE p.unmet
763
+ ),
764
+ fair_slot_grant AS (
765
+ SELECT
766
+ CASE
767
+ WHEN (SELECT requesters FROM fair_share) <= 1
768
+ THEN (SELECT free FROM free_slots)
769
+ ELSE FLOOR(LEAST(
770
+ (SELECT free FROM free_slots),
771
+ own.remaining
772
+ + GREATEST(
773
+ 0,
774
+ (SELECT free FROM free_slots)
775
+ - own.remaining
776
+ - COALESCE((SELECT SUM(reserved) FROM fair_peer_slots), 0)
777
+ )
778
+ ))::int
779
+ END AS slot_cap
780
+ FROM (
781
+ SELECT GREATEST(
782
+ 0,
783
+ (SELECT slot_share FROM fair_slots) - (SELECT own_active FROM fair_slots)
784
+ ) AS remaining
785
+ ) own
786
+ ),`
787
+ : '';
788
+ return {
789
+ ctes: `
790
+ fair_window AS (
791
+ SELECT
792
+ r.now_ms,
793
+ r.effective_capacity AS capacity,
794
+ GREATEST(
795
+ ${ADAPTIVE_BURST_HORIZON_MS},
796
+ CEIL(r.capacity / GREATEST(r.refill_per_sec, 0.0001) * 1000)
797
+ )::bigint AS window_ms
798
+ FROM refreshed r
799
+ ORDER BY r.refill_per_sec ASC, r.effective_capacity ASC
800
+ LIMIT 1
801
+ ),
802
+ fair_epoch AS (
803
+ SELECT
804
+ w.now_ms,
805
+ w.capacity,
806
+ w.window_ms,
807
+ w.now_ms - (w.now_ms % w.window_ms) AS started_at_ms
808
+ FROM fair_window w
809
+ ),
810
+ -- What this acquire has to write, if anything. A run asking many times
811
+ -- a second must not pay a sweep and an upsert on every one of them: a
812
+ -- lone run's ledger row only has to exist and stay live, so it is
813
+ -- rewritten when its window rolls or its lease nears expiry, and on
814
+ -- every acquire only while peers are actually present. That keeps the
815
+ -- solo path at roughly one write per window instead of one per acquire.
816
+ peers AS (
817
+ SELECT
818
+ q.requester_id,
819
+ CASE
820
+ WHEN q.window_started_at_ms = e.started_at_ms THEN q.granted_in_window
821
+ ELSE 0
822
+ END AS taken,
823
+ CASE
824
+ WHEN q.window_started_at_ms = e.started_at_ms THEN q.demand_unmet
825
+ ELSE true
826
+ END AS unmet
827
+ FROM ${table} q
828
+ CROSS JOIN fair_epoch e
829
+ WHERE q.bucket_id = $1
830
+ AND q.requester_id <> ${requester}
831
+ AND q.expires_at_ms > e.now_ms
832
+ ),
833
+ fair_write AS (
834
+ SELECT
835
+ COALESCE((
836
+ SELECT
837
+ q.window_started_at_ms <> e.started_at_ms
838
+ OR q.expires_at_ms - e.now_ms < e.window_ms
839
+ FROM ${table} q
840
+ WHERE q.bucket_id = $1
841
+ AND q.requester_id = ${requester}
842
+ ), true) AS refresh,
843
+ EXISTS (SELECT 1 FROM peers) AS contended
844
+ FROM fair_epoch e
845
+ ),
846
+ -- Reclaims the oldest expired rows across every bucket, not just this
847
+ -- one: a bucket whose last run ended is never acquired again, so a
848
+ -- bucket-scoped sweep would leave its rows behind forever. Bounded per
849
+ -- acquire and driven by the expiry index, as the concurrency-observer
850
+ -- reclaim is. The row for this acquire's own (bucket, requester) is
851
+ -- never swept: it is about to be rewritten, and the peer snapshot below
852
+ -- reads this same statement snapshot.
853
+ expire_requesters AS (
854
+ DELETE FROM ${table} AS stale
855
+ WHERE (SELECT refresh OR contended FROM fair_write)
856
+ AND (stale.bucket_id, stale.requester_id) IN (
857
+ SELECT q.bucket_id, q.requester_id
858
+ FROM ${table} q
859
+ WHERE q.expires_at_ms <= (SELECT now_ms FROM fair_epoch)
860
+ AND NOT (q.bucket_id = $1 AND q.requester_id = ${requester})
861
+ ORDER BY q.expires_at_ms
862
+ LIMIT ${FAIR_SHARE_REQUESTER_CLEANUP_BATCH_SIZE}
863
+ )
864
+ RETURNING 1
865
+ ),
866
+ fair_self AS (
867
+ SELECT COALESCE((
868
+ SELECT CASE
869
+ WHEN q.window_started_at_ms = e.started_at_ms THEN q.granted_in_window
870
+ ELSE 0
871
+ END
872
+ FROM ${table} q
873
+ WHERE q.bucket_id = $1
874
+ AND q.requester_id = ${requester}
875
+ ), 0) AS taken
876
+ FROM fair_epoch e
877
+ ),
878
+ fair_share AS (
879
+ SELECT
880
+ (SELECT COUNT(*) FROM peers) + 1 AS requesters,
881
+ CEIL(e.capacity / ((SELECT COUNT(*) FROM peers) + 1)) AS share
882
+ FROM fair_epoch e
883
+ ),
884
+ fair_grant AS (
885
+ SELECT
886
+ raw.available,
887
+ CASE
888
+ WHEN (SELECT requesters FROM fair_share) <= 1 THEN raw.available
889
+ ELSE FLOOR(LEAST(
890
+ raw.available,
891
+ own.remaining
892
+ + GREATEST(
893
+ 0,
894
+ raw.available
895
+ - own.remaining
896
+ - COALESCE((
897
+ SELECT SUM(GREATEST(0, (SELECT share FROM fair_share) - p.taken))
898
+ FROM peers p
899
+ WHERE p.unmet
900
+ ), 0)
901
+ )
902
+ ))::int
903
+ END AS token_cap
904
+ FROM (SELECT ${rawAvailable} AS available FROM refreshed) raw
905
+ CROSS JOIN (
906
+ SELECT GREATEST(
907
+ 0,
908
+ (SELECT share FROM fair_share) - (SELECT taken FROM fair_self)
909
+ ) AS remaining
910
+ ) own
911
+ ),${slotCtes}
912
+ fair_cap AS (
913
+ SELECT
914
+ (SELECT available FROM fair_grant) AS available,
915
+ (SELECT token_cap FROM fair_grant) AS token_cap,
916
+ LEAST(
917
+ (SELECT token_cap FROM fair_grant)${
918
+ options.concurrency
919
+ ? ',\n (SELECT slot_cap FROM fair_slot_grant)'
920
+ : ''
921
+ }
922
+ ) AS cap
923
+ ),`,
924
+ capTerm: ', (SELECT cap FROM fair_cap)',
925
+ waitBranch: options.concurrency
926
+ ? `
927
+ WHEN (SELECT available FROM fair_cap) > 0
928
+ THEN CASE
929
+ WHEN (SELECT token_cap FROM fair_cap) <= 0
930
+ THEN LEAST(
931
+ (SELECT started_at_ms + window_ms - now_ms FROM fair_epoch),
932
+ ${FAIR_SHARE_MAX_WAIT_MS}
933
+ )
934
+ ELSE ${FAIR_SHARE_SLOT_WAIT_MS}
935
+ END`
936
+ : `
937
+ WHEN (SELECT available FROM fair_cap) > 0
938
+ THEN LEAST(
939
+ (SELECT started_at_ms + window_ms - now_ms FROM fair_epoch),
940
+ ${FAIR_SHARE_MAX_WAIT_MS}
941
+ )`,
942
+ rememberCte: `,
943
+ remember_requester AS (
944
+ INSERT INTO ${table} AS requester (
945
+ bucket_id,
946
+ requester_id,
947
+ window_started_at_ms,
948
+ granted_in_window,
949
+ demand_unmet,
950
+ last_seen_ms,
951
+ expires_at_ms
952
+ )
953
+ SELECT
954
+ $1,
955
+ ${requester},
956
+ e.started_at_ms,
957
+ (SELECT granted FROM grant_calc),
958
+ (SELECT granted FROM grant_calc) < ${requested},
959
+ e.now_ms,
960
+ e.now_ms + ${FAIR_SHARE_REQUESTER_IDLE_WINDOWS} * e.window_ms
961
+ FROM fair_epoch e
962
+ WHERE (SELECT refresh OR contended FROM fair_write)
963
+ ON CONFLICT (bucket_id, requester_id) DO UPDATE SET
964
+ granted_in_window = CASE
965
+ WHEN requester.window_started_at_ms = EXCLUDED.window_started_at_ms
966
+ THEN requester.granted_in_window
967
+ ELSE 0
968
+ END + EXCLUDED.granted_in_window,
969
+ window_started_at_ms = EXCLUDED.window_started_at_ms,
970
+ demand_unmet = EXCLUDED.demand_unmet,
971
+ last_seen_ms = EXCLUDED.last_seen_ms,
972
+ expires_at_ms = EXCLUDED.expires_at_ms
973
+ RETURNING 1
974
+ )`,
975
+ };
976
+ }
977
+
291
978
  /**
292
979
  * Pure-rate (no maxConcurrency) acquire. A deterministic row lock inside one
293
980
  * autocommit statement makes every contender reserve from the latest committed
294
981
  * token and dispatch cursor instead of reusing a stale CTE snapshot. It refills
295
982
  * every rule row against clock_timestamp(), grants the integer floor of the min
296
983
  * available tokens across the bucket's rules (multi-rule = most constrained),
297
- * deducts, and folds the AIMD ramp in. Returns the granted count and, when
298
- * granted is 0, the wait until one more token accrues on the binding rule (or
299
- * the cooldown clears).
984
+ * bounded by the requester's fair share when other runs contend (see
985
+ * {@link fairShareSql}), deducts, and folds the AIMD ramp in. Returns the
986
+ * granted count and, when granted is 0, the wait until one more token accrues
987
+ * on the binding rule, the rest of the fair-share window when the share was
988
+ * the limit, or the cooldown remainder.
300
989
  */
301
990
  async function acquirePureRate(
302
991
  client: PostgresSchedulerQueryClient,
303
- input: PlayRunnerRateStateAcquireInput & { nowMs?: number },
992
+ input: RateStateAcquireInput,
304
993
  rules: AcquirePacingRule[],
994
+ seeds: RateBucketSeed[],
305
995
  requested: number,
306
- ): Promise<PlayRunnerRateStateAcquireResult> {
996
+ ): Promise<InternalAcquireOutcome> {
307
997
  const ruleIds = rules.map((rule) => rule.ruleId);
998
+ const requesterId = normalizeRequesterId(input);
999
+ const fair = requesterId
1000
+ ? fairShareSql(input, {
1001
+ requesterParam: '$8',
1002
+ requestedParam: '$4',
1003
+ concurrency: null,
1004
+ })
1005
+ : null;
308
1006
  const result = await client.query<{
309
1007
  granted: string | number;
310
1008
  wait_ms: string | number;
1009
+ observed_rule_rows: string | number;
1010
+ config_drift: boolean | null;
311
1011
  server_now_ms: string | number;
312
1012
  cool_until_ms: string | number;
313
1013
  claimed_retry_at_ms: string | number;
314
1014
  scheduled_at_ms: Array<string | number> | null;
1015
+ reservation_ids: string[] | null;
315
1016
  effective_rps: string | number;
316
1017
  }>(
317
1018
  `
@@ -321,6 +1022,27 @@ async function acquirePureRate(
321
1022
  FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
322
1023
  ) AS now_ms
323
1024
  ),
1025
+ seed_config AS (
1026
+ -- The declared configuration for this acquire, compared below against
1027
+ -- the stored rows. This is the same predicate that gates the seed
1028
+ -- upsert's DO UPDATE, so "drift" means exactly "a seed would change
1029
+ -- this row" — which is how a warm process still repairs a row that was
1030
+ -- widened or edited out of band.
1031
+ SELECT * FROM jsonb_to_recordset($7::jsonb) AS seed(
1032
+ rule_id text,
1033
+ capacity float8,
1034
+ base_refill_per_sec float8,
1035
+ min_rps float8,
1036
+ max_rps float8,
1037
+ adaptive_max_configured boolean
1038
+ )
1039
+ ),
1040
+ reclaim_reservations AS (
1041
+ DELETE FROM ${reservationTableName(input)}
1042
+ WHERE bucket_id = $1
1043
+ AND expires_at_ms <= (SELECT now_ms FROM now_value)
1044
+ RETURNING 1
1045
+ ),
324
1046
  refreshed AS (
325
1047
  SELECT
326
1048
  b.bucket_id,
@@ -335,8 +1057,12 @@ async function acquirePureRate(
335
1057
  b.last_ramp_at_ms,
336
1058
  b.next_dispatch_at_ms,
337
1059
  nv.now_ms AS now_ms,
1060
+ -- Spendable burst: one second of the rate in force, floored at the
1061
+ -- declared window burst. b.capacity stays the seeded declared value
1062
+ -- so config-drift detection keeps comparing like with like.
1063
+ ${EFFECTIVE_CAPACITY_SQL} AS effective_capacity,
338
1064
  LEAST(
339
- b.capacity,
1065
+ ${EFFECTIVE_CAPACITY_SQL},
340
1066
  b.tokens + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0 * b.refill_per_sec
341
1067
  ) AS tokens_after_refill
342
1068
  FROM ${bucketTableName(input)} b
@@ -349,24 +1075,42 @@ async function acquirePureRate(
349
1075
  cooling AS (
350
1076
  SELECT COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms
351
1077
  FROM refreshed
352
- ),
1078
+ ),${fair?.ctes ?? ''}
353
1079
  grant_calc AS (
354
1080
  SELECT
355
1081
  -- Cooling buckets grant nothing; otherwise the integer floor of the
356
1082
  -- minimum post-refill tokens across every rule row (most constrained).
357
1083
  CASE
1084
+ -- No rule rows means the bucket was never seeded (or was deleted).
1085
+ -- LEAST() ignores NULLs, so without this guard an unseeded bucket
1086
+ -- would "grant" the full request against a NULL schedule.
1087
+ WHEN (SELECT COUNT(*) FROM refreshed) = 0 THEN 0
358
1088
  WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
359
1089
  THEN 0
360
- ELSE GREATEST(0, LEAST($4::int, FLOOR(MIN(tokens_after_refill))::int))
1090
+ ELSE GREATEST(0, LEAST($4::int, FLOOR(MIN(tokens_after_refill))::int${fair?.capTerm ?? ''}))
361
1091
  END AS granted
362
1092
  FROM refreshed
363
1093
  ),
364
1094
  schedule_calc AS (
365
1095
  SELECT
1096
+ -- Dispatch debt decays after a ramp. next_dispatch_at_ms is booked
1097
+ -- at the spacing in force when its permits were granted, so a lane
1098
+ -- that started at 0.25 rps carries a minute-long tail that a later
1099
+ -- ramp to 100 rps could never work off (the column is monotone).
1100
+ -- Clamp the carried debt to one full bucket at the CURRENT refill.
1101
+ -- cool_until_ms stays inside the GREATEST, and penalize pushes
1102
+ -- next_dispatch past the cooldown, so a real 429 hold is never
1103
+ -- shortened by this clamp.
366
1104
  GREATEST(
367
1105
  MAX(now_ms),
368
1106
  MAX(cool_until_ms),
369
- MAX(next_dispatch_at_ms)
1107
+ LEAST(
1108
+ MAX(next_dispatch_at_ms),
1109
+ MAX(now_ms)
1110
+ + CEIL(
1111
+ MAX(effective_capacity / GREATEST(refill_per_sec, 0.0001) * 1000)
1112
+ )::bigint
1113
+ )
370
1114
  )::bigint AS first_at_ms,
371
1115
  CEIL(MAX(1000.0 / GREATEST(refill_per_sec, 0.0001)))::bigint AS spacing_ms,
372
1116
  GREATEST(1, FLOOR(MIN(refill_per_sec) / 100.0))::bigint AS burst_size
@@ -388,15 +1132,15 @@ async function acquirePureRate(
388
1132
  * (SELECT burst_size FROM schedule_calc)
389
1133
  ELSE b.next_dispatch_at_ms
390
1134
  END,
391
- -- AIMD ramp, folded in. Only after confirmed provider success,
392
- -- under real demand, not cooling, and at least 1s since the last
393
- -- ramp. Slow-start doubles until the first 429, then adds
1135
+ -- AIMD ramp, folded in. Reaching this refill proves a local lease
1136
+ -- block was exhausted; only a confirmed provider success can turn
1137
+ -- that demand into a rate increase. Slow-start doubles until the
1138
+ -- first 429, then adds
394
1139
  -- max(1, base/10) per step. Never exceeds max_rps.
395
1140
  refill_per_sec = CASE
396
1141
  WHEN $5::int > 0
397
1142
  AND r.now_ms - r.last_ramp_at_ms >= 1000
398
1143
  AND r.cool_until_ms <= r.now_ms
399
- AND r.tokens_after_refill < r.capacity / 2
400
1144
  THEN LEAST(
401
1145
  r.max_rps,
402
1146
  CASE
@@ -411,7 +1155,6 @@ async function acquirePureRate(
411
1155
  WHEN $5::int > 0
412
1156
  AND r.now_ms - r.last_ramp_at_ms >= 1000
413
1157
  AND r.cool_until_ms <= r.now_ms
414
- AND r.tokens_after_refill < r.capacity / 2
415
1158
  THEN r.now_ms
416
1159
  ELSE r.last_ramp_at_ms
417
1160
  END
@@ -421,11 +1164,55 @@ async function acquirePureRate(
421
1164
  RETURNING
422
1165
  b.rule_id,
423
1166
  r.tokens_after_refill,
1167
+ r.effective_capacity AS capacity,
424
1168
  b.refill_per_sec AS new_refill_per_sec,
425
1169
  r.now_ms AS now_ms
426
- )
1170
+ ),
1171
+ reservation_tokens AS (
1172
+ SELECT
1173
+ permit_index,
1174
+ gen_random_uuid()::text AS reservation_id
1175
+ FROM generate_series(1, (SELECT granted FROM grant_calc)) AS permit_index
1176
+ ),
1177
+ minted_reservations AS (
1178
+ INSERT INTO ${reservationTableName(input)} (
1179
+ bucket_id,
1180
+ reservation_id,
1181
+ expires_at_ms
1182
+ )
1183
+ SELECT
1184
+ $1,
1185
+ reservation_id,
1186
+ (SELECT first_at_ms FROM schedule_calc)
1187
+ + FLOOR(
1188
+ (permit_index - 1)::numeric
1189
+ / (SELECT burst_size FROM schedule_calc)
1190
+ )::bigint
1191
+ * (SELECT spacing_ms FROM schedule_calc)
1192
+ * (SELECT burst_size FROM schedule_calc)
1193
+ + $6::bigint
1194
+ FROM reservation_tokens
1195
+ RETURNING reservation_id
1196
+ )${fair?.rememberCte ?? ''}
427
1197
  SELECT
428
1198
  (SELECT granted FROM grant_calc) AS granted,
1199
+ (SELECT COUNT(*)::int FROM refreshed) AS observed_rule_rows,
1200
+ (
1201
+ SELECT COALESCE(
1202
+ bool_or(
1203
+ r.capacity IS DISTINCT FROM sc.capacity
1204
+ OR r.base_refill_per_sec IS DISTINCT FROM sc.base_refill_per_sec
1205
+ OR r.min_rps IS DISTINCT FROM sc.min_rps
1206
+ OR (
1207
+ sc.adaptive_max_configured
1208
+ AND r.max_rps IS DISTINCT FROM sc.max_rps
1209
+ )
1210
+ ),
1211
+ false
1212
+ )
1213
+ FROM refreshed r
1214
+ JOIN seed_config sc ON sc.rule_id = r.rule_id
1215
+ ) AS config_drift,
429
1216
  (SELECT now_ms FROM refreshed LIMIT 1) AS server_now_ms,
430
1217
  (SELECT cool_until_ms FROM cooling) AS cool_until_ms,
431
1218
  (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
@@ -445,15 +1232,41 @@ async function acquirePureRate(
445
1232
  )
446
1233
  ELSE '{}'::bigint[]
447
1234
  END AS scheduled_at_ms,
448
- -- Wait until either the cooldown clears or one token accrues on the
1235
+ CASE
1236
+ WHEN (SELECT COUNT(*) FROM minted_reservations)
1237
+ = (SELECT granted FROM grant_calc)
1238
+ THEN ARRAY(
1239
+ SELECT reservation_id
1240
+ FROM reservation_tokens
1241
+ ORDER BY permit_index
1242
+ )
1243
+ ELSE NULL
1244
+ END AS reservation_ids,
1245
+ -- Wait until either the cooldown clears or a whole BLOCK accrues on the
449
1246
  -- most-constrained rule (whichever a granted=0 caller must sleep for).
1247
+ -- The block target is the caller's own request, bounded by the bucket's
1248
+ -- burst capacity and by what this rule can actually refill inside
1249
+ -- BLOCK_WAIT_HORIZON_MS, and floored at one token — so a slow rule keeps
1250
+ -- exactly today's single-token wait while a hot lane stops paying a
1251
+ -- round trip per permit. The cooldown branch above still wins outright.
450
1252
  CASE
451
1253
  WHEN (SELECT granted FROM grant_calc) > 0 THEN 0
452
1254
  WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
453
- THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)
1255
+ THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)${fair?.waitBranch ?? ''}
454
1256
  ELSE (
455
1257
  SELECT CEIL(
456
- MAX((1.0 - tokens_after_refill) / GREATEST(new_refill_per_sec, 0.0001) * 1000)
1258
+ MAX(
1259
+ (
1260
+ GREATEST(
1261
+ 1.0,
1262
+ LEAST(
1263
+ $4::numeric,
1264
+ FLOOR(capacity),
1265
+ FLOOR(new_refill_per_sec * ${BLOCK_WAIT_HORIZON_MS} / 1000.0)
1266
+ )
1267
+ ) - tokens_after_refill
1268
+ ) / GREATEST(new_refill_per_sec, 0.0001) * 1000
1269
+ )
457
1270
  )::bigint
458
1271
  FROM updated
459
1272
  )
@@ -465,13 +1278,27 @@ async function acquirePureRate(
465
1278
  input.nowMs ?? null,
466
1279
  requested,
467
1280
  Math.max(0, Math.trunc(input.observedSuccesses ?? 0)),
1281
+ RATE_RESERVATION_TTL_MS,
1282
+ stringifyPostgresJson(seeds),
1283
+ ...(requesterId ? [requesterId] : []),
468
1284
  ],
469
1285
  );
470
1286
  const row = result.rows[0];
471
1287
  if (!row) {
472
- // Seeding raced away or rows vanished; treat as a short retryable wait.
473
- return { granted: 0, waitMs: MIN_CONCURRENCY_WAIT_MS, leaseIds: [] };
1288
+ // Seeding raced away or rows vanished; the caller re-seeds and retries.
1289
+ return {
1290
+ granted: 0,
1291
+ waitMs: MIN_CONCURRENCY_WAIT_MS,
1292
+ leaseIds: [],
1293
+ observedRuleRows: 0,
1294
+ configDrift: false,
1295
+ };
474
1296
  }
1297
+ const observedRuleRows = Math.max(
1298
+ 0,
1299
+ Math.trunc(Number(row.observed_rule_rows)),
1300
+ );
1301
+ const configDrift = row.config_drift === true;
475
1302
  const granted = Math.max(0, Math.trunc(Number(row.granted)));
476
1303
  const serverNowMs = Math.max(0, Number(row.server_now_ms));
477
1304
  const coolUntilMs = Math.max(0, Number(row.cool_until_ms));
@@ -479,13 +1306,27 @@ async function acquirePureRate(
479
1306
  const scheduledAtMs = Array.isArray(row.scheduled_at_ms)
480
1307
  ? row.scheduled_at_ms.map(Number)
481
1308
  : [];
1309
+ const reservationIds = Array.isArray(row.reservation_ids)
1310
+ ? row.reservation_ids.filter(
1311
+ (reservationId): reservationId is string =>
1312
+ typeof reservationId === 'string' && reservationId.length > 0,
1313
+ )
1314
+ : [];
1315
+ if (granted > 0 && reservationIds.length !== granted) {
1316
+ throw new Error(
1317
+ `Rate-state reservation minting drift for ${input.bucketId}: granted ${granted} with ${reservationIds.length} cancellation tickets.`,
1318
+ );
1319
+ }
482
1320
  const effectiveRequestsPerSecond = Math.max(0, Number(row.effective_rps));
483
1321
  const waitMs = granted > 0 ? 0 : Math.max(1, Math.trunc(Number(row.wait_ms)));
484
1322
  return {
485
1323
  granted,
486
1324
  waitMs,
487
1325
  serverNowMs,
1326
+ observedRuleRows,
1327
+ configDrift,
488
1328
  leaseIds: [],
1329
+ reservationIds: granted > 0 ? reservationIds : [],
489
1330
  scheduledAtMs,
490
1331
  effectiveRequestsPerSecond,
491
1332
  coolUntilMs,
@@ -500,17 +1341,29 @@ async function acquirePureRate(
500
1341
  * lease minting all execute in ONE multi-CTE statement (one implicit
501
1342
  * transaction), so a crash can never deduct tokens without minting the matching
502
1343
  * lease rows. Lease ids are generated in SQL (gen_random_uuid) and returned to
503
- * the client, one per granted permit, releasable independently.
1344
+ * the client, one per granted permit, releasable independently. With a
1345
+ * requester identity the grant is also bounded by that run's fair share of
1346
+ * both the window and the concurrency slots (see {@link fairShareSql}), and
1347
+ * each minted lease records who holds it.
504
1348
  */
505
1349
  async function acquireWithConcurrency(
506
1350
  client: PostgresSchedulerQueryClient,
507
- input: PlayRunnerRateStateAcquireInput & { nowMs?: number },
1351
+ input: RateStateAcquireInput,
508
1352
  rules: AcquirePacingRule[],
1353
+ seeds: RateBucketSeed[],
509
1354
  requested: number,
510
- ): Promise<PlayRunnerRateStateAcquireResult> {
1355
+ ): Promise<InternalAcquireOutcome> {
511
1356
  const ruleIds = rules.map((rule) => rule.ruleId);
512
1357
  const concurrencyRules = rules.filter((rule) => rule.maxConcurrency != null);
513
1358
  const concurrencyRuleIds = concurrencyRules.map((rule) => rule.ruleId);
1359
+ const requesterId = normalizeRequesterId(input);
1360
+ const fair = requesterId
1361
+ ? fairShareSql(input, {
1362
+ requesterParam: '$13',
1363
+ requestedParam: '$4',
1364
+ concurrency: { ruleIdsParam: '$5' },
1365
+ })
1366
+ : null;
514
1367
  // Per-rule limits, NOT collapsed to a single min. When a bucket carries more
515
1368
  // than one concurrency rule, each rule mints its own lease row per permit, so
516
1369
  // SUM(permits) over all rules double-counts a single in-flight permit. The
@@ -531,10 +1384,13 @@ async function acquireWithConcurrency(
531
1384
  const result = await client.query<{
532
1385
  granted: string | number;
533
1386
  wait_ms: string | number;
1387
+ observed_rule_rows: string | number;
1388
+ config_drift: boolean | null;
534
1389
  server_now_ms: string | number;
535
1390
  cool_until_ms: string | number;
536
1391
  claimed_retry_at_ms: string | number;
537
1392
  lease_ids: string[] | null;
1393
+ concurrency_grants: unknown[] | null;
538
1394
  scheduled_at_ms: Array<string | number> | null;
539
1395
  effective_rps: string | number;
540
1396
  }>(
@@ -545,6 +1401,12 @@ async function acquireWithConcurrency(
545
1401
  FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
546
1402
  ) AS now_ms
547
1403
  ),
1404
+ reclaim_reservations AS (
1405
+ DELETE FROM ${reservationTableName(input)}
1406
+ WHERE bucket_id = $1
1407
+ AND expires_at_ms <= (SELECT now_ms FROM now_value)
1408
+ RETURNING 1
1409
+ ),
548
1410
  reclaim AS (
549
1411
  DELETE FROM ${concurrencyLeaseTableName(input)}
550
1412
  WHERE bucket_id = $1
@@ -552,22 +1414,77 @@ async function acquireWithConcurrency(
552
1414
  AND expires_at_ms <= (SELECT now_ms FROM now_value)
553
1415
  RETURNING 1
554
1416
  ),
1417
+ seed_config AS (
1418
+ -- The declared configuration for this acquire, compared below against
1419
+ -- the stored rows. This is the same predicate that gates the seed
1420
+ -- upsert's DO UPDATE, so "drift" means exactly "a seed would change
1421
+ -- this row" — which is how a warm process still repairs a row that was
1422
+ -- widened or edited out of band.
1423
+ SELECT * FROM jsonb_to_recordset($12::jsonb) AS seed(
1424
+ rule_id text,
1425
+ capacity float8,
1426
+ base_refill_per_sec float8,
1427
+ min_rps float8,
1428
+ max_rps float8,
1429
+ adaptive_max_configured boolean
1430
+ )
1431
+ ),
1432
+ refreshed AS (
1433
+ SELECT
1434
+ b.rule_id,
1435
+ b.capacity,
1436
+ b.min_rps,
1437
+ b.max_rps,
1438
+ b.seen_backpressure,
1439
+ b.base_refill_per_sec,
1440
+ b.learned_concurrency_limit,
1441
+ b.concurrency_minimum_rtt_ms,
1442
+ b.cool_until_ms,
1443
+ b.last_ramp_at_ms,
1444
+ b.next_dispatch_at_ms,
1445
+ nv.now_ms AS now_ms,
1446
+ -- Same derived burst as the pure-rate path. The declared
1447
+ -- maxConcurrency is untouched: free_slots still bounds the grant.
1448
+ ${EFFECTIVE_CAPACITY_SQL} AS effective_capacity,
1449
+ LEAST(
1450
+ ${EFFECTIVE_CAPACITY_SQL},
1451
+ b.tokens + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0 * b.refill_per_sec
1452
+ ) AS tokens_after_refill,
1453
+ b.refill_per_sec
1454
+ FROM ${bucketTableName(input)} b
1455
+ CROSS JOIN now_value nv
1456
+ WHERE b.bucket_id = $1
1457
+ AND b.rule_id = ANY($2::text[])
1458
+ ORDER BY b.rule_id
1459
+ FOR UPDATE OF b
1460
+ ),
555
1461
  limits AS (
556
- -- One row per concurrency rule with its declared cap.
557
- SELECT rule_id, max_concurrency
1462
+ -- Depend on the refreshed rows: a learned-capacity transition locks these
1463
+ -- same rows, so a grant that waits behind it must read the new limit.
1464
+ -- The catalogue is still the hard ceiling.
1465
+ SELECT
1466
+ lim.rule_id,
1467
+ LEAST(
1468
+ lim.max_concurrency,
1469
+ CEIL(
1470
+ COALESCE(
1471
+ bucket.learned_concurrency_limit,
1472
+ LEAST(lim.max_concurrency, ${INITIAL_CONCURRENCY_DISCOVERY_LIMIT})
1473
+ )
1474
+ )
1475
+ )::int AS max_concurrency
558
1476
  FROM jsonb_to_recordset($9::jsonb) AS lim(
559
1477
  rule_id text,
560
1478
  max_concurrency int
561
1479
  )
1480
+ JOIN refreshed bucket ON bucket.rule_id = lim.rule_id
562
1481
  ),
563
1482
  active_by_rule AS (
564
- -- Live in-flight permits PER RULE. A CTE SELECT sees the pre-statement
565
- -- snapshot, so expired rows the reclaim DELETE removes are still visible
566
- -- here; filter them out explicitly by expiry so the free-slot count
567
- -- matches the post-reclaim table. Grouping by rule_id (not SUM across
568
- -- all rules) is what keeps a single permit — which mints one lease row
569
- -- per concurrency rule — from being counted once per rule.
570
- SELECT l.rule_id,
1483
+ -- Live in-flight permits per rule. One physical permit has one lease
1484
+ -- row per rule, so each rule is counted independently before we take
1485
+ -- the tightest free-slot value.
1486
+ SELECT
1487
+ l.rule_id,
571
1488
  COALESCE((
572
1489
  SELECT SUM(cl.permits)
573
1490
  FROM ${concurrencyLeaseTableName(input)} cl
@@ -578,7 +1495,6 @@ async function acquireWithConcurrency(
578
1495
  FROM limits l
579
1496
  ),
580
1497
  free_slots AS (
581
- -- Most-constrained free concurrency across all rules of the bucket.
582
1498
  SELECT COALESCE(
583
1499
  MIN(l.max_concurrency - a.active_count),
584
1500
  $6::int
@@ -586,35 +1502,23 @@ async function acquireWithConcurrency(
586
1502
  FROM limits l
587
1503
  JOIN active_by_rule a ON a.rule_id = l.rule_id
588
1504
  ),
589
- refreshed AS (
1505
+ concurrency_context AS (
590
1506
  SELECT
591
- b.rule_id,
592
- b.capacity,
593
- b.max_rps,
594
- b.seen_backpressure,
595
- b.base_refill_per_sec,
596
- b.cool_until_ms,
597
- b.last_ramp_at_ms,
598
- b.next_dispatch_at_ms,
599
- nv.now_ms AS now_ms,
600
- LEAST(
601
- b.capacity,
602
- b.tokens + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0 * b.refill_per_sec
603
- ) AS tokens_after_refill,
604
- b.refill_per_sec
605
- FROM ${bucketTableName(input)} b
606
- CROSS JOIN now_value nv
607
- WHERE b.bucket_id = $1
608
- AND b.rule_id = ANY($2::text[])
609
- ORDER BY b.rule_id
610
- FOR UPDATE OF b
1507
+ COALESCE(MAX(active_count), 0)::int AS active_count,
1508
+ MIN(concurrency_minimum_rtt_ms) AS minimum_rtt_ms
1509
+ FROM active_by_rule
1510
+ JOIN refreshed USING (rule_id)
611
1511
  ),
612
1512
  cooling AS (
613
1513
  SELECT COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms FROM refreshed
614
- ),
1514
+ ),${fair?.ctes ?? ''}
615
1515
  grant_calc AS (
616
1516
  SELECT
617
1517
  CASE
1518
+ -- No rule rows means the bucket was never seeded (or was deleted).
1519
+ -- LEAST() ignores NULLs, so without this guard an unseeded bucket
1520
+ -- would "grant" the full request against a NULL schedule.
1521
+ WHEN (SELECT COUNT(*) FROM refreshed) = 0 THEN 0
618
1522
  WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
619
1523
  THEN 0
620
1524
  ELSE GREATEST(
@@ -622,7 +1526,7 @@ async function acquireWithConcurrency(
622
1526
  LEAST(
623
1527
  $4::int,
624
1528
  FLOOR(MIN(tokens_after_refill))::int,
625
- (SELECT free FROM free_slots)
1529
+ (SELECT free FROM free_slots)${fair?.capTerm ?? ''}
626
1530
  )
627
1531
  )
628
1532
  END AS granted
@@ -630,10 +1534,24 @@ async function acquireWithConcurrency(
630
1534
  ),
631
1535
  schedule_calc AS (
632
1536
  SELECT
1537
+ -- Dispatch debt decays after a ramp. next_dispatch_at_ms is booked
1538
+ -- at the spacing in force when its permits were granted, so a lane
1539
+ -- that started at 0.25 rps carries a minute-long tail that a later
1540
+ -- ramp to 100 rps could never work off (the column is monotone).
1541
+ -- Clamp the carried debt to one full bucket at the CURRENT refill.
1542
+ -- cool_until_ms stays inside the GREATEST, and penalize pushes
1543
+ -- next_dispatch past the cooldown, so a real 429 hold is never
1544
+ -- shortened by this clamp.
633
1545
  GREATEST(
634
1546
  MAX(now_ms),
635
1547
  MAX(cool_until_ms),
636
- MAX(next_dispatch_at_ms)
1548
+ LEAST(
1549
+ MAX(next_dispatch_at_ms),
1550
+ MAX(now_ms)
1551
+ + CEIL(
1552
+ MAX(effective_capacity / GREATEST(refill_per_sec, 0.0001) * 1000)
1553
+ )::bigint
1554
+ )
637
1555
  )::bigint AS first_at_ms,
638
1556
  CEIL(MAX(1000.0 / GREATEST(refill_per_sec, 0.0001)))::bigint AS spacing_ms,
639
1557
  GREATEST(1, FLOOR(MIN(refill_per_sec) / 100.0))::bigint AS burst_size
@@ -655,13 +1573,13 @@ async function acquireWithConcurrency(
655
1573
  * (SELECT burst_size FROM schedule_calc)
656
1574
  ELSE b.next_dispatch_at_ms
657
1575
  END,
658
- -- Match the pure-rate path: concurrency leases change admission
659
- -- accounting, not the AIMD learning contract.
1576
+ -- Match the pure-rate path: a depleted local lease block plus a
1577
+ -- confirmed success is sufficient demand; concurrency leases change
1578
+ -- admission accounting, not the AIMD learning contract.
660
1579
  refill_per_sec = CASE
661
1580
  WHEN $10::int > 0
662
1581
  AND r.now_ms - r.last_ramp_at_ms >= 1000
663
1582
  AND r.cool_until_ms <= r.now_ms
664
- AND r.tokens_after_refill < r.capacity / 2
665
1583
  THEN LEAST(
666
1584
  r.max_rps,
667
1585
  CASE
@@ -676,7 +1594,6 @@ async function acquireWithConcurrency(
676
1594
  WHEN $10::int > 0
677
1595
  AND r.now_ms - r.last_ramp_at_ms >= 1000
678
1596
  AND r.cool_until_ms <= r.now_ms
679
- AND r.tokens_after_refill < r.capacity / 2
680
1597
  THEN r.now_ms
681
1598
  ELSE r.last_ramp_at_ms
682
1599
  END
@@ -692,8 +1609,10 @@ async function acquireWithConcurrency(
692
1609
  -- functions included), so each lease_id below stays stable across the
693
1610
  -- per-rule cross join in minted — every concurrency rule row shares the
694
1611
  -- same lease_id for a given permit, matching release semantics.
695
- SELECT gen_random_uuid()::text AS lease_id
696
- FROM generate_series(1, (SELECT granted FROM grant_calc))
1612
+ SELECT
1613
+ permit_index,
1614
+ gen_random_uuid()::text AS lease_id
1615
+ FROM generate_series(1, (SELECT granted FROM grant_calc)) AS permit_index
697
1616
  ),
698
1617
  minted AS (
699
1618
  -- Mint the lease rows from grant_calc (NOT by re-reading the lease
@@ -704,20 +1623,63 @@ async function acquireWithConcurrency(
704
1623
  rule_id,
705
1624
  lease_id,
706
1625
  permits,
707
- expires_at_ms
1626
+ expires_at_ms,
1627
+ in_flight_at_start,
1628
+ target_requests_per_second,
1629
+ minimum_rtt_ms${fair ? ',\n requester_id' : ''}
708
1630
  )
709
1631
  SELECT
710
1632
  $1,
711
1633
  rule_id,
712
1634
  lt.lease_id,
713
1635
  1,
714
- (SELECT now_ms FROM now_value) + $8::bigint
1636
+ (SELECT now_ms FROM now_value) + $8::bigint,
1637
+ (SELECT active_count FROM concurrency_context) + lt.permit_index,
1638
+ (SELECT COALESCE(MIN(new_refill_per_sec), 0) FROM updated),
1639
+ (SELECT minimum_rtt_ms FROM concurrency_context)${fair ? ',\n $13::text' : ''}
715
1640
  FROM unnest($5::text[]) AS rule_id
716
1641
  CROSS JOIN lease_tokens lt
717
1642
  RETURNING lease_id
718
- )
1643
+ ),
1644
+ minted_reservations AS (
1645
+ INSERT INTO ${reservationTableName(input)} (
1646
+ bucket_id,
1647
+ reservation_id,
1648
+ expires_at_ms
1649
+ )
1650
+ SELECT
1651
+ $1,
1652
+ lease_id,
1653
+ (SELECT first_at_ms FROM schedule_calc)
1654
+ + FLOOR(
1655
+ (permit_index - 1)::numeric
1656
+ / (SELECT burst_size FROM schedule_calc)
1657
+ )::bigint
1658
+ * (SELECT spacing_ms FROM schedule_calc)
1659
+ * (SELECT burst_size FROM schedule_calc)
1660
+ + $11::bigint
1661
+ FROM lease_tokens
1662
+ RETURNING reservation_id
1663
+ )${fair?.rememberCte ?? ''}
719
1664
  SELECT
720
1665
  (SELECT granted FROM grant_calc) AS granted,
1666
+ (SELECT COUNT(*)::int FROM refreshed) AS observed_rule_rows,
1667
+ (
1668
+ SELECT COALESCE(
1669
+ bool_or(
1670
+ r.capacity IS DISTINCT FROM sc.capacity
1671
+ OR r.base_refill_per_sec IS DISTINCT FROM sc.base_refill_per_sec
1672
+ OR r.min_rps IS DISTINCT FROM sc.min_rps
1673
+ OR (
1674
+ sc.adaptive_max_configured
1675
+ AND r.max_rps IS DISTINCT FROM sc.max_rps
1676
+ )
1677
+ ),
1678
+ false
1679
+ )
1680
+ FROM refreshed r
1681
+ JOIN seed_config sc ON sc.rule_id = r.rule_id
1682
+ ) AS config_drift,
721
1683
  (SELECT now_ms FROM refreshed LIMIT 1) AS server_now_ms,
722
1684
  (SELECT cool_until_ms FROM cooling) AS cool_until_ms,
723
1685
  (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
@@ -737,11 +1699,38 @@ async function acquireWithConcurrency(
737
1699
  )
738
1700
  ELSE '{}'::bigint[]
739
1701
  END AS scheduled_at_ms,
740
- (SELECT COALESCE(ARRAY_AGG(DISTINCT lease_id), '{}') FROM minted) AS lease_ids,
1702
+ CASE
1703
+ WHEN (SELECT COUNT(DISTINCT lease_id) FROM minted)
1704
+ = (SELECT granted FROM grant_calc)
1705
+ AND (SELECT COUNT(*) FROM minted_reservations)
1706
+ = (SELECT granted FROM grant_calc)
1707
+ THEN ARRAY(
1708
+ SELECT lease_id
1709
+ FROM lease_tokens
1710
+ ORDER BY permit_index
1711
+ )
1712
+ ELSE NULL
1713
+ END AS lease_ids,
1714
+ CASE
1715
+ WHEN (SELECT granted FROM grant_calc) > 0 THEN ARRAY(
1716
+ SELECT jsonb_build_object(
1717
+ 'admissionId', lease_id,
1718
+ 'inFlightAtStart',
1719
+ (SELECT active_count FROM concurrency_context) + permit_index,
1720
+ 'targetRequestsPerSecond',
1721
+ (SELECT COALESCE(MIN(new_refill_per_sec), 0) FROM updated),
1722
+ 'minimumRttMs',
1723
+ (SELECT minimum_rtt_ms FROM concurrency_context)
1724
+ )
1725
+ FROM lease_tokens
1726
+ ORDER BY permit_index
1727
+ )
1728
+ ELSE '{}'::jsonb[]
1729
+ END AS concurrency_grants,
741
1730
  CASE
742
1731
  WHEN (SELECT granted FROM grant_calc) > 0 THEN 0
743
1732
  WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
744
- THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)
1733
+ THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)${fair?.waitBranch ?? ''}
745
1734
  WHEN (SELECT free FROM free_slots) <= 0 THEN $7::bigint
746
1735
  ELSE (
747
1736
  SELECT CEIL(
@@ -764,12 +1753,26 @@ async function acquireWithConcurrency(
764
1753
  ACTIVE_LEASE_TTL_MS,
765
1754
  stringifyPostgresJson(concurrencyLimits),
766
1755
  Math.max(0, Math.trunc(input.observedSuccesses ?? 0)),
1756
+ RATE_RESERVATION_TTL_MS,
1757
+ stringifyPostgresJson(seeds),
1758
+ ...(requesterId ? [requesterId] : []),
767
1759
  ],
768
1760
  );
769
1761
  const row = result.rows[0];
770
1762
  if (!row) {
771
- return { granted: 0, waitMs: MIN_CONCURRENCY_WAIT_MS, leaseIds: [] };
1763
+ return {
1764
+ granted: 0,
1765
+ waitMs: MIN_CONCURRENCY_WAIT_MS,
1766
+ leaseIds: [],
1767
+ observedRuleRows: 0,
1768
+ configDrift: false,
1769
+ };
772
1770
  }
1771
+ const observedRuleRows = Math.max(
1772
+ 0,
1773
+ Math.trunc(Number(row.observed_rule_rows)),
1774
+ );
1775
+ const configDrift = row.config_drift === true;
773
1776
  const granted = Math.max(0, Math.trunc(Number(row.granted)));
774
1777
  const serverNowMs = Math.max(0, Number(row.server_now_ms));
775
1778
  const coolUntilMs = Math.max(0, Number(row.cool_until_ms));
@@ -790,11 +1793,20 @@ async function acquireWithConcurrency(
790
1793
  `Rate-state lease minting drift for ${input.bucketId}: granted ${granted} with ${leaseIds.length} lease tokens.`,
791
1794
  );
792
1795
  }
1796
+ const concurrencyGrants = parseConcurrencyGrants(
1797
+ row.concurrency_grants,
1798
+ granted,
1799
+ input.bucketId,
1800
+ );
793
1801
  return {
794
1802
  granted,
795
1803
  waitMs,
796
1804
  serverNowMs,
1805
+ observedRuleRows,
1806
+ configDrift,
797
1807
  leaseIds: granted > 0 ? leaseIds : [],
1808
+ concurrencyGrants,
1809
+ reservationIds: granted > 0 ? leaseIds : [],
798
1810
  scheduledAtMs,
799
1811
  effectiveRequestsPerSecond,
800
1812
  coolUntilMs,
@@ -804,36 +1816,568 @@ async function acquireWithConcurrency(
804
1816
 
805
1817
  export async function acquirePostgresRateState(
806
1818
  client: PostgresSchedulerQueryClient,
807
- input: PlayRunnerRateStateAcquireInput & { nowMs?: number },
1819
+ input: RateStateAcquireInput,
1820
+ ): Promise<PlayRunnerRateStateAcquireResult> {
1821
+ return await withRateStateAvailabilityGuard(input, () =>
1822
+ acquireRateStateStatements(client, input),
1823
+ );
1824
+ }
1825
+
1826
+ type ConcurrencyObservation = Gradient2Observation & {
1827
+ kind: 'success' | 'throttled';
1828
+ };
1829
+
1830
+ type ConcurrencyObservationBatch = {
1831
+ observerId: string;
1832
+ laneId: string;
1833
+ sequence: number;
1834
+ };
1835
+
1836
+ function parseConcurrencyGrants(
1837
+ grants: unknown,
1838
+ granted: number,
1839
+ bucketId: string,
1840
+ ): PlayRunnerRateStateConcurrencyGrant[] {
1841
+ if (granted === 0) return [];
1842
+ if (!Array.isArray(grants) || grants.length !== granted) {
1843
+ throw new Error(
1844
+ `Rate-state concurrency attribution drift for ${bucketId}: granted ${granted} with invalid attribution tokens.`,
1845
+ );
1846
+ }
1847
+ return grants.map((value) => {
1848
+ let candidate: unknown = value;
1849
+ if (typeof candidate === 'string') {
1850
+ try {
1851
+ candidate = JSON.parse(candidate) as unknown;
1852
+ } catch {
1853
+ // The validation below reports the durable-boundary error.
1854
+ }
1855
+ }
1856
+ if (
1857
+ !candidate ||
1858
+ typeof candidate !== 'object' ||
1859
+ Array.isArray(candidate)
1860
+ ) {
1861
+ throw new Error(
1862
+ `Rate-state concurrency attribution drift for ${bucketId}: invalid token.`,
1863
+ );
1864
+ }
1865
+ const record = candidate as Record<string, unknown>;
1866
+ const admissionId =
1867
+ typeof record.admissionId === 'string' ? record.admissionId.trim() : '';
1868
+ const inFlightAtStart = Number(record.inFlightAtStart);
1869
+ const targetRequestsPerSecond = record.targetRequestsPerSecond;
1870
+ const minimumRttMs = record.minimumRttMs;
1871
+ if (
1872
+ !admissionId ||
1873
+ admissionId.length > 64 ||
1874
+ !Number.isSafeInteger(inFlightAtStart) ||
1875
+ inFlightAtStart < 1 ||
1876
+ (targetRequestsPerSecond !== null &&
1877
+ (!Number.isFinite(targetRequestsPerSecond) ||
1878
+ Number(targetRequestsPerSecond) <= 0)) ||
1879
+ (minimumRttMs !== null &&
1880
+ (!Number.isFinite(minimumRttMs) || Number(minimumRttMs) <= 0))
1881
+ ) {
1882
+ throw new Error(
1883
+ `Rate-state concurrency attribution drift for ${bucketId}: invalid token.`,
1884
+ );
1885
+ }
1886
+ return {
1887
+ admissionId,
1888
+ inFlightAtStart,
1889
+ targetRequestsPerSecond:
1890
+ targetRequestsPerSecond === null
1891
+ ? null
1892
+ : Number(targetRequestsPerSecond),
1893
+ minimumRttMs: minimumRttMs === null ? null : Number(minimumRttMs),
1894
+ };
1895
+ });
1896
+ }
1897
+
1898
+ function concurrencyObservationBatchFrom(
1899
+ batch: PlayRunnerRateStateAcquireInput['concurrencyObservationBatch'],
1900
+ ): ConcurrencyObservationBatch | null {
1901
+ if (!batch || typeof batch !== 'object') return null;
1902
+ const observerId = batch.observerId?.trim();
1903
+ const laneId = batch.laneId?.trim();
1904
+ const sequence = Number(batch.sequence);
1905
+ if (
1906
+ !observerId ||
1907
+ observerId.length > 64 ||
1908
+ !laneId ||
1909
+ laneId.length > 64 ||
1910
+ !Number.isSafeInteger(sequence) ||
1911
+ sequence <= 0
1912
+ ) {
1913
+ return null;
1914
+ }
1915
+ return { observerId, laneId, sequence };
1916
+ }
1917
+
1918
+ function concurrencyObservationsFrom(
1919
+ observations: PlayRunnerRateStateAcquireInput['concurrencyObservations'],
1920
+ ): ConcurrencyObservation[] {
1921
+ if (!Array.isArray(observations)) return [];
1922
+ return observations
1923
+ .slice(0, MAX_CONCURRENCY_OBSERVATIONS_PER_ACQUIRE)
1924
+ .flatMap((observation) =>
1925
+ (observation.kind === 'success' || observation.kind === 'throttled') &&
1926
+ Number.isFinite(observation.latencyMs) &&
1927
+ observation.latencyMs > 0
1928
+ ? [{ kind: observation.kind, latencyMs: observation.latencyMs }]
1929
+ : [],
1930
+ );
1931
+ }
1932
+
1933
+ async function concurrencyThrottleFrom(
1934
+ client: PostgresSchedulerQueryClient,
1935
+ input: PlayRunnerRateStatePenalizeInput,
1936
+ rules: readonly AcquirePacingRule[],
1937
+ ): Promise<{
1938
+ observations: ConcurrencyObservation[];
1939
+ batch: ConcurrencyObservationBatch | null;
1940
+ }> {
1941
+ if (
1942
+ input.latencyMs == null ||
1943
+ !Number.isFinite(input.latencyMs) ||
1944
+ input.latencyMs <= 0
1945
+ ) {
1946
+ return { observations: [], batch: null };
1947
+ }
1948
+ const grant = input.concurrencyGrant;
1949
+ if (
1950
+ !grant ||
1951
+ typeof grant.admissionId !== 'string' ||
1952
+ !grant.admissionId.trim() ||
1953
+ grant.admissionId.trim().length > 64
1954
+ ) {
1955
+ return { observations: [], batch: null };
1956
+ }
1957
+ const concurrencyRuleIds = rules
1958
+ .filter((rule) => rule.maxConcurrency != null && rule.maxConcurrency > 0)
1959
+ .map((rule) => rule.ruleId);
1960
+ if (concurrencyRuleIds.length === 0) {
1961
+ return { observations: [], batch: null };
1962
+ }
1963
+ const attributedLease = await client.query<{
1964
+ in_flight_at_start: number | null;
1965
+ target_requests_per_second: number | null;
1966
+ minimum_rtt_ms: number | null;
1967
+ }>(
1968
+ `
1969
+ SELECT
1970
+ in_flight_at_start,
1971
+ target_requests_per_second,
1972
+ minimum_rtt_ms
1973
+ FROM ${concurrencyLeaseTableName(input)}
1974
+ WHERE bucket_id = $1
1975
+ AND lease_id = $2
1976
+ AND rule_id = ANY($3::text[])
1977
+ ORDER BY rule_id
1978
+ LIMIT 1
1979
+ `,
1980
+ [input.bucketId, grant.admissionId.trim(), concurrencyRuleIds],
1981
+ );
1982
+ const lease = attributedLease.rows[0];
1983
+ const inFlightAtStart = Number(lease?.in_flight_at_start);
1984
+ const targetRequestsPerSecond = lease?.target_requests_per_second;
1985
+ const minimumRttMs = lease?.minimum_rtt_ms;
1986
+ if (
1987
+ !Number.isSafeInteger(inFlightAtStart) ||
1988
+ inFlightAtStart < 1 ||
1989
+ (targetRequestsPerSecond !== null &&
1990
+ targetRequestsPerSecond !== undefined &&
1991
+ (!Number.isFinite(targetRequestsPerSecond) ||
1992
+ targetRequestsPerSecond <= 0)) ||
1993
+ (minimumRttMs !== null &&
1994
+ minimumRttMs !== undefined &&
1995
+ (!Number.isFinite(minimumRttMs) || minimumRttMs <= 0))
1996
+ ) {
1997
+ return { observations: [], batch: null };
1998
+ }
1999
+ const attribution = classifyProviderThrottle({
2000
+ grant: {
2001
+ targetRequestsPerSecond: targetRequestsPerSecond ?? undefined,
2002
+ minimumRttMs: minimumRttMs ?? null,
2003
+ },
2004
+ inFlightAtStart,
2005
+ });
2006
+ if (attribution === 'rate') return { observations: [], batch: null };
2007
+ return {
2008
+ observations: [
2009
+ {
2010
+ kind: 'throttled',
2011
+ latencyMs: input.latencyMs,
2012
+ inFlightAtStart,
2013
+ },
2014
+ ],
2015
+ // Provider permits are server-minted. Reusing this unique admission id
2016
+ // makes a retried 429 report an idempotent checkpoint transition.
2017
+ batch: {
2018
+ observerId: 'provider-throttle',
2019
+ laneId: grant.admissionId.trim(),
2020
+ sequence: 1,
2021
+ },
2022
+ };
2023
+ }
2024
+
2025
+ /**
2026
+ * Fold completed provider attempts into the same Postgres rows that enforce
2027
+ * their permits. `FOR UPDATE` makes a checkpoint transition linearizable across
2028
+ * runners: no sandbox-local controller can overwrite another's RTT history.
2029
+ */
2030
+ async function applyConcurrencyObservations(
2031
+ client: PostgresSchedulerQueryClient,
2032
+ input: Pick<PlayRunnerRateStateAcquireInput, 'bucketId'> & {
2033
+ schema?: string | null;
2034
+ schedulerSchema?: string | null;
2035
+ },
2036
+ rules: readonly AcquirePacingRule[],
2037
+ observations: readonly ConcurrencyObservation[],
2038
+ batch: ConcurrencyObservationBatch | null,
2039
+ ): Promise<'unbatched' | 'applied' | 'already_applied'> {
2040
+ const concurrencyRules = rules.filter(
2041
+ (rule) => rule.maxConcurrency != null && rule.maxConcurrency > 0,
2042
+ );
2043
+ if (batch === null) return 'unbatched';
2044
+
2045
+ await client.query('BEGIN');
2046
+ try {
2047
+ const accepted = await client.query<{ last_sequence: number }>(
2048
+ `
2049
+ WITH now_value AS (
2050
+ SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint AS now_ms
2051
+ ),
2052
+ expired AS (
2053
+ DELETE FROM ${concurrencyObserverTableName(input)} AS observer
2054
+ WHERE (observer.bucket_id, observer.observer_id, observer.lane_id) IN (
2055
+ SELECT
2056
+ stale.bucket_id,
2057
+ stale.observer_id,
2058
+ stale.lane_id
2059
+ FROM ${concurrencyObserverTableName(input)} AS stale
2060
+ WHERE stale.expires_at_ms <= (SELECT now_ms FROM now_value)
2061
+ ORDER BY stale.expires_at_ms
2062
+ LIMIT ${CONCURRENCY_OBSERVER_CLEANUP_BATCH_SIZE}
2063
+ )
2064
+ )
2065
+ INSERT INTO ${concurrencyObserverTableName(input)} AS observer (
2066
+ bucket_id,
2067
+ observer_id,
2068
+ lane_id,
2069
+ last_sequence,
2070
+ updated_at_ms,
2071
+ expires_at_ms
2072
+ )
2073
+ SELECT
2074
+ $1,
2075
+ $2,
2076
+ $3,
2077
+ $4,
2078
+ now_ms,
2079
+ now_ms + $5::bigint
2080
+ FROM now_value
2081
+ ON CONFLICT (bucket_id, observer_id, lane_id) DO UPDATE
2082
+ SET
2083
+ last_sequence = EXCLUDED.last_sequence,
2084
+ updated_at_ms = EXCLUDED.updated_at_ms,
2085
+ expires_at_ms = EXCLUDED.expires_at_ms
2086
+ WHERE observer.last_sequence < EXCLUDED.last_sequence
2087
+ RETURNING last_sequence
2088
+ `,
2089
+ [
2090
+ input.bucketId,
2091
+ batch.observerId,
2092
+ batch.laneId,
2093
+ batch.sequence,
2094
+ CONCURRENCY_OBSERVATION_DEDUP_RETENTION_MS,
2095
+ ],
2096
+ );
2097
+ // Same batch after an uncertain response, or a delayed older one. The
2098
+ // checkpoint already contains its transition, so commit a no-op rather
2099
+ // than replaying its samples.
2100
+ if (accepted.rows.length === 0) {
2101
+ await client.query('COMMIT');
2102
+ return 'already_applied';
2103
+ }
2104
+ if (concurrencyRules.length === 0 || observations.length === 0) {
2105
+ await client.query('COMMIT');
2106
+ return 'applied';
2107
+ }
2108
+ const rows = await client.query<{
2109
+ rule_id: string;
2110
+ learned_concurrency_limit: number | null;
2111
+ concurrency_short_rtt_ms: number | null;
2112
+ concurrency_long_rtt_ms: number | null;
2113
+ concurrency_minimum_rtt_ms: number | null;
2114
+ concurrency_successful_samples: number;
2115
+ concurrency_window_limit: number | null;
2116
+ concurrency_has_observed_throttle: boolean;
2117
+ }>(
2118
+ `
2119
+ SELECT
2120
+ rule_id,
2121
+ learned_concurrency_limit,
2122
+ concurrency_short_rtt_ms,
2123
+ concurrency_long_rtt_ms,
2124
+ concurrency_minimum_rtt_ms,
2125
+ concurrency_successful_samples,
2126
+ concurrency_window_limit,
2127
+ concurrency_has_observed_throttle
2128
+ FROM ${bucketTableName(input)}
2129
+ WHERE bucket_id = $1
2130
+ AND rule_id = ANY($2::text[])
2131
+ ORDER BY rule_id
2132
+ FOR UPDATE
2133
+ `,
2134
+ [input.bucketId, concurrencyRules.map((rule) => rule.ruleId)],
2135
+ );
2136
+
2137
+ const ruleById = new Map(
2138
+ concurrencyRules.map((rule) => [rule.ruleId, rule]),
2139
+ );
2140
+ for (const row of rows.rows) {
2141
+ const rule = ruleById.get(row.rule_id);
2142
+ if (!rule?.maxConcurrency) continue;
2143
+ const maximumLimit = Math.max(1, Math.trunc(rule.maxConcurrency));
2144
+ const initialLimit = Math.min(
2145
+ maximumLimit,
2146
+ Math.max(
2147
+ 1,
2148
+ row.learned_concurrency_limit ?? INITIAL_CONCURRENCY_DISCOVERY_LIMIT,
2149
+ ),
2150
+ );
2151
+ const controller = new Gradient2ConcurrencyController(
2152
+ providerConcurrencyControllerOptions({
2153
+ initialLimit,
2154
+ maximumLimit,
2155
+ }),
2156
+ row.learned_concurrency_limit == null
2157
+ ? undefined
2158
+ : {
2159
+ limit: initialLimit,
2160
+ shortRttMs: row.concurrency_short_rtt_ms,
2161
+ longRttMs: row.concurrency_long_rtt_ms,
2162
+ minimumRttMs: row.concurrency_minimum_rtt_ms,
2163
+ successfulSamplesInWindow: Math.max(
2164
+ 0,
2165
+ Math.trunc(row.concurrency_successful_samples ?? 0),
2166
+ ),
2167
+ windowStartedAtLimit: Math.max(
2168
+ 1,
2169
+ Math.trunc(row.concurrency_window_limit ?? initialLimit),
2170
+ ),
2171
+ hasObservedThrottle:
2172
+ row.concurrency_has_observed_throttle === true,
2173
+ },
2174
+ );
2175
+ for (const observation of observations) controller.observe(observation);
2176
+ const checkpoint = controller.checkpoint();
2177
+ await client.query(
2178
+ `
2179
+ UPDATE ${bucketTableName(input)}
2180
+ SET
2181
+ learned_concurrency_limit = $3,
2182
+ concurrency_short_rtt_ms = $4,
2183
+ concurrency_long_rtt_ms = $5,
2184
+ concurrency_minimum_rtt_ms = $6,
2185
+ concurrency_successful_samples = $7,
2186
+ concurrency_window_limit = $8,
2187
+ concurrency_has_observed_throttle = $9
2188
+ WHERE bucket_id = $1 AND rule_id = $2
2189
+ `,
2190
+ [
2191
+ input.bucketId,
2192
+ row.rule_id,
2193
+ checkpoint.limit,
2194
+ checkpoint.shortRttMs,
2195
+ checkpoint.longRttMs,
2196
+ checkpoint.minimumRttMs,
2197
+ checkpoint.successfulSamplesInWindow,
2198
+ checkpoint.windowStartedAtLimit,
2199
+ checkpoint.hasObservedThrottle === true,
2200
+ ],
2201
+ );
2202
+ }
2203
+ await client.query('COMMIT');
2204
+ return 'applied';
2205
+ } catch (error) {
2206
+ try {
2207
+ await client.query('ROLLBACK');
2208
+ } catch {
2209
+ // Preserve the original controller/storage error.
2210
+ }
2211
+ throw error;
2212
+ }
2213
+ }
2214
+
2215
+ async function acquireRateStateStatements(
2216
+ client: PostgresSchedulerQueryClient,
2217
+ input: RateStateAcquireInput,
808
2218
  ): Promise<PlayRunnerRateStateAcquireResult> {
809
2219
  const rules = normalizeRules(input.rules as AcquirePacingRule[]);
810
2220
  if (rules.length === 0) return { granted: 0, waitMs: 0, leaseIds: [] };
811
2221
  const requested = positiveInt(input.requested, 'requested permits');
812
- const nowMs =
813
- input.nowMs ??
814
- Number(
815
- (
816
- await client.query<{ now_ms: string | number }>(
817
- `SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint AS now_ms`,
818
- )
819
- ).rows[0]?.now_ms ?? Date.now(),
820
- );
821
- // Cold path: seed missing rows (ON CONFLICT DO NOTHING, no-op when warm).
822
- await seedBucketRows(client, input, rules, nowMs);
2222
+ const seeds = rules.map(seedForRule);
2223
+ // Warm identity = schema + bucket + the exact declared configuration, so a
2224
+ // changed rule shape is never mistaken for an already-seeded one.
2225
+ const warmKey = `${schemaName(input)}\u0000${input.bucketId}\u0000${stringifyPostgresJson(seeds)}`;
2226
+ // Undefined nowMs stays undefined all the way into SQL: the seed and the
2227
+ // grant each read clock_timestamp() inside their own statement, so an
2228
+ // acquire never spends a round trip asking the database what time it is.
2229
+ const nowMs = input.nowMs ?? null;
823
2230
  const hasConcurrency = rules.some((rule) => rule.maxConcurrency != null);
824
- if (!hasConcurrency) {
825
- return acquirePureRate(client, { ...input, nowMs }, rules, requested);
2231
+ let grantInput = input;
2232
+ const grant = (): Promise<InternalAcquireOutcome> =>
2233
+ hasConcurrency
2234
+ ? acquireWithConcurrency(client, grantInput, rules, seeds, requested)
2235
+ : acquirePureRate(client, grantInput, rules, seeds, requested);
2236
+
2237
+ // Cold path only: seed missing rows / refresh changed configuration. A warm
2238
+ // acquire is exactly one statement.
2239
+ let seeded = false;
2240
+ if (!warmRateBuckets.has(warmKey)) {
2241
+ await seedBucketRows(client, input, seeds, nowMs);
2242
+ seeded = true;
2243
+ }
2244
+ const observationBatchDisposition = await applyConcurrencyObservations(
2245
+ client,
2246
+ input,
2247
+ rules,
2248
+ concurrencyObservationsFrom(input.concurrencyObservations),
2249
+ concurrencyObservationBatchFrom(input.concurrencyObservationBatch),
2250
+ );
2251
+ // The buffered success count and RTT observations share one identity. If a
2252
+ // runner lost the prior acquire response, its retry still needs a fresh
2253
+ // permit, but it must not ramp AIMD or advance Gradient2 a second time.
2254
+ grantInput =
2255
+ observationBatchDisposition === 'already_applied'
2256
+ ? { ...input, observedSuccesses: 0 }
2257
+ : input;
2258
+ let outcome = await grant();
2259
+ if (outcome.observedRuleRows < rules.length) {
2260
+ // The rows this process believed warm are gone (schema reset, manual
2261
+ // delete, lost seed). Never let that decay into a silent granted=0 spin.
2262
+ warmRateBuckets.delete(warmKey);
2263
+ if (seeded || outcome.granted > 0) {
2264
+ throw new Error(
2265
+ `Rate-state bucket ${input.bucketId} locked ${outcome.observedRuleRows} of ${rules.length} rule rows (${rules
2266
+ .map((rule) => rule.ruleId)
2267
+ .join(', ')}) after seeding.`,
2268
+ );
2269
+ }
2270
+ await seedBucketRows(client, input, seeds, nowMs);
2271
+ outcome = await grant();
2272
+ if (outcome.observedRuleRows < rules.length) {
2273
+ throw new Error(
2274
+ `Rate-state bucket ${input.bucketId} still missing rule rows after re-seeding: locked ${outcome.observedRuleRows} of ${rules.length} (${rules
2275
+ .map((rule) => rule.ruleId)
2276
+ .join(', ')}).`,
2277
+ );
2278
+ }
2279
+ }
2280
+ if (outcome.configDrift && !seeded) {
2281
+ // A warm mark is not a promise that nobody edited the rows. The grant
2282
+ // statement compares the stored configuration against this acquire's seed,
2283
+ // so a row widened out of band is repaired here instead of waiting for a
2284
+ // process restart. Repairing after the grant keeps the acquire itself to a
2285
+ // single granting statement, so no permit is ever issued twice.
2286
+ await seedBucketRows(client, input, seeds, nowMs);
826
2287
  }
827
- return acquireWithConcurrency(client, { ...input, nowMs }, rules, requested);
2288
+ markRateBucketWarm(warmKey);
2289
+ return acquireResultOf(outcome);
828
2290
  }
829
2291
 
830
2292
  export async function releasePostgresRateState(
831
2293
  client: PostgresSchedulerQueryClient,
832
2294
  input: PlayRunnerRateStateReleaseInput & { nowMs?: number },
833
2295
  ): Promise<void> {
834
- const rules = normalizeRules(input.rules as AcquirePacingRule[]).filter(
835
- (rule) => rule.maxConcurrency != null,
2296
+ return await withRateStateAvailabilityGuard(input, () =>
2297
+ releaseRateStateStatements(client, input),
836
2298
  );
2299
+ }
2300
+
2301
+ async function releaseRateStateStatements(
2302
+ client: PostgresSchedulerQueryClient,
2303
+ input: PlayRunnerRateStateReleaseInput & { nowMs?: number },
2304
+ ): Promise<void> {
2305
+ const allRules = normalizeRules(input.rules as AcquirePacingRule[]);
2306
+ if (allRules.length === 0) return;
2307
+ const refundReservationIds = [
2308
+ ...new Set(
2309
+ (input.refundReservationIds ?? [])
2310
+ .filter(
2311
+ (reservationId): reservationId is string =>
2312
+ typeof reservationId === 'string',
2313
+ )
2314
+ .map((reservationId) => reservationId.trim())
2315
+ .filter(Boolean),
2316
+ ),
2317
+ ];
2318
+ if (refundReservationIds.length > 0) {
2319
+ // Delete only a server-minted, unexpired reservation. That one mutation is
2320
+ // both authorization and idempotency: arbitrary runner input cannot credit
2321
+ // a bucket, and an HTTP retry sees no reservation left to refund. The shared
2322
+ // dispatch cursor deliberately stays monotonic, so cancellation can never
2323
+ // compress provider starts into a burst.
2324
+ await client.query(
2325
+ `
2326
+ WITH now_value AS (
2327
+ SELECT COALESCE(
2328
+ $3::bigint,
2329
+ FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
2330
+ ) AS now_ms
2331
+ ),
2332
+ refunded AS (
2333
+ DELETE FROM ${reservationTableName(input)}
2334
+ WHERE bucket_id = $1
2335
+ AND reservation_id = ANY($4::text[])
2336
+ AND expires_at_ms > (SELECT now_ms FROM now_value)
2337
+ RETURNING reservation_id
2338
+ ),
2339
+ refund_count AS (
2340
+ SELECT COUNT(*)::float8 AS value FROM refunded
2341
+ ),
2342
+ refreshed AS (
2343
+ SELECT
2344
+ b.bucket_id,
2345
+ b.rule_id,
2346
+ b.capacity,
2347
+ b.refill_per_sec,
2348
+ b.refilled_at_ms,
2349
+ b.tokens,
2350
+ nv.now_ms
2351
+ FROM ${bucketTableName(input)} b
2352
+ CROSS JOIN now_value nv
2353
+ WHERE b.bucket_id = $1
2354
+ AND b.rule_id = ANY($2::text[])
2355
+ ORDER BY b.rule_id
2356
+ FOR UPDATE OF b
2357
+ )
2358
+ UPDATE ${bucketTableName(input)} b
2359
+ SET
2360
+ tokens = LEAST(
2361
+ r.capacity,
2362
+ r.tokens
2363
+ + GREATEST(0, r.now_ms - r.refilled_at_ms) / 1000.0 * r.refill_per_sec
2364
+ + (SELECT value FROM refund_count)
2365
+ ),
2366
+ refilled_at_ms = r.now_ms
2367
+ FROM refreshed r
2368
+ WHERE b.bucket_id = r.bucket_id
2369
+ AND b.rule_id = r.rule_id
2370
+ AND (SELECT value FROM refund_count) > 0
2371
+ `,
2372
+ [
2373
+ input.bucketId,
2374
+ allRules.map((rule) => rule.ruleId),
2375
+ input.nowMs ?? null,
2376
+ refundReservationIds,
2377
+ ],
2378
+ );
2379
+ }
2380
+ const rules = allRules.filter((rule) => rule.maxConcurrency != null);
837
2381
  if (rules.length === 0) return;
838
2382
  const leaseIds = [
839
2383
  ...new Set(
@@ -859,6 +2403,15 @@ export async function releasePostgresRateState(
859
2403
  export async function penalizePostgresRateState(
860
2404
  client: PostgresSchedulerQueryClient,
861
2405
  input: PlayRunnerRateStatePenalizeInput & { nowMs?: number },
2406
+ ): Promise<void> {
2407
+ return await withRateStateAvailabilityGuard(input, () =>
2408
+ penalizeRateStateStatements(client, input),
2409
+ );
2410
+ }
2411
+
2412
+ async function penalizeRateStateStatements(
2413
+ client: PostgresSchedulerQueryClient,
2414
+ input: PlayRunnerRateStatePenalizeInput & { nowMs?: number },
862
2415
  ): Promise<void> {
863
2416
  if (!Number.isFinite(input.cooldownMs) || input.cooldownMs <= 0) return;
864
2417
  // Single autocommit statement applied to every rule row of the bucket:
@@ -904,4 +2457,13 @@ export async function penalizePostgresRateState(
904
2457
  MAX_INTERNAL_COOLDOWN_MS,
905
2458
  ],
906
2459
  );
2460
+ const rules = normalizeRules((input.rules ?? []) as AcquirePacingRule[]);
2461
+ const throttle = await concurrencyThrottleFrom(client, input, rules);
2462
+ await applyConcurrencyObservations(
2463
+ client,
2464
+ input,
2465
+ rules,
2466
+ throttle.observations,
2467
+ throttle.batch,
2468
+ );
907
2469
  }