deepline 0.3.184 → 0.3.185

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 (34) hide show
  1. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  2. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +53 -0
  3. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +751 -193
  4. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +4 -0
  5. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +3 -1
  6. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +437 -150
  8. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +47 -4
  9. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +3 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +3 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +22 -2
  13. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +6 -1
  14. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +11 -5
  15. package/dist/bundling-sources/shared_libs/play-runtime/runtime-flow.ts +102 -12
  16. package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +8 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +136 -28
  18. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-scope.ts +80 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +897 -207
  20. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +33 -1
  21. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +21 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +120 -34
  23. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +82 -2
  24. package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +7 -2
  25. package/dist/cli/index.js +1 -1
  26. package/dist/cli/index.mjs +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/index.mjs +1 -1
  29. package/dist/install-integrity.json +1 -0
  30. package/dist/release.d.mts +1 -1
  31. package/dist/release.d.ts +1 -1
  32. package/dist/release.js +1 -1
  33. package/dist/release.mjs +1 -1
  34. package/package.json +1 -1
@@ -4,9 +4,13 @@ import type {
4
4
  PlayRunnerRateStateConcurrencyGrant,
5
5
  PlayRunnerRateStatePenalizeInput,
6
6
  PlayRunnerRateStateReleaseInput,
7
+ PlayRunnerRateStateValidateInput,
8
+ PlayRunnerRateStateValidateResult,
7
9
  } from '../protocol';
8
10
  import {
9
11
  DEFAULT_ADVISORY_PROVIDER_MAX_RPS,
12
+ DEFAULT_UNKNOWN_PROVIDER_COLD_START_RPS,
13
+ PROVIDER_AIMD_COOLDOWN_CAP_MS,
10
14
  type PacingRule,
11
15
  } from '../governor/rate-state-backend';
12
16
  import { type PostgresSchedulerQueryClient } from './postgres';
@@ -18,6 +22,7 @@ import {
18
22
  } from '../provider-capacity/gradient2-concurrency-controller';
19
23
  import { providerConcurrencyControllerOptions } from '../provider-capacity/provider-concurrency-policy';
20
24
  import { classifyProviderThrottle } from '../provider-capacity/provider-throttle-classifier';
25
+ import { acquireRateScopeSql, updateRateScopeSql } from './postgres-rate-scope';
21
26
 
22
27
  const DEFAULT_SCHEMA = 'runtime_scheduler';
23
28
  const RATE_BUCKET_TABLE = 'rate_bucket';
@@ -27,6 +32,8 @@ const RATE_RESERVATION_TABLE = 'rate_reservations';
27
32
  const RATE_RESERVATION_BUCKET_EXPIRY_INDEX =
28
33
  'runtime_scheduler_rate_reservation_bucket_expiry_idx';
29
34
  const RATE_REQUESTER_TABLE = 'rate_requesters';
35
+ const RATE_SCOPE_HOLD_TABLE = 'rate_scope_holds';
36
+
30
37
  const MIN_CONCURRENCY_WAIT_MS = 10;
31
38
  /**
32
39
  * Longest a requester held back by the fair-share cap sleeps before it re-asks.
@@ -53,7 +60,9 @@ const CONCURRENCY_OBSERVATION_DEDUP_RETENTION_MS = 24 * 60 * 60 * 1000;
53
60
  const CONCURRENCY_OBSERVER_CLEANUP_BATCH_SIZE = 128;
54
61
  const ACTIVE_LEASE_TTL_MS = 5 * 60 * 1000;
55
62
  const RATE_RESERVATION_TTL_MS = 5 * 60 * 1000;
56
- const MIN_ADAPTIVE_RPS_FLOOR = 0.01;
63
+ // A guessed initial rate must not prevent learning an hourly quota. This is a
64
+ // numerical floor (one request/day), not a fraction of the catalog seed.
65
+ const MIN_ADAPTIVE_RPS_FLOOR = 1 / 86_400;
57
66
  /**
58
67
  * Horizon a granted=0 caller is allowed to sleep toward a WHOLE block instead of
59
68
  * a single token. Waiting for one token means one permit per store round trip,
@@ -66,6 +75,32 @@ const MIN_ADAPTIVE_RPS_FLOOR = 0.01;
66
75
  */
67
76
  const BLOCK_WAIT_HORIZON_MS = 1_000;
68
77
 
78
+ /**
79
+ * Bounded summary of one persisted provider-throttle decision. This is emitted
80
+ * as telemetry; it deliberately contains no rule ids, credentials, or row data.
81
+ */
82
+ export type PostgresRateStatePenaltyTelemetry = {
83
+ retryAtMs: number | null;
84
+ coolUntilMs: number | null;
85
+ rateRuleCount: number;
86
+ rateDecreasedRuleCount: number;
87
+ refillMinBeforeRps: number | null;
88
+ refillMaxBeforeRps: number | null;
89
+ refillMinAfterRps: number | null;
90
+ refillMaxAfterRps: number | null;
91
+ observationCycleMaxMs: number | null;
92
+ confirmedSuccessesMax: number | null;
93
+ configuredConcurrencyRuleCount: number;
94
+ gradient2Attribution: 'not_observed' | 'rate' | 'concurrency' | 'ambiguous';
95
+ gradient2Disposition: 'not_observed' | 'applied' | 'already_applied';
96
+ gradient2RuleCount: number;
97
+ gradient2ChangedRuleCount: number;
98
+ gradient2LimitMinBefore: number | null;
99
+ gradient2LimitMaxBefore: number | null;
100
+ gradient2LimitMinAfter: number | null;
101
+ gradient2LimitMaxAfter: number | null;
102
+ };
103
+
69
104
  /**
70
105
  * Burst horizon of the adaptive token bucket, in milliseconds of the rate
71
106
  * ACTUALLY in force.
@@ -74,8 +109,11 @@ const BLOCK_WAIT_HORIZON_MS = 1_000;
74
109
  * its seed: config-drift detection compares it against the seed verbatim, so
75
110
  * persisting a learned capacity would make every warm acquire look like drift
76
111
  * and re-seed the row. The bucket the grant actually spends is therefore
77
- * derived, not stored: one second of `refill_per_sec`, floored at the declared
78
- * burst.
112
+ * derived, not stored: one second of `refill_per_sec` before a throttle and
113
+ * 100ms of it afterwards. Before a throttle the declared burst is a floor;
114
+ * after a throttle only a single probe is the floor.
115
+ * Retaining the catalog burst after learning a slow rate lets a fresh runtime
116
+ * reserve minutes of future slots before another run can register demand.
79
117
  *
80
118
  * Without this the AIMD ramp is invisible to throughput. A grant is bounded by
81
119
  * the bucket's tokens, tokens are clamped to `capacity`, and every grant costs
@@ -84,29 +122,50 @@ const BLOCK_WAIT_HORIZON_MS = 1_000;
84
122
  * (capacity 10) stayed at ~7.7 calls/s and a declared 25 rps lane at ~13
85
123
  * calls/s no matter how high `refill_per_sec` climbed.
86
124
  *
87
- * The burst stays bounded: one second of the learned rate, and the learned rate
88
- * is itself capped at `max_rps`. The dispatch cursor still spaces every permit
89
- * at 1/`refill_per_sec`, so provider arrivals never exceed the rate in force.
125
+ * The burst stays bounded by the learned rate and `max_rps`. The dispatch
126
+ * cursor still spaces every permit at 1/`refill_per_sec`, so provider arrivals
127
+ * never exceed the rate in force. The shorter post-throttle horizon limits
128
+ * fixed-window boundary overshoot while retaining small batches on fast lanes.
90
129
  */
91
130
  const ADAPTIVE_BURST_HORIZON_MS = 1_000;
131
+ const BACKPRESSURED_BURST_HORIZON_MS = 100;
92
132
 
93
133
  /**
94
134
  * Effective (spendable) bucket capacity for a rate-row alias. Derived per
95
- * statement from the learned rate; see {@link ADAPTIVE_BURST_HORIZON_MS}.
135
+ * statement from the learned rate; see the adaptive burst horizon constants.
96
136
  */
