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.
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +53 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +751 -193
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +4 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +3 -1
- package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +437 -150
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +47 -4
- package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +3 -1
- package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
- package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +22 -2
- package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +6 -1
- package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +11 -5
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-flow.ts +102 -12
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +136 -28
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-scope.ts +80 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +897 -207
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +33 -1
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +120 -34
- package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +82 -2
- package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +7 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/install-integrity.json +1 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
135
|
+
* statement from the learned rate; see the adaptive burst horizon constants.
|
|
96
136
|
*/
|
|
97
|
-
function effectiveCapacitySql(
|
|
98
|
-
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
305
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
527
|
-
// fair-share window
|
|
528
|
-
// org
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
683
|
-
*
|
|
684
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
1041
|
+
r.effective_capacity AS burst_capacity,
|
|
1042
|
+
r.refill_per_sec,
|
|
817
1043
|
GREATEST(
|
|
818
1044
|
${ADAPTIVE_BURST_HORIZON_MS},
|
|
819
|
-
CEIL(r.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
1323
|
+
GREATEST(sc.min_rps, ph.refill_per_sec)
|
|
1087
1324
|
) AS refill_per_sec,
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
1151
|
-
|
|
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
|
|
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(
|
|
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(
|
|
1179
|
-
GREATEST(1, FLOOR(MIN(
|
|
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
|
|
1207
|
-
--
|
|
1208
|
-
refill_per_sec =
|
|
1209
|
-
|
|
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
|
|
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
|
-
(
|
|
1287
|
-
|
|
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 (
|
|
1323
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1521
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
1627
|
-
|
|
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
|
|
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(
|
|
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(
|
|
1662
|
-
GREATEST(1, FLOOR(MIN(
|
|
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 =
|
|
1690
|
-
|
|
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
|
|
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
|
-
(
|
|
1796
|
-
|
|
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 (
|
|
1843
|
-
|
|
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,
|
|
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')
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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<
|
|
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<
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
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
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
)
|
|
2555
|
-
|
|
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
|
-
|
|
3169
|
+
cooldownMs,
|
|
2566
3170
|
input.nowMs ?? null,
|
|
2567
|
-
|
|
3171
|
+
PROVIDER_AIMD_COOLDOWN_CAP_MS,
|
|
3172
|
+
retryAtMs,
|
|
2568
3173
|
],
|
|
2569
3174
|
);
|
|
2570
|
-
const
|
|
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
|
-
|
|
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
|
}
|