deepline 0.3.184 → 0.3.186

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  2. package/dist/bundling-sources/sdk/src/types.ts +4 -0
  3. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +53 -0
  4. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +751 -193
  5. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +4 -0
  6. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +3 -1
  7. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
  8. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +437 -150
  9. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +47 -4
  10. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +3 -1
  11. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/launch-identity.ts +229 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +3 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +16 -1
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +3 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +22 -2
  17. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +6 -1
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +11 -5
  19. package/dist/bundling-sources/shared_libs/play-runtime/runtime-flow.ts +102 -12
  20. package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +8 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/runtime-telemetry.ts +9 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +136 -28
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-scope.ts +80 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +897 -207
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +271 -25
  27. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +21 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +120 -34
  29. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +82 -2
  30. package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +7 -2
  31. package/dist/cli/index.js +29 -3
  32. package/dist/cli/index.mjs +29 -3
  33. package/dist/index.d.mts +4 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +1 -1
  36. package/dist/index.mjs +1 -1
  37. package/dist/install-integrity.json +2 -0
  38. package/dist/release.d.mts +1 -1
  39. package/dist/release.d.ts +1 -1
  40. package/dist/release.js +1 -1
  41. package/dist/release.mjs +1 -1
  42. package/package.json +1 -1
@@ -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 this substrate will sleep toward before failing
25
- * the call fast with a typed {@link ProviderExhaustedError}. Duration alone
26
- * decides — there is no quota-vs-rate classification. A hold at or under this is
27
- * slept off (today's behavior); a hold above it is skipped with no spend and no
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 ONLY on the `app_runtime_postgres` (Absurd/Node DB-authoritative)
31
- * backend; the in-memory and coordinator backends never construct this class and
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 = 60_000;
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(bucketId: string): void {
242
- const exhausted = this.pacerExhaustion(bucketId);
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(bucketId: string): ProviderExhaustedError | null {
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.now();
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
- // Duration alone decides. Neither hold is over threshold → let it sleep.
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
- // Retry deadline: the server Retry-After verbatim when present, else the
265
- // pacer's own cooldown deadline.
266
- const retryAtMs =
267
- cooldown.claimedRetryAtMs > 0
268
- ? cooldown.claimedRetryAtMs
269
- : cooldown.coolUntilMs;
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([...rules].sort((a, b) => a.ruleId.localeCompare(b.ruleId))),
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
- return Math.max(floor, Math.min(waiters, Math.trunc(rateBudget)));
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: { coolUntilMs?: number; claimedRetryAtMs?: number },
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: Number.isFinite(coolUntilMs) ? Math.max(0, coolUntilMs) : 0,
449
- claimedRetryAtMs: Number.isFinite(claimedRetryAtMs)
450
- ? Math.max(0, claimedRetryAtMs)
451
- : 0,
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
- Number.isFinite(claimedRetryAtMs) && claimedRetryAtMs > 0
472
- ? claimedRetryAtMs
473
- : Number.isFinite(coolUntilMs) && coolUntilMs > 0
474
- ? coolUntilMs
475
- : this.now() + Math.max(0, response.waitMs);
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 existing = this.refills.get(laneKey);
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(laneKey) === promise) {
508
- this.refills.delete(laneKey);
693
+ if (this.refills.get(refillKey) === promise) {
694
+ this.refills.delete(refillKey);
509
695
  }
510
696
  });
511
- this.refills.set(laneKey, promise);
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 observedSuccesses = this.pendingSuccesses.get(successKey) ?? 0;
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
- observedSuccesses,
556
- ...(pendingConcurrency
557
- ? {
558
- concurrencyObservations,
559
- concurrencyObservationBatch: pendingConcurrency.batch,
560
- }
561
- : {}),
735
+ ...feedback,
562
736
  schedulerSchema: this.schedulerSchema,
563
737
  });
564
- if (observedSuccesses > 0) {
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
- // Live path: the server told us to wait past the threshold. Fail fast
639
- // with a typed PROVIDER_EXHAUSTED instead of sleeping toward it. The
640
- // rejection propagates to every waiter parked on this single-flight
641
- // refill (they all skip with no spend). Duration alone decides.
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
- // PROVIDER_EXHAUSTED is a deliberate skip, not a store failure: it must
654
- // escape this fail-closed retry ladder verbatim so it reaches the
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
- if (input.cooldownMs <= 0) return;
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 this.port.penalize({
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 [, leaseId, reservationId, concurrencyGrant, pacingGrant] = scheduled;
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 (this.blocks.get(laneKey) === block && block.expiresAt > this.now()) {
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(bucketId: string): void {
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
- this.blocks.delete(laneKey);
1148
- this.nextFallbackScheduledAtMs.delete(laneKey);
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
- block.permits.flatMap(([, leaseId]) => (leaseId ? [leaseId] : [])),
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.adaptiveMaxRps != null
1271
- ? rule
1272
- : ({
1273
- ...rule,
1274
- adaptiveMaxRps: Math.max(
1275
- (rule.requestsPerWindow / rule.windowMs) * 1_000,
1276
- DEFAULT_ADVISORY_PROVIDER_MAX_RPS,
1277
- ),
1278
- } as PacingRule),
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 {