deepline 0.1.230 → 0.1.232
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/entry.ts +31 -15
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +18 -12
- package/dist/bundling-sources/sdk/src/release.ts +2 -2
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +18 -3
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +174 -55
- package/dist/bundling-sources/shared_libs/play-runtime/governor/coordinator-rate-state-backend.ts +13 -9
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +66 -27
- package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +12 -1
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +12 -0
- package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +4 -3
- package/dist/cli/index.js +82 -4
- package/dist/cli/index.mjs +82 -4
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts
CHANGED
|
@@ -89,20 +89,8 @@ export interface RateStateCooldown {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
interface LeasedBlock {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
* `maxConcurrency` mint lease tokens; each id maps to a durable slot that must
|
|
95
|
-
* be released (or TTL-expired) so the shared concurrency count drains.
|
|
96
|
-
*/
|
|
97
|
-
leaseIds: string[];
|
|
98
|
-
/**
|
|
99
|
-
* Pure-rate (RPS-only) permits with no concurrency slot behind them. The
|
|
100
|
-
* server decremented the provider window by this count already, so drawing
|
|
101
|
-
* one locally is a no-op that needs no release token. Tracked separately from
|
|
102
|
-
* `leaseIds` so a pure-RPS block (the common `defaultPacingForTool` shape)
|
|
103
|
-
* actually amortizes its whole grant across acquires instead of burning it.
|
|
104
|
-
*/
|
|
105
|
-
windowPermits: number;
|
|
92
|
+
permits: Array<[scheduledAtMs: number, leaseId: string | null]>;
|
|
93
|
+
rateScopeToken: string | null;
|
|
106
94
|
expiresAt: number;
|
|
107
95
|
rulesKey: string;
|
|
108
96
|
rules: PacingRule[];
|
|
@@ -110,6 +98,7 @@ interface LeasedBlock {
|
|
|
110
98
|
|
|
111
99
|
interface PendingRelease {
|
|
112
100
|
bucketId: string;
|
|
101
|
+
rateScopeToken: string | null;
|
|
113
102
|
rules: PacingRule[];
|
|
114
103
|
leaseIds: string[];
|
|
115
104
|
flushing: boolean;
|
|
@@ -156,6 +145,8 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
156
145
|
private readonly waiters = new Map<string, number>();
|
|
157
146
|
/** Last-known server cooldown per bucket (surfaced for PROVIDER_EXHAUSTED). */
|
|
158
147
|
private readonly cooldowns = new Map<string, RateStateCooldown>();
|
|
148
|
+
private readonly effectiveRps = new Map<string, number>();
|
|
149
|
+
private readonly pendingSuccesses = new Map<string, number>();
|
|
159
150
|
private pendingReleaseFailure: Error | null = null;
|
|
160
151
|
|
|
161
152
|
constructor(context: WorkerRuntimeApiContext, options: Options = {}) {
|
|
@@ -220,6 +211,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
220
211
|
|
|
221
212
|
async acquire(input: {
|
|
222
213
|
bucketId: string;
|
|
214
|
+
rateScopeToken?: string | null;
|
|
223
215
|
rules: readonly PacingRule[];
|
|
224
216
|
signal?: AbortSignal;
|
|
225
217
|
}): Promise<PacingPermit> {
|
|
@@ -249,13 +241,29 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
249
241
|
: new Error('Rate-state acquire aborted.');
|
|
250
242
|
}
|
|
251
243
|
const localPermit = this.drawFromBlock(bucketId, rulesKey);
|
|
252
|
-
if (localPermit)
|
|
244
|
+
if (localPermit) {
|
|
245
|
+
const waitMs = localPermit.scheduledAtMs - this.now();
|
|
246
|
+
if (waitMs > 0) await this.sleep(waitMs);
|
|
247
|
+
if (signal?.aborted) {
|
|
248
|
+
localPermit.permit.release();
|
|
249
|
+
throw signal.reason instanceof Error
|
|
250
|
+
? signal.reason
|
|
251
|
+
: new Error('Rate-state acquire aborted.');
|
|
252
|
+
}
|
|
253
|
+
return localPermit.permit;
|
|
254
|
+
}
|
|
253
255
|
// Count this acquirer as waiting on the refill so the single-flight refill
|
|
254
256
|
// can size its block to the live wave. Decremented once a block lands (or
|
|
255
257
|
// the refill errors) — see the finally in `refillBlock`.
|
|
256
258
|
this.waiters.set(bucketId, (this.waiters.get(bucketId) ?? 0) + 1);
|
|
257
259
|
try {
|
|
258
|
-
await this.refillBlock(
|
|
260
|
+
await this.refillBlock(
|
|
261
|
+
bucketId,
|
|
262
|
+
input.rateScopeToken?.trim() || null,
|
|
263
|
+
ordered,
|
|
264
|
+
rulesKey,
|
|
265
|
+
signal,
|
|
266
|
+
);
|
|
259
267
|
} finally {
|
|
260
268
|
const remaining = (this.waiters.get(bucketId) ?? 1) - 1;
|
|
261
269
|
if (remaining > 0) this.waiters.set(bucketId, remaining);
|
|
@@ -279,7 +287,15 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
279
287
|
): number {
|
|
280
288
|
const floor = APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE;
|
|
281
289
|
if (rules.some((rule) => rule.maxConcurrency != null)) return floor;
|
|
290
|
+
const configuredRps = Math.min(
|
|
291
|
+
...rules.map((rule) => (rule.requestsPerWindow / rule.windowMs) * 1_000),
|
|
292
|
+
);
|
|
293
|
+
const horizonBudget = Math.max(
|
|
294
|
+
1,
|
|
295
|
+
Math.ceil(this.effectiveRps.get(bucketId) ?? configuredRps),
|
|
296
|
+
);
|
|
282
297
|
const windowBudget = Math.min(
|
|
298
|
+
horizonBudget,
|
|
283
299
|
...rules
|
|
284
300
|
.map((rule) => rule.requestsPerWindow)
|
|
285
301
|
.filter((value) => Number.isFinite(value) && value > 0),
|
|
@@ -353,6 +369,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
353
369
|
*/
|
|
354
370
|
private refillBlock(
|
|
355
371
|
bucketId: string,
|
|
372
|
+
rateScopeToken: string | null,
|
|
356
373
|
ordered: PacingRule[],
|
|
357
374
|
rulesKey: string,
|
|
358
375
|
signal?: AbortSignal,
|
|
@@ -361,6 +378,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
361
378
|
if (existing) return existing;
|
|
362
379
|
const promise = this.performRefill(
|
|
363
380
|
bucketId,
|
|
381
|
+
rateScopeToken,
|
|
364
382
|
ordered,
|
|
365
383
|
rulesKey,
|
|
366
384
|
signal,
|
|
@@ -375,6 +393,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
375
393
|
|
|
376
394
|
private async performRefill(
|
|
377
395
|
bucketId: string,
|
|
396
|
+
rateScopeToken: string | null,
|
|
378
397
|
ordered: PacingRule[],
|
|
379
398
|
rulesKey: string,
|
|
380
399
|
signal?: AbortSignal,
|
|
@@ -397,19 +416,45 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
397
416
|
if (signal?.aborted) return;
|
|
398
417
|
}
|
|
399
418
|
try {
|
|
419
|
+
const observedSuccesses = this.pendingSuccesses.get(bucketId) ?? 0;
|
|
400
420
|
const response = await this.port.acquire({
|
|
401
421
|
bucketId,
|
|
422
|
+
rateScopeToken,
|
|
402
423
|
// Stamp adaptiveMaxRps on default-pacing rules so the DB row's ceiling
|
|
403
424
|
// matches the old in-memory AIMD range.
|
|
404
425
|
rules: withAdaptiveMaxRps(ordered),
|
|
405
426
|
requested: this.requestedForBucket(bucketId, ordered),
|
|
427
|
+
observedSuccesses,
|
|
406
428
|
schedulerSchema: this.schedulerSchema,
|
|
407
429
|
});
|
|
430
|
+
if (observedSuccesses > 0) {
|
|
431
|
+
const remaining = Math.max(
|
|
432
|
+
0,
|
|
433
|
+
(this.pendingSuccesses.get(bucketId) ?? 0) - observedSuccesses,
|
|
434
|
+
);
|
|
435
|
+
if (remaining > 0) this.pendingSuccesses.set(bucketId, remaining);
|
|
436
|
+
else this.pendingSuccesses.delete(bucketId);
|
|
437
|
+
}
|
|
408
438
|
this.recordCooldown(bucketId, response);
|
|
439
|
+
if (
|
|
440
|
+
Number.isFinite(response.effectiveRequestsPerSecond) &&
|
|
441
|
+
Number(response.effectiveRequestsPerSecond) > 0
|
|
442
|
+
) {
|
|
443
|
+
this.effectiveRps.set(
|
|
444
|
+
bucketId,
|
|
445
|
+
Number(response.effectiveRequestsPerSecond),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
409
448
|
if (response.granted > 0) {
|
|
410
449
|
// Merge the whole grant into the block and let acquirers re-draw. No
|
|
411
450
|
// permit is handed out here, so nothing is double-drawn.
|
|
412
|
-
this.mergeGrantedBlock(
|
|
451
|
+
this.mergeGrantedBlock(
|
|
452
|
+
bucketId,
|
|
453
|
+
rateScopeToken,
|
|
454
|
+
ordered,
|
|
455
|
+
rulesKey,
|
|
456
|
+
response,
|
|
457
|
+
);
|
|
413
458
|
return;
|
|
414
459
|
}
|
|
415
460
|
// Live path: the server told us to wait past the threshold. Fail fast
|
|
@@ -456,9 +501,14 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
456
501
|
*/
|
|
457
502
|
private mergeGrantedBlock(
|
|
458
503
|
bucketId: string,
|
|
504
|
+
rateScopeToken: string | null,
|
|
459
505
|
ordered: PacingRule[],
|
|
460
506
|
rulesKey: string,
|
|
461
|
-
response: {
|
|
507
|
+
response: {
|
|
508
|
+
granted: number;
|
|
509
|
+
leaseIds?: unknown;
|
|
510
|
+
scheduledAtMs?: unknown;
|
|
511
|
+
},
|
|
462
512
|
): void {
|
|
463
513
|
const hasConcurrency = ordered.some((rule) => rule.maxConcurrency != null);
|
|
464
514
|
const leaseIds = Array.isArray(response.leaseIds)
|
|
@@ -477,38 +527,71 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
477
527
|
`Rate-state store returned ${leaseIds.length} lease tokens for pure-rate bucket ${bucketId}.`,
|
|
478
528
|
);
|
|
479
529
|
}
|
|
530
|
+
// Rolling deploy compatibility: an older gateway returns no schedule. It
|
|
531
|
+
// has already debited the block under the legacy token-bucket contract, so
|
|
532
|
+
// keep those permits immediately usable until every gateway serves the new
|
|
533
|
+
// globally reserved timestamps.
|
|
534
|
+
const scheduledAtMs = Array.isArray(response.scheduledAtMs)
|
|
535
|
+
? response.scheduledAtMs.map(Number)
|
|
536
|
+
: Array.from({ length: response.granted }, () => this.now());
|
|
537
|
+
if (
|
|
538
|
+
scheduledAtMs.length !== response.granted ||
|
|
539
|
+
scheduledAtMs.some((value) => !Number.isFinite(value) || value < 0)
|
|
540
|
+
) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Rate-state store granted ${response.granted} permits for ${bucketId} with ${scheduledAtMs.length} dispatch timestamps.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
480
545
|
this.mergeBlock(
|
|
481
546
|
bucketId,
|
|
482
|
-
|
|
483
|
-
|
|
547
|
+
rateScopeToken,
|
|
548
|
+
scheduledAtMs.map(
|
|
549
|
+
(scheduledAtMs, index): [number, string | null] => [
|
|
550
|
+
scheduledAtMs,
|
|
551
|
+
hasConcurrency ? (leaseIds[index] ?? null) : null,
|
|
552
|
+
],
|
|
553
|
+
),
|
|
484
554
|
rulesKey,
|
|
485
555
|
ordered,
|
|
486
556
|
);
|
|
487
557
|
}
|
|
488
558
|
|
|
489
|
-
penalize(input: {
|
|
559
|
+
async penalize(input: {
|
|
560
|
+
bucketId: string;
|
|
561
|
+
rateScopeToken?: string | null;
|
|
562
|
+
cooldownMs: number;
|
|
563
|
+
}): Promise<void> {
|
|
490
564
|
if (input.cooldownMs <= 0) return;
|
|
491
565
|
this.drainBlock(input.bucketId);
|
|
492
|
-
|
|
493
|
-
.penalize({
|
|
566
|
+
try {
|
|
567
|
+
await this.port.penalize({
|
|
494
568
|
bucketId: input.bucketId,
|
|
569
|
+
rateScopeToken: input.rateScopeToken?.trim() || null,
|
|
495
570
|
cooldownMs: input.cooldownMs,
|
|
496
571
|
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
|
-
});
|
|
507
572
|
});
|
|
573
|
+
} catch (error) {
|
|
574
|
+
const normalized = normalizeError(error);
|
|
575
|
+
this.onStoreFailure({
|
|
576
|
+
bucketId: input.bucketId,
|
|
577
|
+
error: normalized.message,
|
|
578
|
+
});
|
|
579
|
+
throw new Error(
|
|
580
|
+
`Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
observeSuccess(input: { bucketId: string }): void {
|
|
586
|
+
this.pendingSuccesses.set(
|
|
587
|
+
input.bucketId,
|
|
588
|
+
Math.min(1_000_000, (this.pendingSuccesses.get(input.bucketId) ?? 0) + 1),
|
|
589
|
+
);
|
|
508
590
|
}
|
|
509
591
|
|
|
510
592
|
private permitFor(
|
|
511
593
|
bucketId: string,
|
|
594
|
+
rateScopeToken: string | null,
|
|
512
595
|
rules: PacingRule[],
|
|
513
596
|
leaseId: string | null,
|
|
514
597
|
): PacingPermit {
|
|
@@ -520,35 +603,49 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
520
603
|
release: () => {
|
|
521
604
|
if (released) return;
|
|
522
605
|
released = true;
|
|
523
|
-
this.releaseReserved(
|
|
606
|
+
this.releaseReserved(
|
|
607
|
+
bucketId,
|
|
608
|
+
rateScopeToken,
|
|
609
|
+
rules,
|
|
610
|
+
leaseId ? [leaseId] : [],
|
|
611
|
+
);
|
|
524
612
|
},
|
|
525
613
|
};
|
|
526
614
|
}
|
|
527
615
|
|
|
528
616
|
private mergeBlock(
|
|
529
617
|
bucketId: string,
|
|
530
|
-
|
|
531
|
-
|
|
618
|
+
rateScopeToken: string | null,
|
|
619
|
+
permits: LeasedBlock['permits'],
|
|
532
620
|
rulesKey: string,
|
|
533
621
|
rules: PacingRule[],
|
|
534
622
|
): void {
|
|
535
|
-
const
|
|
623
|
+
const lastScheduledAtMs = permits.at(-1)?.[0] ?? this.now();
|
|
624
|
+
const freshExpiresAt =
|
|
625
|
+
Math.max(this.now(), lastScheduledAtMs) + leasedBlockTtlMs(rules);
|
|
536
626
|
const existing = this.blocks.get(bucketId);
|
|
537
627
|
if (
|
|
538
628
|
existing &&
|
|
539
629
|
existing.rulesKey === rulesKey &&
|
|
540
630
|
existing.expiresAt > this.now()
|
|
541
631
|
) {
|
|
542
|
-
existing.
|
|
543
|
-
existing.
|
|
544
|
-
existing.expiresAt = Math.
|
|
632
|
+
existing.permits.push(...permits);
|
|
633
|
+
existing.permits.sort((a, b) => a[0] - b[0]);
|
|
634
|
+
existing.expiresAt = Math.max(existing.expiresAt, freshExpiresAt);
|
|
545
635
|
return;
|
|
546
636
|
}
|
|
547
637
|
if (existing)
|
|
548
|
-
this.releaseReserved(
|
|
638
|
+
this.releaseReserved(
|
|
639
|
+
bucketId,
|
|
640
|
+
existing.rateScopeToken,
|
|
641
|
+
existing.rules,
|
|
642
|
+
existing.permits.flatMap(([, leaseId]) =>
|
|
643
|
+
leaseId ? [leaseId] : [],
|
|
644
|
+
),
|
|
645
|
+
);
|
|
549
646
|
this.blocks.set(bucketId, {
|
|
550
|
-
|
|
551
|
-
|
|
647
|
+
permits,
|
|
648
|
+
rateScopeToken,
|
|
552
649
|
expiresAt: freshExpiresAt,
|
|
553
650
|
rulesKey,
|
|
554
651
|
rules,
|
|
@@ -558,7 +655,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
558
655
|
private drawFromBlock(
|
|
559
656
|
bucketId: string,
|
|
560
657
|
rulesKey: string,
|
|
561
|
-
): PacingPermit | null {
|
|
658
|
+
): { permit: PacingPermit; scheduledAtMs: number } | null {
|
|
562
659
|
const block = this.blocks.get(bucketId);
|
|
563
660
|
if (!block) return null;
|
|
564
661
|
if (block.rulesKey !== rulesKey || block.expiresAt <= this.now()) {
|
|
@@ -567,30 +664,50 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
567
664
|
// decremented the provider window for them, and the rule-scoped TTL is
|
|
568
665
|
// capped so at most one block's worth is stranded per bucket/window).
|
|
569
666
|
// Concurrency leases must be released so the shared slot count drains.
|
|
570
|
-
this.releaseReserved(
|
|
667
|
+
this.releaseReserved(
|
|
668
|
+
bucketId,
|
|
669
|
+
block.rateScopeToken,
|
|
670
|
+
block.rules,
|
|
671
|
+
block.permits.flatMap(([, leaseId]) =>
|
|
672
|
+
leaseId ? [leaseId] : [],
|
|
673
|
+
),
|
|
674
|
+
);
|
|
571
675
|
return null;
|
|
572
676
|
}
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
);
|
|
576
|
-
// Concurrency blocks draw a lease token (release semantics); pure-rate
|
|
577
|
-
// blocks draw an untracked window permit that the server already accounted.
|
|
578
|
-
const leaseId = hasConcurrency ? (block.leaseIds.pop() ?? null) : null;
|
|
579
|
-
if (!hasConcurrency && block.windowPermits > 0) block.windowPermits -= 1;
|
|
580
|
-
if (block.leaseIds.length === 0 && block.windowPermits === 0)
|
|
677
|
+
const scheduled = block.permits.shift();
|
|
678
|
+
if (!scheduled) {
|
|
581
679
|
this.blocks.delete(bucketId);
|
|
582
|
-
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
if (block.permits.length === 0) this.blocks.delete(bucketId);
|
|
683
|
+
return {
|
|
684
|
+
permit: this.permitFor(
|
|
685
|
+
bucketId,
|
|
686
|
+
block.rateScopeToken,
|
|
687
|
+
block.rules,
|
|
688
|
+
scheduled[1],
|
|
689
|
+
),
|
|
690
|
+
scheduledAtMs: scheduled[0],
|
|
691
|
+
};
|
|
583
692
|
}
|
|
584
693
|
|
|
585
694
|
private drainBlock(bucketId: string): void {
|
|
586
695
|
const block = this.blocks.get(bucketId);
|
|
587
696
|
if (!block) return;
|
|
588
697
|
this.blocks.delete(bucketId);
|
|
589
|
-
this.releaseReserved(
|
|
698
|
+
this.releaseReserved(
|
|
699
|
+
bucketId,
|
|
700
|
+
block.rateScopeToken,
|
|
701
|
+
block.rules,
|
|
702
|
+
block.permits.flatMap(([, leaseId]) =>
|
|
703
|
+
leaseId ? [leaseId] : [],
|
|
704
|
+
),
|
|
705
|
+
);
|
|
590
706
|
}
|
|
591
707
|
|
|
592
708
|
private releaseReserved(
|
|
593
709
|
bucketId: string,
|
|
710
|
+
rateScopeToken: string | null,
|
|
594
711
|
rules: readonly PacingRule[],
|
|
595
712
|
leaseIds: readonly string[],
|
|
596
713
|
): void {
|
|
@@ -603,6 +720,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
603
720
|
const key = `${bucketId}\0${rulesSignature(ordered)}`;
|
|
604
721
|
const pending = this.pendingReleases.get(key) ?? {
|
|
605
722
|
bucketId,
|
|
723
|
+
rateScopeToken,
|
|
606
724
|
rules: ordered,
|
|
607
725
|
leaseIds: [],
|
|
608
726
|
flushing: false,
|
|
@@ -623,6 +741,7 @@ export class AppRuntimeRateStateBackend implements RateStateBackend {
|
|
|
623
741
|
try {
|
|
624
742
|
await this.port.release({
|
|
625
743
|
bucketId: pending.bucketId,
|
|
744
|
+
rateScopeToken: pending.rateScopeToken,
|
|
626
745
|
rules: pending.rules,
|
|
627
746
|
leaseIds,
|
|
628
747
|
schedulerSchema: this.schedulerSchema,
|
package/dist/bundling-sources/shared_libs/play-runtime/governor/coordinator-rate-state-backend.ts
CHANGED
|
@@ -167,22 +167,26 @@ export class CoordinatorRateStateBackend implements RateStateBackend {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
penalize(input: {
|
|
170
|
+
async penalize(input: {
|
|
171
|
+
bucketId: string;
|
|
172
|
+
cooldownMs: number;
|
|
173
|
+
}): Promise<void> {
|
|
171
174
|
if (input.cooldownMs <= 0) return;
|
|
172
175
|
// Drop any cached block for this bucket so the cooldown takes effect on the
|
|
173
176
|
// very next acquire instead of being masked by already-leased permits.
|
|
174
177
|
this.blocks.delete(input.bucketId);
|
|
175
|
-
|
|
176
|
-
.ratePenalize({
|
|
178
|
+
try {
|
|
179
|
+
await this.port.ratePenalize({
|
|
177
180
|
bucketId: input.bucketId,
|
|
178
181
|
cooldownMs: input.cooldownMs,
|
|
179
|
-
})
|
|
180
|
-
.catch((error) => {
|
|
181
|
-
this.onDegraded({
|
|
182
|
-
bucketId: input.bucketId,
|
|
183
|
-
error: error instanceof Error ? error.message : String(error),
|
|
184
|
-
});
|
|
185
182
|
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
this.onDegraded({
|
|
185
|
+
bucketId: input.bucketId,
|
|
186
|
+
error: error instanceof Error ? error.message : String(error),
|
|
187
|
+
});
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
/**
|
|
@@ -70,6 +70,14 @@ export type PacingResolver = (
|
|
|
70
70
|
toolId: string,
|
|
71
71
|
) => Promise<{ provider: string; rules: PacingRule[] } | null>;
|
|
72
72
|
|
|
73
|
+
export type RateScopeResolver = (
|
|
74
|
+
toolId: string,
|
|
75
|
+
provider: string,
|
|
76
|
+
) =>
|
|
77
|
+
| Promise<{ bucketId: string; token: string } | null>
|
|
78
|
+
| { bucketId: string; token: string }
|
|
79
|
+
| null;
|
|
80
|
+
|
|
73
81
|
export function defaultPacingForTool(
|
|
74
82
|
toolId: string,
|
|
75
83
|
policy: ResolvedExecutionPolicy,
|
|
@@ -96,12 +104,9 @@ export interface PlayExecutionGovernor {
|
|
|
96
104
|
/** Block until a map-row slot is free. */
|
|
97
105
|
acquireRowSlot(opts?: { signal?: AbortSignal }): Promise<WorkLease>;
|
|
98
106
|
/**
|
|
99
|
-
* Block until
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* failed/aborted acquire never consumes budget). A run over tool budget still
|
|
103
|
-
* acquires and holds a slot + pacing permit before the breach is detected; the
|
|
104
|
-
* breach surfaces only once the call is otherwise cleared to run.
|
|
107
|
+
* Block until the per-provider pacer permit and then a global
|
|
108
|
+
* tool-concurrency slot are free, charge the tool-call budget, and return a
|
|
109
|
+
* lease. Parked future permits never consume scarce execution slots.
|
|
105
110
|
*/
|
|
106
111
|
acquireToolSlot(
|
|
107
112
|
toolId: string,
|
|
@@ -132,12 +137,19 @@ export interface PlayExecutionGovernor {
|
|
|
132
137
|
/** Feed a provider's Retry-After back into the shared pacer. */
|
|
133
138
|
reportProviderBackpressure(input: {
|
|
134
139
|
provider: string;
|
|
140
|
+
/**
|
|
141
|
+
* The tool whose provider call received the 429. Credential-scoped rate
|
|
142
|
+
* buckets are resolved per tool, so delayed feedback must retain this
|
|
143
|
+
* identity instead of selecting the latest scope for the provider.
|
|
144
|
+
*/
|
|
145
|
+
toolId?: string;
|
|
135
146
|
retryAfterMs: number;
|
|
136
|
-
}): void
|
|
147
|
+
}): Promise<void>;
|
|
137
148
|
|
|
138
149
|
/** Feed clean provider calls back into adaptive admission. */
|
|
139
150
|
observeProviderSuccess(input: {
|
|
140
151
|
provider: string;
|
|
152
|
+
toolId?: string;
|
|
141
153
|
latencyMs?: number | null;
|
|
142
154
|
}): void;
|
|
143
155
|
/** Feed success back using the same tool-to-provider resolution as acquire. */
|
|
@@ -155,6 +167,7 @@ interface GovernorInput {
|
|
|
155
167
|
rateState: RateStateBackend;
|
|
156
168
|
budgetState?: BudgetStateBackend;
|
|
157
169
|
resolvePacing: PacingResolver;
|
|
170
|
+
resolveRateScope?: RateScopeResolver;
|
|
158
171
|
adaptiveAdmission?: RuntimeAdaptiveAdmission;
|
|
159
172
|
resume?: GovernanceSnapshot;
|
|
160
173
|
}
|
|
@@ -246,6 +259,7 @@ export function createPlayExecutionGovernor(
|
|
|
246
259
|
});
|
|
247
260
|
const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
|
|
248
261
|
const providerByTool = new Map<string, string>();
|
|
262
|
+
const scopeByTool = new Map<string, [string, string]>();
|
|
249
263
|
|
|
250
264
|
// When the rate-state backend owns pacing authoritatively (the Absurd/Node
|
|
251
265
|
// app-runtime Postgres pacer runs the whole token bucket + AIMD in the row),
|
|
@@ -256,7 +270,20 @@ export function createPlayExecutionGovernor(
|
|
|
256
270
|
// undefined and keep the in-memory adaptive admission (byte-identical).
|
|
257
271
|
const dbAuthoritativePacing = input.rateState.kind === 'app_runtime_postgres';
|
|
258
272
|
|
|
259
|
-
const
|
|
273
|
+
const orgBucket = (provider: string) => `${input.scope.orgId}:${provider}`;
|
|
274
|
+
|
|
275
|
+
async function resolveScope(toolId: string, provider: string) {
|
|
276
|
+
const resolved = await input.resolveRateScope?.(toolId, provider);
|
|
277
|
+
const scope = [
|
|
278
|
+
resolved?.bucketId ?? orgBucket(provider),
|
|
279
|
+
resolved?.token ?? '',
|
|
280
|
+
] as [string, string];
|
|
281
|
+
scopeByTool.set(toolId, scope);
|
|
282
|
+
return scope;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const scopeFor = (toolId: string | undefined, provider: string) =>
|
|
286
|
+
scopeByTool.get(toolId ?? '') ?? ([orgBucket(provider), ''] as [string, string]);
|
|
260
287
|
|
|
261
288
|
async function resolveAdaptivePacing(toolId: string): Promise<{
|
|
262
289
|
provider: string;
|
|
@@ -348,23 +375,26 @@ export function createPlayExecutionGovernor(
|
|
|
348
375
|
|
|
349
376
|
acquireRowSlot: (opts) => rowSlots.acquire(opts?.signal),
|
|
350
377
|
async acquireToolSlot(toolId, opts) {
|
|
351
|
-
// 1.
|
|
352
|
-
const slot = await toolSlots.acquire(opts?.signal);
|
|
353
|
-
// 2. per-(org,provider) pacing. The provider comes from the pacing
|
|
378
|
+
// 1. Per-provider pacing. The provider comes from the pacing
|
|
354
379
|
// resolver, so callers only need the toolId. No rules → no pacing.
|
|
355
|
-
|
|
380
|
+
const pacing = await resolveAdaptivePacing(toolId);
|
|
381
|
+
const rateScope = await resolveScope(toolId, pacing.provider);
|
|
382
|
+
const permit = await input.rateState.acquire({
|
|
383
|
+
bucketId: rateScope[0],
|
|
384
|
+
rateScopeToken: rateScope[1],
|
|
385
|
+
rules: pacing.rules,
|
|
386
|
+
signal: opts?.signal,
|
|
387
|
+
});
|
|
388
|
+
// 2. Acquire a scarce execution slot only after the provider permit is
|
|
389
|
+
// due. If the slot wait aborts, refund any concurrency-bearing permit.
|
|
390
|
+
let slot: WorkLease;
|
|
356
391
|
try {
|
|
357
|
-
|
|
358
|
-
permit = await input.rateState.acquire({
|
|
359
|
-
bucketId: bucketId(pacing.provider),
|
|
360
|
-
rules: pacing.rules,
|
|
361
|
-
signal: opts?.signal,
|
|
362
|
-
});
|
|
392
|
+
slot = await toolSlots.acquire(opts?.signal);
|
|
363
393
|
} catch (error) {
|
|
364
|
-
|
|
394
|
+
permit.release();
|
|
365
395
|
throw error;
|
|
366
396
|
}
|
|
367
|
-
// 3.
|
|
397
|
+
// 3. Charge the budget only once the call is actually cleared to run, so a
|
|
368
398
|
// failed/aborted acquisition never permanently consumes tool budget.
|
|
369
399
|
try {
|
|
370
400
|
await chargeBudget('toolCall');
|
|
@@ -409,7 +439,7 @@ export function createPlayExecutionGovernor(
|
|
|
409
439
|
resolveRowConcurrency: (requested) =>
|
|
410
440
|
resolveRowConcurrency(policy, requested),
|
|
411
441
|
|
|
412
|
-
reportProviderBackpressure(bp) {
|
|
442
|
+
async reportProviderBackpressure(bp) {
|
|
413
443
|
// DB-authoritative pacer: route backpressure ONLY to the row via
|
|
414
444
|
// penalize. The in-memory adaptive admission must not also halve, or the
|
|
415
445
|
// provider would be throttled twice (once in the row, once in memory).
|
|
@@ -420,13 +450,22 @@ export function createPlayExecutionGovernor(
|
|
|
420
450
|
retryAfterMs: bp.retryAfterMs,
|
|
421
451
|
});
|
|
422
452
|
}
|
|
423
|
-
|
|
424
|
-
|
|
453
|
+
const rateScope = scopeFor(bp.toolId, bp.provider);
|
|
454
|
+
await input.rateState.penalize({
|
|
455
|
+
bucketId: rateScope[0],
|
|
456
|
+
...(rateScope[1] ? { rateScopeToken: rateScope[1] } : {}),
|
|
425
457
|
cooldownMs: bp.retryAfterMs,
|
|
426
458
|
});
|
|
427
459
|
},
|
|
428
460
|
|
|
429
461
|
observeProviderSuccess(success) {
|
|
462
|
+
if (dbAuthoritativePacing) {
|
|
463
|
+
const rateScope = scopeFor(success.toolId, success.provider);
|
|
464
|
+
input.rateState.observeSuccess?.({
|
|
465
|
+
bucketId: rateScope[0],
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
430
469
|
adaptiveAdmission.observeProviderSuccess({
|
|
431
470
|
orgId: input.scope.orgId,
|
|
432
471
|
provider: success.provider,
|
|
@@ -438,9 +477,9 @@ export function createPlayExecutionGovernor(
|
|
|
438
477
|
const provider =
|
|
439
478
|
providerByTool.get(success.toolId) ??
|
|
440
479
|
defaultPacingForTool(success.toolId, policy).provider;
|
|
441
|
-
|
|
442
|
-
orgId: input.scope.orgId,
|
|
480
|
+
governor.observeProviderSuccess({
|
|
443
481
|
provider,
|
|
482
|
+
toolId: success.toolId,
|
|
444
483
|
latencyMs: success.latencyMs,
|
|
445
484
|
});
|
|
446
485
|
},
|
|
@@ -507,8 +546,8 @@ function createInlineChildGovernor(
|
|
|
507
546
|
root.suggestedParallelism(toolId, fallback),
|
|
508
547
|
chargeBudget: (kind, amount) => root.chargeBudget(kind, amount),
|
|
509
548
|
resolveRowConcurrency: (requested) => root.resolveRowConcurrency(requested),
|
|
510
|
-
reportProviderBackpressure: (input) =>
|
|
511
|
-
root.reportProviderBackpressure(input),
|
|
549
|
+
reportProviderBackpressure: async (input) =>
|
|
550
|
+
await root.reportProviderBackpressure(input),
|
|
512
551
|
observeProviderSuccess: (input) => root.observeProviderSuccess(input),
|
|
513
552
|
observeToolSuccess: (input) => root.observeToolSuccess(input),
|
|
514
553
|
async forkInlineChild(input) {
|
|
@@ -86,6 +86,7 @@ export interface RateStateBackend {
|
|
|
86
86
|
*/
|
|
87
87
|
acquire(input: {
|
|
88
88
|
bucketId: string;
|
|
89
|
+
rateScopeToken?: string | null;
|
|
89
90
|
rules: readonly PacingRule[];
|
|
90
91
|
signal?: AbortSignal;
|
|
91
92
|
}): Promise<PacingPermit>;
|
|
@@ -94,7 +95,17 @@ export interface RateStateBackend {
|
|
|
94
95
|
* Feed a server-observed Retry-After back so future acquires for this bucket
|
|
95
96
|
* back off. Advisory and idempotent; never un-charges an in-flight call.
|
|
96
97
|
*/
|
|
97
|
-
penalize(input: {
|
|
98
|
+
penalize(input: {
|
|
99
|
+
bucketId: string;
|
|
100
|
+
rateScopeToken?: string | null;
|
|
101
|
+
cooldownMs: number;
|
|
102
|
+
}): void | Promise<void>;
|
|
103
|
+
|
|
104
|
+
/** Record confirmed provider success for adaptive recovery. */
|
|
105
|
+
observeSuccess?(input: {
|
|
106
|
+
bucketId: string;
|
|
107
|
+
rateScopeToken?: string | null;
|
|
108
|
+
}): void;
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
const NOOP_PERMIT: PacingPermit = { release() {} };
|