@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/CHANGELOG.md +138 -0
- package/GUIDE.md +14 -2
- package/Pick.d.ts +51 -25
- package/Pick.js +171 -57
- package/Pool.d.ts +63 -17
- package/Pool.js +275 -62
- package/README.md +35 -30
- package/RECIPES.md +132 -36
- package/llms.txt +101 -65
- package/package.json +18 -4
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
|
|
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.
|
|
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
|
|
141
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
566
|
-
* > 0) short-circuits the
|
|
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
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
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).
|
|
636
|
-
* node
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
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
|
|
648
|
-
* snaps the cost up instantly and it decays back over ~tau, so there is NO
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
749
|
-
*
|
|
750
|
-
*
|
|
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
|
-
//
|
|
765
|
-
// (
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
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
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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` (
|
|
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
|
-
/**
|
|
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
|
|
26
|
-
signal?:
|
|
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.
|
|
31
|
-
*
|
|
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)
|
|
36
|
-
*
|
|
37
|
-
* a
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
/**
|