deepline 0.3.184 → 0.3.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +53 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +751 -193
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +4 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +3 -1
- package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +437 -150
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +47 -4
- package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +3 -1
- package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
- package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +22 -2
- package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +6 -1
- package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +11 -5
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-flow.ts +102 -12
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +136 -28
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-scope.ts +80 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +897 -207
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +33 -1
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +120 -34
- package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +82 -2
- package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +7 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/install-integrity.json +1 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts
CHANGED
|
@@ -2,12 +2,16 @@ import {
|
|
|
2
2
|
acquireRateStateViaAppRuntime,
|
|
3
3
|
penalizeRateStateViaAppRuntime,
|
|
4
4
|
releaseRateStateViaAppRuntime,
|
|
5
|
+
RateStateValidationUnavailableError,
|
|
6
|
+
validateRateStateViaAppRuntime,
|
|
5
7
|
type WorkerRuntimeApiContext,
|
|
6
8
|
} from '../app-runtime-api';
|
|
7
9
|
import { randomUUID } from 'node:crypto';
|
|
8
10
|
import {
|
|
9
11
|
DEFAULT_ADVISORY_PROVIDER_MAX_RPS,
|
|
12
|
+
DEFAULT_UNKNOWN_PROVIDER_COLD_START_RPS,
|
|
10
13
|
noopPacingPermit,
|
|
14
|
+
PROVIDER_AIMD_COOLDOWN_CAP_MS,
|
|
11
15
|
type PacingPermit,
|
|
12
16
|
type ProviderConcurrencyGrant,
|
|
13
17
|
type ProviderPacingGrant,
|
|
@@ -19,19 +23,18 @@ import {
|
|
|
19
23
|
ProviderExhaustedError,
|
|
20
24
|
providerNameFromBucketId,
|
|
21
25
|
} from '../run-failure';
|
|
26
|
+
import type { PlayRunnerRateStateValidateResult } from '../protocol';
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
|
-
* Longest provider pacer hold
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* provider dispatch.
|
|
29
|
+
* Longest provider pacer hold one executor will wait through in-process.
|
|
30
|
+
* Longer holds throw a typed handoff before another provider dispatch. Managed
|
|
31
|
+
* durable Plays catch that signal and park on a persisted sleep; callers without
|
|
32
|
+
* durable boundaries receive the typed provider-exhausted outcome.
|
|
29
33
|
*
|
|
30
|
-
* Enforced
|
|
31
|
-
*
|
|
32
|
-
* so keep their sleep-toward-it behavior byte-for-byte.
|
|
34
|
+
* Enforced only by the `app_runtime_postgres` backend. In-memory and coordinator
|
|
35
|
+
* backends do not construct this class and retain their existing wait behavior.
|
|
33
36
|
*/
|
|
34
|
-
export const PROVIDER_EXHAUSTED_MAX_WAIT_MS =
|
|
37
|
+
export const PROVIDER_EXHAUSTED_MAX_WAIT_MS = 300_000;
|
|
35
38
|
/**
|
|
36
39
|
* Total time one acquirer may spend cycling through window-exhausted refills.
|
|
37
40
|
* Each refill's sleep is bounded, but nothing bounded how many of them a row
|
|
@@ -39,6 +42,7 @@ export const PROVIDER_EXHAUSTED_MAX_WAIT_MS = 60_000;
|
|
|
39
42
|
* starter parked here for twenty minutes with its core idle.
|
|
40
43
|
*/
|
|
41
44
|
export const RATE_ACQUIRE_MAX_WAIT_MS = 300_000;
|
|
45
|
+
const PROVIDER_NO_PROGRESS_WARNING_MS = 180_000;
|
|
42
46
|
|
|
43
47
|
/**
|
|
44
48
|
* Permits leased per app-runtime round trip for the direct Node substrate.
|
|
@@ -62,6 +66,9 @@ export interface AppRuntimeRatePort {
|
|
|
62
66
|
penalize(
|
|
63
67
|
input: Parameters<typeof penalizeRateStateViaAppRuntime>[1],
|
|
64
68
|
): ReturnType<typeof penalizeRateStateViaAppRuntime>;
|
|
69
|
+
validate?(
|
|
70
|
+
input: Parameters<typeof validateRateStateViaAppRuntime>[1],
|
|
71
|
+
): ReturnType<typeof validateRateStateViaAppRuntime>;
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
function contextRatePort(context: WorkerRuntimeApiContext): AppRuntimeRatePort {
|
|
@@ -69,6 +76,7 @@ function contextRatePort(context: WorkerRuntimeApiContext): AppRuntimeRatePort {
|
|
|
69
76
|
acquire: (input) => acquireRateStateViaAppRuntime(context, input),
|
|
70
77
|
release: (input) => releaseRateStateViaAppRuntime(context, input),
|
|
71
78
|
penalize: (input) => penalizeRateStateViaAppRuntime(context, input),
|
|
79
|
+
validate: (input) => validateRateStateViaAppRuntime(context, input),
|
|
72
80
|
};
|
|
73
81
|
}
|
|
74
82
|
|
|
@@ -113,6 +121,7 @@ interface LeasedBlock {
|
|
|
113
121
|
reservationId: string | null,
|
|
114
122
|
concurrencyGrant: ProviderConcurrencyGrant | null,
|
|
115
123
|
pacingGrant: ProviderPacingGrant | null,
|
|
124
|
+
scopeRetryAtMsAtGrant: number,
|
|
116
125
|
]
|
|
117
126
|
>;
|
|
118
127
|
rateScopeToken: string | null;
|
|
@@ -130,6 +139,12 @@ type PendingConcurrencyObservations = {
|
|
|
130
139
|
observations: Array<{ kind: 'success'; latencyMs: number }>;
|
|
131
140
|
};
|
|
132
141
|
|
|
142
|
+
type SuccessFeedback = {
|
|
143
|
+
observedSuccesses: number;
|
|
144
|
+
concurrencyObservations: PendingConcurrencyObservations['observations'];
|
|
145
|
+
concurrencyObservationBatch?: PendingConcurrencyObservations['batch'];
|
|
146
|
+
};
|
|
147
|
+
|
|
133
148
|
interface PendingRelease {
|
|
134
149
|
bucketId: string;
|
|
135
150
|
rateScopeToken: string | null;
|
|
@@ -171,6 +186,15 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
171
186
|
private readonly blocks = new Map<string, LeasedBlock>();
|
|
172
187
|
private readonly refills = new Map<string, Promise<void>>();
|
|
173
188
|
private readonly pendingReleases = new Map<string, PendingRelease>();
|
|
189
|
+
private readonly validationFlights = new Map<
|
|
190
|
+
string,
|
|
191
|
+
{ generation: number; promise: Promise<PlayRunnerRateStateValidateResult> }
|
|
192
|
+
>();
|
|
193
|
+
private readonly validationGenerations = new Map<string, number>();
|
|
194
|
+
private readonly validationScopes = new Set<string>();
|
|
195
|
+
private readonly pendingPenaltyWrites = new Map<string, Promise<void>>();
|
|
196
|
+
private readonly serverClockOffsetsMs = new Map<string, number>();
|
|
197
|
+
private readonly localProviderRetryAtMs = new Map<string, number>();
|
|
174
198
|
/**
|
|
175
199
|
* Last locally synthesized dispatch slot per lane. During a rolling deploy,
|
|
176
200
|
* a gateway that omits its global schedule can return another block while
|
|
@@ -186,8 +210,13 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
186
210
|
private readonly waiters = new Map<string, number>();
|
|
187
211
|
/** Last-known server cooldown per bucket (surfaced for PROVIDER_EXHAUSTED). */
|
|
188
212
|
private readonly cooldowns = new Map<string, RateStateCooldown>();
|
|
213
|
+
private readonly providerStalls = new Map<
|
|
214
|
+
string,
|
|
215
|
+
{ startedAt: number; warned: boolean }
|
|
216
|
+
>();
|
|
189
217
|
private readonly effectiveRps = new Map<string, number>();
|
|
190
218
|
private readonly pendingSuccesses = new Map<string, number>();
|
|
219
|
+
private readonly successFeedbackFlights = new Map<string, SuccessFeedback>();
|
|
191
220
|
/**
|
|
192
221
|
* Completion samples travel with the next lease refill. This keeps the
|
|
193
222
|
* Gradient2 checkpoint current without adding one gateway request per row.
|
|
@@ -230,6 +259,10 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
230
259
|
return this.cooldowns.get(bucketId) ?? null;
|
|
231
260
|
}
|
|
232
261
|
|
|
262
|
+
private serverNow(bucketId: string): number {
|
|
263
|
+
return this.now() + (this.serverClockOffsetsMs.get(bucketId) ?? 0);
|
|
264
|
+
}
|
|
265
|
+
|
|
233
266
|
/**
|
|
234
267
|
* Pre-check the last-known server cooldown for a bucket. If the pacer is
|
|
235
268
|
* already holding this provider past PROVIDER_EXHAUSTED_MAX_WAIT_MS, throw a
|
|
@@ -238,35 +271,77 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
238
271
|
* later than the internally-capped `coolUntilMs` and the cooldown is active;
|
|
239
272
|
* otherwise reports `coolUntilMs`. No-op when the bucket has no live hold.
|
|
240
273
|
*/
|
|
241
|
-
private throwIfPacerExhausted(
|
|
242
|
-
|
|
274
|
+
private throwIfPacerExhausted(
|
|
275
|
+
bucketId: string,
|
|
276
|
+
yieldProviderHolds = false,
|
|
277
|
+
): void {
|
|
278
|
+
const exhausted = this.pacerExhaustion(bucketId, yieldProviderHolds);
|
|
243
279
|
if (exhausted) throw exhausted;
|
|
280
|
+
const stall = this.providerStalls.get(bucketId);
|
|
281
|
+
if (!stall) return;
|
|
282
|
+
const elapsedMs = this.now() - stall.startedAt;
|
|
283
|
+
const cooldown = this.cooldowns.get(bucketId);
|
|
284
|
+
const serverNowMs = this.serverNow(bucketId);
|
|
285
|
+
const retryAtMs = Math.max(
|
|
286
|
+
cooldown?.coolUntilMs ?? 0,
|
|
287
|
+
cooldown?.claimedRetryAtMs ?? 0,
|
|
288
|
+
);
|
|
289
|
+
const retryAtActive = retryAtMs > serverNowMs;
|
|
290
|
+
if (!retryAtActive && elapsedMs >= RATE_ACQUIRE_MAX_WAIT_MS) {
|
|
291
|
+
// Keep the no-progress timer across short Retry-After cycles so its
|
|
292
|
+
// warning remains useful. Once its five-minute guard has been spent and
|
|
293
|
+
// the last hold is over, however, reset it before re-admitting; otherwise
|
|
294
|
+
// this process would turn every later run into retry-now exhaustion.
|
|
295
|
+
this.providerStalls.delete(bucketId);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
retryAtActive &&
|
|
300
|
+
(elapsedMs >= RATE_ACQUIRE_MAX_WAIT_MS ||
|
|
301
|
+
retryAtMs - serverNowMs > RATE_ACQUIRE_MAX_WAIT_MS - elapsedMs)
|
|
302
|
+
) {
|
|
303
|
+
throw new ProviderExhaustedError({
|
|
304
|
+
provider: providerNameFromBucketId(bucketId),
|
|
305
|
+
retryAtMs,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (!stall.warned && elapsedMs >= PROVIDER_NO_PROGRESS_WARNING_MS) {
|
|
309
|
+
stall.warned = true;
|
|
310
|
+
console.warn('[runtime.provider.no_progress]', {
|
|
311
|
+
provider: providerNameFromBucketId(bucketId),
|
|
312
|
+
elapsedMs,
|
|
313
|
+
retryAt: retryAtActive ? new Date(retryAtMs).toISOString() : null,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
244
316
|
}
|
|
245
317
|
|
|
246
|
-
private pacerExhaustion(
|
|
318
|
+
private pacerExhaustion(
|
|
319
|
+
bucketId: string,
|
|
320
|
+
yieldProviderHolds = false,
|
|
321
|
+
): ProviderExhaustedError | null {
|
|
247
322
|
const cooldown = this.cooldowns.get(bucketId);
|
|
248
323
|
if (!cooldown) return null;
|
|
249
|
-
const now = this.
|
|
324
|
+
const now = this.serverNow(bucketId);
|
|
250
325
|
const coolActive = cooldown.coolUntilMs - now > 0;
|
|
251
|
-
if (!coolActive) return null;
|
|
252
|
-
const coolWait = cooldown.coolUntilMs - now;
|
|
253
326
|
const claimActive = cooldown.claimedRetryAtMs - now > 0;
|
|
254
|
-
|
|
327
|
+
if (!coolActive && !claimActive) return null;
|
|
328
|
+
const coolWait = coolActive ? cooldown.coolUntilMs - now : 0;
|
|
329
|
+
const claimWait = claimActive ? cooldown.claimedRetryAtMs - now : 0;
|
|
330
|
+
// Duration alone decides. Either active hold can be the effective
|
|
331
|
+
// deadline; in particular, Retry-After can outlive the capped AIMD hold.
|
|
255
332
|
if (
|
|
333
|
+
!yieldProviderHolds &&
|
|
256
334
|
coolWait <= PROVIDER_EXHAUSTED_MAX_WAIT_MS &&
|
|
257
|
-
|
|
258
|
-
claimActive &&
|
|
259
|
-
cooldown.claimedRetryAtMs - now > PROVIDER_EXHAUSTED_MAX_WAIT_MS
|
|
260
|
-
)
|
|
335
|
+
claimWait <= PROVIDER_EXHAUSTED_MAX_WAIT_MS
|
|
261
336
|
) {
|
|
262
337
|
return null;
|
|
263
338
|
}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
const retryAtMs =
|
|
267
|
-
cooldown.
|
|
268
|
-
|
|
269
|
-
|
|
339
|
+
// Report the furthest active hold, even after the internally capped AIMD
|
|
340
|
+
// deadline has elapsed.
|
|
341
|
+
const retryAtMs = Math.max(
|
|
342
|
+
coolActive ? cooldown.coolUntilMs : 0,
|
|
343
|
+
claimActive ? cooldown.claimedRetryAtMs : 0,
|
|
344
|
+
);
|
|
270
345
|
return new ProviderExhaustedError({
|
|
271
346
|
provider: providerNameFromBucketId(bucketId),
|
|
272
347
|
retryAtMs,
|
|
@@ -287,7 +362,9 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
287
362
|
}
|
|
288
363
|
const laneKey = rateLaneKey(
|
|
289
364
|
bucketId,
|
|
290
|
-
rulesSignature(
|
|
365
|
+
rulesSignature(
|
|
366
|
+
[...rules].sort((a, b) => a.ruleId.localeCompare(b.ruleId)),
|
|
367
|
+
),
|
|
291
368
|
);
|
|
292
369
|
const block = this.liveBlock(bucketId, laneKey);
|
|
293
370
|
if (!block) return null;
|
|
@@ -302,6 +379,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
302
379
|
rateScopeToken?: string | null;
|
|
303
380
|
rules: readonly PacingRule[];
|
|
304
381
|
signal?: AbortSignal;
|
|
382
|
+
yieldProviderHolds?: boolean;
|
|
305
383
|
}): Promise<PacingPermit> {
|
|
306
384
|
const { bucketId, rules, signal } = input;
|
|
307
385
|
if (rules.length === 0) return noopPacingPermit();
|
|
@@ -309,7 +387,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
309
387
|
// Pre-check: zero round trips when the pacer is already holding past the
|
|
310
388
|
// threshold. Fails fast with a typed PROVIDER_EXHAUSTED before touching the
|
|
311
389
|
// store.
|
|
312
|
-
this.throwIfPacerExhausted(bucketId);
|
|
390
|
+
this.throwIfPacerExhausted(bucketId, input.yieldProviderHolds === true);
|
|
313
391
|
|
|
314
392
|
const ordered = [...rules].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
|
|
315
393
|
const rulesKey = rulesSignature(ordered);
|
|
@@ -325,6 +403,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
325
403
|
// serialized every map row behind one sleeping acquirer.
|
|
326
404
|
const acquireStartedAt = this.now();
|
|
327
405
|
while (true) {
|
|
406
|
+
this.throwIfPacerExhausted(bucketId, input.yieldProviderHolds === true);
|
|
328
407
|
if (signal?.aborted) {
|
|
329
408
|
throw signal.reason instanceof Error
|
|
330
409
|
? signal.reason
|
|
@@ -361,6 +440,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
361
440
|
rulesKey,
|
|
362
441
|
laneKey,
|
|
363
442
|
signal,
|
|
443
|
+
input.yieldProviderHolds === true,
|
|
364
444
|
);
|
|
365
445
|
} finally {
|
|
366
446
|
const remaining = (this.waiters.get(laneKey) ?? 1) - 1;
|
|
@@ -417,7 +497,10 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
417
497
|
Math.min(waiters, Math.trunc(rateBudget), ...concurrencyLimits),
|
|
418
498
|
);
|
|
419
499
|
}
|
|
420
|
-
|
|
500
|
+
const learnedFloor = this.effectiveRps.has(laneKey)
|
|
501
|
+
? Math.min(floor, rateBudget)
|
|
502
|
+
: floor;
|
|
503
|
+
return Math.max(learnedFloor, Math.min(waiters, Math.trunc(rateBudget)));
|
|
421
504
|
}
|
|
422
505
|
|
|
423
506
|
/**
|
|
@@ -437,21 +520,119 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
437
520
|
/** Store the server's advisory cooldown for a bucket if the response carried one. */
|
|
438
521
|
private recordCooldown(
|
|
439
522
|
bucketId: string,
|
|
440
|
-
response: {
|
|
523
|
+
response: {
|
|
524
|
+
coolUntilMs?: number;
|
|
525
|
+
claimedRetryAtMs?: number;
|
|
526
|
+
serverNowMs?: number;
|
|
527
|
+
},
|
|
441
528
|
): void {
|
|
529
|
+
if (response.serverNowMs != null && Number.isFinite(response.serverNowMs)) {
|
|
530
|
+
this.serverClockOffsetsMs.set(
|
|
531
|
+
bucketId,
|
|
532
|
+
response.serverNowMs - this.now(),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
442
535
|
const coolUntilMs = Number(response.coolUntilMs);
|
|
443
536
|
const claimedRetryAtMs = Number(response.claimedRetryAtMs);
|
|
537
|
+
const now = this.serverNow(bucketId);
|
|
444
538
|
if (!Number.isFinite(coolUntilMs) && !Number.isFinite(claimedRetryAtMs)) {
|
|
445
539
|
return;
|
|
446
540
|
}
|
|
541
|
+
const existing = this.cooldowns.get(bucketId);
|
|
447
542
|
this.cooldowns.set(bucketId, {
|
|
448
|
-
coolUntilMs:
|
|
449
|
-
|
|
450
|
-
? Math.max(0,
|
|
451
|
-
|
|
543
|
+
coolUntilMs: Math.max(
|
|
544
|
+
existing && existing.coolUntilMs > now ? existing.coolUntilMs : 0,
|
|
545
|
+
Number.isFinite(coolUntilMs) ? Math.max(0, coolUntilMs) : 0,
|
|
546
|
+
),
|
|
547
|
+
claimedRetryAtMs: Math.max(
|
|
548
|
+
existing && existing.claimedRetryAtMs > now
|
|
549
|
+
? existing.claimedRetryAtMs
|
|
550
|
+
: 0,
|
|
551
|
+
Number.isFinite(claimedRetryAtMs) && claimedRetryAtMs > now
|
|
552
|
+
? Math.max(0, claimedRetryAtMs)
|
|
553
|
+
: 0,
|
|
554
|
+
),
|
|
452
555
|
});
|
|
453
556
|
}
|
|
454
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Revalidate a cached ticket against the shared provider hold. Concurrent
|
|
560
|
+
* rows in this runner share one in-flight read per provider bucket; completed
|
|
561
|
+
* reads are never cached. A local 429 invalidates that flight synchronously,
|
|
562
|
+
* and its write is awaited before any stale validator can accept a ticket.
|
|
563
|
+
*/
|
|
564
|
+
private async validateProviderPermit(
|
|
565
|
+
bucketId: string,
|
|
566
|
+
rateScopeToken: string | null,
|
|
567
|
+
scopeRetryAtMsAtGrant: number,
|
|
568
|
+
): Promise<boolean> {
|
|
569
|
+
const validate = this.port.validate;
|
|
570
|
+
if (!validate || !this.validationScopes.has(bucketId)) return true;
|
|
571
|
+
|
|
572
|
+
while (true) {
|
|
573
|
+
const pendingPenalty = this.pendingPenaltyWrites.get(bucketId);
|
|
574
|
+
if (pendingPenalty) await pendingPenalty;
|
|
575
|
+
|
|
576
|
+
// This is a database-clock deadline, not a worker-clock deadline.
|
|
577
|
+
// Retain the local fence even if persisting the 429 failed.
|
|
578
|
+
const localRetryAtMs = this.localProviderRetryAtMs.get(bucketId) ?? 0;
|
|
579
|
+
if (localRetryAtMs > this.serverNow(bucketId)) {
|
|
580
|
+
this.drainBlocksForBucket(bucketId);
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const generation = this.validationGenerations.get(bucketId) ?? 0;
|
|
585
|
+
let flight = this.validationFlights.get(bucketId);
|
|
586
|
+
if (!flight || flight.generation !== generation) {
|
|
587
|
+
const promise = Promise.resolve()
|
|
588
|
+
.then(() =>
|
|
589
|
+
this.port.validate!({
|
|
590
|
+
bucketId,
|
|
591
|
+
rateScopeToken,
|
|
592
|
+
schedulerSchema: this.schedulerSchema,
|
|
593
|
+
}),
|
|
594
|
+
)
|
|
595
|
+
.finally(() => {
|
|
596
|
+
if (this.validationFlights.get(bucketId)?.promise === promise) {
|
|
597
|
+
this.validationFlights.delete(bucketId);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
flight = { generation, promise };
|
|
601
|
+
this.validationFlights.set(bucketId, flight);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const result = await flight.promise;
|
|
605
|
+
if ((this.validationGenerations.get(bucketId) ?? 0) !== generation) {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (!result.allowed) {
|
|
609
|
+
const retryAtMs = result.retryAtMs;
|
|
610
|
+
this.localProviderRetryAtMs.set(bucketId, retryAtMs);
|
|
611
|
+
this.drainBlocksForBucket(bucketId);
|
|
612
|
+
this.recordCooldown(bucketId, {
|
|
613
|
+
coolUntilMs: Math.min(
|
|
614
|
+
retryAtMs,
|
|
615
|
+
result.serverNowMs + PROVIDER_AIMD_COOLDOWN_CAP_MS,
|
|
616
|
+
),
|
|
617
|
+
claimedRetryAtMs: retryAtMs,
|
|
618
|
+
serverNowMs: result.serverNowMs,
|
|
619
|
+
});
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
if (localRetryAtMs <= result.serverNowMs) {
|
|
623
|
+
this.localProviderRetryAtMs.delete(bucketId);
|
|
624
|
+
}
|
|
625
|
+
// The hold's deadline is monotonic. Even after it expires, a ticket
|
|
626
|
+
// minted before that hold belongs to the old pace and must be replaced.
|
|
627
|
+
// Preserve newer tickets another waiter may already have acquired.
|
|
628
|
+
if (result.retryAtMs > scopeRetryAtMsAtGrant) {
|
|
629
|
+
this.drainBlocksForBucket(bucketId, result.retryAtMs);
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
455
636
|
/**
|
|
456
637
|
* Build a PROVIDER_EXHAUSTED error for a granted=0 refill response. Retry
|
|
457
638
|
* deadline: the server Retry-After (`claimedRetryAtMs`) verbatim when present,
|
|
@@ -465,14 +646,16 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
465
646
|
claimedRetryAtMs?: number;
|
|
466
647
|
},
|
|
467
648
|
): ProviderExhaustedError {
|
|
649
|
+
const now = this.serverNow(bucketId);
|
|
468
650
|
const claimedRetryAtMs = Number(response.claimedRetryAtMs);
|
|
469
651
|
const coolUntilMs = Number(response.coolUntilMs);
|
|
470
652
|
const retryAtMs =
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
653
|
+
Math.max(
|
|
654
|
+
Number.isFinite(claimedRetryAtMs) && claimedRetryAtMs > now
|
|
655
|
+
? claimedRetryAtMs
|
|
656
|
+
: 0,
|
|
657
|
+
Number.isFinite(coolUntilMs) && coolUntilMs > now ? coolUntilMs : 0,
|
|
658
|
+
) || now + Math.max(0, response.waitMs);
|
|
476
659
|
return new ProviderExhaustedError({
|
|
477
660
|
provider: providerNameFromBucketId(bucketId),
|
|
478
661
|
retryAtMs,
|
|
@@ -493,8 +676,10 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
493
676
|
rulesKey: string,
|
|
494
677
|
laneKey: string,
|
|
495
678
|
signal?: AbortSignal,
|
|
679
|
+
yieldProviderHolds = false,
|
|
496
680
|
): Promise<void> {
|
|
497
|
-
const
|
|
681
|
+
const refillKey = yieldProviderHolds ? `${laneKey}:yield` : laneKey;
|
|
682
|
+
const existing = this.refills.get(refillKey);
|
|
498
683
|
if (existing) return existing;
|
|
499
684
|
const promise = this.performRefill(
|
|
500
685
|
bucketId,
|
|
@@ -503,12 +688,13 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
503
688
|
rulesKey,
|
|
504
689
|
laneKey,
|
|
505
690
|
signal,
|
|
691
|
+
yieldProviderHolds,
|
|
506
692
|
).finally(() => {
|
|
507
|
-
if (this.refills.get(
|
|
508
|
-
this.refills.delete(
|
|
693
|
+
if (this.refills.get(refillKey) === promise) {
|
|
694
|
+
this.refills.delete(refillKey);
|
|
509
695
|
}
|
|
510
696
|
});
|
|
511
|
-
this.refills.set(
|
|
697
|
+
this.refills.set(refillKey, promise);
|
|
512
698
|
return promise;
|
|
513
699
|
}
|
|
514
700
|
|
|
@@ -519,6 +705,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
519
705
|
rulesKey: string,
|
|
520
706
|
laneKey: string,
|
|
521
707
|
signal?: AbortSignal,
|
|
708
|
+
yieldProviderHolds = false,
|
|
522
709
|
): Promise<void> {
|
|
523
710
|
let consecutiveFailures = 0;
|
|
524
711
|
// Yield once before sizing the first request so the whole synchronously-
|
|
@@ -539,71 +726,30 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
539
726
|
}
|
|
540
727
|
try {
|
|
541
728
|
const successKey = pendingSuccessKey(bucketId, ordered);
|
|
542
|
-
const
|
|
543
|
-
const pendingConcurrency =
|
|
544
|
-
this.pendingConcurrencyObservations.get(successKey);
|
|
545
|
-
const concurrencyObservations = [
|
|
546
|
-
...(pendingConcurrency?.observations ?? []),
|
|
547
|
-
];
|
|
729
|
+
const feedback = this.snapshotSuccessFeedback(successKey);
|
|
548
730
|
const response = await this.port.acquire({
|
|
549
731
|
bucketId,
|
|
550
732
|
rateScopeToken,
|
|
551
|
-
// Stamp adaptiveMaxRps on default-pacing rules so the DB row's ceiling
|
|
552
|
-
// matches the old in-memory AIMD range.
|
|
553
733
|
rules: withAdaptiveMaxRps(ordered),
|
|
554
734
|
requested: this.requestedForBucket(laneKey, ordered),
|
|
555
|
-
|
|
556
|
-
...(pendingConcurrency
|
|
557
|
-
? {
|
|
558
|
-
concurrencyObservations,
|
|
559
|
-
concurrencyObservationBatch: pendingConcurrency.batch,
|
|
560
|
-
}
|
|
561
|
-
: {}),
|
|
735
|
+
...feedback,
|
|
562
736
|
schedulerSchema: this.schedulerSchema,
|
|
563
737
|
});
|
|
564
|
-
|
|
565
|
-
const remaining = Math.max(
|
|
566
|
-
0,
|
|
567
|
-
(this.pendingSuccesses.get(successKey) ?? 0) - observedSuccesses,
|
|
568
|
-
);
|
|
569
|
-
if (remaining > 0) this.pendingSuccesses.set(successKey, remaining);
|
|
570
|
-
else this.pendingSuccesses.delete(successKey);
|
|
571
|
-
}
|
|
572
|
-
if (pendingConcurrency) {
|
|
573
|
-
const pending = this.pendingConcurrencyObservations.get(successKey);
|
|
574
|
-
const remainingSuccesses = this.pendingSuccesses.get(successKey) ?? 0;
|
|
575
|
-
if (
|
|
576
|
-
pending &&
|
|
577
|
-
pending.batch.sequence === pendingConcurrency.batch.sequence
|
|
578
|
-
) {
|
|
579
|
-
const remaining = pending.observations.slice(
|
|
580
|
-
concurrencyObservations.length,
|
|
581
|
-
);
|
|
582
|
-
if (remainingSuccesses > 0 || remaining.length > 0) {
|
|
583
|
-
// Observations that arrived while this acquire was in flight need
|
|
584
|
-
// a new identity. Reusing the acknowledged sequence would make
|
|
585
|
-
// the gateway correctly discard their rate and Gradient2 input.
|
|
586
|
-
this.pendingConcurrencyObservations.set(
|
|
587
|
-
successKey,
|
|
588
|
-
this.newPendingConcurrencyObservations(successKey, remaining),
|
|
589
|
-
);
|
|
590
|
-
} else {
|
|
591
|
-
this.pendingConcurrencyObservations.delete(successKey);
|
|
592
|
-
}
|
|
593
|
-
} else if (
|
|
594
|
-
pending &&
|
|
595
|
-
pending.observations.length > concurrencyObservations.length
|
|
596
|
-
) {
|
|
597
|
-
const remaining = pending.observations.slice(
|
|
598
|
-
concurrencyObservations.length,
|
|
599
|
-
);
|
|
600
|
-
this.pendingConcurrencyObservations.set(
|
|
601
|
-
successKey,
|
|
602
|
-
this.newPendingConcurrencyObservations(successKey, remaining),
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
738
|
+
this.acknowledgeSuccessFeedback(successKey, feedback);
|
|
606
739
|
this.recordCooldown(bucketId, response);
|
|
740
|
+
if (response.scopeValidationSupported === true)
|
|
741
|
+
this.validationScopes.add(bucketId);
|
|
742
|
+
if (
|
|
743
|
+
response.granted === 0 &&
|
|
744
|
+
(response.claimedRetryAtMs ?? 0) >
|
|
745
|
+
(response.serverNowMs ?? this.now()) &&
|
|
746
|
+
!this.providerStalls.has(bucketId)
|
|
747
|
+
) {
|
|
748
|
+
this.providerStalls.set(bucketId, {
|
|
749
|
+
startedAt: this.now(),
|
|
750
|
+
warned: false,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
607
753
|
if (
|
|
608
754
|
Number.isFinite(response.effectiveRequestsPerSecond) &&
|
|
609
755
|
Number(response.effectiveRequestsPerSecond) > 0
|
|
@@ -626,6 +772,10 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
626
772
|
);
|
|
627
773
|
return;
|
|
628
774
|
}
|
|
775
|
+
if (yieldProviderHolds) {
|
|
776
|
+
const activeHold = this.pacerExhaustion(bucketId, true);
|
|
777
|
+
if (activeHold) throw activeHold;
|
|
778
|
+
}
|
|
629
779
|
// Zero grants without a finite positive wait cannot make progress.
|
|
630
780
|
// Older gateways return this for an empty/invalid normalized rule set;
|
|
631
781
|
// fail exhausted instead of spinning once per millisecond forever.
|
|
@@ -635,10 +785,10 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
635
785
|
waitMs: 0,
|
|
636
786
|
});
|
|
637
787
|
}
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
// refill
|
|
788
|
+
// Do not hold a sandbox for a multi-minute provider deadline. Managed
|
|
789
|
+
// Plays turn this typed hold into a durable sleep; non-durable callers
|
|
790
|
+
// still receive the typed error. Every waiter on this single-flight
|
|
791
|
+
// refill sees the same deadline without dispatching provider work.
|
|
642
792
|
if (response.waitMs > PROVIDER_EXHAUSTED_MAX_WAIT_MS) {
|
|
643
793
|
throw this.providerExhaustedFromResponse(bucketId, response);
|
|
644
794
|
}
|
|
@@ -650,9 +800,8 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
650
800
|
await this.sleep(this.jitteredWaitMs(response.waitMs));
|
|
651
801
|
return;
|
|
652
802
|
} catch (error) {
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
// acquire caller (and, via step-miss handling, the row).
|
|
803
|
+
// A provider hold is not a store failure. Preserve its deadline for
|
|
804
|
+
// the caller's durable suspension or non-durable error path.
|
|
656
805
|
if (error instanceof ProviderExhaustedError) throw error;
|
|
657
806
|
// Fail closed. A degraded rate-state store must not grant unlimited
|
|
658
807
|
// provider calls; after this bounded retry ladder the run fails loudly.
|
|
@@ -691,6 +840,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
691
840
|
effectiveRequestsPerSecond?: unknown;
|
|
692
841
|
serverNowMs?: unknown;
|
|
693
842
|
scheduledAtMs?: unknown;
|
|
843
|
+
claimedRetryAtMs?: number;
|
|
694
844
|
},
|
|
695
845
|
): void {
|
|
696
846
|
const hasConcurrency = ordered.some((rule) => rule.maxConcurrency != null);
|
|
@@ -767,9 +917,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
767
917
|
);
|
|
768
918
|
const receivedAtMs = this.now();
|
|
769
919
|
const hasGatewaySchedule = Array.isArray(response.scheduledAtMs);
|
|
770
|
-
const gatewayScheduledAtMs: number[] = Array.isArray(
|
|
771
|
-
response.scheduledAtMs,
|
|
772
|
-
)
|
|
920
|
+
const gatewayScheduledAtMs: number[] = Array.isArray(response.scheduledAtMs)
|
|
773
921
|
? response.scheduledAtMs.map((scheduledAtMs) => Number(scheduledAtMs))
|
|
774
922
|
: Array.from(
|
|
775
923
|
{ length: response.granted },
|
|
@@ -790,7 +938,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
790
938
|
receivedAtMs +
|
|
791
939
|
Math.max(0, firstGatewayScheduledAtMs - serverNowMs) +
|
|
792
940
|
(scheduledAtMs - firstGatewayScheduledAtMs),
|
|
793
|
-
|
|
941
|
+
)
|
|
794
942
|
: gatewayScheduledAtMs;
|
|
795
943
|
const previousFallbackScheduledAtMs =
|
|
796
944
|
this.nextFallbackScheduledAtMs.get(laneKey);
|
|
@@ -891,21 +1039,13 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
891
1039
|
bucketId,
|
|
892
1040
|
rateScopeToken,
|
|
893
1041
|
scheduledAtMs.map(
|
|
894
|
-
(
|
|
895
|
-
scheduledAtMs,
|
|
896
|
-
index,
|
|
897
|
-
): [
|
|
898
|
-
number,
|
|
899
|
-
string | null,
|
|
900
|
-
string | null,
|
|
901
|
-
ProviderConcurrencyGrant | null,
|
|
902
|
-
ProviderPacingGrant | null,
|
|
903
|
-
] => [
|
|
1042
|
+
(scheduledAtMs, index): LeasedBlock['permits'][number] => [
|
|
904
1043
|
scheduledAtMs,
|
|
905
1044
|
hasConcurrency ? (leaseIds[index] ?? null) : null,
|
|
906
1045
|
reservationIds[index] ?? null,
|
|
907
1046
|
concurrencyGrants[index] ?? null,
|
|
908
1047
|
pacingGrant,
|
|
1048
|
+
Math.max(0, response.claimedRetryAtMs ?? 0),
|
|
909
1049
|
],
|
|
910
1050
|
),
|
|
911
1051
|
rulesKey,
|
|
@@ -918,24 +1058,78 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
918
1058
|
bucketId: string;
|
|
919
1059
|
rateScopeToken?: string | null;
|
|
920
1060
|
cooldownMs: number;
|
|
1061
|
+
retryAtMs?: number;
|
|
921
1062
|
rules?: readonly PacingRule[];
|
|
922
1063
|
latencyMs?: number | null;
|
|
923
1064
|
concurrencyGrant?: ProviderConcurrencyGrant | null;
|
|
924
1065
|
}): Promise<void> {
|
|
925
|
-
|
|
1066
|
+
const cooldownMs =
|
|
1067
|
+
Number.isFinite(input.cooldownMs) && input.cooldownMs > 0
|
|
1068
|
+
? Math.ceil(input.cooldownMs)
|
|
1069
|
+
: 0;
|
|
1070
|
+
const explicitRetryAtMs =
|
|
1071
|
+
typeof input.retryAtMs === 'number' && Number.isFinite(input.retryAtMs)
|
|
1072
|
+
? Math.max(0, Math.ceil(input.retryAtMs))
|
|
1073
|
+
: null;
|
|
1074
|
+
const hasExplicitRetryAtMs = explicitRetryAtMs !== null;
|
|
1075
|
+
const retryAtMs =
|
|
1076
|
+
explicitRetryAtMs ?? this.serverNow(input.bucketId) + cooldownMs;
|
|
1077
|
+
// Even an HTTP-date that expired during response delivery is fresh rate
|
|
1078
|
+
// feedback. The durable store deduplicates it against the last absolute
|
|
1079
|
+
// deadline while still learning from the 429; only synthesized, already
|
|
1080
|
+
// elapsed cooldowns have no new provider evidence to persist.
|
|
1081
|
+
if (retryAtMs <= this.serverNow(input.bucketId) && !hasExplicitRetryAtMs) {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (!this.providerStalls.has(input.bucketId)) {
|
|
1085
|
+
this.providerStalls.set(input.bucketId, {
|
|
1086
|
+
startedAt: this.now(),
|
|
1087
|
+
warned: false,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
this.validationGenerations.set(
|
|
1092
|
+
input.bucketId,
|
|
1093
|
+
(this.validationGenerations.get(input.bucketId) ?? 0) + 1,
|
|
1094
|
+
);
|
|
1095
|
+
this.validationFlights.delete(input.bucketId);
|
|
1096
|
+
this.localProviderRetryAtMs.set(
|
|
1097
|
+
input.bucketId,
|
|
1098
|
+
Math.max(this.localProviderRetryAtMs.get(input.bucketId) ?? 0, retryAtMs),
|
|
1099
|
+
);
|
|
1100
|
+
this.recordCooldown(input.bucketId, {
|
|
1101
|
+
coolUntilMs: Math.min(
|
|
1102
|
+
retryAtMs,
|
|
1103
|
+
this.serverNow(input.bucketId) + PROVIDER_AIMD_COOLDOWN_CAP_MS,
|
|
1104
|
+
),
|
|
1105
|
+
claimedRetryAtMs: retryAtMs,
|
|
1106
|
+
});
|
|
926
1107
|
this.drainBlocksForBucket(input.bucketId);
|
|
1108
|
+
const successKey = pendingSuccessKey(input.bucketId, input.rules ?? []);
|
|
1109
|
+
const feedback = this.snapshotSuccessFeedback(successKey);
|
|
1110
|
+
const previousPenalty = this.pendingPenaltyWrites.get(input.bucketId);
|
|
1111
|
+
const penaltyWrite = Promise.all([
|
|
1112
|
+
previousPenalty,
|
|
1113
|
+
Promise.resolve().then(async () => {
|
|
1114
|
+
await this.port.penalize({
|
|
1115
|
+
bucketId: input.bucketId,
|
|
1116
|
+
rateScopeToken: input.rateScopeToken?.trim() || null,
|
|
1117
|
+
cooldownMs,
|
|
1118
|
+
retryAtMs,
|
|
1119
|
+
...feedback,
|
|
1120
|
+
...(input.rules ? { rules: [...input.rules] } : {}),
|
|
1121
|
+
...(input.latencyMs != null ? { latencyMs: input.latencyMs } : {}),
|
|
1122
|
+
...(input.concurrencyGrant
|
|
1123
|
+
? { concurrencyGrant: input.concurrencyGrant }
|
|
1124
|
+
: {}),
|
|
1125
|
+
schedulerSchema: this.schedulerSchema,
|
|
1126
|
+
});
|
|
1127
|
+
this.acknowledgeSuccessFeedback(successKey, feedback);
|
|
1128
|
+
}),
|
|
1129
|
+
]).then(() => undefined);
|
|
1130
|
+
this.pendingPenaltyWrites.set(input.bucketId, penaltyWrite);
|
|
927
1131
|
try {
|
|
928
|
-
await
|
|
929
|
-
bucketId: input.bucketId,
|
|
930
|
-
rateScopeToken: input.rateScopeToken?.trim() || null,
|
|
931
|
-
cooldownMs: input.cooldownMs,
|
|
932
|
-
...(input.rules ? { rules: [...input.rules] } : {}),
|
|
933
|
-
...(input.latencyMs != null ? { latencyMs: input.latencyMs } : {}),
|
|
934
|
-
...(input.concurrencyGrant
|
|
935
|
-
? { concurrencyGrant: input.concurrencyGrant }
|
|
936
|
-
: {}),
|
|
937
|
-
schedulerSchema: this.schedulerSchema,
|
|
938
|
-
});
|
|
1132
|
+
await penaltyWrite;
|
|
939
1133
|
} catch (error) {
|
|
940
1134
|
const normalized = normalizeError(error);
|
|
941
1135
|
this.onStoreFailure({
|
|
@@ -945,6 +1139,38 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
945
1139
|
throw new Error(
|
|
946
1140
|
`Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
|
|
947
1141
|
);
|
|
1142
|
+
} finally {
|
|
1143
|
+
if (this.pendingPenaltyWrites.get(input.bucketId) === penaltyWrite) {
|
|
1144
|
+
this.pendingPenaltyWrites.delete(input.bucketId);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
private snapshotSuccessFeedback(key: string): SuccessFeedback {
|
|
1150
|
+
const inFlight = this.successFeedbackFlights.get(key);
|
|
1151
|
+
if (inFlight) return inFlight;
|
|
1152
|
+
const pending = this.pendingConcurrencyObservations.get(key);
|
|
1153
|
+
const feedback: SuccessFeedback = {
|
|
1154
|
+
observedSuccesses: this.pendingSuccesses.get(key) ?? 0,
|
|
1155
|
+
concurrencyObservations: [...(pending?.observations ?? [])],
|
|
1156
|
+
...(pending ? { concurrencyObservationBatch: pending.batch } : {}),
|
|
1157
|
+
};
|
|
1158
|
+
if (pending) {
|
|
1159
|
+
// Freeze the payload until acknowledged. A concurrent acquire and 429
|
|
1160
|
+
// may send it, but cannot send different counts under one identity.
|
|
1161
|
+
this.pendingSuccesses.delete(key);
|
|
1162
|
+
this.pendingConcurrencyObservations.delete(key);
|
|
1163
|
+
this.successFeedbackFlights.set(key, feedback);
|
|
1164
|
+
}
|
|
1165
|
+
return feedback;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private acknowledgeSuccessFeedback(
|
|
1169
|
+
key: string,
|
|
1170
|
+
feedback: SuccessFeedback,
|
|
1171
|
+
): void {
|
|
1172
|
+
if (this.successFeedbackFlights.get(key) === feedback) {
|
|
1173
|
+
this.successFeedbackFlights.delete(key);
|
|
948
1174
|
}
|
|
949
1175
|
}
|
|
950
1176
|
|
|
@@ -953,6 +1179,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
953
1179
|
rules: readonly PacingRule[];
|
|
954
1180
|
latencyMs?: number | null;
|
|
955
1181
|
}): void {
|
|
1182
|
+
this.providerStalls.delete(input.bucketId);
|
|
956
1183
|
const key = pendingSuccessKey(input.bucketId, input.rules);
|
|
957
1184
|
this.pendingSuccesses.set(
|
|
958
1185
|
key,
|
|
@@ -1014,7 +1241,14 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
1014
1241
|
scheduled: LeasedBlock['permits'][number],
|
|
1015
1242
|
): PacingPermit {
|
|
1016
1243
|
const { rateScopeToken, rules } = block;
|
|
1017
|
-
const [
|
|
1244
|
+
const [
|
|
1245
|
+
,
|
|
1246
|
+
leaseId,
|
|
1247
|
+
reservationId,
|
|
1248
|
+
concurrencyGrant,
|
|
1249
|
+
pacingGrant,
|
|
1250
|
+
scopeRetryAtMsAtGrant,
|
|
1251
|
+
] = scheduled;
|
|
1018
1252
|
let settled = false;
|
|
1019
1253
|
const refund = async (): Promise<void> => {
|
|
1020
1254
|
if (!reservationId) {
|
|
@@ -1033,6 +1267,31 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
1033
1267
|
return {
|
|
1034
1268
|
concurrencyGrant,
|
|
1035
1269
|
pacingGrant,
|
|
1270
|
+
gatewayDispatchProof: rateScopeToken
|
|
1271
|
+
? {
|
|
1272
|
+
bucketId,
|
|
1273
|
+
rateScopeToken,
|
|
1274
|
+
retryAtMsAtGrant: scopeRetryAtMsAtGrant,
|
|
1275
|
+
}
|
|
1276
|
+
: null,
|
|
1277
|
+
validate: async () => {
|
|
1278
|
+
try {
|
|
1279
|
+
return await this.validateProviderPermit(
|
|
1280
|
+
bucketId,
|
|
1281
|
+
rateScopeToken,
|
|
1282
|
+
scopeRetryAtMsAtGrant,
|
|
1283
|
+
);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
if (error instanceof RateStateValidationUnavailableError) {
|
|
1286
|
+
throw error;
|
|
1287
|
+
}
|
|
1288
|
+
const normalized = normalizeError(error);
|
|
1289
|
+
this.onStoreFailure({ bucketId, error: normalized.message });
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
`Rate-state permit validation failed for ${bucketId}: ${normalized.message}`,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1036
1295
|
release: () => {
|
|
1037
1296
|
if (settled) return;
|
|
1038
1297
|
settled = true;
|
|
@@ -1054,7 +1313,10 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
1054
1313
|
// Only the block this permit was drawn from may take it back: a lane
|
|
1055
1314
|
// whose block expired, was drained by a penalty, or was replaced has
|
|
1056
1315
|
// already given up its grant, and the permit must be refunded instead.
|
|
1057
|
-
if (
|
|
1316
|
+
if (
|
|
1317
|
+
this.blocks.get(laneKey) === block &&
|
|
1318
|
+
block.expiresAt > this.now()
|
|
1319
|
+
) {
|
|
1058
1320
|
block.permits.push(scheduled);
|
|
1059
1321
|
block.permits.sort((a, b) => a[0] - b[0]);
|
|
1060
1322
|
return;
|
|
@@ -1140,17 +1402,31 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
1140
1402
|
};
|
|
1141
1403
|
}
|
|
1142
1404
|
|
|
1143
|
-
private drainBlocksForBucket(
|
|
1405
|
+
private drainBlocksForBucket(
|
|
1406
|
+
bucketId: string,
|
|
1407
|
+
beforeRetryAtMs?: number,
|
|
1408
|
+
): void {
|
|
1144
1409
|
const prefix = `${bucketId}\0`;
|
|
1145
1410
|
for (const [laneKey, block] of this.blocks) {
|
|
1146
1411
|
if (!laneKey.startsWith(prefix)) continue;
|
|
1147
|
-
|
|
1148
|
-
|
|
1412
|
+
const drained =
|
|
1413
|
+
beforeRetryAtMs === undefined
|
|
1414
|
+
? block.permits
|
|
1415
|
+
: block.permits.filter((permit) => permit[5] < beforeRetryAtMs);
|
|
1416
|
+
if (drained.length === 0 && beforeRetryAtMs !== undefined) continue;
|
|
1417
|
+
block.permits =
|
|
1418
|
+
beforeRetryAtMs === undefined
|
|
1419
|
+
? []
|
|
1420
|
+
: block.permits.filter((permit) => permit[5] >= beforeRetryAtMs);
|
|
1421
|
+
if (block.permits.length === 0) {
|
|
1422
|
+
this.blocks.delete(laneKey);
|
|
1423
|
+
this.nextFallbackScheduledAtMs.delete(laneKey);
|
|
1424
|
+
}
|
|
1149
1425
|
this.releaseReserved(
|
|
1150
1426
|
bucketId,
|
|
1151
1427
|
block.rateScopeToken,
|
|
1152
1428
|
block.rules,
|
|
1153
|
-
|
|
1429
|
+
drained.flatMap(([, leaseId]) => (leaseId ? [leaseId] : [])),
|
|
1154
1430
|
);
|
|
1155
1431
|
}
|
|
1156
1432
|
}
|
|
@@ -1266,17 +1542,28 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
1266
1542
|
* advisory maximum instead of treating a recommendation as a hard ceiling.
|
|
1267
1543
|
*/
|
|
1268
1544
|
function withAdaptiveMaxRps(rules: readonly PacingRule[]): PacingRule[] {
|
|
1269
|
-
return rules.map((rule) =>
|
|
1270
|
-
rule
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1545
|
+
return rules.map((rule) => {
|
|
1546
|
+
// This one shared default rule is synthesized for unknown providers whose
|
|
1547
|
+
// credential bucket spans operations. Keep the generic policy unchanged
|
|
1548
|
+
// for local/coordinator backends, but let Postgres start a new provider
|
|
1549
|
+
// scope with one probe/sec before learning its sustainable rate.
|
|
1550
|
+
const seed =
|
|
1551
|
+
rule.ruleId === 'default:provider-scope'
|
|
1552
|
+
? {
|
|
1553
|
+
...rule,
|
|
1554
|
+
requestsPerWindow: DEFAULT_UNKNOWN_PROVIDER_COLD_START_RPS,
|
|
1555
|
+
windowMs: 1_000,
|
|
1556
|
+
}
|
|
1557
|
+
: rule;
|
|
1558
|
+
if (seed.adaptiveMaxRps != null) return seed;
|
|
1559
|
+
return {
|
|
1560
|
+
...seed,
|
|
1561
|
+
adaptiveMaxRps: Math.max(
|
|
1562
|
+
(rule.requestsPerWindow / rule.windowMs) * 1_000,
|
|
1563
|
+
DEFAULT_ADVISORY_PROVIDER_MAX_RPS,
|
|
1564
|
+
),
|
|
1565
|
+
} as PacingRule;
|
|
1566
|
+
});
|
|
1280
1567
|
}
|
|
1281
1568
|
|
|
1282
1569
|
function rulesSignature(rules: readonly PacingRule[]): string {
|