@mlx-node/server 0.0.9 → 0.0.12
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/chat-session-warm-reuse.d.ts +10 -12
- package/dist/chat-session-warm-reuse.d.ts.map +1 -1
- package/dist/chat-session-warm-reuse.js +10 -12
- package/dist/endpoints/messages.d.ts +2 -2
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +492 -349
- package/dist/endpoints/responses.d.ts +1 -1
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +1149 -1055
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +1 -1
- package/dist/health.d.ts +4 -6
- package/dist/health.d.ts.map +1 -1
- package/dist/host/discover.d.ts +1 -2
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +3 -6
- package/dist/host/index.d.ts +6 -1
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +3 -2
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -9
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +5 -1
- package/dist/mappers/request.d.ts +12 -2
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +26 -2
- package/dist/model-work-coordinator.d.ts +50 -0
- package/dist/model-work-coordinator.d.ts.map +1 -1
- package/dist/model-work-coordinator.js +161 -0
- package/dist/registry.d.ts +15 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +51 -0
- package/dist/server.d.ts +21 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +30 -6
- package/dist/session-registry.d.ts +266 -86
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +421 -107
- package/dist/streaming.d.ts +37 -2
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +122 -1
- package/dist/transport-visibility.d.ts +5 -4
- package/dist/transport-visibility.d.ts.map +1 -1
- package/dist/transport-visibility.js +5 -4
- package/dist/types-anthropic.d.ts +7 -0
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/dist/presets.d.ts +0 -82
- package/dist/presets.d.ts.map +0 -1
- package/dist/presets.js +0 -98
package/dist/session-registry.js
CHANGED
|
@@ -21,18 +21,13 @@
|
|
|
21
21
|
* by response id — no secondary keying on model name because the
|
|
22
22
|
* registry is already scoped per model.
|
|
23
23
|
*
|
|
24
|
-
* - **Single-warm-session invariant.**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* model is therefore an illusion: at most ONE matches real
|
|
32
|
-
* native state (whichever ran most recently). To prevent
|
|
33
|
-
* cross-session corruption this registry holds at most ONE
|
|
34
|
-
* entry — both `getOrCreate` and `adopt` clear the map before
|
|
35
|
-
* returning or inserting.
|
|
24
|
+
* - **Single-warm-session invariant.** The JS warm registry retains at
|
|
25
|
+
* most ONE `ChatSession` entry — both `getOrCreate` and `adopt` clear
|
|
26
|
+
* the map before returning or inserting. This remains the sole safe
|
|
27
|
+
* reuse mechanism for flat-cache models whose native cache is one
|
|
28
|
+
* mutable vector. Block-paged schedulers instead isolate live turns by
|
|
29
|
+
* cache owner and reuse verified physical blocks through the native
|
|
30
|
+
* prefix table; those models may run fresh JS sessions concurrently.
|
|
36
31
|
*
|
|
37
32
|
* - **Lease semantics on hit.** Clear-on-hit also gives single-
|
|
38
33
|
* flight lease semantics: two overlapping requests referencing
|
|
@@ -43,14 +38,13 @@
|
|
|
43
38
|
* `ChatSession`'s single-flight "concurrent send() not allowed"
|
|
44
39
|
* guard.
|
|
45
40
|
*
|
|
46
|
-
* - **
|
|
47
|
-
*
|
|
48
|
-
* `getOrCreate` compares the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* — the registry does not care which is which.
|
|
41
|
+
* - **Prefix compatibility changes miss.** Each entry records its
|
|
42
|
+
* `instructions` plus an opaque fingerprint of the cache salt used to
|
|
43
|
+
* adopt it. `getOrCreate` compares both against the new request;
|
|
44
|
+
* mismatch forces owner release and cold replay instead of reusing a
|
|
45
|
+
* stale prompt or changing the security domain of one live native
|
|
46
|
+
* request. OpenAI `instructions` and Anthropic `system` share the same
|
|
47
|
+
* parameter; both endpoints also thread their mapped `cache_salt`.
|
|
54
48
|
*
|
|
55
49
|
* - **Cache miss fallback.** On a miss (eviction, interleaved turn
|
|
56
50
|
* on a different chain, restart, lease-on-hit) the endpoint
|
|
@@ -73,23 +67,12 @@
|
|
|
73
67
|
* concurrent mutation by design. `sweep()` can be scheduled
|
|
74
68
|
* via `setInterval` without colliding with in-flight calls.
|
|
75
69
|
*
|
|
76
|
-
* - **Per-model
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* cache is a single mutable resource and two parallel
|
|
83
|
-
* `primeHistory()` / `send*()` calls would race. Whichever
|
|
84
|
-
* finished last would win `adopt()`, poisoning the hot path
|
|
85
|
-
* for every subsequent chained turn.
|
|
86
|
-
*
|
|
87
|
-
* `withExclusive(fn)` serializes every per-model dispatch via
|
|
88
|
-
* a FIFO `execLock` chain. `/v1/responses` and `/v1/messages`
|
|
89
|
-
* wrap the full `getOrCreate -> run -> adopt/drop` span in one
|
|
90
|
-
* `withExclusive` so at most one request holds the model at a
|
|
91
|
-
* time. A weaker epoch-token scheme would let the losing
|
|
92
|
-
* `adopt()` no-op but the native KV would already be wrong.
|
|
70
|
+
* - **Per-model admission lane.** Flat and not-yet-batched families use
|
|
71
|
+
* `withExclusive(fn)`, the original FIFO mutex across the full dispatch.
|
|
72
|
+
* A model that explicitly reports `maxConcurrentSequences() > 1` uses
|
|
73
|
+
* `withAdmission(fn)`, a counting semaphore sized to that native
|
|
74
|
+
* scheduler. Per-session serialization still lives in `ChatSession`;
|
|
75
|
+
* only independent sessions share the model lane.
|
|
93
76
|
*/
|
|
94
77
|
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
95
78
|
import { ChatSession } from '@mlx-node/lm';
|
|
@@ -160,12 +143,17 @@ function getNonce() {
|
|
|
160
143
|
}
|
|
161
144
|
return cachedNonce;
|
|
162
145
|
}
|
|
146
|
+
/** Opaque equality token for a cache salt; the caller's raw value is never retained. */
|
|
147
|
+
function fingerprintCacheSalt(cacheSalt) {
|
|
148
|
+
if (cacheSalt == null)
|
|
149
|
+
return null;
|
|
150
|
+
return createHmac('sha256', getNonce()).update(cacheSalt).digest('hex').slice(0, 32);
|
|
151
|
+
}
|
|
163
152
|
/**
|
|
164
|
-
* Test-only hook used by the scoping unit tests to simulate a
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* dedupe cache so tests can re-exercise the once-per-key diagnostic
|
|
168
|
-
* path.
|
|
153
|
+
* Test-only hook used by the scoping unit tests to simulate a server
|
|
154
|
+
* restart: resets the module-scoped HMAC nonce (so every previously stored
|
|
155
|
+
* tier-2 key and cache-salt fingerprint misses) and clears the silent-miss
|
|
156
|
+
* dedupe cache so tests can re-exercise the once-per-key diagnostic path.
|
|
169
157
|
*
|
|
170
158
|
* **Not exported from the package's public `index.ts` surface** —
|
|
171
159
|
* exporting it there would let downstream consumers nuke tier-2
|
|
@@ -296,12 +284,18 @@ export function maybeWarnPromptCacheKeyIneligible(rawKey) {
|
|
|
296
284
|
* can reliably catch it without racing the chain.
|
|
297
285
|
*/
|
|
298
286
|
export class QueueFullError extends Error {
|
|
299
|
-
|
|
287
|
+
queueDepth;
|
|
288
|
+
preDispatchAdmissions;
|
|
289
|
+
admissionFootprint;
|
|
300
290
|
limit;
|
|
301
|
-
constructor(
|
|
302
|
-
|
|
291
|
+
constructor(queueDepth, preDispatchAdmissions, limit) {
|
|
292
|
+
const admissionFootprint = queueDepth + preDispatchAdmissions;
|
|
293
|
+
super(`Model queue full: ${queueDepth} queued, ${preDispatchAdmissions} pre-dispatch ` +
|
|
294
|
+
`(${admissionFootprint} admitted outside the active runner; waiter limit ${limit})`);
|
|
303
295
|
this.name = 'QueueFullError';
|
|
304
|
-
this.
|
|
296
|
+
this.queueDepth = queueDepth;
|
|
297
|
+
this.preDispatchAdmissions = preDispatchAdmissions;
|
|
298
|
+
this.admissionFootprint = admissionFootprint;
|
|
305
299
|
this.limit = limit;
|
|
306
300
|
}
|
|
307
301
|
}
|
|
@@ -313,6 +307,7 @@ export class SessionRegistry {
|
|
|
313
307
|
model;
|
|
314
308
|
ttlSec;
|
|
315
309
|
maxQueueDepth;
|
|
310
|
+
maxConcurrentDispatches;
|
|
316
311
|
/**
|
|
317
312
|
* Per-model sampling defaults forwarded into every new `ChatSession`
|
|
318
313
|
* via its `defaultConfig` constructor option. `undefined` preserves
|
|
@@ -342,6 +337,25 @@ export class SessionRegistry {
|
|
|
342
337
|
* runner-slot caller against the waiter cap.
|
|
343
338
|
*/
|
|
344
339
|
queuedCount = 0;
|
|
340
|
+
/**
|
|
341
|
+
* Requests admitted by {@link beginPreDispatchAdmission} whose permit
|
|
342
|
+
* is still outstanding — parked in the `ModelWorkCoordinator` writer
|
|
343
|
+
* queue (host mode), blocked in pre-lock store lookups
|
|
344
|
+
* (`previous_response_id` continuations), or anywhere else between the
|
|
345
|
+
* endpoint gate and resident-lane placement. None of that parking is
|
|
346
|
+
* visible to `queuedCount`; this counter is what lets the gate bound
|
|
347
|
+
* it. Decremented ONLY by the permit itself: `release()` on a bail-out
|
|
348
|
+
* or the atomic consume inside the selected lane on handoff.
|
|
349
|
+
*/
|
|
350
|
+
preDispatchAdmits = 0;
|
|
351
|
+
/**
|
|
352
|
+
* Consume hooks for outstanding permits, keyed by permit identity.
|
|
353
|
+
* Registry-scoped on purpose: both execution lanes consult THIS map, so a
|
|
354
|
+
* permit minted by a different registry is simply not found and the
|
|
355
|
+
* call falls back to normal waiter charging — a cross-registry handoff
|
|
356
|
+
* cannot corrupt either registry's counters.
|
|
357
|
+
*/
|
|
358
|
+
permitConsumers = new WeakMap();
|
|
345
359
|
/**
|
|
346
360
|
* Holds AT MOST ONE entry under the single-warm invariant (see the
|
|
347
361
|
* module-level rustdoc). `getOrCreate` and `adopt` both clear the
|
|
@@ -350,6 +364,15 @@ export class SessionRegistry {
|
|
|
350
364
|
* turn on another cached entry.
|
|
351
365
|
*/
|
|
352
366
|
entries = new Map();
|
|
367
|
+
/**
|
|
368
|
+
* Eviction is synchronous at the map boundary, but releasing a native
|
|
369
|
+
* scheduler owner is asynchronous. Start every disposal immediately and
|
|
370
|
+
* retain its promise so endpoint admission lanes can wait for command-order
|
|
371
|
+
* visibility before dispatching a replacement turn.
|
|
372
|
+
*/
|
|
373
|
+
pendingDisposals = new Set();
|
|
374
|
+
disposalBySession = new WeakMap();
|
|
375
|
+
failedDisposals = new Set();
|
|
353
376
|
/**
|
|
354
377
|
* Shared sentinel representing "the execution chain is idle" — a
|
|
355
378
|
* pre-resolved promise. `execLock` starts at this value and is
|
|
@@ -375,10 +398,17 @@ export class SessionRegistry {
|
|
|
375
398
|
* cleanly from the idle state.
|
|
376
399
|
*/
|
|
377
400
|
execLock = this.initialLock;
|
|
401
|
+
/** Active holders in the continuous-batching admission lane. */
|
|
402
|
+
activeAdmissions = 0;
|
|
403
|
+
/** FIFO waiters parked behind the continuous-batching admission limit. */
|
|
404
|
+
admissionWaiters = [];
|
|
378
405
|
constructor(opts) {
|
|
379
406
|
this.model = opts.model;
|
|
380
407
|
this.ttlSec = opts.ttlSec ?? 1800;
|
|
381
408
|
this.maxQueueDepth = opts.maxQueueDepth;
|
|
409
|
+
const requestedConcurrency = opts.maxConcurrentDispatches ?? 1;
|
|
410
|
+
this.maxConcurrentDispatches =
|
|
411
|
+
Number.isSafeInteger(requestedConcurrency) && requestedConcurrency > 1 ? requestedConcurrency : 1;
|
|
382
412
|
this.samplingDefaults = opts.samplingDefaults;
|
|
383
413
|
this.maxOutputTokens = opts.maxOutputTokens;
|
|
384
414
|
}
|
|
@@ -398,16 +428,88 @@ export class SessionRegistry {
|
|
|
398
428
|
defaultConfig: this.samplingDefaults,
|
|
399
429
|
});
|
|
400
430
|
}
|
|
431
|
+
scheduleDispose(session) {
|
|
432
|
+
if (this.disposalBySession.has(session))
|
|
433
|
+
return;
|
|
434
|
+
const disposal = this.disposeSession(session)
|
|
435
|
+
.catch((error) => {
|
|
436
|
+
console.error('[server] failed to release an evicted chat-session cache owner:', error);
|
|
437
|
+
})
|
|
438
|
+
.finally(() => {
|
|
439
|
+
this.pendingDisposals.delete(disposal);
|
|
440
|
+
this.disposalBySession.delete(session);
|
|
441
|
+
});
|
|
442
|
+
this.disposalBySession.set(session, disposal);
|
|
443
|
+
this.pendingDisposals.add(disposal);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Dispose one leased session while retaining failed cleanup for a later
|
|
447
|
+
* registry flush. `ChatSession.dispose()` removes successful owners as it
|
|
448
|
+
* goes, so a retry only revisits owners whose native release failed.
|
|
449
|
+
*/
|
|
450
|
+
async disposeSession(session) {
|
|
451
|
+
try {
|
|
452
|
+
await session.dispose();
|
|
453
|
+
this.failedDisposals.delete(session);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
this.failedDisposals.add(session);
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** Remove every cached entry except an optional session being leased. */
|
|
461
|
+
evictEntriesExcept(keep) {
|
|
462
|
+
for (const entry of this.entries.values()) {
|
|
463
|
+
if (entry.session !== keep)
|
|
464
|
+
this.scheduleDispose(entry.session);
|
|
465
|
+
}
|
|
466
|
+
this.entries.clear();
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Wait until every disposal scheduled so far has settled, retrying each
|
|
470
|
+
* failure discovered during this flush once. A persistent failure remains
|
|
471
|
+
* recorded for a later flush instead of spinning forever. Disposals log and
|
|
472
|
+
* absorb their own failures so cleanup cannot rewrite a response that has
|
|
473
|
+
* already reached the client.
|
|
474
|
+
*/
|
|
475
|
+
async flushPendingDisposals() {
|
|
476
|
+
const retried = new Set();
|
|
477
|
+
while (true) {
|
|
478
|
+
const retries = Array.from(this.failedDisposals).filter((session) => !retried.has(session));
|
|
479
|
+
for (const session of retries) {
|
|
480
|
+
this.failedDisposals.delete(session);
|
|
481
|
+
retried.add(session);
|
|
482
|
+
this.scheduleDispose(session);
|
|
483
|
+
}
|
|
484
|
+
if (this.pendingDisposals.size === 0)
|
|
485
|
+
return;
|
|
486
|
+
await Promise.all(Array.from(this.pendingDisposals));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Disposals started but not yet settled — each one is an in-flight
|
|
491
|
+
* native `releaseCacheOwner` round-trip — plus disposals whose initial
|
|
492
|
+
* attempt and bounded retry both failed and remain owed to the native
|
|
493
|
+
* scheduler ({@link failedDisposals}), retried by the next
|
|
494
|
+
* {@link flushPendingDisposals}. Endpoints await that flush before
|
|
495
|
+
* leaving the admission lane, but it runs after the response has
|
|
496
|
+
* finished, so an observer keyed on request completion can still beat
|
|
497
|
+
* the release. `adopt`/`drop`/`sweep` schedule synchronously, so once
|
|
498
|
+
* the request counters read zero any disposal those requests will ever
|
|
499
|
+
* cause is already counted here. Primarily for diagnostics/tests.
|
|
500
|
+
*/
|
|
501
|
+
get pendingDisposalCount() {
|
|
502
|
+
return this.pendingDisposals.size + this.failedDisposals.size;
|
|
503
|
+
}
|
|
401
504
|
/**
|
|
402
|
-
* Number of requests
|
|
403
|
-
*
|
|
404
|
-
* `fn`. Primarily for tests and diagnostics.
|
|
505
|
+
* Number of requests waiting for this model's selected admission lane.
|
|
506
|
+
* Active dispatches are not included. Primarily for diagnostics/tests.
|
|
405
507
|
*/
|
|
406
508
|
get queueDepth() {
|
|
407
509
|
return this.queuedCount;
|
|
408
510
|
}
|
|
409
511
|
/**
|
|
410
|
-
* Configured waiter cap for this model's
|
|
512
|
+
* Configured waiter cap for this model's admission lane, or `undefined`
|
|
411
513
|
* when unbounded. Paired with {@link queueDepth} so a readiness probe can
|
|
412
514
|
* tell "3 waiters, unbounded" (fine) from "3 waiters, cap of 3" (the next
|
|
413
515
|
* request gets a 429) without reaching into private state.
|
|
@@ -415,6 +517,108 @@ export class SessionRegistry {
|
|
|
415
517
|
get queueDepthLimit() {
|
|
416
518
|
return this.maxQueueDepth;
|
|
417
519
|
}
|
|
520
|
+
/** Native continuous-batching capacity used by the endpoint route switch. */
|
|
521
|
+
get concurrentAdmissionLimit() {
|
|
522
|
+
return this.maxConcurrentDispatches;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Outstanding pre-dispatch permits — requests admitted by
|
|
526
|
+
* {@link beginPreDispatchAdmission} that have neither handed their
|
|
527
|
+
* permit to the selected execution lane nor released it yet. These permits,
|
|
528
|
+
* queued callers, and active dispatches share one bounded budget; see
|
|
529
|
+
* {@link assertAdmissionCapacity}. For probes and diagnostics.
|
|
530
|
+
*/
|
|
531
|
+
get preDispatchAdmitCount() {
|
|
532
|
+
return this.preDispatchAdmits;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Single source of truth for the per-model admission budget — the
|
|
536
|
+
* ONLY place the cap arithmetic lives. Every admission path calls
|
|
537
|
+
* this: {@link beginPreDispatchAdmission} before minting a permit,
|
|
538
|
+
* and both execution lanes for every non-handed-off caller. A handed-off
|
|
539
|
+
* permit skips the call for its OWN
|
|
540
|
+
* token only: that token was charged here at acquisition and its
|
|
541
|
+
* conversion keeps the total constant, so re-checking would
|
|
542
|
+
* double-charge an already-admitted request. It never exempts anyone
|
|
543
|
+
* else — all other outstanding state stays counted for every caller
|
|
544
|
+
* that did not pay.
|
|
545
|
+
*
|
|
546
|
+
* Budget invariant: active dispatches + queued callers + outstanding
|
|
547
|
+
* permits never exceed `maxConcurrentDispatches + maxQueueDepth`.
|
|
548
|
+
* On the exclusive lane `maxConcurrentDispatches` is one, exactly the
|
|
549
|
+
* original runner entitlement. On the batched lane it is the native
|
|
550
|
+
* scheduler's sequence capacity.
|
|
551
|
+
*
|
|
552
|
+
* Charging stays at the call sites (`preDispatchAdmits += 1` at the
|
|
553
|
+
* gate, `queuedCount += 1` for waiters, and the selected lane's active
|
|
554
|
+
* count for runners); every admitted unit is counted by exactly one
|
|
555
|
+
* at any time, which is what makes the footprint sum complete across
|
|
556
|
+
* any interleaving of permitted and permitless callers.
|
|
557
|
+
*
|
|
558
|
+
* Throws {@link QueueFullError} — reporting the footprint and the
|
|
559
|
+
* cap — when the caller does not fit; returns normally otherwise.
|
|
560
|
+
* No-op when the registry is unbounded.
|
|
561
|
+
*/
|
|
562
|
+
assertAdmissionCapacity() {
|
|
563
|
+
if (this.maxQueueDepth === undefined)
|
|
564
|
+
return;
|
|
565
|
+
const activeDispatches = this.maxConcurrentDispatches > 1 ? this.activeAdmissions : this.execLock === this.initialLock ? 0 : 1;
|
|
566
|
+
const footprint = activeDispatches + this.queuedCount + this.preDispatchAdmits;
|
|
567
|
+
if (footprint >= this.maxQueueDepth + this.maxConcurrentDispatches) {
|
|
568
|
+
throw new QueueFullError(this.queuedCount, this.preDispatchAdmits, this.maxQueueDepth);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Endpoint-side early admission against this registry's cap, taken BEFORE
|
|
573
|
+
* the request enters any pre-dispatch parking spot the resident lane cannot
|
|
574
|
+
* see. Resident host traffic deliberately bypasses the model-load writer so
|
|
575
|
+
* continuous batching remains reachable, but a continuation can still block
|
|
576
|
+
* in `await store.getChain(...)` (or other pre-lock work) with `queuedCount`
|
|
577
|
+
* unchanged. This permit keeps all such work inside the same bounded budget.
|
|
578
|
+
*
|
|
579
|
+
* Accounting: pre-dispatch permits, active dispatches, and queued callers
|
|
580
|
+
* draw from ONE budget: `maxQueueDepth` waiter slots plus the selected
|
|
581
|
+
* lane's active capacity. Both lanes and this early gate route through
|
|
582
|
+
* {@link assertAdmissionCapacity}, so permitted and permitless arrivals
|
|
583
|
+
* cannot double-spend a slot.
|
|
584
|
+
*
|
|
585
|
+
* Throws {@link QueueFullError} synchronously when over cap (the
|
|
586
|
+
* caller maps it to the same 429 envelope as resident-lane rejection
|
|
587
|
+
* reject). On admission returns a {@link PreDispatchAdmission} permit
|
|
588
|
+
* the caller must RETAIN through ALL pre-lock asynchronous work and
|
|
589
|
+
* then hand to `withExclusive(fn, permit)` or `withAdmission(fn, permit)`,
|
|
590
|
+
* which consumes it
|
|
591
|
+
* atomically as that call's admission — one budget, one token per
|
|
592
|
+
* request, never double-counted. Releasing the permit early instead
|
|
593
|
+
* of handing it off re-opens the hole this gate closes: the request
|
|
594
|
+
* would be counted by NEITHER counter while parked, arrivals would
|
|
595
|
+
* refill the budget, and the resident lane would then admit a second
|
|
596
|
+
* full waiter budget on top. `release()` belongs on bail-out paths
|
|
597
|
+
* only (idempotent, no-op after handoff — an unconditional `finally`
|
|
598
|
+
* release is the recommended shape).
|
|
599
|
+
*/
|
|
600
|
+
beginPreDispatchAdmission() {
|
|
601
|
+
this.assertAdmissionCapacity();
|
|
602
|
+
this.preDispatchAdmits += 1;
|
|
603
|
+
let settled = false;
|
|
604
|
+
const settle = () => {
|
|
605
|
+
if (settled)
|
|
606
|
+
return false;
|
|
607
|
+
settled = true;
|
|
608
|
+
this.preDispatchAdmits -= 1;
|
|
609
|
+
if (this.preDispatchAdmits < 0)
|
|
610
|
+
this.preDispatchAdmits = 0;
|
|
611
|
+
this.permitConsumers.delete(permit);
|
|
612
|
+
return true;
|
|
613
|
+
};
|
|
614
|
+
const permit = {
|
|
615
|
+
release: () => {
|
|
616
|
+
void settle();
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
this.permitConsumers.set(permit, settle);
|
|
620
|
+
return permit;
|
|
621
|
+
}
|
|
418
622
|
/**
|
|
419
623
|
* Current sampling defaults applied to every new `ChatSession` this
|
|
420
624
|
* registry allocates. Exposed primarily for tests and diagnostics.
|
|
@@ -453,11 +657,11 @@ export class SessionRegistry {
|
|
|
453
657
|
*
|
|
454
658
|
* 1. **Tier 1 — `previousResponseId`.** The existing hot path:
|
|
455
659
|
* exact id match on a live, non-expired entry whose stored
|
|
456
|
-
* `instructions`
|
|
660
|
+
* `instructions` and cache-salt fingerprint match the request. On
|
|
457
661
|
* a match the entry is leased out (single-use: removed from the
|
|
458
662
|
* map so a concurrent second request cannot share the live
|
|
459
|
-
* `ChatSession`). On a miss — unknown id, expired,
|
|
460
|
-
*
|
|
663
|
+
* `ChatSession`). On a miss — unknown id, expired, instructions
|
|
664
|
+
* drift, or cache-salt drift — the method falls through to a FRESH
|
|
461
665
|
* session regardless of whether tier 2 would have hit.
|
|
462
666
|
*
|
|
463
667
|
* `previousResponseId` wins unconditionally when supplied. The
|
|
@@ -477,9 +681,9 @@ export class SessionRegistry {
|
|
|
477
681
|
* is to key on the client-supplied `prompt_cache_key`. Scans
|
|
478
682
|
* for any live, non-expired entry whose stored
|
|
479
683
|
* `promptCacheKey` is non-null AND byte-equal to the caller's
|
|
480
|
-
* `promptCacheKey
|
|
481
|
-
*
|
|
482
|
-
* `null` — an opt-out sentinel from a client that forgot to
|
|
684
|
+
* `promptCacheKey`, whose stored `instructions` are byte-equal,
|
|
685
|
+
* AND whose cache-salt fingerprint matches. Empty string is treated
|
|
686
|
+
* as a distinct key from `null` — an opt-out sentinel from a client that forgot to
|
|
483
687
|
* thread the key must NOT collide with another client that
|
|
484
688
|
* did set it to empty. On a match the entry is leased out
|
|
485
689
|
* (same single-use semantics as tier 1). On a miss, fall
|
|
@@ -495,7 +699,8 @@ export class SessionRegistry {
|
|
|
495
699
|
* native `cachedTokens > 0` confirms the prefix-cache machinery
|
|
496
700
|
* actually reused the cached tokens).
|
|
497
701
|
*/
|
|
498
|
-
getOrCreate(previousResponseId, requestedInstructions, promptCacheKey = null) {
|
|
702
|
+
getOrCreate(previousResponseId, requestedInstructions, promptCacheKey = null, requestedCacheSalt = null) {
|
|
703
|
+
const requestedCacheSaltFingerprint = fingerprintCacheSalt(requestedCacheSalt);
|
|
499
704
|
// Tier 1: previousResponseId exact match.
|
|
500
705
|
//
|
|
501
706
|
// Every call is about to overwrite native KV state, so drop any
|
|
@@ -506,18 +711,22 @@ export class SessionRegistry {
|
|
|
506
711
|
if (previousResponseId !== null) {
|
|
507
712
|
const entry = this.entries.get(previousResponseId);
|
|
508
713
|
if (entry === undefined) {
|
|
509
|
-
this.
|
|
714
|
+
this.evictEntriesExcept();
|
|
510
715
|
return { session: this.newSession(), hit: false };
|
|
511
716
|
}
|
|
512
717
|
if (entry.expiresAt < nowSec()) {
|
|
513
|
-
this.
|
|
718
|
+
this.evictEntriesExcept();
|
|
514
719
|
return { session: this.newSession(), hit: false };
|
|
515
720
|
}
|
|
516
|
-
// Prefix-state mismatch forces cold replay so
|
|
517
|
-
//
|
|
518
|
-
//
|
|
721
|
+
// Prefix-state mismatch forces cold replay so new instructions are
|
|
722
|
+
// re-primed; cache-salt mismatch below likewise prevents a live native
|
|
723
|
+
// request from crossing prefix-cache security domains.
|
|
519
724
|
if (entry.instructions !== requestedInstructions) {
|
|
520
|
-
this.
|
|
725
|
+
this.evictEntriesExcept();
|
|
726
|
+
return { session: this.newSession(), hit: false };
|
|
727
|
+
}
|
|
728
|
+
if (entry.cacheSaltFingerprint !== requestedCacheSaltFingerprint) {
|
|
729
|
+
this.evictEntriesExcept();
|
|
521
730
|
return { session: this.newSession(), hit: false };
|
|
522
731
|
}
|
|
523
732
|
// Tier-1 hit: clear and hand the session out as a single-use
|
|
@@ -526,7 +735,7 @@ export class SessionRegistry {
|
|
|
526
735
|
// that even on a prev-id tier-1 MISS we do NOT fall through to
|
|
527
736
|
// tier 2 — see the docstring above for the precedence
|
|
528
737
|
// rationale.
|
|
529
|
-
this.
|
|
738
|
+
this.evictEntriesExcept(entry.session);
|
|
530
739
|
return { session: entry.session, hit: true };
|
|
531
740
|
}
|
|
532
741
|
// Tier 2: promptCacheKey scan (only reached when previousResponseId is null).
|
|
@@ -535,7 +744,7 @@ export class SessionRegistry {
|
|
|
535
744
|
// invariant, so the "scan" is actually a single lookup — walk the
|
|
536
745
|
// map, check the one entry if present, hit or miss. A non-null
|
|
537
746
|
// scoped key on both the request and the entry plus byte-equal
|
|
538
|
-
// instructions is the match condition.
|
|
747
|
+
// instructions and cache-salt fingerprints is the match condition.
|
|
539
748
|
//
|
|
540
749
|
// SECURITY: raw caller-supplied keys never touch the map. They
|
|
541
750
|
// are run through {@link scopePromptCacheKey}, which (a) returns
|
|
@@ -556,16 +765,18 @@ export class SessionRegistry {
|
|
|
556
765
|
continue;
|
|
557
766
|
if (entry.instructions !== requestedInstructions)
|
|
558
767
|
continue;
|
|
768
|
+
if (entry.cacheSaltFingerprint !== requestedCacheSaltFingerprint)
|
|
769
|
+
continue;
|
|
559
770
|
// Tier-2 hit: clear and lease (same single-warm / single-use
|
|
560
771
|
// semantics as tier 1).
|
|
561
|
-
this.
|
|
772
|
+
this.evictEntriesExcept(entry.session);
|
|
562
773
|
return { session: entry.session, hit: true };
|
|
563
774
|
}
|
|
564
775
|
}
|
|
565
776
|
// Fall through: fresh session. Clear any leftover entry so a
|
|
566
777
|
// later lookup cannot hand out a wrapper whose assumed state has
|
|
567
778
|
// been overwritten by this dispatch.
|
|
568
|
-
this.
|
|
779
|
+
this.evictEntriesExcept();
|
|
569
780
|
return { session: this.newSession(), hit: false };
|
|
570
781
|
}
|
|
571
782
|
/**
|
|
@@ -634,17 +845,18 @@ export class SessionRegistry {
|
|
|
634
845
|
* registry's own warm slot.
|
|
635
846
|
*
|
|
636
847
|
* Behaviour: walk the registry's at-most-one warm entry. If it is
|
|
637
|
-
* non-expired
|
|
638
|
-
* `requestedInstructions`,
|
|
639
|
-
*
|
|
640
|
-
*
|
|
848
|
+
* non-expired, its stored `instructions` are byte-equal to
|
|
849
|
+
* `requestedInstructions`, AND its stored cache salt equals
|
|
850
|
+
* `requestedCacheSalt`, lease it out (single-use — `entries.clear()`
|
|
851
|
+
* before return, mirroring the tier-1 / tier-2 lease-on-hit semantics).
|
|
852
|
+
* Otherwise clear the map and return a fresh session.
|
|
641
853
|
*
|
|
642
854
|
* Crucially, this lookup IGNORES `entry.promptCacheKey` and ignores
|
|
643
855
|
* the entry's prior `previousResponseId` keying — any warm slot is
|
|
644
|
-
* fair game for `/v1/messages` reuse.
|
|
645
|
-
*
|
|
646
|
-
* forces cold replay
|
|
647
|
-
*
|
|
856
|
+
* fair game for `/v1/messages` reuse. Byte-equal instructions and cache
|
|
857
|
+
* salt are the correctness gates: a system prompt or prefix-cache
|
|
858
|
+
* security-domain change forces cold replay instead of reusing stale state
|
|
859
|
+
* or asking the native adapter to mutate a live request's salt.
|
|
648
860
|
*
|
|
649
861
|
* **Adoption sentinel.** `/v1/messages` adopts back under the literal
|
|
650
862
|
* sentinel id `'__msg_warm__'`. That sentinel will never appear as a
|
|
@@ -680,9 +892,10 @@ export class SessionRegistry {
|
|
|
680
892
|
* `responses.ts` (around the `runSessionNonStreaming` /
|
|
681
893
|
* `runSessionStreaming` branches) describes.
|
|
682
894
|
*/
|
|
683
|
-
getOrCreateWarmAny(requestedInstructions) {
|
|
895
|
+
getOrCreateWarmAny(requestedInstructions, requestedCacheSalt = null) {
|
|
896
|
+
const requestedCacheSaltFingerprint = fingerprintCacheSalt(requestedCacheSalt);
|
|
684
897
|
// Single-warm invariant: at most one entry. Walk it once, lease
|
|
685
|
-
// on a fresh + instructions-matched hit, otherwise clear and
|
|
898
|
+
// on a fresh + instructions-and-salt-matched hit, otherwise clear and
|
|
686
899
|
// cold-start. The ignored fields (promptCacheKey,
|
|
687
900
|
// previousResponseId-keying) are deliberate — see the docstring.
|
|
688
901
|
for (const entry of this.entries.values()) {
|
|
@@ -690,14 +903,16 @@ export class SessionRegistry {
|
|
|
690
903
|
continue;
|
|
691
904
|
if (entry.instructions !== requestedInstructions)
|
|
692
905
|
continue;
|
|
906
|
+
if (entry.cacheSaltFingerprint !== requestedCacheSaltFingerprint)
|
|
907
|
+
continue;
|
|
693
908
|
// Hit: clear and lease (single-use semantics, same as tiers 1/2).
|
|
694
|
-
this.
|
|
909
|
+
this.evictEntriesExcept(entry.session);
|
|
695
910
|
return { session: entry.session, hit: true };
|
|
696
911
|
}
|
|
697
|
-
// Miss (no entry, expired, or
|
|
912
|
+
// Miss (no entry, expired, instructions drift, or salt drift). Clear the map
|
|
698
913
|
// so a stale wrapper cannot leak into a later lookup, and return
|
|
699
914
|
// a fresh session.
|
|
700
|
-
this.
|
|
915
|
+
this.evictEntriesExcept();
|
|
701
916
|
return { session: this.newSession(), hit: false };
|
|
702
917
|
}
|
|
703
918
|
/**
|
|
@@ -708,6 +923,8 @@ export class SessionRegistry {
|
|
|
708
923
|
* `instructions` is the prefix/system state used for this turn;
|
|
709
924
|
* stored on the entry and compared on the next `getOrCreate` to
|
|
710
925
|
* detect prefix changes that must force a cold replay.
|
|
926
|
+
* `cacheSalt` is HMAC-fingerprinted before storage and compared alongside
|
|
927
|
+
* the prefix so the raw security-domain key is not retained.
|
|
711
928
|
*
|
|
712
929
|
* `promptCacheKey` is the client-supplied conversation-chain key
|
|
713
930
|
* that enables the registry's tier-2 lookup for stateless agent
|
|
@@ -718,17 +935,18 @@ export class SessionRegistry {
|
|
|
718
935
|
* key-equality on both sides can hit tier 2). See
|
|
719
936
|
* {@link SessionRegistry.getOrCreate} for the precedence rules.
|
|
720
937
|
*/
|
|
721
|
-
adopt(responseId, session, instructions, promptCacheKey = null) {
|
|
938
|
+
adopt(responseId, session, instructions, promptCacheKey = null, cacheSalt = null) {
|
|
722
939
|
// Scope the caller-supplied key BEFORE storing so a later
|
|
723
940
|
// `getOrCreate` can only resolve entries via the same opt-in +
|
|
724
941
|
// HMAC path. When tier-2 reuse is disabled (or the key is too
|
|
725
942
|
// short / absent) `scopePromptCacheKey` returns `null`, which
|
|
726
943
|
// disables this entry from ever matching a tier-2 lookup — the
|
|
727
944
|
// raw caller-supplied key is NEVER stored.
|
|
728
|
-
this.
|
|
945
|
+
this.evictEntriesExcept(session);
|
|
729
946
|
this.entries.set(responseId, {
|
|
730
947
|
session,
|
|
731
948
|
instructions,
|
|
949
|
+
cacheSaltFingerprint: fingerprintCacheSalt(cacheSalt),
|
|
732
950
|
promptCacheKey: scopePromptCacheKey(promptCacheKey ?? null),
|
|
733
951
|
expiresAt: nowSec() + this.ttlSec,
|
|
734
952
|
});
|
|
@@ -737,7 +955,11 @@ export class SessionRegistry {
|
|
|
737
955
|
* Remove a session by response id. No-op if the key is not present.
|
|
738
956
|
*/
|
|
739
957
|
drop(responseId) {
|
|
958
|
+
const entry = this.entries.get(responseId);
|
|
959
|
+
if (entry === undefined)
|
|
960
|
+
return;
|
|
740
961
|
this.entries.delete(responseId);
|
|
962
|
+
this.scheduleDispose(entry.session);
|
|
741
963
|
}
|
|
742
964
|
/**
|
|
743
965
|
* Walk the map and drop the entry if its TTL has expired.
|
|
@@ -749,12 +971,13 @@ export class SessionRegistry {
|
|
|
749
971
|
for (const [key, entry] of this.entries) {
|
|
750
972
|
if (entry.expiresAt < cutoff) {
|
|
751
973
|
this.entries.delete(key);
|
|
974
|
+
this.scheduleDispose(entry.session);
|
|
752
975
|
}
|
|
753
976
|
}
|
|
754
977
|
}
|
|
755
978
|
/** Empty the registry. Useful at shutdown and in tests. */
|
|
756
979
|
clear() {
|
|
757
|
-
this.
|
|
980
|
+
this.evictEntriesExcept();
|
|
758
981
|
}
|
|
759
982
|
/**
|
|
760
983
|
* Serialize `fn` against every other dispatch through this
|
|
@@ -770,33 +993,50 @@ export class SessionRegistry {
|
|
|
770
993
|
* the new tail, awaits the old tail, then runs `fn`. The
|
|
771
994
|
* `finally` releases regardless of whether `fn` threw.
|
|
772
995
|
*
|
|
773
|
-
* **Admission control.**
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
996
|
+
* **Admission control.** Every non-handed-off call — waiter AND
|
|
997
|
+
* runner — routes through {@link assertAdmissionCapacity}, the single
|
|
998
|
+
* source of truth for the budget: the combined admission footprint
|
|
999
|
+
* (`queuedCount + preDispatchAdmits`) plus this caller must fit
|
|
1000
|
+
* within `maxQueueDepth` plus the idle-chain runner entitlement.
|
|
1001
|
+
* Over-budget calls throw {@link QueueFullError} — SYNCHRONOUSLY
|
|
1002
|
+
* from the caller's perspective, not merely before `await prev`. The
|
|
1003
|
+
* wrapper is deliberately NOT declared `async` so the admission gate
|
|
1004
|
+
* throws on the caller's stack frame, letting endpoint handlers wrap
|
|
1005
|
+
* the call site in a plain try/catch without racing promise
|
|
781
1006
|
* microtasks. On acceptance the async body takes over via the
|
|
782
1007
|
* returned `Promise<T>`.
|
|
783
1008
|
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
* default (undefined) preserves
|
|
1009
|
+
* A cap of N permits one running dispatch plus N admitted-but-not-
|
|
1010
|
+
* running requests (queued waiters and outstanding permits
|
|
1011
|
+
* combined), rejecting the next. The default (undefined) preserves
|
|
1012
|
+
* the original unbounded behaviour.
|
|
787
1013
|
*
|
|
788
1014
|
* **Runner-slot admission.** Whether a given caller counts as the
|
|
789
1015
|
* runner slot or as a waiter is decided up front by comparing
|
|
790
1016
|
* `execLock` against the idle sentinel `initialLock`. If they are
|
|
791
|
-
* identical, nobody is currently in-flight and this caller
|
|
792
|
-
*
|
|
793
|
-
* never touches `queuedCount`. Otherwise it is a waiter
|
|
794
|
-
* normal
|
|
795
|
-
* what keeps a synchronous burst such as `Promise.all([fn, fn])`
|
|
1017
|
+
* identical, nobody is currently in-flight and this caller is
|
|
1018
|
+
* admitted against the extra runner entitlement rather than a waiter
|
|
1019
|
+
* slot — it never touches `queuedCount`. Otherwise it is a waiter
|
|
1020
|
+
* and the normal charge / increment / decrement cycle applies. This
|
|
1021
|
+
* is what keeps a synchronous burst such as `Promise.all([fn, fn])`
|
|
796
1022
|
* admissible under `maxQueueDepth = 1` — Call 1 is the runner,
|
|
797
|
-
* Call 2 is the one allowed waiter, Call 3 would throw.
|
|
1023
|
+
* Call 2 is the one allowed waiter, Call 3 would throw. The runner
|
|
1024
|
+
* path is NOT exempt from the budget: while the chain is idle,
|
|
1025
|
+
* {@link beginPreDispatchAdmission} lends out `maxQueueDepth + 1`
|
|
1026
|
+
* permits precisely because one of them is entitled to become the
|
|
1027
|
+
* runner, so a permitless call that would take that seat while the
|
|
1028
|
+
* whole runner-plus-waiter capacity is already spoken for must
|
|
1029
|
+
* reject — otherwise the outstanding permits would later convert on
|
|
1030
|
+
* top of it and breach the cap.
|
|
1031
|
+
*
|
|
1032
|
+
* **Permit handoff.** A caller that was already admitted by
|
|
1033
|
+
* {@link beginPreDispatchAdmission} passes its permit as the second
|
|
1034
|
+
* argument; the permit is consumed atomically as this call's
|
|
1035
|
+
* admission instead of charging `queuedCount` a second time — one
|
|
1036
|
+
* budget, one token per request. See the handoff comment in the
|
|
1037
|
+
* body and {@link PreDispatchAdmission}.
|
|
798
1038
|
*/
|
|
799
|
-
withExclusive(fn) {
|
|
1039
|
+
withExclusive(fn, permit) {
|
|
800
1040
|
// Distinguish runner-slot from waiter admission. If the chain is
|
|
801
1041
|
// idle (`execLock === initialLock`) the current caller is about
|
|
802
1042
|
// to become the active holder on its very first `await prev`
|
|
@@ -804,13 +1044,37 @@ export class SessionRegistry {
|
|
|
804
1044
|
// must NOT touch `queuedCount`. Only chained callers (someone
|
|
805
1045
|
// else still holds or is ahead in the FIFO) count as waiters.
|
|
806
1046
|
const asWaiter = this.execLock !== this.initialLock;
|
|
1047
|
+
// Atomic permit handoff (see `beginPreDispatchAdmission`). A caller
|
|
1048
|
+
// handing in a still-outstanding permit from THIS registry already
|
|
1049
|
+
// owns one unit of the shared budget: consuming it here — in the
|
|
1050
|
+
// same synchronous block as the waiter accounting below — converts
|
|
1051
|
+
// that unit into this call's admission (waiter path increments
|
|
1052
|
+
// `queuedCount`, keeping the total constant) or retires it (runner
|
|
1053
|
+
// path). The handoff skips only the caller's OWN token: its budget
|
|
1054
|
+
// share was charged at acquisition and conversion keeps the total
|
|
1055
|
+
// constant, so the handoff itself can never create a breach —
|
|
1056
|
+
// whereas re-checking would double-charge and could reject a
|
|
1057
|
+
// request that was already admitted at the endpoint gate. A permit
|
|
1058
|
+
// minted by a DIFFERENT registry is not in `permitConsumers` and
|
|
1059
|
+
// falls through to normal charging (its own budget stays balanced
|
|
1060
|
+
// by the caller's `finally` release).
|
|
1061
|
+
const consume = permit === undefined ? undefined : this.permitConsumers.get(permit);
|
|
1062
|
+
const handedOff = consume !== undefined && consume();
|
|
807
1063
|
// Admission check — raised synchronously so endpoint handlers
|
|
808
|
-
// can reliably catch `QueueFullError` without racing any
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
1064
|
+
// can reliably catch `QueueFullError` without racing any `await`.
|
|
1065
|
+
// Every non-handed-off caller — runner AND waiter — runs the same
|
|
1066
|
+
// shared predicate as `beginPreDispatchAdmission`: the combined
|
|
1067
|
+
// footprint (queued waiters PLUS outstanding pre-dispatch permits)
|
|
1068
|
+
// against the cap plus the idle-chain runner entitlement. Checking
|
|
1069
|
+
// `queuedCount` alone would let a permitless caller spend a slot
|
|
1070
|
+
// an outstanding permit already owns (e.g. a continuation parked
|
|
1071
|
+
// in `store.getChain`); exempting the runner path would let a
|
|
1072
|
+
// permitless call take the runner seat the gate already lent to
|
|
1073
|
+
// one of `maxQueueDepth + 1` idle-chain permits — either way the
|
|
1074
|
+
// cap breaches once those permits convert. Nothing is mutated on
|
|
1075
|
+
// the reject path; the request never queued.
|
|
1076
|
+
if (!handedOff) {
|
|
1077
|
+
this.assertAdmissionCapacity();
|
|
814
1078
|
}
|
|
815
1079
|
const prev = this.execLock;
|
|
816
1080
|
let release;
|
|
@@ -823,6 +1087,56 @@ export class SessionRegistry {
|
|
|
823
1087
|
}
|
|
824
1088
|
return this._runExclusive(prev, myLock, release, fn, asWaiter);
|
|
825
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Admit one dispatch to the model's continuous-batching lane.
|
|
1092
|
+
*
|
|
1093
|
+
* Admission rejection is synchronous, matching {@link withExclusive} and
|
|
1094
|
+
* preserving the endpoint's existing QueueFullError-to-429 mapping. Once
|
|
1095
|
+
* accepted, at most {@link concurrentAdmissionLimit} closures run at once;
|
|
1096
|
+
* excess accepted callers wait FIFO and contribute to {@link queueDepth}.
|
|
1097
|
+
* A pre-dispatch permit is consumed atomically into either an active slot or
|
|
1098
|
+
* a queued slot, so the early endpoint gate and this semaphore share one
|
|
1099
|
+
* bounded budget.
|
|
1100
|
+
*/
|
|
1101
|
+
withAdmission(fn, permit) {
|
|
1102
|
+
const consume = permit === undefined ? undefined : this.permitConsumers.get(permit);
|
|
1103
|
+
const handedOff = consume !== undefined && consume();
|
|
1104
|
+
if (!handedOff) {
|
|
1105
|
+
this.assertAdmissionCapacity();
|
|
1106
|
+
}
|
|
1107
|
+
let acquire;
|
|
1108
|
+
const acquired = new Promise((resolve) => {
|
|
1109
|
+
acquire = resolve;
|
|
1110
|
+
});
|
|
1111
|
+
if (this.activeAdmissions < this.maxConcurrentDispatches) {
|
|
1112
|
+
this.activeAdmissions += 1;
|
|
1113
|
+
acquire();
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
this.queuedCount += 1;
|
|
1117
|
+
this.admissionWaiters.push(() => {
|
|
1118
|
+
this.queuedCount -= 1;
|
|
1119
|
+
if (this.queuedCount < 0)
|
|
1120
|
+
this.queuedCount = 0;
|
|
1121
|
+
this.activeAdmissions += 1;
|
|
1122
|
+
acquire();
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
return this._runAdmission(acquired, fn);
|
|
1126
|
+
}
|
|
1127
|
+
async _runAdmission(acquired, fn) {
|
|
1128
|
+
await acquired;
|
|
1129
|
+
try {
|
|
1130
|
+
return await fn();
|
|
1131
|
+
}
|
|
1132
|
+
finally {
|
|
1133
|
+
this.activeAdmissions -= 1;
|
|
1134
|
+
if (this.activeAdmissions < 0)
|
|
1135
|
+
this.activeAdmissions = 0;
|
|
1136
|
+
const next = this.admissionWaiters.shift();
|
|
1137
|
+
next?.();
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
826
1140
|
/**
|
|
827
1141
|
* Async tail of {@link withExclusive}. Kept separate so the public
|
|
828
1142
|
* wrapper stays a plain (non-async) function whose admission-gate
|