@danypops/vehicle-core 0.18.3 → 0.18.4

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/index.d.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * classification, invocation context), events, manifest, client (the port a
7
7
  * caller programs against), approvals (the Approval Gate's wire shapes), jobs
8
8
  * (Vehicle Jobs' pure pieces), schedules, watches, persistence (atomic
9
- * JSON), concurrency (timer-based scheduling primitives), and cli-safety
10
- * (argv-injection guards for any operation shelling out to a CLI) -- the
11
- * latter three are technical utilities, not Vehicle protocol capabilities,
12
- * kept distinct for that reason. Every symbol below is re-exported unchanged
9
+ * JSON), concurrency (timer-based scheduling primitives), cli-safety
10
+ * (argv-injection guards for any operation shelling out to a CLI), and
11
+ * resource-pool (bounded admission/eviction/leasing for a pooled expensive
12
+ * stateful resource) -- the latter four are technical utilities, not
13
+ * Vehicle protocol capabilities, kept distinct for that reason. Every symbol below is re-exported unchanged
13
14
  * from its historical flat-file home, so root-level `import { X } from
14
15
  * "@danypops/vehicle-core"` usage is completely unaffected by this layout.
15
16
  */
@@ -25,6 +26,7 @@ export * from "./jobs/index.js";
25
26
  export * from "./manifest/index.js";
26
27
  export * from "./operations/index.js";
27
28
  export * from "./persistence/index.js";
29
+ export * from "./resource-pool/index.js";
28
30
  export * from "./schedules/index.js";
29
31
  export * from "./schemas/index.js";
30
32
  export * from "./watches/index.js";
package/dist/index.js CHANGED
@@ -6,10 +6,11 @@
6
6
  * classification, invocation context), events, manifest, client (the port a
7
7
  * caller programs against), approvals (the Approval Gate's wire shapes), jobs
8
8
  * (Vehicle Jobs' pure pieces), schedules, watches, persistence (atomic
9
- * JSON), concurrency (timer-based scheduling primitives), and cli-safety
10
- * (argv-injection guards for any operation shelling out to a CLI) -- the
11
- * latter three are technical utilities, not Vehicle protocol capabilities,
12
- * kept distinct for that reason. Every symbol below is re-exported unchanged
9
+ * JSON), concurrency (timer-based scheduling primitives), cli-safety
10
+ * (argv-injection guards for any operation shelling out to a CLI), and
11
+ * resource-pool (bounded admission/eviction/leasing for a pooled expensive
12
+ * stateful resource) -- the latter four are technical utilities, not
13
+ * Vehicle protocol capabilities, kept distinct for that reason. Every symbol below is re-exported unchanged
13
14
  * from its historical flat-file home, so root-level `import { X } from
14
15
  * "@danypops/vehicle-core"` usage is completely unaffected by this layout.
15
16
  */
@@ -25,6 +26,7 @@ export * from "./jobs/index.js";
25
26
  export * from "./manifest/index.js";
26
27
  export * from "./operations/index.js";
27
28
  export * from "./persistence/index.js";
29
+ export * from "./resource-pool/index.js";
28
30
  export * from "./schedules/index.js";
29
31
  export * from "./schemas/index.js";
30
32
  export * from "./watches/index.js";
