deepline 0.1.208 → 0.1.210
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/apps/play-runner-workers/src/coordinator-entry.ts +0 -2
- package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +102 -138
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +2 -12
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +12 -4
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +25 -92
- package/dist/bundling-sources/sdk/src/client.ts +10 -0
- package/dist/bundling-sources/sdk/src/play.ts +2 -2
- package/dist/bundling-sources/sdk/src/release.ts +2 -2
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +16 -31
- package/dist/bundling-sources/shared_libs/play-runtime/child-execution-strategy.ts +18 -0
- package/dist/bundling-sources/shared_libs/play-runtime/child-run-id.ts +4 -5
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +316 -232
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +16 -2
- package/dist/bundling-sources/shared_libs/play-runtime/daytona-runtime-config.ts +0 -5
- package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +2 -4
- package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +429 -102
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +27 -5
- package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +31 -12
- package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +103 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +105 -10
- package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +21 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +32 -86
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +141 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-session-execution.ts +45 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +180 -447
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +12 -0
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +33 -1
- package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
- package/dist/bundling-sources/shared_libs/plays/artifact-kind-guard.ts +112 -0
- package/dist/cli/index.js +33 -8
- package/dist/cli/index.mjs +33 -8
- package/dist/index.d.mts +11 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.js +26 -4
- package/dist/index.mjs +26 -4
- package/package.json +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/child-run-lifecycle.ts +0 -70
package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts
CHANGED
|
@@ -9,7 +9,25 @@ import {
|
|
|
9
9
|
type PacingPermit,
|
|
10
10
|
type PacingRule,
|
|
11
11
|
type RateStateBackend,
|
|
12
|
+
type RateStateBackendKind,
|
|
12
13
|
} from './rate-state-backend';
|
|
14
|
+
import {
|
|
15
|
+
ProviderExhaustedError,
|
|
16
|
+
providerNameFromBucketId,
|
|
17
|
+
} from '../run-failure';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Longest provider pacer hold this substrate will sleep toward before failing
|
|
21
|
+
* the call fast with a typed {@link ProviderExhaustedError}. Duration alone
|
|
22
|
+
* decides — there is no quota-vs-rate classification. A hold at or under this is
|
|
23
|
+
* slept off (today's behavior); a hold above it is skipped with no spend and no
|
|
24
|
+
* provider dispatch.
|
|
25
|
+
*
|
|
26
|
+
* Enforced ONLY on the `app_runtime_postgres` (Absurd/Node DB-authoritative)
|
|
27
|
+
* backend; the in-memory and coordinator backends never construct this class and
|
|
28
|
+
* so keep their sleep-toward-it behavior byte-for-byte.
|
|
29
|
+
*/
|
|
30
|
+
export const PROVIDER_EXHAUSTED_MAX_WAIT_MS = 60_000;
|
|
13
31
|
|
|
14
32
|
/**
|
|
15
33
|
* Permits leased per app-runtime round trip for the direct Node substrate.
|
|
@@ -17,11 +35,58 @@ import {
|
|
|
17
35
|
* Eight keeps DB/API round trips amortized for batch-heavy maps while limiting
|
|
18
36
|
* stale-block over-issue to 8 x live runner processes per bucket/window.
|
|
19
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* Transport seam for the app-runtime rate-state store. Mirrors the three module
|
|
40
|
+
* calls the backend makes, minus the `context` argument (the default port binds
|
|
41
|
+
* it). Aligns this backend with {@link CoordinatorRateStateBackend}'s injectable
|
|
42
|
+
* port so both are driven — and tested — the same way.
|
|
43
|
+
*/
|
|
44
|
+
export interface AppRuntimeRatePort {
|
|
45
|
+
acquire(
|
|
46
|
+
input: Parameters<typeof acquireRateStateViaAppRuntime>[1],
|
|
47
|
+
): ReturnType<typeof acquireRateStateViaAppRuntime>;
|
|
48
|
+
release(
|
|
49
|
+
input: Parameters<typeof releaseRateStateViaAppRuntime>[1],
|
|
50
|
+
): ReturnType<typeof releaseRateStateViaAppRuntime>;
|
|
51
|
+
penalize(
|
|
52
|
+
input: Parameters<typeof penalizeRateStateViaAppRuntime>[1],
|
|
53
|
+
): ReturnType<typeof penalizeRateStateViaAppRuntime>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function contextRatePort(context: WorkerRuntimeApiContext): AppRuntimeRatePort {
|
|
57
|
+
return {
|
|
58
|
+
acquire: (input) => acquireRateStateViaAppRuntime(context, input),
|
|
59
|
+
release: (input) => releaseRateStateViaAppRuntime(context, input),
|
|
60
|
+
penalize: (input) => penalizeRateStateViaAppRuntime(context, input),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Permits leased per app-runtime round trip. Eight keeps DB/API round trips
|
|
66
|
+
* amortized for batch-heavy maps while bounding stale-block over-issue to 8 x
|
|
67
|
+
* live runner processes per bucket/window. Deliberately conservative: absurd
|
|
68
|
+
* runs many concurrent sandboxes against the same org+provider bucket, so a
|
|
69
|
+
* larger block would let one runner reserve a whole window and starve peers.
|
|
70
|
+
*/
|
|
20
71
|
export const APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE = 8;
|
|
21
72
|
const LEASE_BLOCK_MIN_TTL_MS = 250;
|
|
22
73
|
const LEASE_BLOCK_MAX_TTL_MS = 5_000;
|
|
23
74
|
const MAX_ACQUIRE_SLEEP_MS = 5_000;
|
|
24
75
|
const STORE_FAILURE_RETRY_DELAYS_MS = [100, 250] as const;
|
|
76
|
+
/**
|
|
77
|
+
* Jitter fraction applied to a server-supplied `waitMs` so a synchronized wave
|
|
78
|
+
* of buckets that all hit granted=0 at the same instant re-probe at slightly
|
|
79
|
+
* different times instead of thundering the store in lockstep. ±10%.
|
|
80
|
+
*/
|
|
81
|
+
const ACQUIRE_WAIT_JITTER_FRACTION = 0.1;
|
|
82
|
+
|
|
83
|
+
/** Last-known server cooldown surface for a bucket (consumed by PROVIDER_EXHAUSTED). */
|
|
84
|
+
export interface RateStateCooldown {
|
|
85
|
+
/** Absolute epoch-ms until which the pacer's own (internally-capped) cooldown holds. */
|
|
86
|
+
coolUntilMs: number;
|
|
87
|
+
/** Most recent server Retry-After, stored verbatim as an absolute epoch-ms deadline. */
|
|
88
|
+
claimedRetryAtMs: number;
|
|
89
|
+
}
|
|
25
90
|
|
|
26
91
|
interface LeasedBlock {
|
|
27
92
|
/**
|
|
@@ -55,29 +120,52 @@ interface Options {
|
|
|
55
120
|
now?: () => number;
|
|
56
121
|
sleep?: (ms: number) => Promise<void>;
|
|
57
122
|
onStoreFailure?: (info: { bucketId: string; error: string }) => void;
|
|
123
|
+
/** Override the transport (tests inject a counting/faulting port). */
|
|
124
|
+
port?: AppRuntimeRatePort;
|
|
125
|
+
/**
|
|
126
|
+
* Deterministic jitter source in [0,1) for the ±10% wait jitter. Defaults to
|
|
127
|
+
* Math.random; tests pin it to assert the jittered sleep stays in bounds.
|
|
128
|
+
*/
|
|
129
|
+
jitter?: () => number;
|
|
58
130
|
}
|
|
59
131
|
|
|
60
132
|
export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
61
|
-
|
|
133
|
+
/**
|
|
134
|
+
* DB-authoritative: the whole token bucket + AIMD runs in Postgres, so the
|
|
135
|
+
* Governor defers pacing to this backend (see `resolveAdaptivePacing` /
|
|
136
|
+
* `reportProviderBackpressure` in governor.ts).
|
|
137
|
+
*/
|
|
138
|
+
readonly kind: RateStateBackendKind = 'app_runtime_postgres';
|
|
139
|
+
private readonly port: AppRuntimeRatePort;
|
|
62
140
|
private readonly schedulerSchema: string | null;
|
|
63
141
|
private readonly now: () => number;
|
|
64
142
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
143
|
+
private readonly jitter: () => number;
|
|
65
144
|
private readonly onStoreFailure: (info: {
|
|
66
145
|
bucketId: string;
|
|
67
146
|
error: string;
|
|
68
147
|
}) => void;
|
|
69
148
|
private readonly blocks = new Map<string, LeasedBlock>();
|
|
70
|
-
private readonly
|
|
149
|
+
private readonly refills = new Map<string, Promise<void>>();
|
|
71
150
|
private readonly pendingReleases = new Map<string, PendingRelease>();
|
|
151
|
+
/**
|
|
152
|
+
* Acquirers currently parked on each bucket's refill (empty block, awaiting a
|
|
153
|
+
* grant). Demand-sizing reads this to request a block sized to the live wave
|
|
154
|
+
* instead of a fixed floor, so a 200-row map fans into ~one round trip.
|
|
155
|
+
*/
|
|
156
|
+
private readonly waiters = new Map<string, number>();
|
|
157
|
+
/** Last-known server cooldown per bucket (surfaced for PROVIDER_EXHAUSTED). */
|
|
158
|
+
private readonly cooldowns = new Map<string, RateStateCooldown>();
|
|
72
159
|
private pendingReleaseFailure: Error | null = null;
|
|
73
160
|
|
|
74
161
|
constructor(context: WorkerRuntimeApiContext, options: Options = {}) {
|
|
75
|
-
this.
|
|
162
|
+
this.port = options.port ?? contextRatePort(context);
|
|
76
163
|
this.schedulerSchema = options.schedulerSchema?.trim() || null;
|
|
77
164
|
this.now = options.now ?? (() => Date.now());
|
|
78
165
|
this.sleep =
|
|
79
166
|
options.sleep ??
|
|
80
167
|
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
168
|
+
this.jitter = options.jitter ?? Math.random;
|
|
81
169
|
this.onStoreFailure =
|
|
82
170
|
options.onStoreFailure ??
|
|
83
171
|
((info) => {
|
|
@@ -85,6 +173,51 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
85
173
|
});
|
|
86
174
|
}
|
|
87
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Last-known server cooldown for a bucket, or null if the bucket has never
|
|
178
|
+
* reported one. Minimal read surface for the PROVIDER_EXHAUSTED task: it lets
|
|
179
|
+
* a caller learn how long the pacer is holding a provider off without issuing
|
|
180
|
+
* its own acquire.
|
|
181
|
+
*/
|
|
182
|
+
cooldownFor(bucketId: string): RateStateCooldown | null {
|
|
183
|
+
return this.cooldowns.get(bucketId) ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Pre-check the last-known server cooldown for a bucket. If the pacer is
|
|
188
|
+
* already holding this provider past PROVIDER_EXHAUSTED_MAX_WAIT_MS, throw a
|
|
189
|
+
* typed {@link ProviderExhaustedError} before any store round trip. Prefers
|
|
190
|
+
* the server Retry-After (`claimedRetryAtMs`) as the retry deadline when it is
|
|
191
|
+
* later than the internally-capped `coolUntilMs` and the cooldown is active;
|
|
192
|
+
* otherwise reports `coolUntilMs`. No-op when the bucket has no live hold.
|
|
193
|
+
*/
|
|
194
|
+
private throwIfPacerExhausted(bucketId: string): void {
|
|
195
|
+
const cooldown = this.cooldowns.get(bucketId);
|
|
196
|
+
if (!cooldown) return;
|
|
197
|
+
const now = this.now();
|
|
198
|
+
const coolActive = cooldown.coolUntilMs - now > 0;
|
|
199
|
+
if (!coolActive) return;
|
|
200
|
+
const coolWait = cooldown.coolUntilMs - now;
|
|
201
|
+
const claimActive = cooldown.claimedRetryAtMs - now > 0;
|
|
202
|
+
// Duration alone decides. Neither hold is over threshold → let it sleep.
|
|
203
|
+
if (
|
|
204
|
+
coolWait <= PROVIDER_EXHAUSTED_MAX_WAIT_MS &&
|
|
205
|
+
!(claimActive && cooldown.claimedRetryAtMs - now > PROVIDER_EXHAUSTED_MAX_WAIT_MS)
|
|
206
|
+
) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Retry deadline: the server Retry-After verbatim when present, else the
|
|
210
|
+
// pacer's own cooldown deadline.
|
|
211
|
+
const retryAtMs =
|
|
212
|
+
cooldown.claimedRetryAtMs > 0
|
|
213
|
+
? cooldown.claimedRetryAtMs
|
|
214
|
+
: cooldown.coolUntilMs;
|
|
215
|
+
throw new ProviderExhaustedError({
|
|
216
|
+
provider: providerNameFromBucketId(bucketId),
|
|
217
|
+
retryAtMs,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
88
221
|
async acquire(input: {
|
|
89
222
|
bucketId: string;
|
|
90
223
|
rules: readonly PacingRule[];
|
|
@@ -93,125 +226,285 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
93
226
|
const { bucketId, rules, signal } = input;
|
|
94
227
|
if (rules.length === 0) return noopPacingPermit();
|
|
95
228
|
this.throwPendingReleaseFailure();
|
|
229
|
+
// Pre-check: zero round trips when the pacer is already holding past the
|
|
230
|
+
// threshold. Fails fast with a typed PROVIDER_EXHAUSTED before touching the
|
|
231
|
+
// store.
|
|
232
|
+
this.throwIfPacerExhausted(bucketId);
|
|
96
233
|
|
|
97
234
|
const ordered = [...rules].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
|
|
98
235
|
const rulesKey = rulesSignature(ordered);
|
|
99
|
-
|
|
236
|
+
|
|
237
|
+
// Drawing a cached permit is a synchronous, single-threaded critical
|
|
238
|
+
// section (`drawFromBlock` has no `await`), so concurrent acquires can never
|
|
239
|
+
// hand out the same permit — no lock is needed for the fast path. The ONLY
|
|
240
|
+
// thing that needs coordination is the block refill, which we single-flight
|
|
241
|
+
// per bucket: one network round-trip (and any window-exhausted sleep) serves
|
|
242
|
+
// the whole waiting wave, then everyone re-draws. This replaces the old
|
|
243
|
+
// per-bucket lock that held across the RPC + up-to-5s sleep and thereby
|
|
244
|
+
// serialized every map row behind one sleeping acquirer.
|
|
245
|
+
while (true) {
|
|
246
|
+
if (signal?.aborted) {
|
|
247
|
+
throw signal.reason instanceof Error
|
|
248
|
+
? signal.reason
|
|
249
|
+
: new Error('Rate-state acquire aborted.');
|
|
250
|
+
}
|
|
100
251
|
const localPermit = this.drawFromBlock(bucketId, rulesKey);
|
|
101
252
|
if (localPermit) return localPermit;
|
|
253
|
+
// Count this acquirer as waiting on the refill so the single-flight refill
|
|
254
|
+
// can size its block to the live wave. Decremented once a block lands (or
|
|
255
|
+
// the refill errors) — see the finally in `refillBlock`.
|
|
256
|
+
this.waiters.set(bucketId, (this.waiters.get(bucketId) ?? 0) + 1);
|
|
257
|
+
try {
|
|
258
|
+
await this.refillBlock(bucketId, ordered, rulesKey, signal);
|
|
259
|
+
} finally {
|
|
260
|
+
const remaining = (this.waiters.get(bucketId) ?? 1) - 1;
|
|
261
|
+
if (remaining > 0) this.waiters.set(bucketId, remaining);
|
|
262
|
+
else this.waiters.delete(bucketId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* How many permits to request on the next refill for a bucket. Buckets with any
|
|
269
|
+
* `maxConcurrency` rule stay at the fixed floor block: each permit pins a
|
|
270
|
+
* durable concurrency slot, so over-reserving would strand real slots. Pure-rate
|
|
271
|
+
* buckets demand-size: clamp the live waiter count to
|
|
272
|
+
* [block floor, window budget] where the window budget is the smallest
|
|
273
|
+
* `requestsPerWindow` across the pure-rate rules (never reserve more than one
|
|
274
|
+
* window can hold). A 200-row wave then lands in ~one round trip.
|
|
275
|
+
*/
|
|
276
|
+
private requestedForBucket(
|
|
277
|
+
bucketId: string,
|
|
278
|
+
rules: readonly PacingRule[],
|
|
279
|
+
): number {
|
|
280
|
+
const floor = APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE;
|
|
281
|
+
if (rules.some((rule) => rule.maxConcurrency != null)) return floor;
|
|
282
|
+
const windowBudget = Math.min(
|
|
283
|
+
...rules
|
|
284
|
+
.map((rule) => rule.requestsPerWindow)
|
|
285
|
+
.filter((value) => Number.isFinite(value) && value > 0),
|
|
286
|
+
);
|
|
287
|
+
if (!Number.isFinite(windowBudget) || windowBudget <= 0) return floor;
|
|
288
|
+
const waiters = this.waiters.get(bucketId) ?? 1;
|
|
289
|
+
return Math.max(floor, Math.min(waiters, Math.trunc(windowBudget)));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Sleep duration for a granted=0 refill: the server's `waitMs`, spread by ±10%
|
|
294
|
+
* jitter and capped by MAX_ACQUIRE_SLEEP_MS. Always at least 1ms.
|
|
295
|
+
*/
|
|
296
|
+
private jitteredWaitMs(serverWaitMs: number): number {
|
|
297
|
+
const base = Math.max(1, Math.min(serverWaitMs, MAX_ACQUIRE_SLEEP_MS));
|
|
298
|
+
// jitter() in [0,1) → factor in [1-0.1, 1+0.1).
|
|
299
|
+
const factor = 1 + (this.jitter() * 2 - 1) * ACQUIRE_WAIT_JITTER_FRACTION;
|
|
300
|
+
return Math.max(
|
|
301
|
+
1,
|
|
302
|
+
Math.min(Math.round(base * factor), MAX_ACQUIRE_SLEEP_MS),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Store the server's advisory cooldown for a bucket if the response carried one. */
|
|
307
|
+
private recordCooldown(
|
|
308
|
+
bucketId: string,
|
|
309
|
+
response: { coolUntilMs?: number; claimedRetryAtMs?: number },
|
|
310
|
+
): void {
|
|
311
|
+
const coolUntilMs = Number(response.coolUntilMs);
|
|
312
|
+
const claimedRetryAtMs = Number(response.claimedRetryAtMs);
|
|
313
|
+
if (!Number.isFinite(coolUntilMs) && !Number.isFinite(claimedRetryAtMs)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.cooldowns.set(bucketId, {
|
|
317
|
+
coolUntilMs: Number.isFinite(coolUntilMs) ? Math.max(0, coolUntilMs) : 0,
|
|
318
|
+
claimedRetryAtMs: Number.isFinite(claimedRetryAtMs)
|
|
319
|
+
? Math.max(0, claimedRetryAtMs)
|
|
320
|
+
: 0,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Build a PROVIDER_EXHAUSTED error for a granted=0 refill response. Retry
|
|
326
|
+
* deadline: the server Retry-After (`claimedRetryAtMs`) verbatim when present,
|
|
327
|
+
* else the pacer cooldown deadline (`coolUntilMs`), else `now + waitMs`.
|
|
328
|
+
*/
|
|
329
|
+
private providerExhaustedFromResponse(
|
|
330
|
+
bucketId: string,
|
|
331
|
+
response: { waitMs: number; coolUntilMs?: number; claimedRetryAtMs?: number },
|
|
332
|
+
): ProviderExhaustedError {
|
|
333
|
+
const claimedRetryAtMs = Number(response.claimedRetryAtMs);
|
|
334
|
+
const coolUntilMs = Number(response.coolUntilMs);
|
|
335
|
+
const retryAtMs =
|
|
336
|
+
Number.isFinite(claimedRetryAtMs) && claimedRetryAtMs > 0
|
|
337
|
+
? claimedRetryAtMs
|
|
338
|
+
: Number.isFinite(coolUntilMs) && coolUntilMs > 0
|
|
339
|
+
? coolUntilMs
|
|
340
|
+
: this.now() + Math.max(0, response.waitMs);
|
|
341
|
+
return new ProviderExhaustedError({
|
|
342
|
+
provider: providerNameFromBucketId(bucketId),
|
|
343
|
+
retryAtMs,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Single-flight a block refill for a bucket. Concurrent acquires that find the
|
|
349
|
+
* block empty all await the SAME in-flight refill instead of each issuing its
|
|
350
|
+
* own serialized round-trip, so the RPC and any window-exhausted sleep happen
|
|
351
|
+
* once per wave. Rejection (fail-closed store failure) propagates to every
|
|
352
|
+
* waiter.
|
|
353
|
+
*/
|
|
354
|
+
private refillBlock(
|
|
355
|
+
bucketId: string,
|
|
356
|
+
ordered: PacingRule[],
|
|
357
|
+
rulesKey: string,
|
|
358
|
+
signal?: AbortSignal,
|
|
359
|
+
): Promise<void> {
|
|
360
|
+
const existing = this.refills.get(bucketId);
|
|
361
|
+
if (existing) return existing;
|
|
362
|
+
const promise = this.performRefill(
|
|
363
|
+
bucketId,
|
|
364
|
+
ordered,
|
|
365
|
+
rulesKey,
|
|
366
|
+
signal,
|
|
367
|
+
).finally(() => {
|
|
368
|
+
if (this.refills.get(bucketId) === promise) {
|
|
369
|
+
this.refills.delete(bucketId);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
this.refills.set(bucketId, promise);
|
|
373
|
+
return promise;
|
|
374
|
+
}
|
|
102
375
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
376
|
+
private async performRefill(
|
|
377
|
+
bucketId: string,
|
|
378
|
+
ordered: PacingRule[],
|
|
379
|
+
rulesKey: string,
|
|
380
|
+
signal?: AbortSignal,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
let consecutiveFailures = 0;
|
|
383
|
+
// Yield once before sizing the first request so the whole synchronously-
|
|
384
|
+
// scheduled acquire wave finishes parking (incrementing `waiters`) before
|
|
385
|
+
// `requestedForBucket` reads the count. Without this, the single-flight
|
|
386
|
+
// refill fires while only the first acquirer has registered, and demand
|
|
387
|
+
// sizing collapses to the floor. Single-flight still holds — this is the
|
|
388
|
+
// one and only refill for the bucket; peers just enqueue behind it.
|
|
389
|
+
let firstPass = true;
|
|
390
|
+
while (true) {
|
|
391
|
+
// Abort is enforced per-acquirer in the acquire loop; a shared refill just
|
|
392
|
+
// stops early so waiters re-check their own signal.
|
|
393
|
+
if (signal?.aborted) return;
|
|
394
|
+
if (firstPass) {
|
|
395
|
+
firstPass = false;
|
|
396
|
+
await Promise.resolve();
|
|
397
|
+
if (signal?.aborted) return;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const response = await this.port.acquire({
|
|
401
|
+
bucketId,
|
|
402
|
+
// Stamp adaptiveMaxRps on default-pacing rules so the DB row's ceiling
|
|
403
|
+
// matches the old in-memory AIMD range.
|
|
404
|
+
rules: withAdaptiveMaxRps(ordered),
|
|
405
|
+
requested: this.requestedForBucket(bucketId, ordered),
|
|
406
|
+
schedulerSchema: this.schedulerSchema,
|
|
407
|
+
});
|
|
408
|
+
this.recordCooldown(bucketId, response);
|
|
409
|
+
if (response.granted > 0) {
|
|
410
|
+
// Merge the whole grant into the block and let acquirers re-draw. No
|
|
411
|
+
// permit is handed out here, so nothing is double-drawn.
|
|
412
|
+
this.mergeGrantedBlock(bucketId, ordered, rulesKey, response);
|
|
413
|
+
return;
|
|
109
414
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
const currentLeaseId = hasConcurrency ? leaseIds[0]! : null;
|
|
139
|
-
const remainingLeaseIds = hasConcurrency ? leaseIds.slice(1) : [];
|
|
140
|
-
const remainingWindowPermits = hasConcurrency
|
|
141
|
-
? 0
|
|
142
|
-
: response.granted - 1;
|
|
143
|
-
if (remainingLeaseIds.length > 0 || remainingWindowPermits > 0) {
|
|
144
|
-
this.mergeBlock(
|
|
145
|
-
bucketId,
|
|
146
|
-
remainingLeaseIds,
|
|
147
|
-
remainingWindowPermits,
|
|
148
|
-
rulesKey,
|
|
149
|
-
ordered,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
return this.permitFor(bucketId, ordered, currentLeaseId);
|
|
153
|
-
}
|
|
154
|
-
await this.sleep(
|
|
155
|
-
Math.max(1, Math.min(response.waitMs, MAX_ACQUIRE_SLEEP_MS)),
|
|
156
|
-
);
|
|
157
|
-
} catch (error) {
|
|
158
|
-
// Fail closed. A degraded rate-state store must not grant unlimited
|
|
159
|
-
// provider calls; after this bounded retry ladder the run fails loudly.
|
|
160
|
-
consecutiveFailures += 1;
|
|
161
|
-
const normalized = normalizeError(error);
|
|
162
|
-
this.onStoreFailure({ bucketId, error: normalized.message });
|
|
163
|
-
if (consecutiveFailures > STORE_FAILURE_RETRY_DELAYS_MS.length) {
|
|
164
|
-
throw new Error(
|
|
165
|
-
`Rate-state store unavailable for ${bucketId}; failing closed after ${consecutiveFailures} acquire attempts: ${normalized.message}`,
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
await this.sleep(
|
|
169
|
-
STORE_FAILURE_RETRY_DELAYS_MS[consecutiveFailures - 1] ?? 250,
|
|
415
|
+
// Live path: the server told us to wait past the threshold. Fail fast
|
|
416
|
+
// with a typed PROVIDER_EXHAUSTED instead of sleeping toward it. The
|
|
417
|
+
// rejection propagates to every waiter parked on this single-flight
|
|
418
|
+
// refill (they all skip with no spend). Duration alone decides.
|
|
419
|
+
if (response.waitMs > PROVIDER_EXHAUSTED_MAX_WAIT_MS) {
|
|
420
|
+
throw this.providerExhaustedFromResponse(bucketId, response);
|
|
421
|
+
}
|
|
422
|
+
// Window exhausted: sleep the SERVER's waitMs once for the whole waiting
|
|
423
|
+
// wave (not per row, and not a guess), then return so acquirers re-draw
|
|
424
|
+
// and single-flight another refill if still empty. ±10% jitter so a
|
|
425
|
+
// synchronized wave re-probes at spread-out times; capped by
|
|
426
|
+
// MAX_ACQUIRE_SLEEP_MS.
|
|
427
|
+
await this.sleep(this.jitteredWaitMs(response.waitMs));
|
|
428
|
+
return;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// PROVIDER_EXHAUSTED is a deliberate skip, not a store failure: it must
|
|
431
|
+
// escape this fail-closed retry ladder verbatim so it reaches the
|
|
432
|
+
// acquire caller (and, via step-miss handling, the row).
|
|
433
|
+
if (error instanceof ProviderExhaustedError) throw error;
|
|
434
|
+
// Fail closed. A degraded rate-state store must not grant unlimited
|
|
435
|
+
// provider calls; after this bounded retry ladder the run fails loudly.
|
|
436
|
+
consecutiveFailures += 1;
|
|
437
|
+
const normalized = normalizeError(error);
|
|
438
|
+
this.onStoreFailure({ bucketId, error: normalized.message });
|
|
439
|
+
if (consecutiveFailures > STORE_FAILURE_RETRY_DELAYS_MS.length) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Rate-state store unavailable for ${bucketId}; failing closed after ${consecutiveFailures} acquire attempts: ${normalized.message}`,
|
|
170
442
|
);
|
|
171
443
|
}
|
|
444
|
+
await this.sleep(
|
|
445
|
+
STORE_FAILURE_RETRY_DELAYS_MS[consecutiveFailures - 1] ?? 250,
|
|
446
|
+
);
|
|
172
447
|
}
|
|
173
|
-
}
|
|
448
|
+
}
|
|
174
449
|
}
|
|
175
450
|
|
|
176
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Validate a granted acquire response and merge its permits into the bucket's
|
|
453
|
+
* local block. Concurrency rules carry one lease token per permit (release
|
|
454
|
+
* semantics); pure-rate rules carry untracked window permits the store already
|
|
455
|
+
* decremented.
|
|
456
|
+
*/
|
|
457
|
+
private mergeGrantedBlock(
|
|
177
458
|
bucketId: string,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
459
|
+
ordered: PacingRule[],
|
|
460
|
+
rulesKey: string,
|
|
461
|
+
response: { granted: number; leaseIds?: unknown },
|
|
462
|
+
): void {
|
|
463
|
+
const hasConcurrency = ordered.some((rule) => rule.maxConcurrency != null);
|
|
464
|
+
const leaseIds = Array.isArray(response.leaseIds)
|
|
465
|
+
? response.leaseIds.filter(
|
|
466
|
+
(leaseId): leaseId is string =>
|
|
467
|
+
typeof leaseId === 'string' && leaseId.trim().length > 0,
|
|
468
|
+
)
|
|
469
|
+
: [];
|
|
470
|
+
if (hasConcurrency && leaseIds.length !== response.granted) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`Rate-state store granted ${response.granted} concurrency permits for ${bucketId} with ${leaseIds.length} lease tokens.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (!hasConcurrency && leaseIds.length > 0) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`Rate-state store returned ${leaseIds.length} lease tokens for pure-rate bucket ${bucketId}.`,
|
|
478
|
+
);
|
|
195
479
|
}
|
|
480
|
+
this.mergeBlock(
|
|
481
|
+
bucketId,
|
|
482
|
+
hasConcurrency ? leaseIds : [],
|
|
483
|
+
hasConcurrency ? 0 : response.granted,
|
|
484
|
+
rulesKey,
|
|
485
|
+
ordered,
|
|
486
|
+
);
|
|
196
487
|
}
|
|
197
488
|
|
|
198
489
|
penalize(input: { bucketId: string; cooldownMs: number }): void {
|
|
199
490
|
if (input.cooldownMs <= 0) return;
|
|
200
491
|
this.drainBlock(input.bucketId);
|
|
201
|
-
void
|
|
202
|
-
|
|
203
|
-
cooldownMs: input.cooldownMs,
|
|
204
|
-
schedulerSchema: this.schedulerSchema,
|
|
205
|
-
}).catch((error) => {
|
|
206
|
-
const normalized = normalizeError(error);
|
|
207
|
-
this.pendingReleaseFailure = new Error(
|
|
208
|
-
`Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
|
|
209
|
-
);
|
|
210
|
-
this.onStoreFailure({
|
|
492
|
+
void this.port
|
|
493
|
+
.penalize({
|
|
211
494
|
bucketId: input.bucketId,
|
|
212
|
-
|
|
495
|
+
cooldownMs: input.cooldownMs,
|
|
496
|
+
schedulerSchema: this.schedulerSchema,
|
|
497
|
+
})
|
|
498
|
+
.catch((error) => {
|
|
499
|
+
const normalized = normalizeError(error);
|
|
500
|
+
this.pendingReleaseFailure = new Error(
|
|
501
|
+
`Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
|
|
502
|
+
);
|
|
503
|
+
this.onStoreFailure({
|
|
504
|
+
bucketId: input.bucketId,
|
|
505
|
+
error: normalized.message,
|
|
506
|
+
});
|
|
213
507
|
});
|
|
214
|
-
});
|
|
215
508
|
}
|
|
216
509
|
|
|
217
510
|
private permitFor(
|
|
@@ -328,7 +621,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
328
621
|
while (pending.leaseIds.length > 0) {
|
|
329
622
|
const leaseIds = pending.leaseIds.splice(0);
|
|
330
623
|
try {
|
|
331
|
-
await
|
|
624
|
+
await this.port.release({
|
|
332
625
|
bucketId: pending.bucketId,
|
|
333
626
|
rules: pending.rules,
|
|
334
627
|
leaseIds,
|
|
@@ -364,6 +657,40 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
364
657
|
}
|
|
365
658
|
}
|
|
366
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Multiplier applied to a default-pacing rule's `requestsPerWindow` to derive the
|
|
662
|
+
* `adaptiveMaxRps` ceiling. Matches the DB seed's `DEFAULT_PACING_MAX_RPS_MULTIPLIER`
|
|
663
|
+
* and the old in-memory AIMD range (default RPS ramps up to base * 25).
|
|
664
|
+
*/
|
|
665
|
+
const DEFAULT_PACING_ADAPTIVE_MAX_RPS_MULTIPLIER = 25;
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* A rule id shaped `default:<toolId>` is the undeclared-tool default-pacing rule
|
|
669
|
+
* minted by `defaultPacingForTool` in governor.ts. Only those ramp above their
|
|
670
|
+
* base rate; declared providers pin at their contractual rate.
|
|
671
|
+
*/
|
|
672
|
+
function isDefaultPacingRuleId(ruleId: string): boolean {
|
|
673
|
+
return ruleId.startsWith('default:');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Stamp `adaptiveMaxRps` onto default-pacing rules so the DB row's ceiling matches
|
|
678
|
+
* the old in-memory AIMD range (base * 25). Declared rules pass through untouched
|
|
679
|
+
* (the DB pins them at base). The field is extra on the wire; the server's
|
|
680
|
+
* `normalizeRules` reads it, everyone else ignores it.
|
|
681
|
+
*/
|
|
682
|
+
function withAdaptiveMaxRps(rules: readonly PacingRule[]): PacingRule[] {
|
|
683
|
+
return rules.map((rule) =>
|
|
684
|
+
isDefaultPacingRuleId(rule.ruleId)
|
|
685
|
+
? ({
|
|
686
|
+
...rule,
|
|
687
|
+
adaptiveMaxRps:
|
|
688
|
+
rule.requestsPerWindow * DEFAULT_PACING_ADAPTIVE_MAX_RPS_MULTIPLIER,
|
|
689
|
+
} as PacingRule)
|
|
690
|
+
: rule,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
367
694
|
function rulesSignature(rules: readonly PacingRule[]): string {
|
|
368
695
|
return [...rules]
|
|
369
696
|
.map(
|
|
@@ -282,6 +282,15 @@ export function createPlayExecutionGovernor(
|
|
|
282
282
|
const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
|
|
283
283
|
const providerByTool = new Map<string, string>();
|
|
284
284
|
|
|
285
|
+
// When the rate-state backend owns pacing authoritatively (the Absurd/Node
|
|
286
|
+
// app-runtime Postgres pacer runs the whole token bucket + AIMD in the row),
|
|
287
|
+
// the Governor's in-memory adaptive admission becomes a passthrough: it only
|
|
288
|
+
// SEEDS declared/default rules and never halves/ramps them here. Backpressure
|
|
289
|
+
// is routed solely to `rateState.penalize` so the DB row is the single source
|
|
290
|
+
// of pacing truth. The coordinator and in-memory backends leave `kind`
|
|
291
|
+
// undefined and keep the in-memory adaptive admission (byte-identical).
|
|
292
|
+
const dbAuthoritativePacing = input.rateState.kind === 'app_runtime_postgres';
|
|
293
|
+
|
|
285
294
|
const bucketId = (provider: string) => `${input.scope.orgId}:${provider}`;
|
|
286
295
|
|
|
287
296
|
async function resolveAdaptivePacing(toolId: string): Promise<{
|
|
@@ -291,6 +300,14 @@ export function createPlayExecutionGovernor(
|
|
|
291
300
|
const resolvedPacing = await input.resolvePacing(toolId);
|
|
292
301
|
const declaredPacing =
|
|
293
302
|
resolvedPacing && resolvedPacing.rules.length > 0 ? resolvedPacing : null;
|
|
303
|
+
if (dbAuthoritativePacing) {
|
|
304
|
+
// Passthrough: hand the DB-backed pacer the declared rules verbatim (or
|
|
305
|
+
// the default-pacing shape when undeclared). No in-memory admission — the
|
|
306
|
+
// row is authoritative.
|
|
307
|
+
const pacing = declaredPacing ?? defaultPacingForTool(toolId, policy);
|
|
308
|
+
providerByTool.set(toolId, pacing.provider);
|
|
309
|
+
return pacing;
|
|
310
|
+
}
|
|
294
311
|
const pacing = adaptiveAdmission.resolvePacing({
|
|
295
312
|
orgId: input.scope.orgId,
|
|
296
313
|
toolId,
|
|
@@ -531,11 +548,16 @@ export function createPlayExecutionGovernor(
|
|
|
531
548
|
resolveRowConcurrency(policy, requested),
|
|
532
549
|
|
|
533
550
|
reportProviderBackpressure(bp) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
551
|
+
// DB-authoritative pacer: route backpressure ONLY to the row via
|
|
552
|
+
// penalize. The in-memory adaptive admission must not also halve, or the
|
|
553
|
+
// provider would be throttled twice (once in the row, once in memory).
|
|
554
|
+
if (!dbAuthoritativePacing) {
|
|
555
|
+
adaptiveAdmission.observeProviderBackpressure({
|
|
556
|
+
orgId: input.scope.orgId,
|
|
557
|
+
provider: bp.provider,
|
|
558
|
+
retryAfterMs: bp.retryAfterMs,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
539
561
|
input.rateState.penalize({
|
|
540
562
|
bucketId: bucketId(bp.provider),
|
|
541
563
|
cooldownMs: bp.retryAfterMs,
|