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