@orkestrel/pool 0.0.13 → 0.0.15

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.
@@ -1,4 +1,4 @@
1
- import { holds, isError, isFunction, isInstance, isNumber, isString } from "@orkestrel/contract";
1
+ import { attempt, holds, isError, isFunction, isInstance, isNumber, isString } from "@orkestrel/contract";
2
2
  import { Emitter } from "@orkestrel/emitter";
3
3
  //#region src/core/errors.ts
4
4
  /**
@@ -94,9 +94,9 @@ function isPoolSignal(value) {
94
94
  //#endregion
95
95
  //#region src/core/Pool.ts
96
96
  /**
97
- * Represents a capacity-aware resource pool whose opaque ownership records preserve FIFO
98
- * settlement, cancellation, exact lease release, and deterministic teardown under concurrent
99
- * hooks.
97
+ * Represents a resource pool with optional bounded capacity and a warm floor, whose opaque
98
+ * ownership records preserve FIFO settlement, cancellation, exact lease release, and
99
+ * deterministic teardown under concurrent hooks.
100
100
  *
101
101
  * @typeParam T - The pooled resource value
102
102
  *
@@ -119,6 +119,16 @@ var Pool = class {
119
119
  #destroy;
120
120
  #validate;
121
121
  #max;
122
+ #min;
123
+ #restarts;
124
+ #watch;
125
+ #error;
126
+ #watches = /* @__PURE__ */ new Map();
127
+ #used = /* @__PURE__ */ new WeakSet();
128
+ #births = /* @__PURE__ */ new WeakMap();
129
+ #settled = /* @__PURE__ */ new WeakSet();
130
+ #survivors = /* @__PURE__ */ new Map();
131
+ #owing = /* @__PURE__ */ new Set();
122
132
  #emitter;
123
133
  #resources = /* @__PURE__ */ new Map();
124
134
  #available = [];
