@zakkster/lite-pick 0.9.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 +194 -0
- package/GUIDE.md +105 -0
- package/Pick.d.ts +85 -28
- package/Pick.js +364 -62
- package/Pool.d.ts +63 -17
- package/Pool.js +275 -62
- package/README.md +73 -36
- package/RECIPES.md +132 -36
- package/llms.txt +166 -75
- package/package.json +22 -5
package/Pick.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
|
|
6
|
-
* (a Maglev lookup table),
|
|
7
|
-
* Maglev table + an occupancy cap that overflows a hot backend)
|
|
4
|
+
* M10 (1.0.0): substrate seams + TEN strategies (the roster-complete release) -- RoundRobin,
|
|
5
|
+
* SmoothWRR, P2C, the exact LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
|
|
6
|
+
* ConsistentHash (a Maglev lookup table), BoundedLoad (Consistent Hashing with Bounded Loads:
|
|
7
|
+
* the Maglev table + an occupancy cap that overflows a hot backend), and WeightedRandom (O(1)
|
|
8
|
+
* Vose alias-table sampling with rejection-sampling eligibility). This file ships:
|
|
8
9
|
*
|
|
9
10
|
* - VERSION the single source-of-truth version stamp (3-place sync).
|
|
10
11
|
* - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
|
|
@@ -29,7 +30,9 @@
|
|
|
29
30
|
* - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
|
|
30
31
|
* exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
|
|
31
32
|
* - PeakEwmaBalancer latency-aware P2C (Twitter Finagle's peak-EWMA): draws two distinct
|
|
32
|
-
* 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).
|
|
33
36
|
* Decay-on-READ (pick() never writes -> 0 B/op); the balancer OWNS the Float64
|
|
34
37
|
* _ewma/_stamp state and is its SOLE writer via the warm recordRtt() feedback
|
|
35
38
|
* path (also 0 B/op). Caller-supplied nanosecond clock. O(d)=O(1)/pick.
|
|
@@ -47,6 +50,16 @@
|
|
|
47
50
|
* stickiness + minimal disruption PLUS the hotspot protection plain CH lacks. It
|
|
48
51
|
* extends ConsistentHashBalancer (reusing its Maglev build + probe VERBATIM) and
|
|
49
52
|
* OWNS a running `_total` (sole writer: the warm note(i, delta) seam). O(1), 0 B/op.
|
|
53
|
+
* - WeightedRandomBalancer O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (one
|
|
54
|
+
* column draw + one probability compare -> a candidate), with rejection-sampling
|
|
55
|
+
* eligibility (retry an ineligible candidate up to a bounded count, then a 0-B/op
|
|
56
|
+
* rotated linear eligible scan). The alias table is built COLD over the eligible-
|
|
57
|
+
* INDEPENDENT weights (a weight-0 node is NEVER a column), so rejection over the
|
|
58
|
+
* bitmap renormalizes the weight distribution across the SURVIVING eligible mass.
|
|
59
|
+
* The balancer OWNS its derived table (_prob/_alias) and is its SOLE writer via cold
|
|
60
|
+
* setWeight/rebuild (the SmoothWRR precedent); an eligibility flap never rebuilds.
|
|
61
|
+
* The stateless O(1) sample (no accumulators to desync) for VERY LARGE pools where
|
|
62
|
+
* SmoothWRR's O(cap) scan hurts. O(1), 0 B/op. (Vose 1991 / Walker alias method.)
|
|
50
63
|
*
|
|
51
64
|
* The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
|
|
52
65
|
* It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
|
|
@@ -55,8 +68,11 @@
|
|
|
55
68
|
*
|
|
56
69
|
* Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
|
|
57
70
|
* P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash [M8], BoundedLoad [M9],
|
|
58
|
-
* WeightedRandom [
|
|
59
|
-
*
|
|
71
|
+
* WeightedRandom [M10] -- roster complete for now (NOT closed: AZ-aware routing, hedging, and
|
|
72
|
+
* subsetting are queued post-1.0). The EXACT-O(log n) fewest-in-flight variant is a deferred
|
|
73
|
+
* @zakkster/lite-logn BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan;
|
|
74
|
+
* a lite-logn Fenwick tree is the deferred DYNAMIC-weight complement to WeightedRandom's static
|
|
75
|
+
* alias table, and lite-o1 AliasTable a deferred duck-typed optional-peer upgrade for the build.
|
|
60
76
|
*
|
|
61
77
|
* M5 (0.5.0) adds the ergonomic request layer at the @zakkster/lite-pick/pool subpath (a
|
|
62
78
|
* SEPARATE file, Pool.js -- the async dispatch/settle counter wrapper + distinct-endpoint
|
|
@@ -67,7 +83,7 @@
|
|
|
67
83
|
*/
|
|
68
84
|
|
|
69
85
|
/** Version stamp. Synced across package.json and llms.txt (three-place rule). */
|
|
70
|
-
export const VERSION = '0.
|
|
86
|
+
export const VERSION = '1.0.1';
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
89
|
* Fail-closed sentinel returned by pick() when no endpoint is eligible.
|
|
@@ -117,14 +133,33 @@ export class Prng {
|
|
|
117
133
|
}
|
|
118
134
|
}
|
|
119
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
|
+
|
|
120
151
|
/**
|
|
121
152
|
* BalancerBase -- the shared eligibility seam for every strategy.
|
|
122
153
|
*
|
|
123
154
|
* It owns ONLY: the fixed capacity, a reference to the caller/sibling-owned eligibility
|
|
124
155
|
* Uint8Array (never copied), and an O(1) `_live` count maintained on the cold setEligible()
|
|
125
156
|
* path so a strategy can fail closed in O(1). It never allocates after construction and
|
|
126
|
-
* never calls into a health source
|
|
127
|
-
*
|
|
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.
|
|
128
163
|
*
|
|
129
164
|
* Subclasses (M1+) implement pick(). BalancerBase.pick() throws, so an unfinished strategy
|
|
130
165
|
* fails loudly rather than silently returning a dead index.
|
|
@@ -158,9 +193,10 @@ export class BalancerBase {
|
|
|
158
193
|
return this._live;
|
|
159
194
|
}
|
|
160
195
|
|
|
161
|
-
/** 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). */
|
|
162
198
|
isEligible(i) {
|
|
163
|
-
return i
|
|
199
|
+
return (i >>> 0) === i && i < this._cap && this._eligible[i] !== 0;
|
|
164
200
|
}
|
|
165
201
|
|
|
166
202
|
/**
|
|
@@ -170,9 +206,7 @@ export class BalancerBase {
|
|
|
170
206
|
* @param {boolean} up
|
|
171
207
|
*/
|
|
172
208
|
setEligible(i, up) {
|
|
173
|
-
|
|
174
|
-
throw new RangeError('[lite-pick] index out of range: ' + i);
|
|
175
|
-
}
|
|
209
|
+
_vIdx(i, this._cap);
|
|
176
210
|
const was = this._eligible[i];
|
|
177
211
|
const now = up ? 1 : 0;
|
|
178
212
|
if (was !== now) {
|
|
@@ -308,12 +342,13 @@ export class SmoothWRRBalancer extends BalancerBase {
|
|
|
308
342
|
* @param {number} w new weight (uint32)
|
|
309
343
|
*/
|
|
310
344
|
setWeight(i, w) {
|
|
311
|
-
|
|
345
|
+
_vIdx(i, this._cap);
|
|
312
346
|
const nw = w >>> 0;
|
|
313
347
|
if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
|
|
314
348
|
const old = this._weights[i];
|
|
315
349
|
if (nw === old) return;
|
|
316
350
|
this._weights[i] = nw;
|
|
351
|
+
this._current[i] = 0; // reset credit: a reweighted node holds no stale accumulator
|
|
317
352
|
if (this._eligible[i]) this._totalEligibleWeight += nw - old;
|
|
318
353
|
}
|
|
319
354
|
|
|
@@ -327,13 +362,15 @@ export class SmoothWRRBalancer extends BalancerBase {
|
|
|
327
362
|
const cap = this._cap, el = this._eligible, wt = this._weights, cur = this._current;
|
|
328
363
|
let best = -1, bestCur = -Infinity;
|
|
329
364
|
for (let i = 0; i < cap; i++) {
|
|
330
|
-
if (el[i]) {
|
|
365
|
+
if (el[i] && wt[i] > 0) { // eligible AND positive weight: a weight-0 node is never a candidate
|
|
331
366
|
const c = cur[i] + wt[i];
|
|
332
367
|
cur[i] = c;
|
|
333
368
|
if (c > bestCur) { bestCur = c; best = i; }
|
|
334
369
|
}
|
|
335
370
|
}
|
|
336
|
-
|
|
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;
|
|
337
374
|
return best;
|
|
338
375
|
}
|
|
339
376
|
}
|
|
@@ -437,7 +474,8 @@ export class P2cBalancer extends BalancerBase {
|
|
|
437
474
|
* directly between picks; that is the whole point of the shared-counter seam.
|
|
438
475
|
*
|
|
439
476
|
* Bound: O(cap) per pick (one scan). Steady-state pick(): integer compares + one index write,
|
|
440
|
-
* 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);
|
|
441
479
|
* the feedback loop breaks a startup all-zero tie by raising the picked node's count. Fails
|
|
442
480
|
* closed (PICK_NONE) when the whole pool is down.
|
|
443
481
|
*
|
|
@@ -462,7 +500,7 @@ export class LeastConnBalancer extends BalancerBase {
|
|
|
462
500
|
|
|
463
501
|
/**
|
|
464
502
|
* The eligible endpoint with the fewest in-flight requests, or PICK_NONE (fail closed).
|
|
465
|
-
* O(cap), zero-alloc.
|
|
503
|
+
* O(cap), zero-alloc. Tie order unspecified in 1.0.1 (deterministic; rotating tie-break 1.1.0).
|
|
466
504
|
* @returns {number}
|
|
467
505
|
*/
|
|
468
506
|
pick() {
|
|
@@ -519,7 +557,8 @@ export class SedBalancer extends BalancerBase {
|
|
|
519
557
|
|
|
520
558
|
/**
|
|
521
559
|
* The eligible endpoint minimizing (inflight + 1) / weight, or PICK_NONE (fail closed).
|
|
522
|
-
* 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.
|
|
523
562
|
* @returns {number}
|
|
524
563
|
*/
|
|
525
564
|
pick() {
|
|
@@ -548,8 +587,9 @@ export class SedBalancer extends BalancerBase {
|
|
|
548
587
|
* case: spin up idle capacity first, only weigh expected delay once everyone is busy.
|
|
549
588
|
*
|
|
550
589
|
* Ownership (ADR 0001, ADR 0006): identical to SED -- caller-owned inflight + weights, read
|
|
551
|
-
* live, no derived aggregate. The first idle eligible node
|
|
552
|
-
* > 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).
|
|
553
593
|
*
|
|
554
594
|
* Bound: O(cap) worst case (no idle node -> a full SED scan); O(1) when a low-index endpoint is
|
|
555
595
|
* idle. 0 B/op. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
|
|
@@ -600,11 +640,25 @@ export class NqBalancer extends BalancerBase {
|
|
|
600
640
|
* PeakEwmaBalancer -- latency-aware power-of-two-choices (M7), Twitter Finagle's peak-EWMA.
|
|
601
641
|
*
|
|
602
642
|
* `pick(now)` draws TWO distinct eligible endpoints (the same rejection-sampling machinery as
|
|
603
|
-
* P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
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).
|
|
608
662
|
*
|
|
609
663
|
* Ownership (ADR 0001, ADR 0009): `inflight` is the CALLER's Uint32Array, read LIVE (the P2C /
|
|
610
664
|
* LeastConn seam). The EWMA state -- `_ewma` (the decayed rtt estimate) and `_stamp` (the ns
|
|
@@ -613,25 +667,34 @@ export class NqBalancer extends BalancerBase {
|
|
|
613
667
|
* warm `recordRtt()` feedback path. `pick()` NEVER writes: it decays ON READ, so the hot path
|
|
614
668
|
* stays a pure read -> 0 B/op.
|
|
615
669
|
*
|
|
616
|
-
* 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
|
|
617
671
|
* call on the gated path -- `now` (and the rtt sample) are CALLER-supplied nanoseconds, consistent
|
|
618
672
|
* between `pick(now)` and `recordRtt(i, sampleNs, now)`, so the whole strategy is deterministic
|
|
619
|
-
* 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)`.
|
|
675
|
+
*
|
|
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.
|
|
620
682
|
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
* `
|
|
627
|
-
*
|
|
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.
|
|
628
690
|
*
|
|
629
691
|
* Contract: `now` (in `pick(now)` / `recordRtt`) and `sampleNs` MUST be FINITE numbers. `recordRtt`
|
|
630
692
|
* throws on a non-finite argument (the warm path); `pick(now)` never throws (the fail-closed
|
|
631
693
|
* contract), so a non-finite `now` yields P2C-random selection rather than an error.
|
|
632
694
|
*
|
|
633
|
-
* Anti-flap (ADR 0002, ADR 0009): the EWMA
|
|
634
|
-
* 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.
|
|
635
698
|
*
|
|
636
699
|
* Deferred (ADR 0009 / llms.txt): a p99-aware variant scoring inflight x p99Rtt via a per-node
|
|
637
700
|
* @zakkster/lite-sketch `DDSketch` (optional peer, 0 B/op `add`). EWMA-mean is the shipped,
|
|
@@ -642,13 +705,20 @@ export class NqBalancer extends BalancerBase {
|
|
|
642
705
|
* the whole pool is down.
|
|
643
706
|
*/
|
|
644
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
|
+
|
|
645
715
|
/**
|
|
646
716
|
* @param {number} capacity endpoint count (fixed).
|
|
647
717
|
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
648
718
|
* @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
|
|
649
719
|
* caller-owned and only READ here.
|
|
650
|
-
* @param {number} tauNs the EWMA
|
|
651
|
-
* 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.
|
|
652
722
|
* @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
|
|
653
723
|
*/
|
|
654
724
|
constructor(capacity, eligible, inflight, tauNs, seed = 0x9e3779b9) {
|
|
@@ -668,6 +738,12 @@ export class PeakEwmaBalancer extends BalancerBase {
|
|
|
668
738
|
this._rng = new Prng(seed);
|
|
669
739
|
this._ewma = new Float64Array(capacity);
|
|
670
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);
|
|
671
747
|
// Cold start: _ewma seeds to 1.0 and _stamp to a NEGATIVE "unsampled" sentinel (-1). The
|
|
672
748
|
// sentinel makes ewmaAt read the baseline UNDECAYED (graceful LeastConn) regardless of the
|
|
673
749
|
// caller's clock magnitude -- a plain _stamp=0 would decay as exp(-now/tau) -> 0 under a
|
|
@@ -697,7 +773,9 @@ export class PeakEwmaBalancer extends BalancerBase {
|
|
|
697
773
|
ewmaAt(i, now) {
|
|
698
774
|
const s = this._stamp[i];
|
|
699
775
|
if (s < 0) return this._ewma[i]; // unsampled: undecayed baseline, clock-magnitude-independent
|
|
700
|
-
|
|
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);
|
|
701
779
|
}
|
|
702
780
|
|
|
703
781
|
/**
|
|
@@ -712,10 +790,10 @@ export class PeakEwmaBalancer extends BalancerBase {
|
|
|
712
790
|
* @param {number} now caller-supplied nanoseconds (finite), consistent with pick(now)
|
|
713
791
|
*/
|
|
714
792
|
recordRtt(i, sampleNs, now) {
|
|
715
|
-
|
|
793
|
+
_vIdx(i, this._cap);
|
|
794
|
+
if (typeof sampleNs !== 'number' || typeof now !== 'number') {
|
|
716
795
|
throw new TypeError('[lite-pick] recordRtt(i, sampleNs, now) requires numbers');
|
|
717
796
|
}
|
|
718
|
-
if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
|
|
719
797
|
if (!Number.isFinite(sampleNs) || sampleNs < 0) {
|
|
720
798
|
throw new RangeError('[lite-pick] sampleNs must be a finite number >= 0');
|
|
721
799
|
}
|
|
@@ -723,17 +801,26 @@ export class PeakEwmaBalancer extends BalancerBase {
|
|
|
723
801
|
if (this._stamp[i] < 0) {
|
|
724
802
|
this._ewma[i] = sampleNs; // first sample: exact init, no decay (clock-independent)
|
|
725
803
|
} else {
|
|
726
|
-
|
|
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);
|
|
727
807
|
const e = this._ewma[i] * w;
|
|
728
808
|
this._ewma[i] = sampleNs > e ? sampleNs : e + (sampleNs - e) * (1 - w);
|
|
729
809
|
}
|
|
730
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;
|
|
731
815
|
}
|
|
732
816
|
|
|
733
817
|
/**
|
|
734
|
-
* Pick by latency-aware power-of-two-choices: two distinct eligible draws,
|
|
735
|
-
*
|
|
736
|
-
*
|
|
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).
|
|
737
824
|
* @param {number} now caller-supplied nanoseconds (consistent with recordRtt)
|
|
738
825
|
* @returns {number}
|
|
739
826
|
*/
|
|
@@ -747,11 +834,34 @@ export class PeakEwmaBalancer extends BalancerBase {
|
|
|
747
834
|
for (let t = 0; b === a && t < 32; t++) b = this._draw();
|
|
748
835
|
if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
|
|
749
836
|
const inf = this._inflight, ewma = this._ewma, stamp = this._stamp, tau = this._tau;
|
|
750
|
-
//
|
|
751
|
-
// (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
}
|
|
755
865
|
return costB < costA ? b : a; // lower cost wins; tie -> the first draw
|
|
756
866
|
}
|
|
757
867
|
}
|
|
@@ -833,6 +943,13 @@ function chIsPrime(n) {
|
|
|
833
943
|
* pick, over-conservative only under mass outage; ADR 0010).
|
|
834
944
|
*/
|
|
835
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
|
+
|
|
836
953
|
/**
|
|
837
954
|
* @param {number} capacity backend count (fixed; add/remove is a cold rebuild).
|
|
838
955
|
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
@@ -945,7 +1062,7 @@ export class ConsistentHashBalancer extends BalancerBase {
|
|
|
945
1062
|
* @param {number} w new weight (uint32)
|
|
946
1063
|
*/
|
|
947
1064
|
setWeight(i, w) {
|
|
948
|
-
|
|
1065
|
+
_vIdx(i, this._cap);
|
|
949
1066
|
const nw = w >>> 0;
|
|
950
1067
|
if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
|
|
951
1068
|
if (nw === this._weights[i]) return;
|
|
@@ -1005,7 +1122,13 @@ export class ConsistentHashBalancer extends BalancerBase {
|
|
|
1005
1122
|
* window (home + CH_PROBE_LIMIT slots) and return the FIRST backend that is ELIGIBLE AND UNDER cap
|
|
1006
1123
|
* (`inflight[b] < cap`). If none in the window is under cap, FALL BACK to the first eligible seen
|
|
1007
1124
|
* (sticky wins; the cap is a soft preference, never a dead pick). When `_total === 0` the cap test is
|
|
1008
|
-
* 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.
|
|
1009
1132
|
*
|
|
1010
1133
|
* Ownership (ADR 0001, ADR 0004, ADR 0010, ADR 0011): the Maglev lookup table + weights are
|
|
1011
1134
|
* BALANCER-OWNED and built COLD (reused from ConsistentHashBalancer VERBATIM -- `_build`, `setWeight`,
|
|
@@ -1014,9 +1137,11 @@ export class ConsistentHashBalancer extends BalancerBase {
|
|
|
1014
1137
|
* `_total` is BALANCER-OWNED and its SOLE writer is the warm `note(i, delta)` feedback path
|
|
1015
1138
|
* (dispatch +1 / settle -1), so the cap's mean stays O(1)-current without a scan.
|
|
1016
1139
|
*
|
|
1017
|
-
* CONTRACT (the SmoothWRR-weights asymmetry): when using BoundedLoad
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
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.
|
|
1020
1145
|
*
|
|
1021
1146
|
* Bound: O(1) per pick (modulo + table read + bounded cap-aware probe), 0 B/op on BOTH `pick()` and
|
|
1022
1147
|
* `note()` (torture + PerfGate). Fails closed (PICK_NONE) ONLY when no eligible backend is reachable
|
|
@@ -1073,7 +1198,7 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
|
|
|
1073
1198
|
* @param {number} delta integer occupancy change (+1 dispatch, -1 settle)
|
|
1074
1199
|
*/
|
|
1075
1200
|
note(i, delta) {
|
|
1076
|
-
|
|
1201
|
+
_vIdx(i, this._cap);
|
|
1077
1202
|
if (typeof delta !== 'number') throw new TypeError('[lite-pick] delta must be a number');
|
|
1078
1203
|
if (!Number.isInteger(delta)) throw new RangeError('[lite-pick] delta must be an integer: ' + delta);
|
|
1079
1204
|
const t = this._total + delta;
|
|
@@ -1083,8 +1208,8 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
|
|
|
1083
1208
|
/**
|
|
1084
1209
|
* Map an INTEGER key to a backend, honouring the occupancy cap, or PICK_NONE (fail closed). O(1),
|
|
1085
1210
|
* 0 B/op, never throws. slot = (keyHash >>> 0) % M; walk the M8 probe window (home + CH_PROBE_LIMIT
|
|
1086
|
-
* slots) and return the FIRST backend that is ELIGIBLE AND under cap = (1+eps) x _total /
|
|
1087
|
-
* 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
|
|
1088
1213
|
* a soft preference, never a dead pick). `_total === 0` skips the cap test -> pure ConsistentHash.
|
|
1089
1214
|
* PICK_NONE ONLY when no eligible backend is reachable within the window.
|
|
1090
1215
|
* @param {number} keyHash a caller-supplied integer key hash (coerced to uint32)
|
|
@@ -1096,7 +1221,10 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
|
|
|
1096
1221
|
const total = this._total;
|
|
1097
1222
|
// cap is only meaningful once occupancy is known; _total === 0 -> pure ConsistentHash.
|
|
1098
1223
|
const capActive = total > 0;
|
|
1099
|
-
|
|
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
|
|
1100
1228
|
let slot = (keyHash >>> 0) % M; // integer key; NaN >>> 0 = 0 (never throws)
|
|
1101
1229
|
let firstEligible = -1; // the pure-ConsistentHash sticky fallback answer
|
|
1102
1230
|
let i = lookup[slot];
|
|
@@ -1118,3 +1246,177 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
|
|
|
1118
1246
|
return firstEligible; // -1 (PICK_NONE) iff NO eligible backend was reachable in the window
|
|
1119
1247
|
}
|
|
1120
1248
|
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* WeightedRandomBalancer -- O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (M10),
|
|
1252
|
+
* the roster-completing strategy.
|
|
1253
|
+
*
|
|
1254
|
+
* `pick()` draws ONE column uniformly (`prng.nextBelow(cap)`), compares one fresh uniform against
|
|
1255
|
+
* `_prob[col]`, and takes `col` or `_alias[col]` -- a constant handful of integer/float ops that
|
|
1256
|
+
* return an endpoint proportional to its weight. This is the STATELESS O(1) weighted selector: no
|
|
1257
|
+
* per-endpoint accumulator to desync (SmoothWRR's `_current`), just a static table sampled with a
|
|
1258
|
+
* PRNG -- the fit for VERY LARGE pools where SmoothWRR's O(cap)-per-pick scan hurts. It trades
|
|
1259
|
+
* SmoothWRR's deterministic low-variance smoothness for sampling variance (any single pick is
|
|
1260
|
+
* random; the LAW OF LARGE NUMBERS delivers the weight ratios over a run -- balance.mjs anchors it).
|
|
1261
|
+
*
|
|
1262
|
+
* ELIGIBILITY is REJECTION SAMPLING over the shared bitmap (the ADR 0005 / P2C discipline, not a
|
|
1263
|
+
* table rebuild): if the drawn candidate is ineligible, redraw up to a bounded 64 times, then fall
|
|
1264
|
+
* back to a 0-B/op rotated linear scan from a random start for the degenerate heavy-outage case.
|
|
1265
|
+
* Because the alias table is built over the ELIGIBLE-INDEPENDENT weights and a candidate is ALWAYS
|
|
1266
|
+
* a positive-weight node (a weight-0 node is never a column -- see _build), rejecting the ineligible
|
|
1267
|
+
* draws RENORMALIZES the weight distribution over the SURVIVING eligible mass: each eligible node's
|
|
1268
|
+
* long-run share converges to weight[i] / sum(eligible weights) (ADR 0012 Fork 1). The rare fallback
|
|
1269
|
+
* scan returns the first eligible positive-weight node from a random offset (unbiased first-after-
|
|
1270
|
+
* offset), a correctness net, not a proportional path.
|
|
1271
|
+
*
|
|
1272
|
+
* Ownership (ADR 0001, ADR 0004, ADR 0012): `weights` is the CALLER's Uint32Array (length >= capacity)
|
|
1273
|
+
* -- the SmoothWRR / SED weight seam -- and the balancer is the SOLE writer of its DERIVED alias table
|
|
1274
|
+
* (`_prob` Float64Array + `_alias` Int32Array, both balancer-owned) via the cold `setWeight` / `rebuild`
|
|
1275
|
+
* (which read `weights` and rebuild the table); mutating `weights` directly desyncs the table (UB, the
|
|
1276
|
+
* SmoothWRR asymmetry). The alias build reuses COLD scratch worklists allocated once in the ctor -- the
|
|
1277
|
+
* build allocates nothing per call, and pick() allocates nothing per call.
|
|
1278
|
+
*
|
|
1279
|
+
* Fail-closed (ADR 0012 Fork 2): `pick()` returns PICK_NONE (-1) IFF `live === 0` OR no eligible node
|
|
1280
|
+
* has a positive weight (all-zero weights, or every eligible node's weight is 0). NEVER a dead pick,
|
|
1281
|
+
* a weight-0 return, or an out-of-range index. `pick()` never throws.
|
|
1282
|
+
*
|
|
1283
|
+
* Bound: O(1) per pick (one column draw + one compare, expected O(1) rejection draws when eligibility
|
|
1284
|
+
* is dense), 0 B/op (integer/float locals only) -- proven by test/torture.mjs + test/perf/PerfGate.test.mjs.
|
|
1285
|
+
*
|
|
1286
|
+
* DEFERRED optional-peer seams (import NOTHING; peerDependencies STAYS `{}` until a shipped path imports
|
|
1287
|
+
* one): a `@zakkster/lite-o1` `AliasTable` as a duck-typed drop-in for the inline Vose build, and a
|
|
1288
|
+
* `@zakkster/lite-logn` Fenwick/BinaryIndexedTree for the DYNAMIC-weight case (O(log n) update + sample)
|
|
1289
|
+
* -- the mutable-weight complement to this static table's O(1) sample / O(cap) rebuild (ADR 0012).
|
|
1290
|
+
*
|
|
1291
|
+
* NOT `@zakkster/lite-random`: that sibling is a GAME RNG (Mulberry32; loot tables, particles, gaussian)
|
|
1292
|
+
* whose `weighted(items, weights)` returns an ITEM one-shot, is NOT eligibility-aware, holds no reusable
|
|
1293
|
+
* table, and uses a different PRNG. lite-pick's WeightedRandom returns an endpoint INDEX, honours the
|
|
1294
|
+
* shared eligibility bitmap (fail-closed), owns a persistent alias table rebuilt only on reweight, and
|
|
1295
|
+
* uses the in-repo xorshift32. Different domain + contract -- not a peer, not a substrate (ADR 0012 / GUIDE.md).
|
|
1296
|
+
*/
|
|
1297
|
+
export class WeightedRandomBalancer extends BalancerBase {
|
|
1298
|
+
/**
|
|
1299
|
+
* @param {number} capacity endpoint count (fixed; add/remove is a cold rebuild).
|
|
1300
|
+
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
1301
|
+
* @param {Uint32Array} weights caller-owned per-endpoint weights (length >= capacity); the balancer
|
|
1302
|
+
* is the sole writer of the DERIVED alias table via setWeight (direct mutation desyncs it -- UB).
|
|
1303
|
+
* @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
|
|
1304
|
+
*/
|
|
1305
|
+
constructor(capacity, eligible, weights, seed = 0x9e3779b9) {
|
|
1306
|
+
super(capacity, eligible);
|
|
1307
|
+
// Validate typeof-first, BEFORE allocating the owned table / scratch (fail closed early -- the
|
|
1308
|
+
// PeakEWMA / ConsistentHash / BoundedLoad discipline).
|
|
1309
|
+
if (!(weights instanceof Uint32Array) || weights.length < capacity) {
|
|
1310
|
+
throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
|
|
1311
|
+
}
|
|
1312
|
+
this._weights = weights;
|
|
1313
|
+
this._rng = new Prng(seed);
|
|
1314
|
+
// Balancer-owned derived table: _prob (the split probability per column) + _alias (the column's
|
|
1315
|
+
// alternate). A candidate is ALWAYS a positive-weight node (see _build), so pick() never returns
|
|
1316
|
+
// a weight-0 index.
|
|
1317
|
+
this._prob = new Float64Array(capacity);
|
|
1318
|
+
this._alias = new Int32Array(capacity);
|
|
1319
|
+
// COLD scratch worklists for the Vose build (small/large index stacks + the scaled probabilities),
|
|
1320
|
+
// allocated ONCE here and reused by every _build -- the build never allocates per call.
|
|
1321
|
+
this._small = new Int32Array(capacity);
|
|
1322
|
+
this._large = new Int32Array(capacity);
|
|
1323
|
+
this._scaled = new Float64Array(capacity);
|
|
1324
|
+
this._psum = 0; // sum of ALL weights (the eligible-independent normalizer); 0 => degenerate.
|
|
1325
|
+
this._builds = 0; // COLD rebuild counter (observability / the anti-flap gate: a flap adds 0).
|
|
1326
|
+
this._build();
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* COLD: (re)build the Vose/Walker alias table from the current caller weights. The standard
|
|
1331
|
+
* small/large worklist over `scaled[i] = weights[i] * cap / total` (mean-1 normalization): pair a
|
|
1332
|
+
* deficient (< 1) column with a surplus (>= 1) one until one worklist empties, then drain the
|
|
1333
|
+
* residue (numerically ~1 full columns) to prob 1. A weight-0 node has scaled 0, so it is popped
|
|
1334
|
+
* once, assigned prob 0 + a POSITIVE-weight alias, and NEVER reaches the prob-1 drain -- it can
|
|
1335
|
+
* never be returned as its own column. All-zero weights (total 0) leaves _psum 0 and pick() fails
|
|
1336
|
+
* closed. Reuses the cold scratch worklists -- allocates nothing. ~15 lines (do NOT re-implement
|
|
1337
|
+
* lite-o1's AliasTable; this is the inline standard build, ADR 0012 Fork 0).
|
|
1338
|
+
*/
|
|
1339
|
+
_build() {
|
|
1340
|
+
this._builds++;
|
|
1341
|
+
const cap = this._cap, wt = this._weights, prob = this._prob, alias = this._alias;
|
|
1342
|
+
const scaled = this._scaled, small = this._small, large = this._large;
|
|
1343
|
+
let total = 0;
|
|
1344
|
+
for (let i = 0; i < cap; i++) total += wt[i];
|
|
1345
|
+
this._psum = total;
|
|
1346
|
+
if (total <= 0) {
|
|
1347
|
+
// Degenerate all-zero weights: no positive-weight column. pick() short-circuits on _psum===0
|
|
1348
|
+
// (PICK_NONE), so the table is never read -- fill it defensively (each column self-referential).
|
|
1349
|
+
for (let i = 0; i < cap; i++) { prob[i] = 0; alias[i] = i; }
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const scale = cap / total;
|
|
1353
|
+
let ns = 0, nl = 0; // small / large stack heights (indices into the scratch)
|
|
1354
|
+
for (let i = 0; i < cap; i++) {
|
|
1355
|
+
const v = wt[i] * scale;
|
|
1356
|
+
scaled[i] = v;
|
|
1357
|
+
if (v < 1) small[ns++] = i; else large[nl++] = i;
|
|
1358
|
+
}
|
|
1359
|
+
while (ns > 0 && nl > 0) {
|
|
1360
|
+
const s = small[--ns];
|
|
1361
|
+
const l = large[--nl];
|
|
1362
|
+
prob[s] = scaled[s];
|
|
1363
|
+
alias[s] = l; // l is surplus (scaled >= 1) => positive weight
|
|
1364
|
+
const rem = (scaled[l] + scaled[s]) - 1;
|
|
1365
|
+
scaled[l] = rem;
|
|
1366
|
+
if (rem < 1) small[ns++] = l; else large[nl++] = l;
|
|
1367
|
+
}
|
|
1368
|
+
while (nl > 0) { const l = large[--nl]; prob[l] = 1; alias[l] = l; } // full columns
|
|
1369
|
+
while (ns > 0) { const s = small[--ns]; prob[s] = 1; alias[s] = s; } // float residue ~1
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* COLD: reconfigure endpoint i's weight (uint32) and REBUILD the alias table from the new weights.
|
|
1374
|
+
* The balancer is the sole writer of the derived table (the SmoothWRR / ConsistentHash precedent).
|
|
1375
|
+
* @param {number} i
|
|
1376
|
+
* @param {number} w new weight (uint32)
|
|
1377
|
+
*/
|
|
1378
|
+
setWeight(i, w) {
|
|
1379
|
+
_vIdx(i, this._cap);
|
|
1380
|
+
const nw = w >>> 0;
|
|
1381
|
+
if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
|
|
1382
|
+
if (nw === this._weights[i]) return;
|
|
1383
|
+
this._weights[i] = nw;
|
|
1384
|
+
this._build();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** COLD: rebuild the alias table from the current caller weights (e.g. after a membership change). */
|
|
1388
|
+
rebuild() {
|
|
1389
|
+
this._build();
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Pick an endpoint index proportional to weight, or PICK_NONE (fail closed). O(1), 0 B/op, never
|
|
1394
|
+
* throws. One column draw + one probability compare yields a positive-weight candidate; an
|
|
1395
|
+
* ineligible candidate is rejection-redrawn up to 64 times (renormalizing the weight distribution
|
|
1396
|
+
* over the eligible mass), then a rotated linear scan from a random start returns the first eligible
|
|
1397
|
+
* positive-weight node. PICK_NONE IFF live === 0 OR no eligible node has a positive weight.
|
|
1398
|
+
* @returns {number}
|
|
1399
|
+
*/
|
|
1400
|
+
pick() {
|
|
1401
|
+
if (this._live === 0 || this._psum === 0) return PICK_NONE; // pool down / no positive weight
|
|
1402
|
+
const cap = this._cap, el = this._eligible, prob = this._prob, alias = this._alias, rng = this._rng;
|
|
1403
|
+
// Fast path: alias draw + rejection on eligibility. A candidate is always positive-weight, so
|
|
1404
|
+
// rejecting the ineligible ones renormalizes weight-proportionality over the surviving mass.
|
|
1405
|
+
for (let t = 0; t < 64; t++) {
|
|
1406
|
+
const col = rng.nextBelow(cap);
|
|
1407
|
+
const u = rng.next() / 4294967296; // fresh uniform in [0, 1)
|
|
1408
|
+
const cand = u < prob[col] ? col : alias[col];
|
|
1409
|
+
if (el[cand]) return cand;
|
|
1410
|
+
}
|
|
1411
|
+
// Degenerate (very sparse eligibility): scan from a random start for the first eligible,
|
|
1412
|
+
// positive-weight node. Zero-alloc; returns PICK_NONE only if none exists.
|
|
1413
|
+
const wt = this._weights;
|
|
1414
|
+
let i = rng.nextBelow(cap);
|
|
1415
|
+
for (let k = 0; k < cap; k++) {
|
|
1416
|
+
if (el[i] && wt[i] > 0) return i;
|
|
1417
|
+
i++;
|
|
1418
|
+
if (i >= cap) i = 0;
|
|
1419
|
+
}
|
|
1420
|
+
return PICK_NONE; // no eligible positive-weight node
|
|
1421
|
+
}
|
|
1422
|
+
}
|