@zakkster/lite-pick 1.0.0 → 1.0.1

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/Pick.js CHANGED
@@ -30,7 +30,9 @@
30
30
  * - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
31
31
  * exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
32
32
  * - PeakEwmaBalancer latency-aware P2C (Twitter Finagle's peak-EWMA): draws two distinct
33
- * eligible endpoints and takes the lower cost = (inflight+1) x decayed EWMA(rtt).
33
+ * eligible endpoints and takes the lower cost (three cases: idle-unsampled 0,
34
+ * busy-unsampled priced at the sample mean, sampled (inflight+1) x decayed EWMA
35
+ * floored by time-since-last-sample while busy -- see the class JSDoc).
34
36
  * Decay-on-READ (pick() never writes -> 0 B/op); the balancer OWNS the Float64
35
37
  * _ewma/_stamp state and is its SOLE writer via the warm recordRtt() feedback
36
38
  * path (also 0 B/op). Caller-supplied nanosecond clock. O(d)=O(1)/pick.
@@ -81,7 +83,7 @@
81
83
  */
82
84
 
83
85
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
84
- export const VERSION = '1.0.0';
86
+ export const VERSION = '1.0.1';
85
87
 
86
88
  /**
87
89
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
@@ -131,14 +133,33 @@ export class Prng {
131
133
  }
132
134
  }
133
135
 
136
+ /**
137
+ * Validate an endpoint index for a COLD/WARM mutator (never the hot pick path): it must be an
138
+ * in-range, non-negative INTEGER. `(i >>> 0) !== i` rejects NaN, fractions (1.5), negatives (-1),
139
+ * and non-numbers (a string like '2' coerces to a different value under `>>> 0`); `i >= cap`
140
+ * rejects out-of-range. Fails closed with the same RangeError style as the pre-existing range
141
+ * checks -- invalid input is an error, never a silent typed-array no-op that desyncs `_live`.
142
+ * @param {number} i
143
+ * @param {number} cap
144
+ */
145
+ function _vIdx(i, cap) {
146
+ if ((i >>> 0) !== i || i >= cap) {
147
+ throw new RangeError('[lite-pick] index out of range: ' + i);
148
+ }
149
+ }
150
+
134
151
  /**
135
152
  * BalancerBase -- the shared eligibility seam for every strategy.
136
153
  *
137
154
  * It owns ONLY: the fixed capacity, a reference to the caller/sibling-owned eligibility
138
155
  * Uint8Array (never copied), and an O(1) `_live` count maintained on the cold setEligible()
139
156
  * path so a strategy can fail closed in O(1). It never allocates after construction and
140
- * never calls into a health source -- writers mutate `eligible` at their own cadence; pick()
141
- * only reads it.
157
+ * never calls into a health source. The eligibility view is flipped ONLY through setEligible()
158
+ * (the sole supported writer, which keeps `_live` -- and SmoothWRR's eligible-weight total --
159
+ * exact); a health source / breaker drives that call. A direct `eligible[i]` write bypasses the
160
+ * cache and desyncs `_live` (fail-closed picks, wrong ratios) -- UB (1.0.1 contract). Each
161
+ * balancer needs its OWN eligibility array (a shared `Eligibility` object is deferred to 2.0).
162
+ * pick() only reads.
142
163
  *
143
164
  * Subclasses (M1+) implement pick(). BalancerBase.pick() throws, so an unfinished strategy
144
165
  * fails loudly rather than silently returning a dead index.
@@ -172,9 +193,10 @@ export class BalancerBase {
172
193
  return this._live;
173
194
  }
174
195
 
175
- /** True iff endpoint i is currently pickable. O(1), zero-alloc. */
196
+ /** True iff endpoint i is currently pickable. O(1), zero-alloc. A non-integer (1.5, NaN)
197
+ * is never pickable -> false (isEligible NEVER throws; it is a pure predicate). */
176
198
  isEligible(i) {
177
- return i >= 0 && i < this._cap && this._eligible[i] !== 0;
199
+ return (i >>> 0) === i && i < this._cap && this._eligible[i] !== 0;
178
200
  }
179
201
 
180
202
  /**
@@ -184,9 +206,7 @@ export class BalancerBase {
184
206
  * @param {boolean} up
185
207
  */
186
208
  setEligible(i, up) {
187
- if (i < 0 || i >= this._cap) {
188
- throw new RangeError('[lite-pick] index out of range: ' + i);
189
- }
209
+ _vIdx(i, this._cap);
190
210
  const was = this._eligible[i];
191
211
  const now = up ? 1 : 0;
192
212
  if (was !== now) {
@@ -322,12 +342,13 @@ export class SmoothWRRBalancer extends BalancerBase {
322
342
  * @param {number} w new weight (uint32)
323
343
  */
324
344
  setWeight(i, w) {
325
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
345
+ _vIdx(i, this._cap);
326
346
  const nw = w >>> 0;
327
347
  if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
328
348
  const old = this._weights[i];
329
349
  if (nw === old) return;
330
350
  this._weights[i] = nw;
351
+ this._current[i] = 0; // reset credit: a reweighted node holds no stale accumulator
331
352
  if (this._eligible[i]) this._totalEligibleWeight += nw - old;
332
353
  }
333
354
 
@@ -341,13 +362,15 @@ export class SmoothWRRBalancer extends BalancerBase {
341
362
  const cap = this._cap, el = this._eligible, wt = this._weights, cur = this._current;
342
363
  let best = -1, bestCur = -Infinity;
343
364
  for (let i = 0; i < cap; i++) {
344
- if (el[i]) {
365
+ if (el[i] && wt[i] > 0) { // eligible AND positive weight: a weight-0 node is never a candidate
345
366
  const c = cur[i] + wt[i];
346
367
  cur[i] = c;
347
368
  if (c > bestCur) { bestCur = c; best = i; }
348
369
  }
349
370
  }
350
- cur[best] -= total; // best >= 0 guaranteed while total > 0
371
+ // best >= 0 while total > 0 -- provided eligibility is written ONLY through setEligible (the
372
+ // 1.0.1 contract that keeps _totalEligibleWeight in lockstep); a direct eligible[] write desyncs it.
373
+ cur[best] -= total;
351
374
  return best;
352
375
  }
353
376
  }
@@ -451,7 +474,8 @@ export class P2cBalancer extends BalancerBase {
451
474
  * directly between picks; that is the whole point of the shared-counter seam.
452
475
  *
453
476
  * Bound: O(cap) per pick (one scan). Steady-state pick(): integer compares + one index write,
454
- * no object/closure/array created -- 0 B/op. Tie-break is the lowest index (deterministic);
477
+ * no object/closure/array created -- 0 B/op. Tie order is UNSPECIFIED in 1.0.1 (deterministic,
478
+ * but callers must not depend on which tied node wins; a rotating tie-break is planned for 1.1.0);
455
479
  * the feedback loop breaks a startup all-zero tie by raising the picked node's count. Fails
456
480
  * closed (PICK_NONE) when the whole pool is down.
457
481
  *
@@ -476,7 +500,7 @@ export class LeastConnBalancer extends BalancerBase {
476
500
 
477
501
  /**
478
502
  * The eligible endpoint with the fewest in-flight requests, or PICK_NONE (fail closed).
479
- * O(cap), zero-alloc. Lowest index on a tie.
503
+ * O(cap), zero-alloc. Tie order unspecified in 1.0.1 (deterministic; rotating tie-break 1.1.0).
480
504
  * @returns {number}
481
505
  */
482
506
  pick() {
@@ -533,7 +557,8 @@ export class SedBalancer extends BalancerBase {
533
557
 
534
558
  /**
535
559
  * The eligible endpoint minimizing (inflight + 1) / weight, or PICK_NONE (fail closed).
536
- * O(cap), zero-alloc. Lowest index on a tie; weight-0 nodes are not candidates.
560
+ * O(cap), zero-alloc. Tie order unspecified in 1.0.1 (deterministic; rotating tie-break 1.1.0);
561
+ * weight-0 nodes are not candidates.
537
562
  * @returns {number}
538
563
  */
539
564
  pick() {
@@ -562,8 +587,9 @@ export class SedBalancer extends BalancerBase {
562
587
  * case: spin up idle capacity first, only weigh expected delay once everyone is busy.
563
588
  *
564
589
  * Ownership (ADR 0001, ADR 0006): identical to SED -- caller-owned inflight + weights, read
565
- * live, no derived aggregate. The first idle eligible node (lowest index, in-flight 0, weight
566
- * > 0) short-circuits the scan.
590
+ * live, no derived aggregate. The first idle eligible node found in the scan (in-flight 0, weight
591
+ * > 0) short-circuits it; when several are idle the one returned is unspecified in 1.0.1
592
+ * (deterministic; a rotating tie-break is planned for 1.1.0).
567
593
  *
568
594
  * Bound: O(cap) worst case (no idle node -> a full SED scan); O(1) when a low-index endpoint is
569
595
  * idle. 0 B/op. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
@@ -614,11 +640,25 @@ export class NqBalancer extends BalancerBase {
614
640
  * PeakEwmaBalancer -- latency-aware power-of-two-choices (M7), Twitter Finagle's peak-EWMA.
615
641
  *
616
642
  * `pick(now)` draws TWO distinct eligible endpoints (the same rejection-sampling machinery as
617
- * P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST,
618
- * where cost(i) = (inflight[i] + 1) x ewmaAt(i, now). It is P2C over a LATENCY signal instead of
619
- * raw in-flight count: a slow endpoint (high EWMA rtt) is avoided even when its queue is short,
620
- * so the pool steers around a degraded-but-up node -- the strategy the multi-region FE case wants.
621
- * O(d) = O(1) per pick.
643
+ * P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST. It
644
+ * is P2C over a LATENCY signal instead of raw in-flight count: a slow endpoint (high EWMA rtt) is
645
+ * avoided even when its queue is short, so the pool steers around a degraded-but-up node -- the
646
+ * strategy the multi-region FE case wants. O(d) = O(1) per pick.
647
+ *
648
+ * Cost, per candidate i (a pure READ -- scalar-only, no write, no clock call, 0 B/op):
649
+ * - unsampled (`_stamp < 0`) AND idle (`inflight === 0`) -> cost 0. This is NOT a one-shot probe the
650
+ * kernel can enforce: an idle unsampled node costs 0 EVERY time it is idle, so it holds exactly one
651
+ * request in flight at a time (the next pick sees inflight > 0) until its FIRST recordRtt. A node
652
+ * that never gets a sample -- e.g. one that fails fast so the caller records nothing -- stays at
653
+ * cost 0 whenever idle and keeps winning. Callers MUST record failures too (@zakkster/lite-pick/pool
654
+ * does this from 1.0.1) or a fast-failing endpoint is a black hole the kernel alone cannot see.
655
+ * - unsampled AND busy (`inflight > 0`) -> `(inflight + 1) x mean`, where `mean` is the pool's
656
+ * LIFETIME mean sampled rtt (`_samp[0] / _samp[1]` = sum / count, or 1.0 before ANY sample). A
657
+ * cold-but-busy node is priced at the pool mean, NOT the old 1.0 ns that made it a black hole (H1).
658
+ * - sampled -> `(inflight + 1) x base`, `base = max(decayedEWMA, dt)` WHILE BUSY else `decayedEWMA`
659
+ * (`decayedEWMA = ewma x exp(-dt/tau)`, `dt = max(now - stamp, 0)`). The busy floor means a hung
660
+ * node -- inflight > 0 and no completion, so `dt` grows without bound -- gets MORE expensive over
661
+ * time instead of decaying toward 0 and becoming the most attractive pick (H1/L6).
622
662
  *
623
663
  * Ownership (ADR 0001, ADR 0009): `inflight` is the CALLER's Uint32Array, read LIVE (the P2C /
624
664
  * LeastConn seam). The EWMA state -- `_ewma` (the decayed rtt estimate) and `_stamp` (the ns
@@ -627,25 +667,34 @@ export class NqBalancer extends BalancerBase {
627
667
  * warm `recordRtt()` feedback path. `pick()` NEVER writes: it decays ON READ, so the hot path
628
668
  * stays a pure read -> 0 B/op.
629
669
  *
630
- * Decay-on-read: ewmaAt(i, now) = _ewma[i] x exp(-(now - _stamp[i]) / tau). No write, no clock
670
+ * Decay-on-read: ewmaAt(i, now) = _ewma[i] x exp(-max(now - _stamp[i], 0) / tau). No write, no clock
631
671
  * call on the gated path -- `now` (and the rtt sample) are CALLER-supplied nanoseconds, consistent
632
672
  * between `pick(now)` and `recordRtt(i, sampleNs, now)`, so the whole strategy is deterministic
633
- * and testable and allocates nothing.
673
+ * and testable and allocates nothing. `dt` is clamped at 0 (L6) so a non-monotonic clock can never
674
+ * inflate the estimate via `exp(+x)`.
634
675
  *
635
- * Cold start: `_ewma` seeds to 1.0 and `_stamp` to a NEGATIVE "unsampled" sentinel (-1). While a
636
- * node is unsampled `ewmaAt` returns the baseline 1.0 UNDECAYED, so before any sample cost(i) =
637
- * (inflight[i] + 1) x 1 and PeakEWMA degrades GRACEFULLY to plain least-connections (P2C-over-
638
- * inflight) REGARDLESS of the caller's clock magnitude -- a plain `_stamp = 0` would decay as
639
- * exp(-now/tau) -> 0 under a real large clock and collapse a cold pool to random. The first
640
- * `recordRtt` initializes the EWMA EXACTLY to the sample (clock-independent); the peak rule applies
641
- * only from the second sample on. It is never NaN.
676
+ * Cold start: `_ewma` seeds to 1.0 and `_stamp` to a NEGATIVE "unsampled" sentinel (-1). An unsampled
677
+ * node costs 0 WHILE IDLE (so it holds one request in flight at a time until its first recordRtt) and
678
+ * the pool's lifetime mean ONCE BUSY (1.0 only before the very first sample), so a cold node that took
679
+ * work is never mistaken for a 1.0 ns node and cannot become a black hole once samples flow (H1). The
680
+ * first `recordRtt` initializes the EWMA EXACTLY to the sample (clock-independent); the peak rule
681
+ * applies only from the second sample on. It is never NaN.
682
+ *
683
+ * ACCEPTED CAVEATS (1.1 refinements): (a) a node that was idle and then receives a request is priced by
684
+ * the time since its LAST response (the `dt` floor / the mean) until that in-flight request completes --
685
+ * there is no precise per-dispatch "busy since" stamp yet, so the busy floor uses time-since-last-sample
686
+ * as its proxy, over-pricing a node that has just started a fresh (not hung) request. (b) `mean` is a
687
+ * LIFETIME mean over every sample ever recorded -- it never forgets a latency-regime change; decaying it
688
+ * is a 1.1 item. `_samp[0]` (the running sum) saturates to +Infinity after ~1.8e308 of summed rtt and
689
+ * stays Infinity (a cold-but-busy node then prices at Infinity) -- never NaN.
642
690
  *
643
691
  * Contract: `now` (in `pick(now)` / `recordRtt`) and `sampleNs` MUST be FINITE numbers. `recordRtt`
644
692
  * throws on a non-finite argument (the warm path); `pick(now)` never throws (the fail-closed
645
693
  * contract), so a non-finite `now` yields P2C-random selection rather than an error.
646
694
  *
647
- * Anti-flap (ADR 0002, ADR 0009): the EWMA half-life IS the smoothing -- a single slow sample
648
- * snaps the cost up instantly and it decays back over ~tau, so there is NO extra dwell/hysteresis.
695
+ * Anti-flap (ADR 0002, ADR 0009): the EWMA time constant IS the smoothing -- a single slow sample
696
+ * snaps the cost up instantly and it decays back over ~tau (half-life = tau x ln2), so there is NO
697
+ * extra dwell/hysteresis.
649
698
  *
650
699
  * Deferred (ADR 0009 / llms.txt): a p99-aware variant scoring inflight x p99Rtt via a per-node
651
700
  * @zakkster/lite-sketch `DDSketch` (optional peer, 0 B/op `add`). EWMA-mean is the shipped,
@@ -656,13 +705,20 @@ export class NqBalancer extends BalancerBase {
656
705
  * the whole pool is down.
657
706
  */
658
707
  export class PeakEwmaBalancer extends BalancerBase {
708
+ /**
709
+ * Marker: this is a LATENCY-AWARE strategy -- pick() consumes a clock reading (`now`), so
710
+ * @zakkster/lite-pick/pool REQUIRES an `opts.clock` and feeds recordRtt() from it. Read via
711
+ * `balancer.constructor.LATENCY` so Pool stays duck-typed (imports nothing new).
712
+ */
713
+ static LATENCY = true;
714
+
659
715
  /**
660
716
  * @param {number} capacity endpoint count (fixed).
661
717
  * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
662
718
  * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
663
719
  * caller-owned and only READ here.
664
- * @param {number} tauNs the EWMA time-constant / half-life in nanoseconds (> 0, finite):
665
- * larger tau = slower decay = longer memory of a latency spike.
720
+ * @param {number} tauNs the EWMA TIME CONSTANT in nanoseconds (> 0, finite; the half-life is
721
+ * tauNs x ln2): larger tau = slower decay = longer memory of a latency spike.
666
722
  * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
667
723
  */
668
724
  constructor(capacity, eligible, inflight, tauNs, seed = 0x9e3779b9) {
@@ -682,6 +738,12 @@ export class PeakEwmaBalancer extends BalancerBase {
682
738
  this._rng = new Prng(seed);
683
739
  this._ewma = new Float64Array(capacity);
684
740
  this._stamp = new Float64Array(capacity);
741
+ // Lifetime running mean of ALL rtt samples (O(1), warm-path maintained in recordRtt): the price
742
+ // an unsampled-but-BUSY node pays, so a cold node is not mistaken for a 1.0 ns node once it has
743
+ // work in flight. mean = _samp[0] / _samp[1] (sum / count); count 0 (no sample yet) -> 1.0. Held
744
+ // in a pre-allocated Float64Array (the hot-path law: pre-allocate typed-array scalars, never a
745
+ // per-op object). _samp[0] saturates to +Infinity past ~1.8e308 of summed rtt -- never NaN.
746
+ this._samp = new Float64Array(2);
685
747
  // Cold start: _ewma seeds to 1.0 and _stamp to a NEGATIVE "unsampled" sentinel (-1). The
686
748
  // sentinel makes ewmaAt read the baseline UNDECAYED (graceful LeastConn) regardless of the
687
749
  // caller's clock magnitude -- a plain _stamp=0 would decay as exp(-now/tau) -> 0 under a
@@ -711,7 +773,9 @@ export class PeakEwmaBalancer extends BalancerBase {
711
773
  ewmaAt(i, now) {
712
774
  const s = this._stamp[i];
713
775
  if (s < 0) return this._ewma[i]; // unsampled: undecayed baseline, clock-magnitude-independent
714
- return this._ewma[i] * Math.exp(-(now - s) / this._tau);
776
+ let dt = now - s;
777
+ if (dt < 0) dt = 0; // L6: clamp a non-monotonic clock -- exp(+x) must never inflate
778
+ return this._ewma[i] * Math.exp(-dt / this._tau);
715
779
  }
716
780
 
717
781
  /**
@@ -726,10 +790,10 @@ export class PeakEwmaBalancer extends BalancerBase {
726
790
  * @param {number} now caller-supplied nanoseconds (finite), consistent with pick(now)
727
791
  */
728
792
  recordRtt(i, sampleNs, now) {
729
- if (typeof i !== 'number' || typeof sampleNs !== 'number' || typeof now !== 'number') {
793
+ _vIdx(i, this._cap);
794
+ if (typeof sampleNs !== 'number' || typeof now !== 'number') {
730
795
  throw new TypeError('[lite-pick] recordRtt(i, sampleNs, now) requires numbers');
731
796
  }
732
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
733
797
  if (!Number.isFinite(sampleNs) || sampleNs < 0) {
734
798
  throw new RangeError('[lite-pick] sampleNs must be a finite number >= 0');
735
799
  }
@@ -737,17 +801,26 @@ export class PeakEwmaBalancer extends BalancerBase {
737
801
  if (this._stamp[i] < 0) {
738
802
  this._ewma[i] = sampleNs; // first sample: exact init, no decay (clock-independent)
739
803
  } else {
740
- const w = Math.exp(-(now - this._stamp[i]) / this._tau);
804
+ let dt = now - this._stamp[i];
805
+ if (dt < 0) dt = 0; // L6: clamp a non-monotonic clock -- exp(+x) must never inflate
806
+ const w = Math.exp(-dt / this._tau);
741
807
  const e = this._ewma[i] * w;
742
808
  this._ewma[i] = sampleNs > e ? sampleNs : e + (sampleNs - e) * (1 - w);
743
809
  }
744
810
  this._stamp[i] = now;
811
+ // O(1) warm running lifetime mean over ALL samples: the price a cold-but-busy node pays in
812
+ // pick(). Kept in an unboxed Float64Array (see the ctor); _samp[0] saturates to +Infinity, never NaN.
813
+ this._samp[0] += sampleNs;
814
+ this._samp[1] += 1;
745
815
  }
746
816
 
747
817
  /**
748
- * Pick by latency-aware power-of-two-choices: two distinct eligible draws, lower cost =
749
- * (inflight+1) x ewmaAt(now) wins; a tie goes to the first draw. PICK_NONE (fail closed) iff
750
- * the whole pool is down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call).
818
+ * Pick by latency-aware power-of-two-choices: two distinct eligible draws, LOWER COST wins (a tie
819
+ * goes to the first draw). Cost is the three-case function documented on the class (unsampled+idle
820
+ * -> 0; unsampled+busy -> (inflight+1) x lifetime mean; sampled -> (inflight+1) x max(decayedEWMA,
821
+ * dt-while-busy)), NOT a plain (inflight+1) x ewmaAt. PICK_NONE (fail closed) iff the whole pool is
822
+ * down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call; the mean division runs only in the
823
+ * unsampled-and-busy arm, never in the both-sampled steady state).
751
824
  * @param {number} now caller-supplied nanoseconds (consistent with recordRtt)
752
825
  * @returns {number}
753
826
  */
@@ -761,11 +834,34 @@ export class PeakEwmaBalancer extends BalancerBase {
761
834
  for (let t = 0; b === a && t < 32; t++) b = this._draw();
762
835
  if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
763
836
  const inf = this._inflight, ewma = this._ewma, stamp = this._stamp, tau = this._tau;
764
- // Decay-on-read with the unsampled sentinel: `_stamp < 0` reads the undecayed baseline
765
- // (graceful LeastConn), else exponential decay. A cheap per-candidate compare, no alloc.
766
- const sa = stamp[a], sb = stamp[b];
767
- const costA = (inf[a] + 1) * (sa < 0 ? ewma[a] : ewma[a] * Math.exp(-(now - sa) / tau));
768
- const costB = (inf[b] + 1) * (sb < 0 ? ewma[b] : ewma[b] * Math.exp(-(now - sb) / tau));
837
+ // Per-candidate cost (pure READ, scalar-only, 0 B/op). See the class JSDoc for the three cases.
838
+ // unsampled + idle -> 0 (costs 0 while idle until its first recordRtt; NOT a one-shot probe).
839
+ // unsampled + busy -> (inf+1) x lifetime mean (the mean DIVISION runs ONLY here -- never in
840
+ // the both-sampled steady state -- priced at the pool mean, not 1.0 ns).
841
+ // sampled -> (inf+1) x base, base = decayed EWMA, floored at dt WHILE BUSY so a hung
842
+ // node (dt grows, no completion) gets MORE expensive, not less.
843
+ const sa = stamp[a];
844
+ let costA;
845
+ if (sa < 0) {
846
+ costA = inf[a] === 0 ? 0 : (inf[a] + 1) * (this._samp[1] > 0 ? this._samp[0] / this._samp[1] : 1.0);
847
+ } else {
848
+ let dtA = now - sa;
849
+ if (dtA < 0) dtA = 0; // L6: clamp non-monotonic clock
850
+ const decA = ewma[a] * Math.exp(-dtA / tau);
851
+ const baseA = inf[a] > 0 ? (decA > dtA ? decA : dtA) : decA; // busy floor: >= time since last sample
852
+ costA = (inf[a] + 1) * baseA;
853
+ }
854
+ const sb = stamp[b];
855
+ let costB;
856
+ if (sb < 0) {
857
+ costB = inf[b] === 0 ? 0 : (inf[b] + 1) * (this._samp[1] > 0 ? this._samp[0] / this._samp[1] : 1.0);
858
+ } else {
859
+ let dtB = now - sb;
860
+ if (dtB < 0) dtB = 0; // L6: clamp non-monotonic clock
861
+ const decB = ewma[b] * Math.exp(-dtB / tau);
862
+ const baseB = inf[b] > 0 ? (decB > dtB ? decB : dtB) : decB; // busy floor
863
+ costB = (inf[b] + 1) * baseB;
864
+ }
769
865
  return costB < costA ? b : a; // lower cost wins; tie -> the first draw
770
866
  }
771
867
  }
@@ -847,6 +943,13 @@ function chIsPrime(n) {
847
943
  * pick, over-conservative only under mass outage; ADR 0010).
848
944
  */
849
945
  export class ConsistentHashBalancer extends BalancerBase {
946
+ /**
947
+ * Marker: this is a KEYED strategy -- pick(keyHash) routes by an integer key, so
948
+ * @zakkster/lite-pick/pool REQUIRES a numeric `opts.key`. Inherited by BoundedLoadBalancer.
949
+ * Read via `balancer.constructor.KEYED` so Pool stays duck-typed (imports nothing new).
950
+ */
951
+ static KEYED = true;
952
+
850
953
  /**
851
954
  * @param {number} capacity backend count (fixed; add/remove is a cold rebuild).
852
955
  * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
@@ -959,7 +1062,7 @@ export class ConsistentHashBalancer extends BalancerBase {
959
1062
  * @param {number} w new weight (uint32)
960
1063
  */
961
1064
  setWeight(i, w) {
962
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1065
+ _vIdx(i, this._cap);
963
1066
  const nw = w >>> 0;
964
1067
  if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
965
1068
  if (nw === this._weights[i]) return;
@@ -1019,7 +1122,13 @@ export class ConsistentHashBalancer extends BalancerBase {
1019
1122
  * window (home + CH_PROBE_LIMIT slots) and return the FIRST backend that is ELIGIBLE AND UNDER cap
1020
1123
  * (`inflight[b] < cap`). If none in the window is under cap, FALL BACK to the first eligible seen
1021
1124
  * (sticky wins; the cap is a soft preference, never a dead pick). When `_total === 0` the cap test is
1022
- * skipped entirely -> behaves as pure ConsistentHash. `cap = (1 + eps) x _total / live`.
1125
+ * skipped entirely -> behaves as pure ConsistentHash. `cap = ceil((1 + eps) x (_total + 1) / live)`
1126
+ * -- the load-bearing change from the old `(1 + eps) x _total / live` is the +1 that counts the
1127
+ * INCOMING request (Mirrokni-Thorup-Zadimoghaddam per-bin capacity); the Math.ceil matches the
1128
+ * paper's integer capacity but is a no-op for the `inf < cap` test (for integer inf,
1129
+ * `inf < ceil(x)` == `inf < x`). A second concurrent same-key request correctly overflows the home
1130
+ * until `(1+eps)(_total+1)/live > 1`. HAProxy's `hash-balance-factor` shares the +1 but distributes
1131
+ * ONE global `ceil((m+1)F/100)` slot budget across servers by weight (min 1), which is stricter.
1023
1132
  *
1024
1133
  * Ownership (ADR 0001, ADR 0004, ADR 0010, ADR 0011): the Maglev lookup table + weights are
1025
1134
  * BALANCER-OWNED and built COLD (reused from ConsistentHashBalancer VERBATIM -- `_build`, `setWeight`,
@@ -1028,9 +1137,11 @@ export class ConsistentHashBalancer extends BalancerBase {
1028
1137
  * `_total` is BALANCER-OWNED and its SOLE writer is the warm `note(i, delta)` feedback path
1029
1138
  * (dispatch +1 / settle -1), so the cap's mean stays O(1)-current without a scan.
1030
1139
  *
1031
- * CONTRACT (the SmoothWRR-weights asymmetry): when using BoundedLoad the mirrored inflight counter is
1032
- * mutated ONLY through `note()` / the /pool adapter. Direct mutation desyncs `_total` from the true
1033
- * sum, so the cap goes wrong -- UB. `note()` clamps `_total` at 0; `totalInflight` exposes it.
1140
+ * CONTRACT (the SmoothWRR-weights asymmetry): when using BoundedLoad, update `inflight[i]` AND call
1141
+ * `note(i, +/-1)` in LOCKSTEP (or drive it through the /pool adapter, which does both). `note`
1142
+ * maintains `_total`; it does NOT write `inflight`. A direct `inflight` write without the matching
1143
+ * `note` desyncs `_total` from the true sum, so the cap goes wrong -- UB. `note()` clamps `_total`
1144
+ * at 0; `totalInflight` exposes it.
1034
1145
  *
1035
1146
  * Bound: O(1) per pick (modulo + table read + bounded cap-aware probe), 0 B/op on BOTH `pick()` and
1036
1147
  * `note()` (torture + PerfGate). Fails closed (PICK_NONE) ONLY when no eligible backend is reachable
@@ -1087,7 +1198,7 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1087
1198
  * @param {number} delta integer occupancy change (+1 dispatch, -1 settle)
1088
1199
  */
1089
1200
  note(i, delta) {
1090
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1201
+ _vIdx(i, this._cap);
1091
1202
  if (typeof delta !== 'number') throw new TypeError('[lite-pick] delta must be a number');
1092
1203
  if (!Number.isInteger(delta)) throw new RangeError('[lite-pick] delta must be an integer: ' + delta);
1093
1204
  const t = this._total + delta;
@@ -1097,8 +1208,8 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1097
1208
  /**
1098
1209
  * Map an INTEGER key to a backend, honouring the occupancy cap, or PICK_NONE (fail closed). O(1),
1099
1210
  * 0 B/op, never throws. slot = (keyHash >>> 0) % M; walk the M8 probe window (home + CH_PROBE_LIMIT
1100
- * slots) and return the FIRST backend that is ELIGIBLE AND under cap = (1+eps) x _total / live. If
1101
- * none in the window is under cap, fall back to the FIRST eligible seen (sticky wins -- the cap is
1211
+ * slots) and return the FIRST backend that is ELIGIBLE AND under cap = ceil((1+eps) x (_total+1) /
1212
+ * live). If none in the window is under cap, fall back to the FIRST eligible seen (sticky wins -- the cap is
1102
1213
  * a soft preference, never a dead pick). `_total === 0` skips the cap test -> pure ConsistentHash.
1103
1214
  * PICK_NONE ONLY when no eligible backend is reachable within the window.
1104
1215
  * @param {number} keyHash a caller-supplied integer key hash (coerced to uint32)
@@ -1110,7 +1221,10 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1110
1221
  const total = this._total;
1111
1222
  // cap is only meaningful once occupancy is known; _total === 0 -> pure ConsistentHash.
1112
1223
  const capActive = total > 0;
1113
- const cap = capActive ? (1 + this._eps) * total / this._live : 0; // finite: total>0, live>0
1224
+ // CHBL cap (Mirrokni-Thorup-Zadimoghaddam per-bin capacity). The load-bearing part is the +1
1225
+ // that counts the INCOMING request; the Math.ceil matches the paper's integer capacity but is a
1226
+ // no-op for the `inf < cap` test (integer inf: `inf < ceil(x)` == `inf < x`).
1227
+ const cap = capActive ? Math.ceil((1 + this._eps) * (total + 1) / this._live) : 0; // >= 1: total>0, live>0
1114
1228
  let slot = (keyHash >>> 0) % M; // integer key; NaN >>> 0 = 0 (never throws)
1115
1229
  let firstEligible = -1; // the pure-ConsistentHash sticky fallback answer
1116
1230
  let i = lookup[slot];
@@ -1262,7 +1376,7 @@ export class WeightedRandomBalancer extends BalancerBase {
1262
1376
  * @param {number} w new weight (uint32)
1263
1377
  */
1264
1378
  setWeight(i, w) {
1265
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1379
+ _vIdx(i, this._cap);
1266
1380
  const nw = w >>> 0;
1267
1381
  if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
1268
1382
  if (nw === this._weights[i]) return;
package/Pool.d.ts CHANGED
@@ -8,13 +8,31 @@
8
8
  /** The source-of-truth version stamp (re-exported from the core). */
9
9
  export const VERSION: string;
10
10
 
11
- /** The minimal balancer shape Pool drives (any lite-pick strategy satisfies it). */
11
+ /**
12
+ * The minimal abort-signal shape Pool reads (L15). A structural type -- NOT the global DOM
13
+ * `AbortSignal` -- so a consumer compiling with `lib: ["ES2022"]` only (no DOM, no @types/node)
14
+ * still type-checks. A real `AbortSignal` (DOM or node:) satisfies it.
15
+ */
16
+ export interface AbortLike {
17
+ readonly aborted: boolean;
18
+ readonly reason?: unknown;
19
+ throwIfAborted?(): void;
20
+ }
21
+
22
+ /**
23
+ * The minimal balancer shape Pool drives. A KEYED strategy (ConsistentHash / BoundedLoad) exposes a
24
+ * static `KEYED === true` and takes `pick(keyHash)`; a LATENCY strategy (PeakEWMA) exposes a static
25
+ * `LATENCY === true` and takes `pick(now)`; a plain strategy takes `pick()`. All lite-pick strategies
26
+ * satisfy this (their required-arg `pick` is bivariant-compatible with the zero-arg method here).
27
+ */
12
28
  export interface Balancer {
13
- /** `now` (PeakEwma clock) or `keyHash` (ConsistentHash/BoundedLoad) when supplied via opts. */
29
+ /** `now` (latency clock) or `keyHash` (keyed) when the strategy requires it, else no argument. */
14
30
  pick(arg?: number): number;
15
31
  readonly capacity: number;
16
32
  readonly live: number;
17
- /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is supplied. */
33
+ /** True iff endpoint `i` is currently pickable (used by distinct failover's untried scan). */
34
+ isEligible?(i: number): boolean;
35
+ /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is in use. */
18
36
  recordRtt?(i: number, sampleNs: number, now: number): void;
19
37
  /** Optional occupancy sink (BoundedLoadBalancer); fed +1 on dispatch, -1 on settle. */
20
38
  note?(i: number, delta: number): void;
@@ -22,21 +40,34 @@ export interface Balancer {
22
40
 
23
41
  /** Options for `Pool.run`. */
24
42
  export interface RunOptions {
25
- /** Passed to `fn`; when already aborted after a failure, stops failover (abort propagates). */
26
- signal?: AbortSignal;
43
+ /** Passed to `fn`; when already aborted, stops dispatch/failover (the abort propagates). */
44
+ signal?: AbortLike;
27
45
  /** Max distinct-endpoint attempts (default 1 = no failover). */
28
46
  tries?: number;
29
47
  /**
30
- * A caller-owned nanosecond clock. When present it drives `pick(now)` and the opt-in
31
- * `recordRtt` latency feedback for a latency-aware balancer (PeakEwma); otherwise inert.
48
+ * A caller-owned nanosecond clock. REQUIRED for a latency-aware balancer (PeakEWMA): `run`
49
+ * validates each reading is finite, drives `pick(now)`, and feeds `recordRtt` on settle (and a
50
+ * failure penalty on a throw). For a non-latency balancer it is optional and only feeds
51
+ * `recordRtt` if the balancer duck-types it; otherwise inert. Omitting it for a latency balancer
52
+ * is a runtime error.
32
53
  */
33
54
  clock?: () => number;
34
55
  /**
35
- * An integer routing key for a keyed balancer (ConsistentHash / BoundedLoad). When present,
36
- * `run` calls `pick(key)`; the opt-in `note` occupancy hook is driven on dispatch/settle for
37
- * a bounded-load balancer. Ignored by non-keyed strategies.
56
+ * An integer routing key. REQUIRED for a keyed balancer (ConsistentHash / BoundedLoad): `run`
57
+ * drives `pick(key)` (sticky / bounded-load routing). Omitting it for a keyed balancer is a
58
+ * runtime error. It is NOT passed to a latency balancer as `now`, and is ignored by non-keyed,
59
+ * non-latency strategies. The `note` occupancy hook is driven whenever the balancer duck-types
60
+ * `note`, independently of `key`.
38
61
  */
39
62
  key?: number;
63
+ /**
64
+ * The minimum rtt penalty (nanoseconds) a thrown attempt feeds a latency-aware balancer via
65
+ * `recordRtt(i, max(elapsed, failurePenaltyNs), done)`, so a fast-failing endpoint stops being the
66
+ * cheapest pick. Finite, > 0. Default 1e9 (1 s). RECOVERY: the penalized estimate decays back to
67
+ * competitive after roughly `tauNs * ln(failurePenaltyNs / healthyRttNs)`, so the node is
68
+ * periodically RE-PROBED at that cadence (recovery works) while its steady-state share stays low.
69
+ */
70
+ failurePenaltyNs?: number;
40
71
  }
41
72
 
42
73
  /**
@@ -56,31 +87,46 @@ export class Pool {
56
87
  /** The shared in-flight view Pool increments on dispatch and decrements on settle. */
57
88
  readonly inflight: Uint32Array;
58
89
  /**
59
- * Run `fn` against a chosen endpoint (in-flight incremented on dispatch, decremented on
60
- * settle), with up to `opts.tries` distinct-endpoint failover attempts on a throw. Rejects
61
- * with a `LITE_PICK_NONE`-coded error when no endpoint is eligible, or the last error when
62
- * every attempt fails.
90
+ * Run `fn` against a chosen endpoint (in-flight incremented on dispatch, decremented on settle),
91
+ * with up to `opts.tries` genuinely DISTINCT-endpoint failover attempts on a throw (failover
92
+ * targets are spread across keys for a keyed run and cursor-rotated for an unkeyed run). A keyed
93
+ * balancer requires `opts.key` and a latency balancer requires `opts.clock` (a `LITE_PICK_KEY_REQUIRED`
94
+ * / `LITE_PICK_CLOCK_REQUIRED`-coded error otherwise).
95
+ *
96
+ * Rejections: `LITE_PICK_NONE` when no endpoint is eligible; the last error when every attempt
97
+ * fails; an already-aborted `signal` dispatches NOTHING and rejects (the signal's `reason`, or a
98
+ * `LITE_PICK_ABORTED`-coded error). Feedback is loud and never re-runs `fn`: if SETTLE-time feedback
99
+ * after a SUCCESS fails (clock throws / non-finite, or `recordRtt` throws), `run` rejects with a
100
+ * `LITE_PICK_FEEDBACK`-coded error carrying `.cause` (the feedback error) and `.result` (fn's
101
+ * resolved value). If PENALTY feedback after a FAILURE fails, `run` throws fn's error object
102
+ * unchanged (identity preserved) with the feedback error attached as a non-enumerable
103
+ * `liteFeedbackError`. A backwards-stepping but finite clock reading records NO rtt sample and
104
+ * resolves normally (a failed attempt still records the full penalty). `opts` may be omitted or `null`.
63
105
  */
64
- run<T>(fn: (endpoint: number, signal?: AbortSignal) => Promise<T> | T, opts?: RunOptions): Promise<T>;
106
+ run<T>(fn: (endpoint: number, signal?: AbortLike) => Promise<T> | T, opts?: RunOptions | null): Promise<T>;
65
107
  }
66
108
 
67
109
  /** Context passed to the per-endpoint fetcher. */
68
110
  export interface PerEndpointContext {
69
111
  endpoint: number;
70
112
  key: any;
71
- signal?: AbortSignal;
113
+ signal?: AbortLike;
72
114
  }
73
115
 
74
116
  /** Context a query cache passes to the produced fetcher (lite-query's fetcher shape). */
75
117
  export interface FetcherContext {
76
118
  key: any;
77
- signal?: AbortSignal;
119
+ signal?: AbortLike;
78
120
  }
79
121
 
80
122
  /** Options for `liteQueryFetcher`. */
81
123
  export interface FetcherOptions {
82
124
  /** Spatial failover attempts across the pool (default 1). */
83
125
  tries?: number;
126
+ /** A nanosecond clock forwarded to a latency-aware balancer (PeakEWMA); otherwise inert. */
127
+ clock?: () => number;
128
+ /** Minimum rtt penalty a thrown attempt feeds a latency-aware balancer; forwarded to `pool.run`. */
129
+ failurePenaltyNs?: number;
84
130
  }
85
131
 
86
132
  /**