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