@orkestrel/pool 0.0.13 → 0.0.14

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