@@ -136,25 +146,52 @@ var Pool = class {
136
146
  #ending;
137
147
  #pumping = false;
138
148
  #repump = false;
149
+ #started = false;
150
+ #starting;
151
+ #refilling = false;
152
+ #strikes = 0;
153
+ #epoch = {};
154
+ #owed = 0;
155
+ #cause;
139
156
  /**
140
157
  * Constructs a pool and synchronously validates its capacity contract.
141
158
  *
142
- * @param options - Resource hooks, observation hooks, and optional positive safe `max`
143
- * @throws {@link PoolError} Thrown when `options.max` is present and is not a positive safe
144
- * integer, with `code: 'invalid'`.
159
+ * @param options - Resource hooks, observation hooks, capacity, and optional bounded warm floor
160
+ * @throws {@link PoolError} Thrown with `code: 'invalid'` when `min` or `max` is not a positive
161
+ * safe integer, their explicit values differ, `restarts` is absent with `min`, present without
162
+ * `min`, or not a non-negative safe integer, or `watch` is not callable.
145
163
  */
146
164
  constructor(options) {
147
165
  const max = options.max;
166
+ const min = options.min;
167
+ const restarts = options.restarts;
168
+ const watch = options.watch;
148
169
  if (max !== void 0 && !isPoolMax(max)) throw new PoolError({
149
170
  code: "invalid",
150
171
  context: { value: max }
151
172
  });
173
+ if (min !== void 0 && (!isPoolMax(min) || max !== void 0 && min !== max)) throw new PoolError({
174
+ code: "invalid",
175
+ context: { value: min }
176
+ });
177
+ if (min !== void 0 && restarts === void 0 || restarts !== void 0 && (min === void 0 || !isNumber(restarts) || !Number.isSafeInteger(restarts) || restarts < 0)) throw new PoolError({
178
+ code: "invalid",
179
+ context: { value: restarts }
180
+ });
181
+ if (watch !== void 0 && !isFunction(watch)) throw new PoolError({
182
+ code: "invalid",
183
+ context: { value: watch }
184
+ });
152
185
  const on = options.on;
153
186
  const error = options.error;
154
187
  this.#create = options.create;
155
188
  this.#destroy = options.destroy;
156
189
  this.#validate = options.validate;
157
- this.#max = max;
190
+ this.#max = max ?? min;
191
+ this.#min = min;
192
+ this.#restarts = restarts;
193
+ this.#watch = watch;
194
+ this.#error = error;
158
195
  this.#emitter = new Emitter({
159
196
  ...on === void 0 ? {} : { on },
160
197
  ...error === void 0 ? {} : { error }
@@ -164,7 +201,7 @@ var Pool = class {
164
201
  get emitter() {
165
202
  return this.#emitter;
166
203
  }
167
- /** Counts all owned records, including records validating or destroying. */
204
+ /** Counts all owned records, including records validating, destroying, or retained after failed cleanup. */
168
205
  get size() {
169
206
  return this.#resources.size;
170
207
  }
@@ -177,7 +214,27 @@ var Pool = class {
177
214
  return this.#leased.size;
178
215
  }
179
216
  /**
180
- * Queues and leases one resource in FIFO settlement order.
217
+ * Fills the warm floor and resets the strikes of a spent floor; without a floor, resolves immediately.
218
+ *
219
+ * @returns A promise that resolves when the floor owns `min` live records
220
+ * @throws {@link PoolError} Thrown as a rejection with `code: 'create'` and the last cause
221
+ * when the bound is spent, `code: 'cleanup'` when retained failed cleanup prevents filling,
222
+ * or `code: 'destroyed'` when teardown begins.
223
+ */
224
+ start() {
225
+ if (this.#ending !== void 0) return Promise.reject(new PoolError({ code: "destroyed" }));
226
+ if (this.#min === void 0) return Promise.resolve();
227
+ if (this.#starting !== void 0) return this.#starting.promise;
228
+ const starting = Promise.withResolvers();
229
+ this.#starting = starting;
230
+ this.#started = true;
231
+ if (this.#spent()) this.#strikes = 0;
232
+ this.#pump();
233
+ return starting.promise;
234
+ }
235
+ /**
236
+ * Queues the caller in FIFO order, validates an idle record or waits for a floor refill, and
237
+ * creates on demand only without a floor.
181
238
  *
182
239
  * @param signal - Optional native cancellation signal
183
240
  * @returns A promise for the unique resource lease
@@ -185,9 +242,11 @@ var Pool = class {
185
242
  * with `code: 'invalid'`. This throw is synchronous rather than a rejected promise, so a caller
186
243
  * that handles failures with `.catch()` alone misses it.
187
244
  * @throws {@link PoolError} Thrown as a rejection when `destroy()` has already begun, with
188
- * `code: 'destroyed'`; when the create hook fails, with `code: 'create'` and the hook's thrown
189
- * value as `cause`; and when an invalid record's cleanup fails, with `code: 'cleanup'`. A
190
- * `signal` that aborts rejects with the caller's exact `signal.reason` instead.
245
+ * `code: 'destroyed'`; when the create hook fails or the floor is spent without an idle record,
246
+ * with `code: 'create'` and the last cause; and when an invalid record's cleanup fails or
247
+ * a floor retains a record, owns `min` records, has nothing idle, and has no refill or disposal
248
+ * pending, even with live leases, with `code: 'cleanup'` and the retained cleanup failure as cause.
249
+ * A `signal` that aborts rejects with the caller's exact `signal.reason` instead.
191
250
  */
192
251
  acquire(signal) {
193
252
  if (signal !== void 0 && !isPoolSignal(signal)) throw new PoolError({
@@ -215,7 +274,7 @@ var Pool = class {
215
274
  return waiter.promise;
216
275
  }
217
276
  /**
218
- * Destroys the records that are idle at this call's synchronous snapshot.
277
+ * Destroys the records that are idle at this call's synchronous snapshot and restores a started floor.
219
278
  *
220
279
  * @returns A promise that settles after every snapshot cleanup attempt
221
280
  * @throws {@link PoolError} Thrown when `destroy()` has already begun, with `code: 'destroyed'`.
@@ -238,7 +297,7 @@ var Pool = class {
238
297
  * Tears down the pool permanently and returns its stable completion barrier.
239
298
  *
240
299
  * @returns The exact promise shared by every destroy call
241
- * @throws {@link PoolError} Thrown when a destroy hook failed during teardown, with
300
+ * @throws {@link PoolError} Thrown when a destroy hook failure was retained or occurred during teardown, with
242
301
  * `code: 'cleanup'` and every distinct failure in `context.failures`. The barrier rejects; it
243
302
  * never throws synchronously, and a repeat call receives the same rejected promise.
244
303
  */
@@ -246,6 +305,8 @@ var Pool = class {
246
305
  if (this.#ending !== void 0) return this.#ending.promise;
247
306
  const ending = Promise.withResolvers();
248
307
  this.#ending = ending;
308
+ this.#starting?.reject(new PoolError({ code: "destroyed" }));
309
+ this.#starting = void 0;
249
310
  const waiters = this.#waiters.splice(0);
250
311
  for (const waiter of waiters) {
251
312
  this.#detach(waiter);
@@ -288,18 +349,31 @@ var Pool = class {
288
349
  });
289
350
  }
290
351
  }
291
- #createRelease(record) {
292
- let released = false;
352
+ #token(record, value) {
353
+ const lease = {};
354
+ return {
355
+ value,
356
+ release: this.#createRelease(record, lease),
357
+ destroy: this.#createDestroy(record, lease)
358
+ };
359
+ }
360
+ #createRelease(record, lease) {
293
361
  return () => {
294
- if (released) return;
295
- released = true;
362
+ if (this.#settled.has(lease)) return;
363
+ this.#settled.add(lease);
296
364
  this.#release(record);
297
365
  };
298
366
  }
299
- #token(record, value) {
300
- return {
301
- value,
302
- release: this.#createRelease(record)
367
+ #createDestroy(record, lease) {
368
+ return () => {
369
+ if (this.#settled.has(lease)) return Promise.resolve();
370
+ this.#settled.add(lease);
371
+ return (this.#destroying.get(record) ?? this.#lose(record)).catch((error) => {
372
+ throw new PoolError({
373
+ code: "cleanup",
374
+ cause: error
375
+ });
376
+ });
303
377
  };
304
378
  }
305
379
  #abort(waiter, reason) {
@@ -339,6 +413,30 @@ var Pool = class {
339
413
  this.#startValidation(waiter, record);
340
414
  continue;
341
415
  }
416
+ if (this.#min !== void 0) {
417
+ if (!this.#refilling && this.#destroying.size === 0 && this.#resources.size >= this.#min && this.#survivors.size > 0) {
418
+ this.#ready.set(waiter, {
419
+ success: false,
420
+ error: new PoolError({
421
+ code: "cleanup",
422
+ cause: this.#survivors.values().next().value,
423
+ context: { failures: [...this.#failures] }
424
+ })
425
+ });
426
+ continue;
427
+ }
428
+ if (this.#spent() && !this.#refilling && this.#destroying.size === 0 && this.#owed === 0) {
429
+ this.#ready.set(waiter, {
430
+ success: false,
431
+ error: new PoolError({
432
+ code: "create",
433
+ cause: this.#cause
434
+ })
435
+ });
436
+ continue;
437
+ }
438
+ break;
439
+ }
342
440
  if (this.#max === void 0 || this.#resources.size + this.#reservations.size < this.#max) {
343
441
  this.#assigned.add(waiter);
344
442
  this.#reservations.add(waiter);
@@ -347,8 +445,89 @@ var Pool = class {
347
445
  }
348
446
  break;
349
447
  }
448
+ this.#commit();
350
449
  } while (this.#repump);
351
450
  this.#pumping = false;
451
+ this.#fill();
452
+ }
453
+ #spent() {
454
+ return this.#restarts !== void 0 && this.#strikes > this.#restarts;
455
+ }
456
+ #fill() {
457
+ if (this.#ending !== void 0 || !this.#started || this.#min === void 0) return;
458
+ if (this.#refilling) return;
459
+ if (this.#resources.size - this.#destroying.size - this.#survivors.size >= this.#min) {
460
+ this.#starting?.resolve();
461
+ this.#starting = void 0;
462
+ }
463
+ if (this.#resources.size < this.#min && (!this.#spent() || this.#owed > 0)) {
464
+ if (this.#owed > 0) this.#owed -= 1;
465
+ this.#refilling = true;
466
+ const operation = Promise.resolve().then(() => this.#refill());
467
+ this.#operations.add(operation);
468
+ operation.then(() => {
469
+ this.#refilling = false;
470
+ this.#completeOperation(operation);
471
+ });
472
+ return;
473
+ }
474
+ if (this.#destroying.size > 0) return;
475
+ if (this.#spent() || this.#survivors.size > 0) {
476
+ this.#starting?.reject(this.#spent() ? new PoolError({
477
+ code: "create",
478
+ cause: this.#cause
479
+ }) : this.#cleanupError(this.#failures));
480
+ this.#starting = void 0;
481
+ }
482
+ }
483
+ async #refill() {
484
+ if (this.#ending !== void 0) return;
485
+ let value;
486
+ try {
487
+ value = await this.#create();
488
+ } catch (error) {
489
+ this.#addStrike(error);
490
+ return;
491
+ }
492
+ const record = this.#insert(value);
493
+ this.#recycle(record);
494
+ }
495
+ #insert(value) {
496
+ const record = {};
497
+ this.#births.set(record, this.#epoch);
498
+ this.#resources.set(record, value);
499
+ if (this.#watch !== void 0) {
500
+ const controller = new AbortController();
501
+ this.#watches.set(record, controller);
502
+ const watching = attempt(() => this.#watch?.(value, controller.signal));
503
+ (watching.success ? Promise.resolve(watching.value) : Promise.reject(watching.error)).then(() => this.#own(this.#lose(record)), (error) => {
504
+ if (controller.signal.aborted) return;
505
+ this.#own(this.#lose(record, error));
506
+ attempt(() => this.#error?.(error, "watch"));
507
+ });
508
+ }
509
+ this.#emitter.emit("create");
510
+ return record;
511
+ }
512
+ async #lose(record, cause) {
513
+ if (this.#ending !== void 0 || !this.#resources.has(record) || this.#destroying.has(record) || this.#survivors.has(record)) return;
514
+ if (this.#leased.has(record) && this.#min !== void 0) this.#owing.add(record);
515
+ this.#strike(record, cause);
516
+ try {
517
+ await this.#dispose(record);
518
+ } catch (error) {
519
+ this.#pump();
520
+ throw error;
521
+ }
522
+ this.#pump();
523
+ }
524
+ #strike(record, cause) {
525
+ if (this.#min !== void 0 && !this.#used.has(record)) this.#addStrike(cause);
526
+ }
527
+ #addStrike(cause) {
528
+ this.#strikes += 1;
529
+ this.#cause = cause;
530
+ this.#epoch = {};
352
531
  }
353
532
  #startCreate(waiter) {
354
533
  const operation = Promise.resolve().then(() => this.#createResource(waiter));
@@ -390,9 +569,7 @@ var Pool = class {
390
569
  return;
391
570
  }
392
571
  this.#reservations.delete(waiter);
393
- const record = {};
394
- this.#resources.set(record, value);
395
- this.#emitter.emit("create");
572
+ const record = this.#insert(value);
396
573
  if (this.#ending !== void 0) {
397
574
  this.#assigned.delete(waiter);
398
575
  try {
@@ -418,6 +595,11 @@ var Pool = class {
418
595
  valid = await this.#validate?.(value) === true;
419
596
  } catch {}
420
597
  this.#validating.delete(record);
598
+ if (!this.#resources.has(record) || this.#destroying.has(record) || this.#survivors.has(record)) {
599
+ this.#assigned.delete(waiter);
600
+ this.#pump();
601
+ return;
602
+ }
421
603
  if (this.#ending !== void 0) {
422
604
  this.#assigned.delete(waiter);
423
605
  try {
@@ -425,6 +607,7 @@ var Pool = class {
425
607
  } catch {}
426
608
  return;
427
609
  }
610
+ if (!valid) this.#strike(record);
428
611
  if (!this.#waiters.includes(waiter)) {
429
612
  this.#assigned.delete(waiter);
430
613
  if (valid) this.#recycle(record);
@@ -480,6 +663,12 @@ var Pool = class {
480
663
  if (waiter === void 0) return;
481
664
  const result = this.#ready.get(waiter);
482
665
  if (result === void 0) return;
666
+ if (result.success && (!this.#resources.has(result.record) || this.#destroying.has(result.record) || this.#survivors.has(result.record))) {
667
+ this.#ready.delete(waiter);
668
+ this.#assigned.delete(waiter);
669
+ this.#repump = true;
670
+ return;
671
+ }
483
672
  this.#waiters.shift();
484
673
  this.#repump = true;
485
674
  this.#ready.delete(waiter);
@@ -490,6 +679,8 @@ var Pool = class {
490
679
  continue;
491
680
  }
492
681
  this.#leased.add(result.record);
682
+ this.#used.add(result.record);
683
+ if (this.#births.get(result.record) === this.#epoch) this.#strikes = 0;
493
684
  waiter.resolve(result.token);
494
685
  this.#emitter.emit("acquire");
495
686
  }
@@ -520,6 +711,7 @@ var Pool = class {
520
711
  this.#recycle(record);
521
712
  }
522
713
  #recycle(record) {
714
+ if (!this.#resources.has(record) || this.#destroying.has(record) || this.#survivors.has(record)) return;
523
715
  if (this.#ending !== void 0) {
524
716
  this.#own(this.#dispose(record));
525
717
  return;
@@ -529,6 +721,7 @@ var Pool = class {
529
721
  if (this.#available.includes(record)) this.#emitter.emit("release");
530
722
  }
531
723
  #dispose(record) {
724
+ if (this.#survivors.has(record)) return Promise.resolve();
532
725
  const existing = this.#destroying.get(record);
533
726
  if (existing !== void 0) return existing;
534
727
  for (const [owned, value] of this.#resources) {
@@ -539,6 +732,8 @@ var Pool = class {
539
732
  if (index >= 0) this.#available.splice(index, 1);
540
733
  this.#validating.delete(record);
541
734
  this.#leased.delete(record);
735
+ this.#watches.get(record)?.abort();
736
+ this.#watches.delete(record);
542
737
  if (this.#ending !== void 0) this.#own(cleanup.promise);
543
738
  this.#clean(record, value, cleanup);
544
739
  return cleanup.promise;
@@ -546,26 +741,34 @@ var Pool = class {
546
741
  return Promise.resolve();
547
742
  }
548
743
  async #clean(record, value, cleanup) {
549
- let attempt;
744
+ let disposal;
550
745
  try {
551
- attempt = Promise.resolve(this.#destroy?.(value));
746
+ disposal = Promise.resolve(this.#destroy?.(value));
552
747
  } catch (error) {
553
- attempt = Promise.reject(error);
748
+ disposal = Promise.reject(error);
554
749
  }
555
750
  let failure;
556
751
  let failed = false;
557
752
  try {
558
- await attempt;
753
+ await disposal;
559
754
  } catch (error) {
560
755
  failure = error;
561
756
  failed = true;
562
757
  }
563
- this.#resources.delete(record);
758
+ if (failed && this.#min !== void 0) {
759
+ this.#survivors.set(record, failure);
760
+ this.#record(failure);
761
+ } else {
762
+ this.#resources.delete(record);
763
+ if (this.#owing.has(record)) this.#owed += 1;
764
+ }
765
+ this.#owing.delete(record);
564
766
  if (failed) cleanup.reject(failure);
565
767
  else cleanup.resolve();
566
768
  await Promise.resolve();
567
769
  this.#emitter.emit("destroy");
568
770
  this.#destroying.delete(record);
771
+ if (this.#min !== void 0) this.#pump();
569
772
  this.#finish();
570
773
  }
571
774
  async #settleClear(cleanups) {
@@ -603,11 +806,11 @@ var Pool = class {
603
806
  if (ending === void 0 || this.#operations.size > 0 || this.#owned.size > 0) return;
604
807
  let claimed = false;
605
808
  for (const record of this.#resources.keys()) {
606
- if (this.#validating.has(record) || this.#destroying.has(record)) continue;
809
+ if (this.#validating.has(record) || this.#destroying.has(record) || this.#survivors.has(record)) continue;
607
810
  claimed = true;
608
811
  this.#own(this.#dispose(record));
609
812
  }
610
- if (claimed || this.#resources.size > 0 || this.#destroying.size > 0) return;
813
+ if (claimed || this.#resources.size > this.#survivors.size || this.#destroying.size > 0) return;
611
814
  this.#emitter.destroy();
612
815
  if (this.#failures.length > 0) ending.reject(this.#cleanupError(this.#failures));
613
816
  else ending.resolve();
@@ -624,12 +827,15 @@ var Pool = class {
624
827
  * request order. `clear` owns its idle snapshot; `destroy` returns one stable barrier and
625
828
  * waits for every in-flight create, validation, and destroy attempt before destroying the
626
829
  * emitter last.
830
+ * Set `min` with its required `restarts` bound and call `start()` to warm resources and
831
+ * refill losses independently of queued acquires. `watch` observes loss and its signal
832
+ * aborts at disposal; a token can declare loss through `destroy()`.
627
833
  *
628
834
  * @typeParam T - The pooled resource type
629
- * @param options - Lifecycle hooks, optional positive safe `max`, and observation hooks
835
+ * @param options - Lifecycle hooks, capacity, optional bounded warm floor, and observation hooks
630
836
  * @returns A working {@link PoolInterface}
631
- * @throws {@link PoolError} Thrown when `options.max` is present and is not a positive safe
632
- * integer, with `code: 'invalid'`. Construction validates it synchronously, before the pool exists.
837
+ * @throws {@link PoolError} Thrown with `code: 'invalid'` when capacity, restart bounds, or
838
+ * the watch hook are invalid. Construction validates them synchronously, before the pool exists.
633
839
  *
634
840
  * @example Create a pool
635
841
  * ```ts