97
- function effectiveCapacitySql(alias: string): string {
98
- return `GREATEST(${alias}.capacity, ${alias}.refill_per_sec * ${ADAPTIVE_BURST_HORIZON_MS} / 1000.0)`;
137
+ function effectiveCapacitySql(
138
+ alias: string,
139
+ refillColumn = 'refill_per_sec',
140
+ ): string {
141
+ // After a provider 429, Retry-After marks the earliest safe probe time; it
142
+ // does not authorize banking a full rate-sized burst while the hold runs.
143
+ // rate_observation_started_at_ms stays NULL until the first post-hold grant,
144
+ // so that grant is exactly one probe even when the preceding successful
145
+ // throughput estimate was high. Once the probe starts a fresh observation
146
+ // epoch, only a short micro-burst resumes. A full second of tokens can straddle
147
+ // a fixed provider quota boundary as adjacent bursts and double the allowed
148
+ // starts in one window. A learned rate below the declared base is also
149
+ // backpressure evidence and must not restore a stale (possibly 1,000-wide)
150
+ // configured burst.
151
+ return `CASE
152
+ WHEN ${alias}.seen_backpressure AND ${alias}.rate_observation_started_at_ms IS NULL THEN 1.0
153
+ ELSE GREATEST(
154
+ CASE WHEN ${alias}.seen_backpressure OR ${alias}.${refillColumn} < ${alias}.base_refill_per_sec THEN 1 ELSE ${alias}.capacity END,
155
+ ${alias}.${refillColumn} * CASE
156
+ WHEN ${alias}.seen_backpressure THEN ${BACKPRESSURED_BURST_HORIZON_MS}
157
+ ELSE ${ADAPTIVE_BURST_HORIZON_MS}
158
+ END / 1000.0
159
+ )
160
+ END`;
99
161
  }
100
162
 
101
163
  /**
102
- * Internal cooldown cap. A 429 report (penalize) can carry an arbitrarily large
103
- * server Retry-After; we store that verbatim in `claimed_retry_at_ms` for
104
- * observability but cap the pacer's own cooldown at 45 minutes so a stuck
105
- * provider is re-probed at least this often instead of freezing the bucket for
106
- * the whole (possibly hours-long) Retry-After.
164
+ * AIMD cooldown cap. A 429's absolute Retry-After is separately persisted in
165
+ * `rate_scope_holds` and gates every rule/run until that deadline. This cap only
166
+ * bounds how long the per-rule AIMD state remains in its cooldown phase; it
167
+ * must never shorten or bypass the provider's authoritative hold.
107
168
  */
108
- const MAX_INTERNAL_COOLDOWN_MS = 45 * 60 * 1000;
109
-
110
169
  /**
111
170
  * Buckets this process has already seeded, keyed by schema + bucket id + the
112
171
  * exact seed payload. Seeding is a pure upsert of declared configuration, so
@@ -181,6 +240,33 @@ function requesterTableName(options?: {
181
240
  return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_REQUESTER_TABLE)}`;
182
241
  }
183
242
 
243
+ function scopeHoldTableName(options?: {
244
+ schema?: string | null;
245
+ schedulerSchema?: string | null;
246
+ }): string {
247
+ return `${quoteIdent(schemaName(options))}.${quoteIdent(RATE_SCOPE_HOLD_TABLE)}`;
248
+ }
249
+
250
+ function isMissingRateScopeHoldTableError(error: unknown): boolean {
251
+ if (!error || typeof error !== 'object') return false;
252
+ const candidate = error as { code?: unknown; message?: unknown };
253
+ return (
254
+ candidate.code === '42P01' &&
255
+ typeof candidate.message === 'string' &&
256
+ new RegExp(`\\b${RATE_SCOPE_HOLD_TABLE}\\b`).test(candidate.message)
257
+ );
258
+ }
259
+
260
+ function isMissingRateThrottleCeilingColumnError(error: unknown): boolean {
261
+ if (!error || typeof error !== 'object') return false;
262
+ const candidate = error as { code?: unknown; message?: unknown };
263
+ return (
264
+ candidate.code === '42703' &&
265
+ typeof candidate.message === 'string' &&
266
+ /\brate_throttle_ceiling_rps\b/.test(candidate.message)
267
+ );
268
+ }
269
+
184
270
  /**
185
271
  * v43 added `rate_requesters` and the lease `requester_id` column. Only an
186
272
  * acquire that carries a requester identity touches them, so during the
@@ -271,7 +357,9 @@ async function withRateStateAvailabilityGuard<T>(
271
357
  !isMissingRateReservationTableError(error) &&
272
358
  !isMissingConcurrencyCheckpointError(error) &&
273
359
  !isMissingConcurrencyObserverTableError(error) &&
274
- !isMissingRateRequesterSchemaError(error)
360
+ !isMissingRateRequesterSchemaError(error) &&
361
+ !isMissingRateScopeHoldTableError(error) &&
362
+ !isMissingRateThrottleCeilingColumnError(error)
275
363
  ) {
276
364
  throw error;
277
365
  }
@@ -298,11 +386,19 @@ function normalizeRules(
298
386
  return [...rules]
299
387
  .map((rule) => {
300
388
  const isDefaultPacing = rule.ruleId.startsWith('default:');
389
+ const isUnknownProviderScope = rule.ruleId === 'default:provider-scope';
301
390
  return {
302
391
  ruleId: rule.ruleId,
303
392
  // Rates are fractional; only dispatched requests must be whole.
304
- requestsPerWindow: rule.requestsPerWindow,
305
- windowMs: Math.max(0, Math.trunc(rule.windowMs)),
393
+ // Keep the shared, unhinted credential scope conservative even when
394
+ // an older runner submits the generic 10-RPS policy seed. The shared
395
+ // row's AIMD controller learns upward from successful observations.
396
+ requestsPerWindow: isUnknownProviderScope
397
+ ? DEFAULT_UNKNOWN_PROVIDER_COLD_START_RPS
398
+ : rule.requestsPerWindow,
399
+ windowMs: isUnknownProviderScope
400
+ ? 1_000
401
+ : Math.max(0, Math.trunc(rule.windowMs)),
306
402
  maxConcurrency:
307
403
  rule.maxConcurrency == null
308
404
  ? null
@@ -331,7 +427,7 @@ function normalizeRules(
331
427
  * per second; `capacity` is one window of burst. The adaptive `[min,max]` range
332
428
  * is derived value-first so the same wire shape drives both declared and
333
429
  * default-pacing buckets:
334
- * - min_rps = min(base, max(0.01, base/8))
430
+ * - min_rps = min(base, 1/86400)
335
431
  * (low-RPM rules must still be able to back down below their base)
336
432
  * - max_rps = max(base, 100) (catalogued-rate recommendation)
337
433
  * - max_rps = explicit adaptiveMaxRps when supplied
@@ -350,7 +446,7 @@ function seedForRule(rule: AcquirePacingRule): RateBucketSeed {
350
446
  // Preserve its fractional refill rate, with dispatch times resolved in ms.
351
447
  const capacity = Math.max(1, rule.requestsPerWindow);
352
448
  const base = (rule.requestsPerWindow / rule.windowMs) * 1000;
353
- const minRps = Math.min(base, Math.max(MIN_ADAPTIVE_RPS_FLOOR, base / 8));
449
+ const minRps = Math.min(base, MIN_ADAPTIVE_RPS_FLOOR);
354
450
  const maxRps =
355
451
  rule.adaptiveMaxRps != null && rule.adaptiveMaxRps > 0
356
452
  ? Math.max(rule.adaptiveMaxRps, base)
@@ -401,6 +497,40 @@ export async function ensurePostgresRateStateSchema(
401
497
  PRIMARY KEY (bucket_id, rule_id)
402
498
  )
403
499
  `);
500
+ // Provider Retry-After belongs to the credential/provider bucket, not a
501
+ // particular tool-rule row. This durable row survives worker/run restarts
502
+ // and newly introduced rules without growing with request volume.
503
+ await client.query(`
504
+ CREATE TABLE IF NOT EXISTS ${scopeHoldTableName(options)} (
505
+ bucket_id text PRIMARY KEY,
506
+ retry_at_ms bigint NOT NULL DEFAULT 0
507
+ )
508
+ `);
509
+ // Null refill marks an old hold, not an unusable schema. The first seed
510
+ // initializes its learner without trusting pre-scope per-rule estimates.
511
+ // No runtime wire version or migration-ledger admission check is involved.
512
+ await client.query(`
513
+ ALTER TABLE ${scopeHoldTableName(options)}
514
+ ADD COLUMN IF NOT EXISTS refill_per_sec float8,
515
+ ADD COLUMN IF NOT EXISTS capacity float8 NOT NULL DEFAULT 1,
516
+ ADD COLUMN IF NOT EXISTS max_rps float8 NOT NULL DEFAULT 100,
517
+ ADD COLUMN IF NOT EXISTS tokens float8 NOT NULL DEFAULT 0,
518
+ ADD COLUMN IF NOT EXISTS refilled_at_ms bigint NOT NULL DEFAULT 0,
519
+ ADD COLUMN IF NOT EXISTS next_dispatch_at_ms bigint NOT NULL DEFAULT 0,
520
+ ADD COLUMN IF NOT EXISTS seen_backpressure boolean NOT NULL DEFAULT false,
521
+ ADD COLUMN IF NOT EXISTS cool_until_ms bigint NOT NULL DEFAULT 0,
522
+ ADD COLUMN IF NOT EXISTS last_ramp_at_ms bigint NOT NULL DEFAULT 0,
523
+ ADD COLUMN IF NOT EXISTS rate_observation_started_at_ms bigint,
524
+ ADD COLUMN IF NOT EXISTS rate_successes bigint NOT NULL DEFAULT 0,
525
+ ADD COLUMN IF NOT EXISTS rate_recovery_interval_ms bigint NOT NULL DEFAULT 1000,
526
+ ADD COLUMN IF NOT EXISTS rate_throttle_ceiling_rps float8
527
+ `);
528
+ await client.query(`
529
+ ALTER TABLE ${bucketTableName(options)}
530
+ ADD COLUMN IF NOT EXISTS rate_observation_started_at_ms bigint,
531
+ ADD COLUMN IF NOT EXISTS rate_successes bigint NOT NULL DEFAULT 0,
532
+ ADD COLUMN IF NOT EXISTS rate_recovery_interval_ms bigint NOT NULL DEFAULT 1000
533
+ `);
404
534
  await client.query(`
405
535
  ALTER TABLE ${bucketTableName(options)}
406
536
  ADD COLUMN IF NOT EXISTS next_dispatch_at_ms bigint NOT NULL DEFAULT 0
@@ -523,11 +653,12 @@ export async function ensurePostgresRateStateSchema(
523
653
  CREATE INDEX IF NOT EXISTS runtime_scheduler_rate_bucket_bucket_idx
524
654
  ON ${bucketTableName(options)} (bucket_id)
525
655
  `);
526
- // v43: one row per (bucket, run) remembers how much of the current
527
- // fair-share window that run has drawn, so several runs contending for one
528
- // org's provider window each keep a max-min share instead of first-come
529
- // draining every window. Rows expire after a short idle; a lone requester
530
- // never has peers and is granted exactly as before.
656
+ // v43: one row per (bucket, requester) remembers how much of the current
657
+ // fair-share window it has drawn. New gateway callers identify the
658
+ // authenticated org, so multiple runs from one customer cannot multiply its
659
+ // share of a fleet-managed provider bucket. Older run-keyed rows expire after
660
+ // their normal idle horizon during rollout. A lone requester never has peers
661
+ // and is granted exactly as before.
531
662
  await client.query(`
532
663
  CREATE TABLE IF NOT EXISTS ${requesterTableName(options)} (
533
664
  bucket_id text NOT NULL,
@@ -545,7 +676,7 @@ export async function ensurePostgresRateStateSchema(
545
676
  ON ${requesterTableName(options)} (expires_at_ms)
546
677
  `);
547
678
  // The same share applies to in-flight concurrency slots, which needs each
548
- // live lease attributed to the run that holds it.
679
+ // live lease attributed to its requester.
549
680
  await client.query(`
550
681
  ALTER TABLE ${concurrencyLeaseTableName(options)}
551
682
  ADD COLUMN IF NOT EXISTS requester_id text
@@ -573,6 +704,90 @@ async function seedBucketRows(
573
704
  $3::bigint,
574
705
  FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
575
706
  ) AS now_ms
707
+ ), seed_config AS (
708
+ SELECT * FROM jsonb_to_recordset($2::jsonb) AS seed(
709
+ rule_id text, capacity float8, base_refill_per_sec float8,
710
+ min_rps float8, max_rps float8, adaptive_max_configured boolean
711
+ )
712
+ ), legacy AS (
713
+ SELECT COALESCE(MAX(claimed_retry_at_ms), 0) AS retry_at_ms,
714
+ COALESCE(bool_or(seen_backpressure), false) AS throttled,
715
+ COALESCE(
716
+ bool_and(
717
+ refill_per_sec = base_refill_per_sec AND NOT seen_backpressure
718
+ ) FILTER (WHERE rule_id LIKE 'default:%'),
719
+ false
720
+ ) AND NOT EXISTS (
721
+ SELECT 1
722
+ FROM ${bucketTableName(input)} existing
723
+ JOIN seed_config sc ON sc.rule_id = existing.rule_id
724
+ WHERE existing.bucket_id = $1
725
+ AND existing.base_refill_per_sec = sc.base_refill_per_sec
726
+ AND existing.capacity = sc.capacity
727
+ ) AS unlearned_unknown_provider
728
+ FROM ${bucketTableName(input)} WHERE bucket_id = $1
729
+ ), seed_scope_hold AS (
730
+ -- Preserve actual holds, but never import poisoned per-rule learning.
731
+ -- A legacy throttled scope restarts at at most one probe/second.
732
+ INSERT INTO ${scopeHoldTableName(input)} AS hold (
733
+ bucket_id, retry_at_ms, refill_per_sec, capacity, max_rps, tokens,
734
+ refilled_at_ms, next_dispatch_at_ms, seen_backpressure
735
+ )
736
+ SELECT $1, legacy.retry_at_ms,
737
+ LEAST(MIN(sc.base_refill_per_sec), CASE WHEN legacy.throttled THEN 1 ELSE 'Infinity'::float8 END),
738
+ MIN(sc.capacity), MAX(sc.max_rps),
739
+ CASE WHEN legacy.throttled THEN 0 ELSE MIN(sc.capacity) END,
740
+ sn.now_ms, GREATEST(sn.now_ms, legacy.retry_at_ms), legacy.throttled
741
+ FROM seed_config sc CROSS JOIN seed_now sn CROSS JOIN legacy
742
+ GROUP BY sn.now_ms, legacy.retry_at_ms, legacy.throttled
743
+ ON CONFLICT (bucket_id) DO UPDATE SET
744
+ retry_at_ms = GREATEST(hold.retry_at_ms, EXCLUDED.retry_at_ms),
745
+ refill_per_sec = CASE
746
+ -- Older runners seeded this shared credential row at the generic
747
+ -- default (10 RPS). If that old value never moved from its seed,
748
+ -- adopt the conservative shared-scope seed on the first acquire
749
+ -- after upgrade. Preserve any rate with actual ramp or throttle
750
+ -- evidence, and never erase a provider Retry-After hold.
751
+ WHEN EXISTS (
752
+ SELECT 1 FROM seed_config
753
+ WHERE rule_id = 'default:provider-scope'
754
+ )
755
+ AND (SELECT unlearned_unknown_provider FROM legacy)
756
+ AND NOT hold.seen_backpressure
757
+ AND hold.retry_at_ms <= EXCLUDED.refilled_at_ms
758
+ THEN EXCLUDED.refill_per_sec
759
+ ELSE COALESCE(hold.refill_per_sec,
760
+ LEAST(EXCLUDED.refill_per_sec, CASE WHEN hold.retry_at_ms > 0 THEN 1 ELSE 'Infinity'::float8 END))
761
+ END,
762
+ capacity = CASE
763
+ WHEN EXISTS (
764
+ SELECT 1 FROM seed_config
765
+ WHERE rule_id = 'default:provider-scope'
766
+ )
767
+ AND (SELECT unlearned_unknown_provider FROM legacy)
768
+ AND NOT hold.seen_backpressure
769
+ AND hold.retry_at_ms <= EXCLUDED.refilled_at_ms
770
+ THEN EXCLUDED.capacity
771
+ ELSE GREATEST(hold.capacity, EXCLUDED.capacity)
772
+ END,
773
+ max_rps = GREATEST(hold.max_rps, EXCLUDED.max_rps),
774
+ tokens = CASE
775
+ WHEN EXISTS (
776
+ SELECT 1 FROM seed_config
777
+ WHERE rule_id = 'default:provider-scope'
778
+ )
779
+ AND (SELECT unlearned_unknown_provider FROM legacy)
780
+ AND NOT hold.seen_backpressure
781
+ AND hold.retry_at_ms <= EXCLUDED.refilled_at_ms
782
+ THEN LEAST(hold.tokens, EXCLUDED.capacity)
783
+ WHEN hold.refill_per_sec IS NULL THEN
784
+ CASE WHEN hold.retry_at_ms > 0 THEN 0 ELSE EXCLUDED.tokens END
785
+ ELSE hold.tokens
786
+ END,
787
+ refilled_at_ms = CASE WHEN hold.refill_per_sec IS NULL THEN EXCLUDED.refilled_at_ms ELSE hold.refilled_at_ms END,
788
+ next_dispatch_at_ms = CASE WHEN hold.refill_per_sec IS NULL THEN GREATEST(hold.retry_at_ms, EXCLUDED.next_dispatch_at_ms) ELSE hold.next_dispatch_at_ms END,
789
+ seen_backpressure = hold.seen_backpressure OR EXCLUDED.seen_backpressure OR hold.retry_at_ms > 0
790
+ RETURNING *
576
791
  )
577
792
  INSERT INTO ${bucketTableName(input)} AS existing (
578
793
  bucket_id,
@@ -589,24 +804,26 @@ async function seedBucketRows(
589
804
  claimed_retry_at_ms,
590
805
  last_ramp_at_ms,
591
806
  refilled_at_ms,
592
- next_dispatch_at_ms
807
+ next_dispatch_at_ms,
808
+ rate_recovery_interval_ms
593
809
  )
594
810
  SELECT
595
811
  $1,
596
812
  seed.rule_id,
597
813
  seed.capacity,
598
814
  seed.capacity,
599
- seed.base_refill_per_sec,
815
+ LEAST(seed.base_refill_per_sec, COALESCE(learned.refill_per_sec, seed.base_refill_per_sec)),
600
816
  seed.base_refill_per_sec,
601
817
  seed.min_rps,
602
818
  seed.max_rps,
603
819
  seed.adaptive_max_configured,
604
- false,
820
+ learned.seen_backpressure,
605
821
  0,
606
822
  0,
607
823
  0,
608
824
  sn.now_ms,
609
- sn.now_ms
825
+ sn.now_ms,
826
+ learned.rate_recovery_interval_ms
610
827
  FROM jsonb_to_recordset($2::jsonb) AS seed(
611
828
  rule_id text,
612
829
  capacity float8,
@@ -616,6 +833,9 @@ async function seedBucketRows(
616
833
  adaptive_max_configured boolean
617
834
  )
618
835
  CROSS JOIN seed_now sn
836
+ CROSS JOIN seed_scope_hold learned
837
+ -- Force the same hold -> bucket lock order as acquire and penalty.
838
+ -- An unreferenced data-modifying CTE may run after the bucket write.
619
839
  ON CONFLICT (bucket_id, rule_id) DO UPDATE SET
620
840
  tokens = LEAST(existing.tokens, EXCLUDED.capacity),
621
841
  capacity = EXCLUDED.capacity,
@@ -679,14 +899,18 @@ type RateStateAcquireInput = PlayRunnerRateStateAcquireInput & {
679
899
  };
680
900
 
681
901
  /**
682
- * Fair-share identity for a rate-state acquire: the root (billing) run when the
683
- * executor token carries one, else the run itself, so a parent and the child
684
- * plays it launches draw from one slice instead of multiplying their count.
902
+ * Fair-share identity for a rate-state acquire. A provider bucket can be shared
903
+ * by many runs (and, for a fleet-managed credential, many organizations), so
904
+ * runs must not multiply one customer's share. Prefer the authenticated org;
905
+ * retain a run fallback for older internal callers without org attribution.
685
906
  */
686
907
  export function rateStateRequesterId(auth: {
908
+ orgId?: string | null;
687
909
  internalExecutorRunId?: string | null;
688
910
  internalExecutorBillingRunId?: string | null;
689
911
  }): string | null {
912
+ const orgId = auth.orgId?.trim();
913
+ if (orgId) return `org:${orgId}`;
690
914
  const requesterId =
691
915
  auth.internalExecutorBillingRunId?.trim() ||
692
916
  auth.internalExecutorRunId?.trim() ||
@@ -714,14 +938,15 @@ type FairShareSql = {
714
938
  };
715
939
 
716
940
  /**
717
- * Max-min fair share across the runs contending for one bucket, folded into
718
- * the single grant statement.
941
+ * Max-min fair share across requesters contending for one bucket, folded into
942
+ * the single grant statement. Authenticated gateway callers use organization
943
+ * identity, so independent runs by one organization consume one share.
719
944
  *
720
945
  * A fair-share window is the time the binding rule (slowest refill) takes to
721
946
  * refill its spendable burst: one second for an adaptive lane, the declared
722
947
  * window for a slow rule. Windows are aligned to the clock so every requester
723
948
  * agrees on the current one. Each requester's grant inside a window is capped
724
- * at `ceil(capacity / N)` (N = requesters seen within the last
949
+ * at `ceil(window permits / N)` (N = requesters seen within the last
725
950
  * {@link FAIR_SHARE_REQUESTER_IDLE_WINDOWS} windows) plus whatever the bucket
726
951
  * holds beyond the slices still reserved for peers with unmet demand. Peers
727
952
  * whose last ask was fully granted reserve nothing, and an idle peer drops
@@ -733,7 +958,7 @@ type FairShareSql = {
733
958
  * On the concurrency path the same share bounds in-flight leases per
734
959
  * requester, otherwise a run holding every slot re-takes each one it releases.
735
960
  *
736
- * A run alone on a bucket writes its ledger row only when its window rolls or
961
+ * A requester alone on a bucket writes its ledger row only when its window rolls or
737
962
  * its lease nears expiry, not on every acquire, so the solo path pays reads
738
963
  * and no extra writes. The cost of that is one window: a peer arriving
739
964
  * mid-window sees the incumbent's draw from when it last wrote, so the
@@ -752,8 +977,8 @@ function fairShareSql(
752
977
  const requester = `${options.requesterParam}::text`;
753
978
  const requested = `${options.requestedParam}::int`;
754
979
  const rawAvailable = options.concurrency
755
- ? `GREATEST(0, LEAST(${requested}, FLOOR(MIN(tokens_after_refill))::int, (SELECT free FROM free_slots)))`
756
- : `GREATEST(0, LEAST(${requested}, FLOOR(MIN(tokens_after_refill))::int))`;
980
+ ? `GREATEST(0, LEAST(${requested}, FLOOR(MIN(tokens_after_refill) + 1e-9)::int, (SELECT free FROM free_slots)))`
981
+ : `GREATEST(0, LEAST(${requested}, FLOOR(MIN(tokens_after_refill) + 1e-9)::int))`;
757
982
  const liveLeasesHeldBy = (holder: string): string =>
758
983
  options.concurrency
759
984
  ? `(
@@ -813,10 +1038,11 @@ function fairShareSql(
813
1038
  fair_window AS (
814
1039
  SELECT
815
1040
  r.now_ms,
816
- r.effective_capacity AS capacity,
1041
+ r.effective_capacity AS burst_capacity,
1042
+ r.refill_per_sec,
817
1043
  GREATEST(
818
1044
  ${ADAPTIVE_BURST_HORIZON_MS},
819
- CEIL(r.capacity / GREATEST(r.refill_per_sec, 0.0001) * 1000)
1045
+ CEIL(r.effective_capacity / GREATEST(r.refill_per_sec, ${MIN_ADAPTIVE_RPS_FLOOR}) * 1000)
820
1046
  )::bigint AS window_ms
821
1047
  FROM refreshed r
822
1048
  ORDER BY r.refill_per_sec ASC, r.effective_capacity ASC
@@ -825,12 +1051,19 @@ function fairShareSql(
825
1051
  fair_epoch AS (
826
1052
  SELECT
827
1053
  w.now_ms,
828
- w.capacity,
1054
+ -- Fairness allocates the permits replenished during this window,
1055
+ -- not just the instantaneous token-bucket burst. After a 429 the
1056
+ -- burst intentionally shrinks to one probe, while a learned 5 RPS
1057
+ -- pace still has five permits to share over a one-second window.
1058
+ GREATEST(
1059
+ w.burst_capacity,
1060
+ CEIL(w.refill_per_sec * w.window_ms / 1000.0)
1061
+ ) AS capacity,
829
1062
  w.window_ms,
830
1063
  w.now_ms - (w.now_ms % w.window_ms) AS started_at_ms
831
1064
  FROM fair_window w
832
1065
  ),
833
- -- What this acquire has to write, if anything. A run asking many times
1066
+ -- What this acquire has to write, if anything. A requester asking many times
834
1067
  -- a second must not pay a sweep and an upsert on every one of them: a
835
1068
  -- lone run's ledger row only has to exist and stay live, so it is
836
1069
  -- rewritten when its window rolls or its lease nears expiry, and on
@@ -866,26 +1099,6 @@ function fairShareSql(
866
1099
  EXISTS (SELECT 1 FROM peers) AS contended
867
1100
  FROM fair_epoch e
868
1101
  ),
869
- -- Reclaims the oldest expired rows across every bucket, not just this
870
- -- one: a bucket whose last run ended is never acquired again, so a
871
- -- bucket-scoped sweep would leave its rows behind forever. Bounded per
872
- -- acquire and driven by the expiry index, as the concurrency-observer
873
- -- reclaim is. The row for this acquire's own (bucket, requester) is
874
- -- never swept: it is about to be rewritten, and the peer snapshot below
875
- -- reads this same statement snapshot.
876
- expire_requesters AS (
877
- DELETE FROM ${table} AS stale
878
- WHERE (SELECT refresh OR contended FROM fair_write)
879
- AND (stale.bucket_id, stale.requester_id) IN (
880
- SELECT q.bucket_id, q.requester_id
881
- FROM ${table} q
882
- WHERE q.expires_at_ms <= (SELECT now_ms FROM fair_epoch)
883
- AND NOT (q.bucket_id = $1 AND q.requester_id = ${requester})
884
- ORDER BY q.expires_at_ms
885
- LIMIT ${FAIR_SHARE_REQUESTER_CLEANUP_BATCH_SIZE}
886
- )
887
- RETURNING 1
888
- ),
889
1102
  fair_self AS (
890
1103
  SELECT COALESCE((
891
1104
  SELECT CASE
@@ -994,6 +1207,28 @@ function fairShareSql(
994
1207
  last_seen_ms = EXCLUDED.last_seen_ms,
995
1208
  expires_at_ms = EXCLUDED.expires_at_ms
996
1209
  RETURNING 1
1210
+ ),
1211
+ -- Reclaims the oldest expired rows across every bucket, not just this
1212
+ -- one: a bucket whose last run ended is never acquired again, so a
1213
+ -- bucket-scoped sweep would leave its rows behind forever. Bounded per
1214
+ -- acquire and driven by the expiry index, as the concurrency-observer
1215
+ -- reclaim is. The row for this acquire's own (bucket, requester) is
1216
+ -- never swept: it is about to be rewritten, and the peer snapshot below
1217
+ -- reads this same statement snapshot.
1218
+ expire_requesters AS (
1219
+ DELETE FROM ${table} AS stale
1220
+ WHERE (SELECT refresh OR contended FROM fair_write)
1221
+ AND (SELECT COUNT(*) FROM remember_requester) >= 0
1222
+ AND (stale.bucket_id, stale.requester_id) IN (
1223
+ SELECT q.bucket_id, q.requester_id
1224
+ FROM ${table} q
1225
+ WHERE q.expires_at_ms <= (SELECT now_ms FROM fair_epoch)
1226
+ AND NOT (q.bucket_id = $1 AND q.requester_id = ${requester})
1227
+ ORDER BY q.expires_at_ms
1228
+ LIMIT ${FAIR_SHARE_REQUESTER_CLEANUP_BATCH_SIZE}
1229
+ FOR UPDATE OF q SKIP LOCKED
1230
+ )
1231
+ RETURNING 1
997
1232
  )`,
998
1233
  };
999
1234
  }
@@ -1060,9 +1295,11 @@ async function acquirePureRate(
1060
1295
  adaptive_max_configured boolean
1061
1296
  )
1062
1297
  ),
1298
+ ${acquireRateScopeSql(scopeHoldTableName(input), '$5')},
1063
1299
  reclaim_reservations AS (
1064
1300
  DELETE FROM ${reservationTableName(input)}
1065
1301
  WHERE bucket_id = $1
1302
+ AND EXISTS (SELECT 1 FROM provider_hold_rows)
1066
1303
  AND expires_at_ms <= (SELECT now_ms FROM now_value)
1067
1304
  RETURNING 1
1068
1305
  ),
@@ -1076,18 +1313,21 @@ async function acquirePureRate(
1076
1313
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1077
1314
  ELSE b.max_rps
1078
1315
  END AS max_rps,
1079
- b.seen_backpressure,
1316
+ ph.seen_backpressure,
1080
1317
  sc.base_refill_per_sec,
1081
1318
  LEAST(
1082
1319
  CASE
1083
1320
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1084
1321
  ELSE b.max_rps
1085
1322
  END,
1086
- GREATEST(sc.min_rps, b.refill_per_sec)
1323
+ GREATEST(sc.min_rps, ph.refill_per_sec)
1087
1324
  ) AS refill_per_sec,
1088
- b.cool_until_ms,
1089
- b.last_ramp_at_ms,
1090
- b.next_dispatch_at_ms,
1325
+ normalized.next_refill_per_sec,
1326
+ ph.cool_until_ms,
1327
+ ph.last_ramp_at_ms,
1328
+ ph.rate_recovery_interval_ms,
1329
+ ph.observation_was_idle AS observation_was_idle,
1330
+ GREATEST(b.next_dispatch_at_ms, ph.next_dispatch_at_ms) AS next_dispatch_at_ms,
1091
1331
  nv.now_ms AS now_ms,
1092
1332
  -- A warm row can have been widened by an older adaptive ceiling or
1093
1333
  -- edited out of band. Normalize it to the current seed before this
@@ -1100,12 +1340,15 @@ async function acquirePureRate(
1100
1340
  b.max_rps AS stored_max_rps,
1101
1341
  b.adaptive_max_configured OR sc.adaptive_max_configured
1102
1342
  AS adaptive_max_configured,
1343
+ ph.retry_at_ms AS scope_retry_at_ms,
1103
1344
  -- Spendable burst: one second of the rate in force, floored at the
1104
1345
  -- declared window burst. The normalized row is what this acquire
1105
1346
  -- spends; the stored row remains available for drift reporting.
1106
1347
  ${effectiveCapacitySql('normalized')} AS effective_capacity,
1348
+ ${effectiveCapacitySql('normalized', 'next_refill_per_sec')} AS scheduled_capacity,
1107
1349
  LEAST(
1108
1350
  ${effectiveCapacitySql('normalized')},
1351
+ ph.tokens_after_refill,
1109
1352
  LEAST(b.tokens, sc.capacity)
1110
1353
  + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0
1111
1354
  * LEAST(
@@ -1113,22 +1356,33 @@ async function acquirePureRate(
1113
1356
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1114
1357
  ELSE b.max_rps
1115
1358
  END,
1116
- GREATEST(sc.min_rps, b.refill_per_sec)
1359
+ GREATEST(sc.min_rps, ph.refill_per_sec)
1117
1360
  )
1118
1361
  ) AS tokens_after_refill
1119
1362
  FROM ${bucketTableName(input)} b
1120
1363
  JOIN seed_config sc ON sc.rule_id = b.rule_id
1121
1364
  CROSS JOIN now_value nv
1365
+ CROSS JOIN provider_hold ph
1122
1366
  CROSS JOIN LATERAL (
1123
1367
  SELECT
1124
1368
  sc.capacity AS capacity,
1369
+ sc.base_refill_per_sec AS base_refill_per_sec,
1370
+ ph.seen_backpressure AS seen_backpressure,
1371
+ ph.rate_observation_started_at_ms AS rate_observation_started_at_ms,
1125
1372
  LEAST(
1126
1373
  CASE
1127
1374
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1128
1375
  ELSE b.max_rps
1129
1376
  END,
1130
- GREATEST(sc.min_rps, b.refill_per_sec)
1131
- ) AS refill_per_sec
1377
+ GREATEST(sc.min_rps, ph.refill_per_sec)
1378
+ ) AS refill_per_sec,
1379
+ LEAST(
1380
+ CASE
1381
+ WHEN sc.adaptive_max_configured THEN sc.max_rps
1382
+ ELSE b.max_rps
1383
+ END,
1384
+ GREATEST(sc.min_rps, ph.next_refill_per_sec)
1385
+ ) AS next_refill_per_sec
1132
1386
  ) normalized
1133
1387
  WHERE b.bucket_id = $1
1134
1388
  AND b.rule_id = ANY($2::text[])
@@ -1136,7 +1390,9 @@ async function acquirePureRate(
1136
1390
  FOR UPDATE OF b
1137
1391
  ),
1138
1392
  cooling AS (
1139
- SELECT COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms
1393
+ SELECT
1394
+ COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms,
1395
+ COALESCE(MAX(scope_retry_at_ms), 0) AS retry_at_ms
1140
1396
  FROM refreshed
1141
1397
  ),${fair?.ctes ?? ''}
1142
1398
  grant_calc AS (
@@ -1147,10 +1403,14 @@ async function acquirePureRate(
1147
1403
  -- No rule rows means the bucket was never seeded (or was deleted).
1148
1404
  -- LEAST() ignores NULLs, so without this guard an unseeded bucket
1149
1405
  -- would "grant" the full request against a NULL schedule.
1150
- WHEN (SELECT COUNT(*) FROM refreshed) = 0 THEN 0
1151
- WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
1406
+ WHEN (SELECT COUNT(*) FROM refreshed) = 0
1407
+ OR (SELECT refill_per_sec FROM provider_hold) IS NULL THEN 0
1408
+ WHEN GREATEST(
1409
+ (SELECT cool_until_ms FROM cooling),
1410
+ (SELECT retry_at_ms FROM cooling)
1411
+ ) > (SELECT now_ms FROM refreshed LIMIT 1)
1152
1412
  THEN 0
1153
- ELSE GREATEST(0, LEAST($4::int, FLOOR(MIN(tokens_after_refill))::int${fair?.capTerm ?? ''}))
1413
+ ELSE GREATEST(0, LEAST($4::int, FLOOR(MIN(tokens_after_refill) + 1e-9)::int${fair?.capTerm ?? ''}))
1154
1414
  END AS granted
1155
1415
  FROM refreshed
1156
1416
  ),
@@ -1160,25 +1420,36 @@ async function acquirePureRate(
1160
1420
  -- at the spacing in force when its permits were granted, so a lane
1161
1421
  -- that started at 0.25 rps carries a minute-long tail that a later
1162
1422
  -- ramp to 100 rps could never work off (the column is monotone).
1163
- -- Clamp the carried debt to one full bucket at the CURRENT refill.
1423
+ -- Clamp the carried debt to one full bucket at the refill this
1424
+ -- transaction is about to persist and report. Otherwise the grant
1425
+ -- is rate-limited by the previous AIMD step for one extra batch.
1164
1426
  -- cool_until_ms stays inside the GREATEST, and penalize pushes
1165
1427
  -- next_dispatch past the cooldown, so a real 429 hold is never
1166
1428
  -- shortened by this clamp.
1167
1429
  GREATEST(
1168
1430
  MAX(now_ms),
1169
1431
  MAX(cool_until_ms),
1432
+ MAX(scope_retry_at_ms),
1170
1433
  LEAST(
1171
1434
  MAX(next_dispatch_at_ms),
1172
1435
  MAX(now_ms)
1173
1436
  + CEIL(
1174
- MAX(effective_capacity / GREATEST(refill_per_sec, 0.0001) * 1000)
1437
+ MAX(
1438
+ scheduled_capacity
1439
+ / GREATEST(
1440
+ next_refill_per_sec,
1441
+ ${MIN_ADAPTIVE_RPS_FLOOR}
1442
+ )
1443
+ * 1000
1444
+ )
1175
1445
  )::bigint
1176
1446
  )
1177
1447
  )::bigint AS first_at_ms,
1178
- CEIL(MAX(1000.0 / GREATEST(refill_per_sec, 0.0001)))::bigint AS spacing_ms,
1179
- GREATEST(1, FLOOR(MIN(refill_per_sec) / 100.0))::bigint AS burst_size
1448
+ CEIL(MAX(1000.0 / GREATEST(next_refill_per_sec, ${MIN_ADAPTIVE_RPS_FLOOR})))::bigint AS spacing_ms,
1449
+ GREATEST(1, FLOOR(MIN(next_refill_per_sec) / 100.0))::bigint AS burst_size
1180
1450
  FROM refreshed
1181
1451
  ),
1452
+ ${updateRateScopeSql(scopeHoldTableName(input))},
1182
1453
  updated AS (
1183
1454
  UPDATE ${bucketTableName(input)} b
1184
1455
  SET
@@ -1189,6 +1460,10 @@ async function acquirePureRate(
1189
1460
  adaptive_max_configured = r.adaptive_max_configured,
1190
1461
  tokens = r.tokens_after_refill - (SELECT granted FROM grant_calc),
1191
1462
  refilled_at_ms = r.now_ms,
1463
+ rate_observation_started_at_ms = (SELECT rate_observation_started_at_ms FROM updated_scope),
1464
+ rate_successes = (SELECT rate_successes FROM updated_scope),
1465
+ rate_recovery_interval_ms = (SELECT rate_recovery_interval_ms FROM updated_scope),
1466
+ seen_backpressure = (SELECT seen_backpressure FROM updated_scope),
1192
1467
  next_dispatch_at_ms = CASE
1193
1468
  WHEN (SELECT granted FROM grant_calc) > 0
1194
1469
  THEN (SELECT first_at_ms FROM schedule_calc)
@@ -1203,29 +1478,10 @@ async function acquirePureRate(
1203
1478
  -- AIMD ramp, folded in. Reaching this refill proves a local lease
1204
1479
  -- block was exhausted; only a confirmed provider success can turn
1205
1480
  -- that demand into a rate increase. Slow-start doubles until the
1206
- -- first 429, then adds
1207
- -- max(1, base/10) per step. Never exceeds max_rps.
1208
- refill_per_sec = CASE
1209
- WHEN $5::int > 0
1210
- AND r.now_ms - r.last_ramp_at_ms >= 1000
1211
- AND r.cool_until_ms <= r.now_ms
1212
- THEN LEAST(
1213
- r.max_rps,
1214
- CASE
1215
- WHEN r.seen_backpressure
1216
- THEN r.refill_per_sec + GREATEST(1, r.base_refill_per_sec / 10)
1217
- ELSE r.refill_per_sec * 2
1218
- END
1219
- )
1220
- ELSE r.refill_per_sec
1221
- END,
1222
- last_ramp_at_ms = CASE
1223
- WHEN $5::int > 0
1224
- AND r.now_ms - r.last_ramp_at_ms >= 1000
1225
- AND r.cool_until_ms <= r.now_ms
1226
- THEN r.now_ms
1227
- ELSE r.last_ramp_at_ms
1228
- END
1481
+ -- first 429, then increases by at most 25% per successful recovery
1482
+ -- step. Never exceeds max_rps.
1483
+ refill_per_sec = LEAST(r.max_rps, (SELECT next_refill_per_sec FROM provider_hold)),
1484
+ last_ramp_at_ms = (SELECT next_ramp_at_ms FROM provider_hold)
1229
1485
  FROM refreshed r
1230
1486
  WHERE b.bucket_id = r.bucket_id
1231
1487
  AND b.rule_id = r.rule_id
@@ -1264,7 +1520,8 @@ async function acquirePureRate(
1264
1520
  )${fair?.rememberCte ?? ''}
1265
1521
  SELECT
1266
1522
  (SELECT granted FROM grant_calc) AS granted,
1267
- (SELECT COUNT(*)::int FROM refreshed) AS observed_rule_rows,
1523
+ CASE WHEN (SELECT refill_per_sec FROM provider_hold) IS NULL THEN 0
1524
+ ELSE (SELECT COUNT(*)::int FROM refreshed) END AS observed_rule_rows,
1268
1525
  (
1269
1526
  SELECT COALESCE(
1270
1527
  bool_or(
@@ -1283,8 +1540,11 @@ async function acquirePureRate(
1283
1540
  ) AS config_drift,
1284
1541
  (SELECT now_ms FROM refreshed LIMIT 1) AS server_now_ms,
1285
1542
  (SELECT cool_until_ms FROM cooling) AS cool_until_ms,
1286
- (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
1287
- WHERE bucket_id = $1 AND rule_id = ANY($2::text[])) AS claimed_retry_at_ms,
1543
+ GREATEST(
1544
+ (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
1545
+ WHERE bucket_id = $1 AND rule_id = ANY($2::text[])),
1546
+ (SELECT retry_at_ms FROM provider_hold)
1547
+ ) AS claimed_retry_at_ms,
1288
1548
  (SELECT COALESCE(MIN(new_refill_per_sec), 0) FROM updated) AS effective_rps,
1289
1549
  CASE
1290
1550
  WHEN (SELECT granted FROM grant_calc) > 0 THEN ARRAY(
@@ -1319,8 +1579,14 @@ async function acquirePureRate(
1319
1579
  -- round trip per permit. The cooldown branch above still wins outright.
1320
1580
  CASE
1321
1581
  WHEN (SELECT granted FROM grant_calc) > 0 THEN 0
1322
- WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
1323
- THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)${fair?.waitBranch ?? ''}
1582
+ WHEN GREATEST(
1583
+ (SELECT cool_until_ms FROM cooling),
1584
+ (SELECT retry_at_ms FROM cooling)
1585
+ ) > (SELECT now_ms FROM refreshed LIMIT 1)
1586
+ THEN GREATEST(
1587
+ (SELECT cool_until_ms FROM cooling),
1588
+ (SELECT retry_at_ms FROM cooling)
1589
+ ) - (SELECT now_ms FROM refreshed LIMIT 1)${fair?.waitBranch ?? ''}
1324
1590
  ELSE (
1325
1591
  SELECT CEIL(
1326
1592
  MAX(
@@ -1333,7 +1599,7 @@ async function acquirePureRate(
1333
1599
  FLOOR(new_refill_per_sec * ${BLOCK_WAIT_HORIZON_MS} / 1000.0)
1334
1600
  )
1335
1601
  ) - tokens_after_refill
1336
- ) / GREATEST(new_refill_per_sec, 0.0001) * 1000
1602
+ ) / GREATEST(new_refill_per_sec, ${MIN_ADAPTIVE_RPS_FLOOR}) * 1000
1337
1603
  )
1338
1604
  )::bigint
1339
1605
  FROM updated
@@ -1469,15 +1735,18 @@ async function acquireWithConcurrency(
1469
1735
  FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
1470
1736
  ) AS now_ms
1471
1737
  ),
1738
+ ${acquireRateScopeSql(scopeHoldTableName(input), '$10')},
1472
1739
  reclaim_reservations AS (
1473
1740
  DELETE FROM ${reservationTableName(input)}
1474
1741
  WHERE bucket_id = $1
1742
+ AND EXISTS (SELECT 1 FROM provider_hold_rows)
1475
1743
  AND expires_at_ms <= (SELECT now_ms FROM now_value)
1476
1744
  RETURNING 1
1477
1745
  ),
1478
1746
  reclaim AS (
1479
1747
  DELETE FROM ${concurrencyLeaseTableName(input)}
1480
1748
  WHERE bucket_id = $1
1749
+ AND EXISTS (SELECT 1 FROM provider_hold_rows)
1481
1750
  AND rule_id = ANY($5::text[])
1482
1751
  AND expires_at_ms <= (SELECT now_ms FROM now_value)
1483
1752
  RETURNING 1
@@ -1506,20 +1775,23 @@ async function acquireWithConcurrency(
1506
1775
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1507
1776
  ELSE b.max_rps
1508
1777
  END AS max_rps,
1509
- b.seen_backpressure,
1778
+ ph.seen_backpressure,
1510
1779
  sc.base_refill_per_sec,
1511
1780
  LEAST(
1512
1781
  CASE
1513
1782
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1514
1783
  ELSE b.max_rps
1515
1784
  END,
1516
- GREATEST(sc.min_rps, b.refill_per_sec)
1785
+ GREATEST(sc.min_rps, ph.refill_per_sec)
1517
1786
  ) AS refill_per_sec,
1787
+ normalized.next_refill_per_sec,
1518
1788
  b.learned_concurrency_limit,
1519
1789
  b.concurrency_minimum_rtt_ms,
1520
- b.cool_until_ms,
1521
- b.last_ramp_at_ms,
1522
- b.next_dispatch_at_ms,
1790
+ ph.cool_until_ms,
1791
+ ph.last_ramp_at_ms,
1792
+ GREATEST(b.next_dispatch_at_ms, ph.next_dispatch_at_ms) AS next_dispatch_at_ms,
1793
+ ph.rate_recovery_interval_ms,
1794
+ ph.observation_was_idle AS observation_was_idle,
1523
1795
  nv.now_ms AS now_ms,
1524
1796
  -- Normalize a warm row before deriving its grant. The stored values
1525
1797
  -- are retained for the config_drift diagnostic below.
@@ -1529,11 +1801,14 @@ async function acquireWithConcurrency(
1529
1801
  b.max_rps AS stored_max_rps,
1530
1802
  b.adaptive_max_configured OR sc.adaptive_max_configured
1531
1803
  AS adaptive_max_configured,
1804
+ ph.retry_at_ms AS scope_retry_at_ms,
1532
1805
  -- Same derived burst as the pure-rate path. The declared
1533
1806
  -- maxConcurrency is untouched: free_slots still bounds the grant.
1534
1807
  ${effectiveCapacitySql('normalized')} AS effective_capacity,
1808
+ ${effectiveCapacitySql('normalized', 'next_refill_per_sec')} AS scheduled_capacity,
1535
1809
  LEAST(
1536
1810
  ${effectiveCapacitySql('normalized')},
1811
+ ph.tokens_after_refill,
1537
1812
  LEAST(b.tokens, sc.capacity)
1538
1813
  + GREATEST(0, nv.now_ms - b.refilled_at_ms) / 1000.0
1539
1814
  * LEAST(
@@ -1541,22 +1816,33 @@ async function acquireWithConcurrency(
1541
1816
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1542
1817
  ELSE b.max_rps
1543
1818
  END,
1544
- GREATEST(sc.min_rps, b.refill_per_sec)
1819
+ GREATEST(sc.min_rps, ph.refill_per_sec)
1545
1820
  )
1546
1821
  ) AS tokens_after_refill
1547
1822
  FROM ${bucketTableName(input)} b
1548
1823
  JOIN seed_config sc ON sc.rule_id = b.rule_id
1549
1824
  CROSS JOIN now_value nv
1825
+ CROSS JOIN provider_hold ph
1550
1826
  CROSS JOIN LATERAL (
1551
1827
  SELECT
1552
1828
  sc.capacity AS capacity,
1829
+ sc.base_refill_per_sec AS base_refill_per_sec,
1830
+ ph.seen_backpressure AS seen_backpressure,
1831
+ ph.rate_observation_started_at_ms AS rate_observation_started_at_ms,
1553
1832
  LEAST(
1554
1833
  CASE
1555
1834
  WHEN sc.adaptive_max_configured THEN sc.max_rps
1556
1835
  ELSE b.max_rps
1557
1836
  END,
1558
- GREATEST(sc.min_rps, b.refill_per_sec)
1559
- ) AS refill_per_sec
1837
+ GREATEST(sc.min_rps, ph.refill_per_sec)
1838
+ ) AS refill_per_sec,
1839
+ LEAST(
1840
+ CASE
1841
+ WHEN sc.adaptive_max_configured THEN sc.max_rps
1842
+ ELSE b.max_rps
1843
+ END,
1844
+ GREATEST(sc.min_rps, ph.next_refill_per_sec)
1845
+ ) AS next_refill_per_sec
1560
1846
  ) normalized
1561
1847
  WHERE b.bucket_id = $1
1562
1848
  AND b.rule_id = ANY($2::text[])
@@ -1615,7 +1901,10 @@ async function acquireWithConcurrency(
1615
1901
  JOIN refreshed USING (rule_id)
1616
1902
  ),
1617
1903
  cooling AS (
1618
- SELECT COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms FROM refreshed
1904
+ SELECT
1905
+ COALESCE(MAX(cool_until_ms), 0) AS cool_until_ms,
1906
+ COALESCE(MAX(scope_retry_at_ms), 0) AS retry_at_ms
1907
+ FROM refreshed
1619
1908
  ),${fair?.ctes ?? ''}
1620
1909
  grant_calc AS (
1621
1910
  SELECT
@@ -1623,14 +1912,18 @@ async function acquireWithConcurrency(
1623
1912
  -- No rule rows means the bucket was never seeded (or was deleted).
1624
1913
  -- LEAST() ignores NULLs, so without this guard an unseeded bucket
1625
1914
  -- would "grant" the full request against a NULL schedule.
1626
- WHEN (SELECT COUNT(*) FROM refreshed) = 0 THEN 0
1627
- WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
1915
+ WHEN (SELECT COUNT(*) FROM refreshed) = 0
1916
+ OR (SELECT refill_per_sec FROM provider_hold) IS NULL THEN 0
1917
+ WHEN GREATEST(
1918
+ (SELECT cool_until_ms FROM cooling),
1919
+ (SELECT retry_at_ms FROM cooling)
1920
+ ) > (SELECT now_ms FROM refreshed LIMIT 1)
1628
1921
  THEN 0
1629
1922
  ELSE GREATEST(
1630
1923
  0,
1631
1924
  LEAST(
1632
1925
  $4::int,
1633
- FLOOR(MIN(tokens_after_refill))::int,
1926
+ FLOOR(MIN(tokens_after_refill) + 1e-9)::int,
1634
1927
  (SELECT free FROM free_slots)${fair?.capTerm ?? ''}
1635
1928
  )
1636
1929
  )
@@ -1643,25 +1936,36 @@ async function acquireWithConcurrency(
1643
1936
  -- at the spacing in force when its permits were granted, so a lane
1644
1937
  -- that started at 0.25 rps carries a minute-long tail that a later
1645
1938
  -- ramp to 100 rps could never work off (the column is monotone).
1646
- -- Clamp the carried debt to one full bucket at the CURRENT refill.
1939
+ -- Clamp the carried debt to one full bucket at the refill this
1940
+ -- transaction is about to persist and report. Otherwise the grant
1941
+ -- is rate-limited by the previous AIMD step for one extra batch.
1647
1942
  -- cool_until_ms stays inside the GREATEST, and penalize pushes
1648
1943
  -- next_dispatch past the cooldown, so a real 429 hold is never
1649
1944
  -- shortened by this clamp.
1650
1945
  GREATEST(
1651
1946
  MAX(now_ms),
1652
1947
  MAX(cool_until_ms),
1948
+ MAX(scope_retry_at_ms),
1653
1949
  LEAST(
1654
1950
  MAX(next_dispatch_at_ms),
1655
1951
  MAX(now_ms)
1656
1952
  + CEIL(
1657
- MAX(effective_capacity / GREATEST(refill_per_sec, 0.0001) * 1000)
1953
+ MAX(
1954
+ scheduled_capacity
1955
+ / GREATEST(
1956
+ next_refill_per_sec,
1957
+ ${MIN_ADAPTIVE_RPS_FLOOR}
1958
+ )
1959
+ * 1000
1960
+ )
1658
1961
  )::bigint
1659
1962
  )
1660
1963
  )::bigint AS first_at_ms,
1661
- CEIL(MAX(1000.0 / GREATEST(refill_per_sec, 0.0001)))::bigint AS spacing_ms,
1662
- GREATEST(1, FLOOR(MIN(refill_per_sec) / 100.0))::bigint AS burst_size
1964
+ CEIL(MAX(1000.0 / GREATEST(next_refill_per_sec, ${MIN_ADAPTIVE_RPS_FLOOR})))::bigint AS spacing_ms,
1965
+ GREATEST(1, FLOOR(MIN(next_refill_per_sec) / 100.0))::bigint AS burst_size
1663
1966
  FROM refreshed
1664
1967
  ),
1968
+ ${updateRateScopeSql(scopeHoldTableName(input))},
1665
1969
  updated AS (
1666
1970
  UPDATE ${bucketTableName(input)} b
1667
1971
  SET
@@ -1683,30 +1987,15 @@ async function acquireWithConcurrency(
1683
1987
  * (SELECT burst_size FROM schedule_calc)
1684
1988
  ELSE b.next_dispatch_at_ms
1685
1989
  END,
1990
+ rate_observation_started_at_ms = (SELECT rate_observation_started_at_ms FROM updated_scope),
1991
+ rate_successes = (SELECT rate_successes FROM updated_scope),
1992
+ rate_recovery_interval_ms = (SELECT rate_recovery_interval_ms FROM updated_scope),
1993
+ seen_backpressure = (SELECT seen_backpressure FROM updated_scope),
1686
1994
  -- Match the pure-rate path: a depleted local lease block plus a
1687
1995
  -- confirmed success is sufficient demand; concurrency leases change
1688
1996
  -- admission accounting, not the AIMD learning contract.
1689
- refill_per_sec = CASE
1690
- WHEN $10::int > 0
1691
- AND r.now_ms - r.last_ramp_at_ms >= 1000
1692
- AND r.cool_until_ms <= r.now_ms
1693
- THEN LEAST(
1694
- r.max_rps,
1695
- CASE
1696
- WHEN r.seen_backpressure
1697
- THEN r.refill_per_sec + GREATEST(1, r.base_refill_per_sec / 10)
1698
- ELSE r.refill_per_sec * 2
1699
- END
1700
- )
1701
- ELSE r.refill_per_sec
1702
- END,
1703
- last_ramp_at_ms = CASE
1704
- WHEN $10::int > 0
1705
- AND r.now_ms - r.last_ramp_at_ms >= 1000
1706
- AND r.cool_until_ms <= r.now_ms
1707
- THEN r.now_ms
1708
- ELSE r.last_ramp_at_ms
1709
- END
1997
+ refill_per_sec = LEAST(r.max_rps, (SELECT next_refill_per_sec FROM provider_hold)),
1998
+ last_ramp_at_ms = (SELECT next_ramp_at_ms FROM provider_hold)
1710
1999
  FROM refreshed r
1711
2000
  WHERE b.bucket_id = $1
1712
2001
  AND b.rule_id = r.rule_id
@@ -1773,7 +2062,8 @@ async function acquireWithConcurrency(
1773
2062
  )${fair?.rememberCte ?? ''}
1774
2063
  SELECT
1775
2064
  (SELECT granted FROM grant_calc) AS granted,
1776
- (SELECT COUNT(*)::int FROM refreshed) AS observed_rule_rows,
2065
+ CASE WHEN (SELECT refill_per_sec FROM provider_hold) IS NULL THEN 0
2066
+ ELSE (SELECT COUNT(*)::int FROM refreshed) END AS observed_rule_rows,
1777
2067
  (
1778
2068
  SELECT COALESCE(
1779
2069
  bool_or(
@@ -1792,8 +2082,11 @@ async function acquireWithConcurrency(
1792
2082
  ) AS config_drift,
1793
2083
  (SELECT now_ms FROM refreshed LIMIT 1) AS server_now_ms,
1794
2084
  (SELECT cool_until_ms FROM cooling) AS cool_until_ms,
1795
- (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
1796
- WHERE bucket_id = $1 AND rule_id = ANY($2::text[])) AS claimed_retry_at_ms,
2085
+ GREATEST(
2086
+ (SELECT COALESCE(MAX(claimed_retry_at_ms), 0) FROM ${bucketTableName(input)}
2087
+ WHERE bucket_id = $1 AND rule_id = ANY($2::text[])),
2088
+ (SELECT retry_at_ms FROM provider_hold)
2089
+ ) AS claimed_retry_at_ms,
1797
2090
  (SELECT COALESCE(MIN(new_refill_per_sec), 0) FROM updated) AS effective_rps,
1798
2091
  CASE
1799
2092
  WHEN (SELECT granted FROM grant_calc) > 0 THEN ARRAY(
@@ -1839,12 +2132,18 @@ async function acquireWithConcurrency(
1839
2132
  END AS concurrency_grants,
1840
2133
  CASE
1841
2134
  WHEN (SELECT granted FROM grant_calc) > 0 THEN 0
1842
- WHEN (SELECT cool_until_ms FROM cooling) > (SELECT now_ms FROM refreshed LIMIT 1)
1843
- THEN (SELECT cool_until_ms FROM cooling) - (SELECT now_ms FROM refreshed LIMIT 1)${fair?.waitBranch ?? ''}
2135
+ WHEN GREATEST(
2136
+ (SELECT cool_until_ms FROM cooling),
2137
+ (SELECT retry_at_ms FROM cooling)
2138
+ ) > (SELECT now_ms FROM refreshed LIMIT 1)
2139
+ THEN GREATEST(
2140
+ (SELECT cool_until_ms FROM cooling),
2141
+ (SELECT retry_at_ms FROM cooling)
2142
+ ) - (SELECT now_ms FROM refreshed LIMIT 1)${fair?.waitBranch ?? ''}
1844
2143
  WHEN (SELECT free FROM free_slots) <= 0 THEN $7::bigint
1845
2144
  ELSE (
1846
2145
  SELECT CEIL(
1847
- MAX((1.0 - tokens_after_refill) / GREATEST(new_refill_per_sec, 0.0001) * 1000)
2146
+ MAX((1.0 - tokens_after_refill) / GREATEST(new_refill_per_sec, ${MIN_ADAPTIVE_RPS_FLOOR}) * 1000)
1848
2147
  )::bigint
1849
2148
  FROM updated
1850
2149
  )
@@ -1933,6 +2232,60 @@ export async function acquirePostgresRateState(
1933
2232
  );
1934
2233
  }
1935
2234
 
2235
+ /**
2236
+ * Validate a cached permit at the physical dispatch boundary. The shared hold
2237
+ * row lock linearizes this check with a concurrent Retry-After write; callers
2238
+ * that lose the race return their local slots before reacquiring.
2239
+ */
2240
+ export async function validatePostgresRateStatePermit(
2241
+ client: PostgresSchedulerQueryClient,
2242
+ input: PlayRunnerRateStateValidateInput & {
2243
+ nowMs?: number;
2244
+ },
2245
+ ): Promise<PlayRunnerRateStateValidateResult> {
2246
+ return await withRateStateAvailabilityGuard(input, async () => {
2247
+ const result = await client.query<{
2248
+ server_now_ms: string | number;
2249
+ retry_at_ms: string | number;
2250
+ }>(
2251
+ `
2252
+ WITH now_value AS (
2253
+ SELECT COALESCE(
2254
+ $2::bigint,
2255
+ FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
2256
+ ) AS now_ms
2257
+ ),
2258
+ hold_rows AS (
2259
+ SELECT retry_at_ms
2260
+ FROM ${scopeHoldTableName(input)}
2261
+ WHERE bucket_id = $1
2262
+ FOR SHARE
2263
+ )
2264
+ SELECT
2265
+ now_value.now_ms AS server_now_ms,
2266
+ COALESCE(MAX(hold_rows.retry_at_ms), 0) AS retry_at_ms
2267
+ FROM now_value
2268
+ LEFT JOIN hold_rows ON true
2269
+ GROUP BY now_value.now_ms
2270
+ `,
2271
+ [input.bucketId, input.nowMs ?? null],
2272
+ );
2273
+ const row = result.rows[0];
2274
+ if (!row) {
2275
+ throw new Error(
2276
+ `Rate-state hold validation returned no row for ${input.bucketId}.`,
2277
+ );
2278
+ }
2279
+ const serverNowMs = Math.max(0, Number(row.server_now_ms));
2280
+ const retryAtMs = Math.max(0, Number(row.retry_at_ms));
2281
+ return {
2282
+ allowed: retryAtMs <= serverNowMs,
2283
+ serverNowMs,
2284
+ retryAtMs,
2285
+ };
2286
+ });
2287
+ }
2288
+
1936
2289
  type ConcurrencyObservation = Gradient2Observation & {
1937
2290
  kind: 'success' | 'throttled';
1938
2291
  };
@@ -2045,6 +2398,7 @@ async function concurrencyThrottleFrom(
2045
2398
  input: PlayRunnerRateStatePenalizeInput,
2046
2399
  rules: readonly AcquirePacingRule[],
2047
2400
  ): Promise<{
2401
+ attribution: PostgresRateStatePenaltyTelemetry['gradient2Attribution'];
2048
2402
  observations: ConcurrencyObservation[];
2049
2403
  batch: ConcurrencyObservationBatch | null;
2050
2404
  }> {
@@ -2053,7 +2407,7 @@ async function concurrencyThrottleFrom(
2053
2407
  !Number.isFinite(input.latencyMs) ||
2054
2408
  input.latencyMs <= 0
2055
2409
  ) {
2056
- return { observations: [], batch: null };
2410
+ return { attribution: 'not_observed', observations: [], batch: null };
2057
2411
  }
2058
2412
  const grant = input.concurrencyGrant;
2059
2413
  if (
@@ -2062,13 +2416,13 @@ async function concurrencyThrottleFrom(
2062
2416
  !grant.admissionId.trim() ||
2063
2417
  grant.admissionId.trim().length > 64
2064
2418
  ) {
2065
- return { observations: [], batch: null };
2419
+ return { attribution: 'not_observed', observations: [], batch: null };
2066
2420
  }
2067
2421
  const concurrencyRuleIds = rules
2068
2422
  .filter((rule) => rule.maxConcurrency != null && rule.maxConcurrency > 0)
2069
2423
  .map((rule) => rule.ruleId);
2070
2424
  if (concurrencyRuleIds.length === 0) {
2071
- return { observations: [], batch: null };
2425
+ return { attribution: 'not_observed', observations: [], batch: null };
2072
2426
  }
2073
2427
  const attributedLease = await client.query<{
2074
2428
  in_flight_at_start: number | null;
@@ -2104,7 +2458,7 @@ async function concurrencyThrottleFrom(
2104
2458
  minimumRttMs !== undefined &&
2105
2459
  (!Number.isFinite(minimumRttMs) || minimumRttMs <= 0))
2106
2460
  ) {
2107
- return { observations: [], batch: null };
2461
+ return { attribution: 'not_observed', observations: [], batch: null };
2108
2462
  }
2109
2463
  const attribution = classifyProviderThrottle({
2110
2464
  grant: {
@@ -2113,8 +2467,11 @@ async function concurrencyThrottleFrom(
2113
2467
  },
2114
2468
  inFlightAtStart,
2115
2469
  });
2116
- if (attribution === 'rate') return { observations: [], batch: null };
2470
+ if (attribution === 'rate') {
2471
+ return { attribution, observations: [], batch: null };
2472
+ }
2117
2473
  return {
2474
+ attribution,
2118
2475
  observations: [
2119
2476
  {
2120
2477
  kind: 'throttled',
@@ -2142,15 +2499,65 @@ async function applyConcurrencyObservations(
2142
2499
  input: Pick<PlayRunnerRateStateAcquireInput, 'bucketId'> & {
2143
2500
  schema?: string | null;
2144
2501
  schedulerSchema?: string | null;
2502
+ observedSuccesses?: number;
2145
2503
  },
2146
2504
  rules: readonly AcquirePacingRule[],
2147
2505
  observations: readonly ConcurrencyObservation[],
2148
2506
  batch: ConcurrencyObservationBatch | null,
2149
- ): Promise<'unbatched' | 'applied' | 'already_applied'> {
2507
+ ): Promise<{
2508
+ disposition: 'unbatched' | 'applied' | 'already_applied';
2509
+ ruleCount: number;
2510
+ changedRuleCount: number;
2511
+ limitMinBefore: number | null;
2512
+ limitMaxBefore: number | null;
2513
+ limitMinAfter: number | null;
2514
+ limitMaxAfter: number | null;
2515
+ }> {
2150
2516
  const concurrencyRules = rules.filter(
2151
2517
  (rule) => rule.maxConcurrency != null && rule.maxConcurrency > 0,
2152
2518
  );
2153
- if (batch === null) return 'unbatched';
2519
+ const countSuccesses = async () => {
2520
+ const successes = Math.max(
2521
+ 0,
2522
+ Math.min(1_000_000, Math.trunc(input.observedSuccesses ?? 0)),
2523
+ );
2524
+ if (successes <= 0) return;
2525
+ await client.query(
2526
+ `
2527
+ UPDATE ${scopeHoldTableName(input)}
2528
+ SET rate_successes = rate_successes + $2::bigint,
2529
+ refill_per_sec = CASE
2530
+ -- A 429 can beat other requests already in flight. Until the
2531
+ -- first post-hold grant starts a new observation epoch, their
2532
+ -- confirmed successes refine the same estimate, never past the
2533
+ -- first throttle's multiplicative-decrease ceiling.
2534
+ WHEN rate_observation_started_at_ms IS NULL
2535
+ AND rate_throttle_ceiling_rps IS NOT NULL
2536
+ AND rate_recovery_interval_ms > 0
2537
+ THEN GREATEST(refill_per_sec, LEAST(
2538
+ rate_throttle_ceiling_rps,
2539
+ (rate_successes + $2::bigint) * 1000.0 / rate_recovery_interval_ms
2540
+ ))
2541
+ ELSE refill_per_sec END
2542
+ WHERE bucket_id = $1
2543
+ `,
2544
+ [input.bucketId, successes],
2545
+ );
2546
+ };
2547
+ if (batch === null) {
2548
+ // Legacy writers have no observation identity. Preserve their success
2549
+ // input; new writers always supply the replay-safe batch below.
2550
+ await countSuccesses();
2551
+ return {
2552
+ disposition: 'unbatched',
2553
+ ruleCount: 0,
2554
+ changedRuleCount: 0,
2555
+ limitMinBefore: null,
2556
+ limitMaxBefore: null,
2557
+ limitMinAfter: null,
2558
+ limitMaxAfter: null,
2559
+ };
2560
+ }
2154
2561
 
2155
2562
  await client.query('BEGIN');
2156
2563
  try {
@@ -2159,19 +2566,7 @@ async function applyConcurrencyObservations(
2159
2566
  WITH now_value AS (
2160
2567
  SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint AS now_ms
2161
2568
  ),
2162
- expired AS (
2163
- DELETE FROM ${concurrencyObserverTableName(input)} AS observer
2164
- WHERE (observer.bucket_id, observer.observer_id, observer.lane_id) IN (
2165
- SELECT
2166
- stale.bucket_id,
2167
- stale.observer_id,
2168
- stale.lane_id
2169
- FROM ${concurrencyObserverTableName(input)} AS stale
2170
- WHERE stale.expires_at_ms <= (SELECT now_ms FROM now_value)
2171
- ORDER BY stale.expires_at_ms
2172
- LIMIT ${CONCURRENCY_OBSERVER_CLEANUP_BATCH_SIZE}
2173
- )
2174
- )
2569
+ accepted AS (
2175
2570
  INSERT INTO ${concurrencyObserverTableName(input)} AS observer (
2176
2571
  bucket_id,
2177
2572
  observer_id,
@@ -2195,6 +2590,23 @@ async function applyConcurrencyObservations(
2195
2590
  expires_at_ms = EXCLUDED.expires_at_ms
2196
2591
  WHERE observer.last_sequence < EXCLUDED.last_sequence
2197
2592
  RETURNING last_sequence
2593
+ ), expired AS (
2594
+ -- Own this observation before opportunistically reclaiming others.
2595
+ -- Never wait for another feedback writer or delete our renewed row
2596
+ -- through this statement's pre-update snapshot.
2597
+ DELETE FROM ${concurrencyObserverTableName(input)} AS observer
2598
+ WHERE (SELECT COUNT(*) FROM accepted) >= 0
2599
+ AND (observer.bucket_id, observer.observer_id, observer.lane_id) IN (
2600
+ SELECT stale.bucket_id, stale.observer_id, stale.lane_id
2601
+ FROM ${concurrencyObserverTableName(input)} AS stale
2602
+ WHERE stale.expires_at_ms <= (SELECT now_ms FROM now_value)
2603
+ AND NOT (stale.bucket_id = $1 AND stale.observer_id = $2 AND stale.lane_id = $3)
2604
+ ORDER BY stale.expires_at_ms
2605
+ LIMIT ${CONCURRENCY_OBSERVER_CLEANUP_BATCH_SIZE}
2606
+ FOR UPDATE OF stale SKIP LOCKED
2607
+ )
2608
+ )
2609
+ SELECT last_sequence FROM accepted
2198
2610
  `,
2199
2611
  [
2200
2612
  input.bucketId,
@@ -2209,11 +2621,30 @@ async function applyConcurrencyObservations(
2209
2621
  // than replaying its samples.
2210
2622
  if (accepted.rows.length === 0) {
2211
2623
  await client.query('COMMIT');
2212
- return 'already_applied';
2624
+ return {
2625
+ disposition: 'already_applied',
2626
+ ruleCount: 0,
2627
+ changedRuleCount: 0,
2628
+ limitMinBefore: null,
2629
+ limitMaxBefore: null,
2630
+ limitMinAfter: null,
2631
+ limitMaxAfter: null,
2632
+ };
2213
2633
  }
2634
+ // Count real responses, not leased tickets. Commit the count with its
2635
+ // observation identity so a lost response cannot inflate learned goodput.
2636
+ await countSuccesses();
2214
2637
  if (concurrencyRules.length === 0 || observations.length === 0) {
2215
2638
  await client.query('COMMIT');
2216
- return 'applied';
2639
+ return {
2640
+ disposition: 'applied',
2641
+ ruleCount: 0,
2642
+ changedRuleCount: 0,
2643
+ limitMinBefore: null,
2644
+ limitMaxBefore: null,
2645
+ limitMinAfter: null,
2646
+ limitMaxAfter: null,
2647
+ };
2217
2648
  }
2218
2649
  const rows = await client.query<{
2219
2650
  rule_id: string;
@@ -2247,6 +2678,9 @@ async function applyConcurrencyObservations(
2247
2678
  const ruleById = new Map(
2248
2679
  concurrencyRules.map((rule) => [rule.ruleId, rule]),
2249
2680
  );
2681
+ const limitsBefore: number[] = [];
2682
+ const limitsAfter: number[] = [];
2683
+ let changedRuleCount = 0;
2250
2684
  for (const row of rows.rows) {
2251
2685
  const rule = ruleById.get(row.rule_id);
2252
2686
  if (!rule?.maxConcurrency) continue;
@@ -2258,6 +2692,7 @@ async function applyConcurrencyObservations(
2258
2692
  row.learned_concurrency_limit ?? INITIAL_CONCURRENCY_DISCOVERY_LIMIT,
2259
2693
  ),
2260
2694
  );
2695
+ limitsBefore.push(initialLimit);
2261
2696
  const controller = new Gradient2ConcurrencyController(
2262
2697
  providerConcurrencyControllerOptions({
2263
2698
  initialLimit,
@@ -2284,6 +2719,8 @@ async function applyConcurrencyObservations(
2284
2719
  );
2285
2720
  for (const observation of observations) controller.observe(observation);
2286
2721
  const checkpoint = controller.checkpoint();
2722
+ limitsAfter.push(checkpoint.limit);
2723
+ if (checkpoint.limit !== initialLimit) changedRuleCount += 1;
2287
2724
  await client.query(
2288
2725
  `
2289
2726
  UPDATE ${bucketTableName(input)}
@@ -2310,8 +2747,18 @@ async function applyConcurrencyObservations(
2310
2747
  ],
2311
2748
  );
2312
2749
  }
2750
+ const beforeRange = numberRange(limitsBefore);
2751
+ const afterRange = numberRange(limitsAfter);
2313
2752
  await client.query('COMMIT');
2314
- return 'applied';
2753
+ return {
2754
+ disposition: 'applied',
2755
+ ruleCount: limitsBefore.length,
2756
+ changedRuleCount,
2757
+ limitMinBefore: beforeRange.min,
2758
+ limitMaxBefore: beforeRange.max,
2759
+ limitMinAfter: afterRange.min,
2760
+ limitMaxAfter: afterRange.max,
2761
+ };
2315
2762
  } catch (error) {
2316
2763
  try {
2317
2764
  await client.query('ROLLBACK');
@@ -2351,7 +2798,7 @@ async function acquireRateStateStatements(
2351
2798
  await seedBucketRows(client, input, seeds, nowMs);
2352
2799
  seeded = true;
2353
2800
  }
2354
- const observationBatchDisposition = await applyConcurrencyObservations(
2801
+ const observationBatch = await applyConcurrencyObservations(
2355
2802
  client,
2356
2803
  input,
2357
2804
  rules,
@@ -2362,7 +2809,7 @@ async function acquireRateStateStatements(
2362
2809
  // runner lost the prior acquire response, its retry still needs a fresh
2363
2810
  // permit, but it must not ramp AIMD or advance Gradient2 a second time.
2364
2811
  grantInput =
2365
- observationBatchDisposition === 'already_applied'
2812
+ observationBatch.disposition === 'already_applied'
2366
2813
  ? { ...input, observedSuccesses: 0 }
2367
2814
  : input;
2368
2815
  let outcome = await grant();
@@ -2426,6 +2873,21 @@ async function releaseRateStateStatements(
2426
2873
  ),
2427
2874
  ];
2428
2875
  if (refundReservationIds.length > 0) {
2876
+ // A pre-expansion gateway can leave a valid reservation and rule row but
2877
+ // no scope owner. Create its uninitialized hold before taking the normal
2878
+ // hold -> reservation -> rule lock path; seedBucketRows will initialize its
2879
+ // learner on the next admission and preserve this inherited deadline.
2880
+ await client.query(
2881
+ `
2882
+ INSERT INTO ${scopeHoldTableName(input)} (bucket_id, retry_at_ms)
2883
+ SELECT bucket_id, MAX(claimed_retry_at_ms)
2884
+ FROM ${bucketTableName(input)}
2885
+ WHERE bucket_id = $1
2886
+ GROUP BY bucket_id
2887
+ ON CONFLICT (bucket_id) DO NOTHING
2888
+ `,
2889
+ [input.bucketId],
2890
+ );
2429
2891
  // Delete only a server-minted, unexpired reservation. That one mutation is
2430
2892
  // both authorization and idempotency: arbitrary runner input cannot credit
2431
2893
  // a bucket, and an HTTP retry sees no reservation left to refund. The shared
@@ -2439,9 +2901,13 @@ async function releaseRateStateStatements(
2439
2901
  FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
2440
2902
  ) AS now_ms
2441
2903
  ),
2904
+ held AS (
2905
+ SELECT * FROM ${scopeHoldTableName(input)} WHERE bucket_id = $1 FOR UPDATE
2906
+ ),
2442
2907
  refunded AS (
2443
2908
  DELETE FROM ${reservationTableName(input)}
2444
2909
  WHERE bucket_id = $1
2910
+ AND EXISTS (SELECT 1 FROM held)
2445
2911
  AND reservation_id = ANY($4::text[])
2446
2912
  AND expires_at_ms > (SELECT now_ms FROM now_value)
2447
2913
  RETURNING reservation_id
@@ -2449,6 +2915,19 @@ async function releaseRateStateStatements(
2449
2915
  refund_count AS (
2450
2916
  SELECT COUNT(*)::float8 AS value FROM refunded
2451
2917
  ),
2918
+ refunded_scope AS (
2919
+ UPDATE ${scopeHoldTableName(input)} h SET
2920
+ tokens = CASE WHEN h.refill_per_sec IS NULL THEN h.tokens ELSE LEAST(
2921
+ GREATEST(CASE WHEN h.seen_backpressure THEN 1 ELSE h.capacity END, h.refill_per_sec),
2922
+ h.tokens + GREATEST(0, nv.now_ms - h.refilled_at_ms) / 1000.0 * h.refill_per_sec
2923
+ + (SELECT value FROM refund_count)
2924
+ ) END,
2925
+ refilled_at_ms = CASE WHEN h.refill_per_sec IS NULL
2926
+ THEN h.refilled_at_ms ELSE nv.now_ms END
2927
+ FROM now_value nv
2928
+ WHERE h.bucket_id = $1 AND (SELECT value FROM refund_count) > 0
2929
+ RETURNING h.bucket_id
2930
+ ),
2452
2931
  refreshed AS (
2453
2932
  SELECT
2454
2933
  b.bucket_id,
@@ -2460,6 +2939,7 @@ async function releaseRateStateStatements(
2460
2939
  nv.now_ms
2461
2940
  FROM ${bucketTableName(input)} b
2462
2941
  CROSS JOIN now_value nv
2942
+ JOIN refunded_scope ON refunded_scope.bucket_id = b.bucket_id
2463
2943
  WHERE b.bucket_id = $1
2464
2944
  AND b.rule_id = ANY($2::text[])
2465
2945
  ORDER BY b.rule_id
@@ -2513,7 +2993,7 @@ async function releaseRateStateStatements(
2513
2993
  export async function penalizePostgresRateState(
2514
2994
  client: PostgresSchedulerQueryClient,
2515
2995
  input: PlayRunnerRateStatePenalizeInput & { nowMs?: number },
2516
- ): Promise<void> {
2996
+ ): Promise<PostgresRateStatePenaltyTelemetry> {
2517
2997
  return await withRateStateAvailabilityGuard(input, () =>
2518
2998
  penalizeRateStateStatements(client, input),
2519
2999
  );
@@ -2522,58 +3002,268 @@ export async function penalizePostgresRateState(
2522
3002
  async function penalizeRateStateStatements(
2523
3003
  client: PostgresSchedulerQueryClient,
2524
3004
  input: PlayRunnerRateStatePenalizeInput & { nowMs?: number },
2525
- ): Promise<void> {
2526
- if (!Number.isFinite(input.cooldownMs) || input.cooldownMs <= 0) return;
2527
- // Single autocommit statement applied to every rule row of the bucket:
2528
- // - claimed_retry_at_ms: server Retry-After stored verbatim (observability).
2529
- // - cool_until_ms: extended by the cooldown, internally capped at 45min.
3005
+ ): Promise<PostgresRateStatePenaltyTelemetry> {
3006
+ const cooldownMs =
3007
+ Number.isFinite(input.cooldownMs) && input.cooldownMs > 0
3008
+ ? Math.ceil(input.cooldownMs)
3009
+ : 0;
3010
+ const retryAtMs =
3011
+ input.retryAtMs != null && Number.isFinite(input.retryAtMs)
3012
+ ? Math.max(0, Math.ceil(input.retryAtMs))
3013
+ : null;
3014
+ if (cooldownMs <= 0 && retryAtMs == null) {
3015
+ return emptyRateStatePenaltyTelemetry();
3016
+ }
3017
+ const rules = normalizeRules((input.rules ?? []) as AcquirePacingRule[]);
3018
+ await applyConcurrencyObservations(
3019
+ client,
3020
+ input,
3021
+ rules,
3022
+ concurrencyObservationsFrom(input.concurrencyObservations),
3023
+ concurrencyObservationBatchFrom(input.concurrencyObservationBatch),
3024
+ );
3025
+ // Lock/update the provider-scope hold before touching rate-rule rows, matching
3026
+ // acquire's lock order. The absolute deadline is idempotent across transport
3027
+ // retries and gates admission independently of the capped AIMD cooldown.
2530
3028
  // - refill_per_sec: multiplicative decrease, but ONLY once per active
2531
3029
  // cooldown window (halve-once guard: skip if already cooling), floored at
2532
3030
  // min_rps.
2533
3031
  // - seen_backpressure flips the ramp from slow-start (x2) to additive.
2534
3032
  // - tokens drained: no more grants until refill re-accrues past the cooldown.
2535
3033
  await client.query(
3034
+ `INSERT INTO ${scopeHoldTableName(input)} (bucket_id) VALUES ($1) ON CONFLICT DO NOTHING`,
3035
+ [input.bucketId],
3036
+ );
3037
+ const rateStateResult = await client.query<{
3038
+ retry_at_ms: string | number | null;
3039
+ cool_until_ms: string | number | null;
3040
+ rate_rule_count: string | number;
3041
+ rate_decreased_rule_count: string | number;
3042
+ refill_min_before_rps: string | number | null;
3043
+ refill_max_before_rps: string | number | null;
3044
+ refill_min_after_rps: string | number | null;
3045
+ refill_max_after_rps: string | number | null;
3046
+ observation_cycle_max_ms: string | number | null;
3047
+ confirmed_successes_max: string | number | null;
3048
+ }>(
2536
3049
  `
2537
3050
  WITH now_value AS (
2538
3051
  SELECT COALESCE(
2539
3052
  $3::bigint,
2540
3053
  FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
2541
3054
  ) AS now_ms
3055
+ ), held AS (
3056
+ SELECT * FROM ${scopeHoldTableName(input)} WHERE bucket_id = $1 FOR UPDATE
3057
+ ), evidence AS (
3058
+ SELECT h.*,
3059
+ COALESCE(h.refill_per_sec, 1) AS refill_before,
3060
+ GREATEST(h.retry_at_ms, COALESCE($5::bigint, nv.now_ms + $2::bigint)) AS deadline,
3061
+ (
3062
+ COALESCE($5::bigint, nv.now_ms + $2::bigint) > nv.now_ms
3063
+ -- A short HTTP-date may expire between the provider response and
3064
+ -- this durable feedback write. A strictly newer absolute deadline
3065
+ -- is still fresh throttle evidence and must update AIMD even when
3066
+ -- it no longer requires a wait. Equal/older deadlines are replayed
3067
+ -- feedback and must not start another decrease episode.
3068
+ OR ($5::bigint IS NOT NULL AND $5::bigint > h.retry_at_ms)
3069
+ )
3070
+ AND nv.now_ms >= h.retry_at_ms
3071
+ AND (h.rate_observation_started_at_ms IS NOT NULL OR h.retry_at_ms = 0)
3072
+ AS new_episode,
3073
+ GREATEST(1000,
3074
+ CASE
3075
+ WHEN $5::bigint IS NOT NULL AND h.rate_successes <= 0 THEN 1000
3076
+ WHEN $5::bigint IS NOT NULL THEN
3077
+ GREATEST(h.retry_at_ms, $5::bigint)
3078
+ - COALESCE(h.rate_observation_started_at_ms, nv.now_ms)
3079
+ ELSE
3080
+ GREATEST(h.retry_at_ms, COALESCE($5::bigint, nv.now_ms + $2::bigint))
3081
+ - COALESCE(h.rate_observation_started_at_ms, nv.now_ms)
3082
+ END) AS cycle_ms,
3083
+ nv.now_ms
3084
+ FROM held h CROSS JOIN now_value nv
3085
+ ), throttle_ceiling AS (
3086
+ SELECT e.*,
3087
+ GREATEST(${MIN_ADAPTIVE_RPS_FLOOR},
3088
+ CASE WHEN e.seen_backpressure AND e.rate_successes = 0 THEN
3089
+ CASE WHEN $5::bigint IS NOT NULL
3090
+ -- An explicit Retry-After is only the provider's next safe
3091
+ -- dispatch deadline. It is not a rate measurement; with no
3092
+ -- successful-call evidence, use one conservative probe per
3093
+ -- second after that deadline instead of deriving a permanent
3094
+ -- RPM limit from the length of the hold.
3095
+ THEN GREATEST(e.refill_before, 1000.0 / e.cycle_ms)
3096
+ ELSE e.refill_before END
3097
+ ELSE e.refill_before / 2 END
3098
+ ) AS next_ceiling_rps
3099
+ FROM evidence e
3100
+ ), learned AS (
3101
+ SELECT e.*,
3102
+ CASE WHEN e.new_episode THEN GREATEST(${MIN_ADAPTIVE_RPS_FLOOR}, LEAST(
3103
+ e.next_ceiling_rps,
3104
+ GREATEST(1, e.rate_successes) * 1000.0 / e.cycle_ms
3105
+ )) ELSE e.refill_before END AS refill_after
3106
+ FROM throttle_ceiling e
3107
+ ), provider_hold AS (
3108
+ UPDATE ${scopeHoldTableName(input)} h SET
3109
+ retry_at_ms = e.deadline,
3110
+ refill_per_sec = e.refill_after,
3111
+ seen_backpressure = e.seen_backpressure OR e.new_episode,
3112
+ cool_until_ms = GREATEST(e.cool_until_ms, LEAST(e.deadline, e.now_ms + LEAST($2::bigint, $4::bigint))),
3113
+ tokens = CASE WHEN e.new_episode THEN 0 ELSE e.tokens END,
3114
+ rate_successes = e.rate_successes,
3115
+ rate_throttle_ceiling_rps = CASE WHEN e.new_episode
3116
+ THEN e.next_ceiling_rps ELSE e.rate_throttle_ceiling_rps END,
3117
+ rate_observation_started_at_ms = CASE WHEN e.new_episode THEN NULL ELSE e.rate_observation_started_at_ms END,
3118
+ rate_recovery_interval_ms = CASE WHEN e.new_episode THEN e.cycle_ms ELSE e.rate_recovery_interval_ms END,
3119
+ last_ramp_at_ms = CASE WHEN e.new_episode THEN e.deadline ELSE e.last_ramp_at_ms END,
3120
+ -- All pre-429 permits are revalidated against this durable hold
3121
+ -- before dispatch. Their obsolete schedule debt must not add a
3122
+ -- second wait after the upstream's explicit retry deadline.
3123
+ next_dispatch_at_ms = CASE WHEN e.new_episode
3124
+ THEN e.deadline ELSE GREATEST(e.next_dispatch_at_ms, e.deadline) END
3125
+ FROM learned e WHERE h.bucket_id = e.bucket_id
3126
+ RETURNING h.*, e.new_episode, e.cycle_ms, e.rate_successes AS successes_before
3127
+ ), locked_bucket_rows AS (
3128
+ SELECT b.bucket_id, b.rule_id, b.refill_per_sec AS refill_before
3129
+ FROM ${bucketTableName(input)} b
3130
+ JOIN provider_hold h ON h.bucket_id = b.bucket_id
3131
+ ORDER BY b.rule_id FOR UPDATE OF b
3132
+ ), updated AS (
3133
+ -- Compatibility projections only: no per-rule learning history is
3134
+ -- consulted. Inactive operations inherit the same scope evidence.
3135
+ UPDATE ${bucketTableName(input)} b SET
3136
+ claimed_retry_at_ms = GREATEST(b.claimed_retry_at_ms, h.retry_at_ms),
3137
+ refill_per_sec = LEAST(b.max_rps, GREATEST(b.min_rps, h.refill_per_sec)),
3138
+ rate_successes = h.rate_successes,
3139
+ rate_observation_started_at_ms = h.rate_observation_started_at_ms,
3140
+ rate_recovery_interval_ms = h.rate_recovery_interval_ms,
3141
+ last_ramp_at_ms = h.last_ramp_at_ms,
3142
+ cool_until_ms = h.cool_until_ms,
3143
+ seen_backpressure = h.seen_backpressure,
3144
+ tokens = CASE WHEN h.new_episode THEN 0 ELSE b.tokens END,
3145
+ next_dispatch_at_ms = CASE WHEN h.new_episode
3146
+ THEN h.next_dispatch_at_ms
3147
+ ELSE GREATEST(b.next_dispatch_at_ms, h.next_dispatch_at_ms) END
3148
+ FROM locked_bucket_rows locked CROSS JOIN provider_hold h
3149
+ WHERE b.bucket_id = locked.bucket_id AND b.rule_id = locked.rule_id
3150
+ RETURNING locked.refill_before, h.cycle_ms, h.successes_before,
3151
+ b.refill_per_sec AS refill_after, b.cool_until_ms
2542
3152
  )
2543
- UPDATE ${bucketTableName(input)} b
2544
- SET
2545
- claimed_retry_at_ms = (SELECT now_ms FROM now_value) + $2::bigint,
2546
- refill_per_sec = CASE
2547
- WHEN (SELECT now_ms FROM now_value) >= b.cool_until_ms
2548
- THEN GREATEST(b.min_rps, b.refill_per_sec / 2)
2549
- ELSE b.refill_per_sec
2550
- END,
2551
- cool_until_ms = GREATEST(
2552
- b.cool_until_ms,
2553
- (SELECT now_ms FROM now_value) + LEAST($2::bigint, $4::bigint)
2554
- ),
2555
- seen_backpressure = true,
2556
- tokens = 0,
2557
- next_dispatch_at_ms = GREATEST(
2558
- b.next_dispatch_at_ms,
2559
- (SELECT now_ms FROM now_value) + LEAST($2::bigint, $4::bigint)
2560
- )
2561
- WHERE b.bucket_id = $1
3153
+ SELECT
3154
+ (SELECT MAX(retry_at_ms) FROM provider_hold) AS retry_at_ms,
3155
+ MAX(updated.cool_until_ms) AS cool_until_ms,
3156
+ COUNT(*)::int AS rate_rule_count,
3157
+ COUNT(*) FILTER (WHERE updated.refill_after < updated.refill_before)::int
3158
+ AS rate_decreased_rule_count,
3159
+ MIN(updated.refill_before) AS refill_min_before_rps,
3160
+ MAX(updated.refill_before) AS refill_max_before_rps,
3161
+ MIN(updated.refill_after) AS refill_min_after_rps,
3162
+ MAX(updated.refill_after) AS refill_max_after_rps,
3163
+ MAX(updated.cycle_ms) AS observation_cycle_max_ms,
3164
+ MAX(updated.successes_before) AS confirmed_successes_max
3165
+ FROM updated
2562
3166
  `,
2563
3167
  [
2564
3168
  input.bucketId,
2565
- Math.ceil(input.cooldownMs),
3169
+ cooldownMs,
2566
3170
  input.nowMs ?? null,
2567
- MAX_INTERNAL_COOLDOWN_MS,
3171
+ PROVIDER_AIMD_COOLDOWN_CAP_MS,
3172
+ retryAtMs,
2568
3173
  ],
2569
3174
  );
2570
- const rules = normalizeRules((input.rules ?? []) as AcquirePacingRule[]);
3175
+ const rateRow = rateStateResult.rows[0];
3176
+ const aimd = {
3177
+ retryAtMs: finiteNumberOrNull(rateRow?.retry_at_ms),
3178
+ coolUntilMs: finiteNumberOrNull(rateRow?.cool_until_ms),
3179
+ rateRuleCount: Math.max(
3180
+ 0,
3181
+ Math.trunc(Number(rateRow?.rate_rule_count ?? 0)),
3182
+ ),
3183
+ rateDecreasedRuleCount: Math.max(
3184
+ 0,
3185
+ Math.trunc(Number(rateRow?.rate_decreased_rule_count ?? 0)),
3186
+ ),
3187
+ refillMinBeforeRps: finiteNumberOrNull(rateRow?.refill_min_before_rps),
3188
+ refillMaxBeforeRps: finiteNumberOrNull(rateRow?.refill_max_before_rps),
3189
+ refillMinAfterRps: finiteNumberOrNull(rateRow?.refill_min_after_rps),
3190
+ refillMaxAfterRps: finiteNumberOrNull(rateRow?.refill_max_after_rps),
3191
+ observationCycleMaxMs: finiteNumberOrNull(
3192
+ rateRow?.observation_cycle_max_ms,
3193
+ ),
3194
+ confirmedSuccessesMax: finiteNumberOrNull(rateRow?.confirmed_successes_max),
3195
+ };
3196
+ const configuredConcurrencyRuleCount = rules.filter(
3197
+ (rule) => rule.maxConcurrency != null && rule.maxConcurrency > 0,
3198
+ ).length;
2571
3199
  const throttle = await concurrencyThrottleFrom(client, input, rules);
2572
- await applyConcurrencyObservations(
3200
+ // The first observation fold consumed buffered successes before AIMD used
3201
+ // them. This second fold owns Gradient2's throttle sample only.
3202
+ const gradient2 = await applyConcurrencyObservations(
2573
3203
  client,
2574
- input,
3204
+ { ...input, observedSuccesses: 0 },
2575
3205
  rules,
2576
3206
  throttle.observations,
2577
3207
  throttle.batch,
2578
3208
  );
3209
+ return {
3210
+ ...aimd,
3211
+ configuredConcurrencyRuleCount,
3212
+ gradient2Attribution: throttle.attribution,
3213
+ gradient2Disposition:
3214
+ throttle.attribution === 'rate' ||
3215
+ throttle.attribution === 'not_observed' ||
3216
+ gradient2.disposition === 'unbatched'
3217
+ ? 'not_observed'
3218
+ : gradient2.disposition,
3219
+ gradient2RuleCount: gradient2.ruleCount,
3220
+ gradient2ChangedRuleCount: gradient2.changedRuleCount,
3221
+ gradient2LimitMinBefore: gradient2.limitMinBefore,
3222
+ gradient2LimitMaxBefore: gradient2.limitMaxBefore,
3223
+ gradient2LimitMinAfter: gradient2.limitMinAfter,
3224
+ gradient2LimitMaxAfter: gradient2.limitMaxAfter,
3225
+ };
3226
+ }
3227
+
3228
+ function emptyRateStatePenaltyTelemetry(): PostgresRateStatePenaltyTelemetry {
3229
+ return {
3230
+ retryAtMs: null,
3231
+ coolUntilMs: null,
3232
+ rateRuleCount: 0,
3233
+ rateDecreasedRuleCount: 0,
3234
+ refillMinBeforeRps: null,
3235
+ refillMaxBeforeRps: null,
3236
+ refillMinAfterRps: null,
3237
+ refillMaxAfterRps: null,
3238
+ observationCycleMaxMs: null,
3239
+ confirmedSuccessesMax: null,
3240
+ configuredConcurrencyRuleCount: 0,
3241
+ gradient2Attribution: 'not_observed',
3242
+ gradient2Disposition: 'not_observed',
3243
+ gradient2RuleCount: 0,
3244
+ gradient2ChangedRuleCount: 0,
3245
+ gradient2LimitMinBefore: null,
3246
+ gradient2LimitMaxBefore: null,
3247
+ gradient2LimitMinAfter: null,
3248
+ gradient2LimitMaxAfter: null,
3249
+ };
3250
+ }
3251
+
3252
+ function finiteNumberOrNull(value: unknown): number | null {
3253
+ const parsed = Number(value);
3254
+ return value != null && Number.isFinite(parsed) ? parsed : null;
3255
+ }
3256
+
3257
+ function numberRange(values: readonly number[]): {
3258
+ min: number | null;
3259
+ max: number | null;
3260
+ } {
3261
+ let min: number | null = null;
3262
+ let max: number | null = null;
3263
+ for (const value of values) {
3264
+ if (!Number.isFinite(value)) continue;
3265
+ min = min === null ? value : Math.min(min, value);
3266
+ max = max === null ? value : Math.max(max, value);
3267
+ }
3268
+ return { min, max };
2579
3269
  }