@@ -0,0 +1,139 @@
1
+ import { ResourceAdmissionQueueFull, ResourceAdmissionQueueTimedOut, ResourceCapacityExceeded, ResourceInUse } from "./errors.js";
2
+ import type { ResourcePoolActiveCeilingSource, ResourcePoolResourcePolicy } from "./resource-policy.js";
3
+ export { ResourceAdmissionQueueFull, ResourceAdmissionQueueTimedOut, ResourceCapacityExceeded, ResourceInUse };
4
+ /** A resource the pool can shut down when it goes cold. costHandle, when present, is an opaque token a cost-sampling hook may key off (e.g. a subprocess pid) -- undefined for a resource with nothing external to sample. */
5
+ export interface PooledResource {
6
+ close(): Promise<void>;
7
+ isAlive?(): boolean;
8
+ readonly costHandle?: unknown;
9
+ }
10
+ /**
11
+ * Distinguishes an interactive human/agent-facing request from a self-scheduled background one.
12
+ * Foreground admission is never queued or reduced below reservedForegroundSlots' effective
13
+ * ceiling -- background is the only work kind that ever waits. Defaults to "foreground": a caller
14
+ * that never opts in gets today's exact unreserved behavior.
15
+ */
16
+ export type ResourceWorkKind = "foreground" | "background";
17
+ export type ResourcePoolEvent = {
18
+ readonly kind: "admission-evicted" | "dead-replaced" | "resource-pressure-evicted";
19
+ readonly partitionKey: string;
20
+ } | {
21
+ readonly kind: "close-failed";
22
+ readonly reason: "admission" | "dead-replacement" | "idle-reap" | "resource-pressure";
23
+ readonly partitionKey: string;
24
+ readonly errorName: string;
25
+ };
26
+ export interface ResourcePoolStatus<Status = unknown> {
27
+ readonly active: number;
28
+ readonly leased: number;
29
+ readonly maxActive: number;
30
+ /** The count ceiling actually in effect for the most recent admission -- may exceed maxActive when a resource policy's own soft ceiling raised it, never exceeds absoluteMaxActive. */
31
+ readonly effectiveMaxActive: number;
32
+ readonly activeCeilingSource: ResourcePoolActiveCeilingSource;
33
+ readonly absoluteMaxActive: number;
34
+ readonly byPartition: Readonly<Record<string, number>>;
35
+ readonly resources?: Status;
36
+ /** How many background admissions are currently waiting for a slot reserved for foreground work -- count-only, zero whenever reservedForegroundSlots is unset or nothing is contending. */
37
+ readonly waitingBackgroundAdmissions: number;
38
+ }
39
+ export interface PoolLease<Value> extends AsyncDisposable {
40
+ readonly value: Value;
41
+ }
42
+ export interface ResourcePoolOptions<Status = unknown> {
43
+ readonly maxActive?: number;
44
+ readonly partitionLimits?: Readonly<Record<string, number>>;
45
+ readonly resourcePolicy?: ResourcePoolResourcePolicy<Status>;
46
+ /** The hard structural ceiling a resource policy's own soft ceiling can never raise maxActive past. Defaults to 32. Must be >= maxActive. */
47
+ readonly absoluteMaxActive?: number;
48
+ readonly observe?: (event: ResourcePoolEvent) => void;
49
+ /** Slots background admission alone can never grow into. Default 0 (no reservation). */
50
+ readonly reservedForegroundSlots?: number;
51
+ /** How long a queued background admission waits for a slot before giving up with ResourceAdmissionQueueTimedOut. Default 10s. */
52
+ readonly backgroundAdmissionQueueTimeoutMs?: number;
53
+ /** How many background admissions may be simultaneously waiting before a new one fails fast with ResourceAdmissionQueueFull instead of growing the wait queue further. Default 8. */
54
+ readonly maxQueuedBackgroundAdmissions?: number;
55
+ /** Fed one (partitionKey, costHandle) pair per active entry with a costHandle on calibrateCosts(). */
56
+ readonly costRecorder?: {
57
+ recordSample(partitionKey: string, costHandle: unknown): void;
58
+ };
59
+ readonly now?: () => number;
60
+ }
61
+ /**
62
+ * Owns the bounded lifecycle of pooled, expensive, stateful resources partitioned by an
63
+ * (ownerKey, partitionKey) pair -- e.g. one warm language-server process per (workspace,
64
+ * language), one warm browser context per (tenant, profile). Never knows how to build a resource:
65
+ * `acquire()` takes a lazy factory invoked only on an actual cache miss, so every caller-specific
66
+ * concept (what a resource even is, how to construct one) lives entirely in the caller's own
67
+ * closure at each call site.
68
+ */
69
+ export declare class BoundedResourcePool<OwnerKey extends string, Resource extends PooledResource, Status = unknown> {
70
+ private readonly options;
71
+ private readonly entries;
72
+ private readonly now;
73
+ private readonly maxActive;
74
+ private readonly absoluteMaxActive;
75
+ private lastActiveCeilingSource;
76
+ private lastEffectiveMaxActive;
77
+ private readonly partitionLimits;
78
+ private admissionTail;
79
+ private nextSequence;
80
+ private readonly reservedForegroundSlots;
81
+ private readonly backgroundAdmissionQueueTimeoutMs;
82
+ private readonly maxQueuedBackgroundAdmissions;
83
+ private readonly admissionWaiters;
84
+ private queuedBackgroundAdmissions;
85
+ private readonly waitingCounts;
86
+ constructor(options?: ResourcePoolOptions<Status>);
87
+ private key;
88
+ private partitionLimit;
89
+ private countPartition;
90
+ private activePartitions;
91
+ private leastRecentlyUsedIdle;
92
+ private evict;
93
+ /** Wakes every queued background admission to re-check the real state -- called whenever an entry is removed OR a lease completes (an idle candidate an admit() retry might now be able to evict). A false wake just re-checks and re-waits; never a correctness issue, only a wasted retry. */
94
+ private notifyAdmissionWaiters;
95
+ /** True while at least one background admission for this owner is currently waiting for a reserved-slot conflict to clear. */
96
+ waitingForAdmission(ownerKey: OwnerKey): boolean;
97
+ /**
98
+ * Runs entirely outside the serialized admission lock -- admissionTail is the single global
99
+ * admission mutex, and this wait can legitimately take up to backgroundAdmissionQueueTimeoutMs.
100
+ * Holding that lock for the whole wait would block every other admission request, foreground
101
+ * included, which is the exact starvation this exists to prevent.
102
+ */
103
+ private waitForAdmissionRoom;
104
+ private admit;
105
+ private serialized;
106
+ /** Acquires a lease for (ownerKey, partitionKey), reusing an already-admitted resource if one is warm, or admitting a fresh one via `create()` -- called only on an actual cache miss, never speculatively. workKind defaults to "foreground". */
107
+ acquire(ownerKey: OwnerKey, partitionKey: string, create: () => Resource, workKind?: ResourceWorkKind): Promise<PoolLease<Resource>>;
108
+ private lease;
109
+ has(ownerKey: OwnerKey, partitionKey: string): boolean;
110
+ hasAny(ownerKey: OwnerKey): boolean;
111
+ /** Every currently active resource belonging to `ownerKey` -- lets a caller's own fan-out (file-watch notifications, etc) stay pool-backed instead of duplicating the entry map. */
112
+ activeResourcesForOwner(ownerKey: OwnerKey): readonly Resource[];
113
+ /**
114
+ * Derives the count ceiling actually in effect right now, independent of any particular
115
+ * admission attempt -- the resource policy's own soft ceiling raises maxActive when it reports
116
+ * more room, clamped to absoluteMaxActive, and falls back to maxActive alone (source
117
+ * "configured") on any metric loss -- fails closed, never treated as "unlimited room."
118
+ */
119
+ private baseActiveCeiling;
120
+ status(): ResourcePoolStatus<Status>;
121
+ /** Samples every currently active entry with a real costHandle and folds it into the configured recorder, if any -- a no-op without one. Read-only over the entry map, so it deliberately does not run inside serialized(). */
122
+ calibrateCosts(): void;
123
+ /** Unconditional force-close of every one of `ownerKey`'s resources, regardless of any active lease -- for the case of a remote resource swapped out from under an already-warm one, where correctness requires closing regardless of who still holds it. */
124
+ closeOwner(ownerKey: OwnerKey): Promise<void>;
125
+ /** Unconditional force-close of one (ownerKey, partitionKey) resource, if any -- the single-partition sibling of closeOwner, for a caller that has already identified exactly which partition needs invalidating. */
126
+ closePartition(ownerKey: OwnerKey, partitionKey: string): Promise<void>;
127
+ /**
128
+ * The safe sibling of closeOwner: refuses (does not evict anything) while any of this owner's
129
+ * resources has an active lease. Serialized against concurrent admission so a lease can't be
130
+ * granted between the check and the close.
131
+ */
132
+ releaseOwnerIfIdle(ownerKey: OwnerKey): Promise<{
133
+ readonly closed: number;
134
+ }>;
135
+ closeAll(): Promise<void>;
136
+ private reconcileResourcesUnsafe;
137
+ reconcileResources(): Promise<number>;
138
+ reapIdle(maxIdleMs: number): Promise<number>;
139
+ }
@@ -0,0 +1,427 @@
1
+ import { ResourceAdmissionQueueFull, ResourceAdmissionQueueTimedOut, ResourceCapacityExceeded, ResourceInUse } from "./errors.js";
2
+ export { ResourceAdmissionQueueFull, ResourceAdmissionQueueTimedOut, ResourceCapacityExceeded, ResourceInUse };
3
+ const DEFAULT_MAX_ACTIVE = 3;
4
+ /** A genuine structural ceiling on resource count -- independent of memory, protecting against pathological OS-level exhaustion (file descriptors, threads, scheduler overhead) that no amount of available headroom makes safe to exceed. maxActive itself can never be raised past this by a resource policy's own soft ceiling. */
5
+ const DEFAULT_ABSOLUTE_MAX_ACTIVE = 32;
6
+ const DEFAULT_BACKGROUND_ADMISSION_QUEUE_TIMEOUT_MS = 10_000;
7
+ const DEFAULT_MAX_QUEUED_BACKGROUND_ADMISSIONS = 8;
8
+ /**
9
+ * Internal signal only: admit() throws this to tell acquire() "release the serialized lock and
10
+ * wait outside it" -- never surfaced to a caller. Waiting for a background admission's turn can
11
+ * legitimately take seconds; holding admissionTail (the single global admission mutex) for that
12
+ * whole span would block every OTHER admission request, including foreground's, which is exactly
13
+ * the starvation this exists to prevent.
14
+ */
15
+ class NeedsBackgroundAdmissionWait extends Error {
16
+ }
17
+ /**
18
+ * Owns the bounded lifecycle of pooled, expensive, stateful resources partitioned by an
19
+ * (ownerKey, partitionKey) pair -- e.g. one warm language-server process per (workspace,
20
+ * language), one warm browser context per (tenant, profile). Never knows how to build a resource:
21
+ * `acquire()` takes a lazy factory invoked only on an actual cache miss, so every caller-specific
22
+ * concept (what a resource even is, how to construct one) lives entirely in the caller's own
23
+ * closure at each call site.
24
+ */
25
+ export class BoundedResourcePool {
26
+ options;
27
+ entries = new Map();
28
+ now;
29
+ maxActive;
30
+ absoluteMaxActive;
31
+ lastActiveCeilingSource = "configured";
32
+ lastEffectiveMaxActive;
33
+ partitionLimits;
34
+ admissionTail = Promise.resolve();
35
+ nextSequence = 0;
36
+ reservedForegroundSlots;
37
+ backgroundAdmissionQueueTimeoutMs;
38
+ maxQueuedBackgroundAdmissions;
39
+ admissionWaiters = new Set();
40
+ queuedBackgroundAdmissions = 0;
41
+ waitingCounts = new Map();
42
+ constructor(options = {}) {
43
+ this.options = options;
44
+ this.now = options.now ?? Date.now;
45
+ this.maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
46
+ this.partitionLimits = options.partitionLimits ?? {};
47
+ if (!Number.isSafeInteger(this.maxActive) || this.maxActive < 1)
48
+ throw new TypeError("maxActive must be a positive safe integer");
49
+ this.absoluteMaxActive = options.absoluteMaxActive ?? Math.max(DEFAULT_ABSOLUTE_MAX_ACTIVE, this.maxActive);
50
+ if (!Number.isSafeInteger(this.absoluteMaxActive) || this.absoluteMaxActive < this.maxActive) {
51
+ throw new TypeError("absoluteMaxActive must be a safe integer no smaller than maxActive");
52
+ }
53
+ this.lastEffectiveMaxActive = this.maxActive;
54
+ for (const [partitionKey, limit] of Object.entries(this.partitionLimits)) {
55
+ if (!partitionKey || !Number.isSafeInteger(limit) || limit < 1)
56
+ throw new TypeError("partition limits must be positive safe integers keyed by partition key");
57
+ }
58
+ this.reservedForegroundSlots = options.reservedForegroundSlots ?? 0;
59
+ if (!Number.isSafeInteger(this.reservedForegroundSlots) || this.reservedForegroundSlots < 0) {
60
+ throw new TypeError("reservedForegroundSlots must be a non-negative safe integer");
61
+ }
62
+ this.backgroundAdmissionQueueTimeoutMs = options.backgroundAdmissionQueueTimeoutMs ?? DEFAULT_BACKGROUND_ADMISSION_QUEUE_TIMEOUT_MS;
63
+ if (!Number.isSafeInteger(this.backgroundAdmissionQueueTimeoutMs) || this.backgroundAdmissionQueueTimeoutMs < 0) {
64
+ throw new TypeError("backgroundAdmissionQueueTimeoutMs must be a non-negative safe integer");
65
+ }
66
+ this.maxQueuedBackgroundAdmissions = options.maxQueuedBackgroundAdmissions ?? DEFAULT_MAX_QUEUED_BACKGROUND_ADMISSIONS;
67
+ if (!Number.isSafeInteger(this.maxQueuedBackgroundAdmissions) || this.maxQueuedBackgroundAdmissions < 1) {
68
+ throw new TypeError("maxQueuedBackgroundAdmissions must be a positive safe integer");
69
+ }
70
+ }
71
+ key(ownerKey, partitionKey) {
72
+ return `${ownerKey}:${partitionKey}`;
73
+ }
74
+ partitionLimit(partitionKey) {
75
+ return this.partitionLimits[partitionKey] ?? this.maxActive;
76
+ }
77
+ countPartition(partitionKey) {
78
+ let count = 0;
79
+ for (const entry of this.entries.values())
80
+ if (entry.partitionKey === partitionKey)
81
+ count++;
82
+ return count;
83
+ }
84
+ activePartitions() {
85
+ return Array.from(this.entries.values(), (entry) => entry.partitionKey);
86
+ }
87
+ leastRecentlyUsedIdle(partitionKey) {
88
+ let selected;
89
+ for (const candidate of this.entries) {
90
+ const entry = candidate[1];
91
+ if (entry.activeLeases > 0 || (partitionKey !== undefined && entry.partitionKey !== partitionKey))
92
+ continue;
93
+ const current = selected?.[1];
94
+ if (!current ||
95
+ entry.lastUsedAt < current.lastUsedAt ||
96
+ (entry.lastUsedAt === current.lastUsedAt && entry.recencySequence < current.recencySequence))
97
+ selected = candidate;
98
+ }
99
+ return selected;
100
+ }
101
+ async evict(entry, reason = "admission") {
102
+ try {
103
+ await entry[1].resource.close();
104
+ }
105
+ catch (error) {
106
+ this.options.observe?.({
107
+ kind: "close-failed",
108
+ reason,
109
+ partitionKey: entry[1].partitionKey,
110
+ errorName: error instanceof Error ? error.name : "UnknownError",
111
+ });
112
+ throw error;
113
+ }
114
+ this.entries.delete(entry[0]);
115
+ const kind = reason === "admission" ? "admission-evicted" : reason === "dead-replacement" ? "dead-replaced" : "resource-pressure-evicted";
116
+ this.options.observe?.({ kind, partitionKey: entry[1].partitionKey });
117
+ this.notifyAdmissionWaiters();
118
+ }
119
+ /** Wakes every queued background admission to re-check the real state -- called whenever an entry is removed OR a lease completes (an idle candidate an admit() retry might now be able to evict). A false wake just re-checks and re-waits; never a correctness issue, only a wasted retry. */
120
+ notifyAdmissionWaiters() {
121
+ if (this.admissionWaiters.size === 0)
122
+ return;
123
+ const waiters = Array.from(this.admissionWaiters);
124
+ this.admissionWaiters.clear();
125
+ for (const waiter of waiters)
126
+ waiter();
127
+ }
128
+ /** True while at least one background admission for this owner is currently waiting for a reserved-slot conflict to clear. */
129
+ waitingForAdmission(ownerKey) {
130
+ return (this.waitingCounts.get(ownerKey) ?? 0) > 0;
131
+ }
132
+ /**
133
+ * Runs entirely outside the serialized admission lock -- admissionTail is the single global
134
+ * admission mutex, and this wait can legitimately take up to backgroundAdmissionQueueTimeoutMs.
135
+ * Holding that lock for the whole wait would block every other admission request, foreground
136
+ * included, which is the exact starvation this exists to prevent.
137
+ */
138
+ async waitForAdmissionRoom(partitionKey, ownerKey) {
139
+ if (this.queuedBackgroundAdmissions >= this.maxQueuedBackgroundAdmissions) {
140
+ throw new ResourceAdmissionQueueFull(partitionKey, this.maxQueuedBackgroundAdmissions);
141
+ }
142
+ this.queuedBackgroundAdmissions++;
143
+ this.waitingCounts.set(ownerKey, (this.waitingCounts.get(ownerKey) ?? 0) + 1);
144
+ try {
145
+ const gotSignal = await new Promise((resolve) => {
146
+ let settled = false;
147
+ const finish = (ready) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ this.admissionWaiters.delete(onSignal);
153
+ resolve(ready);
154
+ };
155
+ const onSignal = () => finish(true);
156
+ const timer = setTimeout(() => finish(false), this.backgroundAdmissionQueueTimeoutMs);
157
+ this.admissionWaiters.add(onSignal);
158
+ });
159
+ if (!gotSignal)
160
+ throw new ResourceAdmissionQueueTimedOut(partitionKey, this.backgroundAdmissionQueueTimeoutMs);
161
+ }
162
+ finally {
163
+ this.queuedBackgroundAdmissions--;
164
+ const remaining = (this.waitingCounts.get(ownerKey) ?? 1) - 1;
165
+ if (remaining <= 0)
166
+ this.waitingCounts.delete(ownerKey);
167
+ else
168
+ this.waitingCounts.set(ownerKey, remaining);
169
+ }
170
+ }
171
+ async admit(ownerKey, partitionKey, create, workKind) {
172
+ const partitionLimit = this.partitionLimit(partitionKey);
173
+ while (this.countPartition(partitionKey) >= partitionLimit) {
174
+ const victim = this.leastRecentlyUsedIdle(partitionKey);
175
+ if (!victim)
176
+ throw new ResourceCapacityExceeded(partitionKey, this.maxActive, partitionLimit);
177
+ await this.evict(victim);
178
+ }
179
+ const { ceiling: baseCeiling, source: ceilingSource } = this.baseActiveCeiling();
180
+ this.lastEffectiveMaxActive = baseCeiling;
181
+ this.lastActiveCeilingSource = ceilingSource;
182
+ // "Borrowable": background's own effective ceiling is reduced, but only background is ever
183
+ // held to it -- it constrains what background alone can grow the pool into, not a hard
184
+ // set-aside nothing else can reach. Foreground keeps using the full (possibly resource-
185
+ // budget-raised) baseCeiling unchanged.
186
+ const effectiveMaxActive = workKind === "background" ? Math.max(baseCeiling - this.reservedForegroundSlots, 0) : baseCeiling;
187
+ while (this.entries.size >= effectiveMaxActive) {
188
+ const victim = this.leastRecentlyUsedIdle();
189
+ if (victim) {
190
+ await this.evict(victim);
191
+ continue;
192
+ }
193
+ if (workKind === "background")
194
+ throw new NeedsBackgroundAdmissionWait();
195
+ throw new ResourceCapacityExceeded(partitionKey, baseCeiling, partitionLimit);
196
+ }
197
+ while (this.options.resourcePolicy && !this.options.resourcePolicy.canAdmit(this.activePartitions(), partitionKey)) {
198
+ const victim = this.leastRecentlyUsedIdle();
199
+ if (!victim)
200
+ throw new ResourceCapacityExceeded(partitionKey, baseCeiling, partitionLimit);
201
+ await this.evict(victim, "resource-pressure");
202
+ }
203
+ return {
204
+ resource: create(),
205
+ ownerKey,
206
+ partitionKey,
207
+ recencySequence: this.nextSequence++,
208
+ activeLeases: 0,
209
+ lastUsedAt: this.now(),
210
+ };
211
+ }
212
+ async serialized(operation) {
213
+ const previous = this.admissionTail;
214
+ let release = () => { };
215
+ this.admissionTail = new Promise((resolve) => {
216
+ release = resolve;
217
+ });
218
+ await previous;
219
+ try {
220
+ return await operation();
221
+ }
222
+ finally {
223
+ release();
224
+ }
225
+ }
226
+ /** Acquires a lease for (ownerKey, partitionKey), reusing an already-admitted resource if one is warm, or admitting a fresh one via `create()` -- called only on an actual cache miss, never speculatively. workKind defaults to "foreground". */
227
+ async acquire(ownerKey, partitionKey, create, workKind = "foreground") {
228
+ for (;;) {
229
+ try {
230
+ const entry = await this.serialized(async () => {
231
+ const key = this.key(ownerKey, partitionKey);
232
+ let entry = this.entries.get(key);
233
+ if (entry?.resource.isAlive?.() === false) {
234
+ if (entry.activeLeases > 0) {
235
+ throw new ResourceCapacityExceeded(partitionKey, this.maxActive, this.partitionLimit(partitionKey));
236
+ }
237
+ await this.evict([key, entry], "dead-replacement");
238
+ entry = undefined;
239
+ }
240
+ if (!entry) {
241
+ entry = await this.admit(ownerKey, partitionKey, create, workKind);
242
+ this.entries.set(key, entry);
243
+ }
244
+ entry.activeLeases++;
245
+ return entry;
246
+ });
247
+ return this.lease(entry.resource, [entry]);
248
+ }
249
+ catch (error) {
250
+ if (!(error instanceof NeedsBackgroundAdmissionWait))
251
+ throw error;
252
+ // Outside the lock deliberately -- see waitForAdmissionRoom's own comment. Throws
253
+ // ResourceAdmissionQueueFull/TimedOut instead of looping back if it can't wait.
254
+ await this.waitForAdmissionRoom(partitionKey, ownerKey);
255
+ }
256
+ }
257
+ }
258
+ lease(value, entries) {
259
+ let released = false;
260
+ return {
261
+ value,
262
+ [Symbol.asyncDispose]: async () => {
263
+ if (released)
264
+ return;
265
+ released = true;
266
+ const completedAt = this.now();
267
+ for (const entry of entries) {
268
+ entry.activeLeases--;
269
+ entry.lastUsedAt = completedAt;
270
+ entry.recencySequence = this.nextSequence++;
271
+ }
272
+ // A lease completing makes its entry newly idle -- exactly the condition a queued
273
+ // background admission's retry is waiting to find, whether or not resource pressure
274
+ // itself ends up evicting anything below.
275
+ this.notifyAdmissionWaiters();
276
+ await this.reconcileResources();
277
+ },
278
+ };
279
+ }
280
+ has(ownerKey, partitionKey) {
281
+ return this.entries.has(this.key(ownerKey, partitionKey));
282
+ }
283
+ hasAny(ownerKey) {
284
+ for (const entry of this.entries.values())
285
+ if (entry.ownerKey === ownerKey)
286
+ return true;
287
+ return false;
288
+ }
289
+ /** Every currently active resource belonging to `ownerKey` -- lets a caller's own fan-out (file-watch notifications, etc) stay pool-backed instead of duplicating the entry map. */
290
+ activeResourcesForOwner(ownerKey) {
291
+ const resources = [];
292
+ for (const entry of this.entries.values())
293
+ if (entry.ownerKey === ownerKey)
294
+ resources.push(entry.resource);
295
+ return resources;
296
+ }
297
+ /**
298
+ * Derives the count ceiling actually in effect right now, independent of any particular
299
+ * admission attempt -- the resource policy's own soft ceiling raises maxActive when it reports
300
+ * more room, clamped to absoluteMaxActive, and falls back to maxActive alone (source
301
+ * "configured") on any metric loss -- fails closed, never treated as "unlimited room."
302
+ */
303
+ baseActiveCeiling() {
304
+ const soft = this.options.resourcePolicy?.softActiveCeiling(this.activePartitions());
305
+ if (soft === undefined || !Number.isFinite(soft) || soft <= this.maxActive)
306
+ return { ceiling: this.maxActive, source: "configured" };
307
+ const clamped = Math.min(Math.floor(soft), this.absoluteMaxActive);
308
+ return { ceiling: clamped, source: clamped >= this.absoluteMaxActive ? "absolute-cap" : "resource-budget" };
309
+ }
310
+ status() {
311
+ const byPartition = {};
312
+ let leased = 0;
313
+ for (const entry of this.entries.values()) {
314
+ byPartition[entry.partitionKey] = (byPartition[entry.partitionKey] ?? 0) + 1;
315
+ if (entry.activeLeases > 0)
316
+ leased++;
317
+ }
318
+ const resources = this.options.resourcePolicy?.status(this.activePartitions());
319
+ return {
320
+ active: this.entries.size,
321
+ leased,
322
+ maxActive: this.maxActive,
323
+ effectiveMaxActive: this.lastEffectiveMaxActive,
324
+ activeCeilingSource: this.lastActiveCeilingSource,
325
+ absoluteMaxActive: this.absoluteMaxActive,
326
+ byPartition,
327
+ waitingBackgroundAdmissions: this.queuedBackgroundAdmissions,
328
+ ...(resources !== undefined ? { resources } : {}),
329
+ };
330
+ }
331
+ /** Samples every currently active entry with a real costHandle and folds it into the configured recorder, if any -- a no-op without one. Read-only over the entry map, so it deliberately does not run inside serialized(). */
332
+ calibrateCosts() {
333
+ const recorder = this.options.costRecorder;
334
+ if (!recorder)
335
+ return;
336
+ for (const entry of this.entries.values()) {
337
+ if (entry.resource.costHandle === undefined)
338
+ continue;
339
+ recorder.recordSample(entry.partitionKey, entry.resource.costHandle);
340
+ }
341
+ }
342
+ /** Unconditional force-close of every one of `ownerKey`'s resources, regardless of any active lease -- for the case of a remote resource swapped out from under an already-warm one, where correctness requires closing regardless of who still holds it. */
343
+ async closeOwner(ownerKey) {
344
+ const stale = Array.from(this.entries.entries()).filter(([, entry]) => entry.ownerKey === ownerKey);
345
+ for (const [key] of stale)
346
+ this.entries.delete(key);
347
+ await Promise.all(stale.map(([, entry]) => entry.resource.close()));
348
+ }
349
+ /** Unconditional force-close of one (ownerKey, partitionKey) resource, if any -- the single-partition sibling of closeOwner, for a caller that has already identified exactly which partition needs invalidating. */
350
+ async closePartition(ownerKey, partitionKey) {
351
+ const key = this.key(ownerKey, partitionKey);
352
+ const entry = this.entries.get(key);
353
+ if (!entry)
354
+ return;
355
+ this.entries.delete(key);
356
+ await entry.resource.close();
357
+ }
358
+ /**
359
+ * The safe sibling of closeOwner: refuses (does not evict anything) while any of this owner's
360
+ * resources has an active lease. Serialized against concurrent admission so a lease can't be
361
+ * granted between the check and the close.
362
+ */
363
+ async releaseOwnerIfIdle(ownerKey) {
364
+ return this.serialized(async () => {
365
+ const matching = Array.from(this.entries.entries()).filter(([, entry]) => entry.ownerKey === ownerKey);
366
+ if (matching.some(([, entry]) => entry.activeLeases > 0))
367
+ throw new ResourceInUse(ownerKey);
368
+ for (const pair of matching)
369
+ await this.evict(pair, "admission");
370
+ return { closed: matching.length };
371
+ });
372
+ }
373
+ async closeAll() {
374
+ const entries = Array.from(this.entries.values());
375
+ this.entries.clear();
376
+ await Promise.all(entries.map((entry) => entry.resource.close()));
377
+ }
378
+ async reconcileResourcesUnsafe() {
379
+ const policy = this.options.resourcePolicy;
380
+ if (!policy)
381
+ return 0;
382
+ let reaped = 0;
383
+ while (policy.isOverBudget(this.activePartitions())) {
384
+ const victim = this.leastRecentlyUsedIdle();
385
+ if (!victim)
386
+ break;
387
+ try {
388
+ await this.evict(victim, "resource-pressure");
389
+ reaped++;
390
+ }
391
+ catch {
392
+ break;
393
+ }
394
+ }
395
+ return reaped;
396
+ }
397
+ async reconcileResources() {
398
+ return this.serialized(() => this.reconcileResourcesUnsafe());
399
+ }
400
+ async reapIdle(maxIdleMs) {
401
+ return this.serialized(async () => {
402
+ let reaped = await this.reconcileResourcesUnsafe();
403
+ const now = this.now();
404
+ const effectiveMaxIdleMs = this.options.resourcePolicy?.maxIdleMs(maxIdleMs, this.activePartitions()) ?? maxIdleMs;
405
+ const idle = Array.from(this.entries.entries()).filter(([, entry]) => entry.activeLeases === 0 && now - entry.lastUsedAt > effectiveMaxIdleMs);
406
+ for (const [key, entry] of idle) {
407
+ try {
408
+ await entry.resource.close();
409
+ if (this.entries.get(key) === entry)
410
+ this.entries.delete(key);
411
+ reaped++;
412
+ }
413
+ catch (error) {
414
+ this.options.observe?.({
415
+ kind: "close-failed",
416
+ reason: "idle-reap",
417
+ partitionKey: entry.partitionKey,
418
+ errorName: error instanceof Error ? error.name : "UnknownError",
419
+ });
420
+ }
421
+ }
422
+ if (idle.length > 0)
423
+ this.notifyAdmissionWaiters();
424
+ return reaped;
425
+ });
426
+ }
427
+ }
@@ -0,0 +1,24 @@
1
+ /** Raised when no idle resource can be evicted to admit a new one within the current ceiling. */
2
+ export declare class ResourceCapacityExceeded extends Error {
3
+ readonly partitionKey: string;
4
+ readonly maxActive: number;
5
+ readonly partitionLimit: number;
6
+ constructor(partitionKey: string, maxActive: number, partitionLimit: number);
7
+ }
8
+ /** Raised by releaseOwnerIfIdle when at least one of the owner's own resources still has an active lease. */
9
+ export declare class ResourceInUse extends Error {
10
+ readonly ownerKey: string;
11
+ constructor(ownerKey: string);
12
+ }
13
+ /** Raised when background admission is already waiting at maxQueuedBackgroundAdmissions -- fails fast rather than growing the wait queue without bound. */
14
+ export declare class ResourceAdmissionQueueFull extends Error {
15
+ readonly partitionKey: string;
16
+ readonly maxQueued: number;
17
+ constructor(partitionKey: string, maxQueued: number);
18
+ }
19
+ /** Raised when a queued background admission waits past backgroundAdmissionQueueTimeoutMs without a slot freeing. */
20
+ export declare class ResourceAdmissionQueueTimedOut extends Error {
21
+ readonly partitionKey: string;
22
+ readonly timeoutMs: number;
23
+ constructor(partitionKey: string, timeoutMs: number);
24
+ }