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