@zakkster/lite-pick 0.9.0 → 1.0.0
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 +56 -0
- package/GUIDE.md +93 -0
- package/Pick.d.ts +34 -3
- package/Pick.js +195 -7
- package/README.md +39 -7
- package/llms.txt +67 -12
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,62 @@ All notable changes to `@zakkster/lite-pick` are documented here. The format fol
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.0.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
The **roster-complete** release: ten selection strategies + the `/pool` request layer + the benchmark
|
|
10
|
+
suite + the docs/GUIDE capstone. Roster complete **for now, not closed** -- AZ-aware routing, the
|
|
11
|
+
lite-await hedging combinator, and subsetting are queued post-1.0 (see [ROADMAP.md](./ROADMAP.md)).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`WeightedRandomBalancer` (M10)** -- O(1) weighted-random selection via an inline **Vose/Walker alias
|
|
16
|
+
table** (one column draw + one probability compare -> a candidate) with **rejection-sampling
|
|
17
|
+
eligibility** (retry an ineligible candidate up to a bounded 64, then a 0-B/op rotated linear eligible
|
|
18
|
+
scan) -- the ADR 0005 / P2C discipline. The alias table is built **cold** over the eligible-independent
|
|
19
|
+
weights, so a **weight-0 node is never a column** (never returned) and rejecting the ineligible draws
|
|
20
|
+
**renormalizes** the weight distribution over the surviving eligible mass (each eligible node's share
|
|
21
|
+
converges to `weight[i] / sum(eligible weights)`). `weights` is the caller's `Uint32Array` (the
|
|
22
|
+
SmoothWRR/SED seam); the balancer is the **sole writer** of its derived table (`_prob` / `_alias`) via
|
|
23
|
+
cold `setWeight` / `rebuild` -- an eligibility flap **never** rebuilds (anti-flap). Validates
|
|
24
|
+
typeof-first before allocating the table. `pick()` is **O(1)**, **0 B/op**, never throws; `PICK_NONE`
|
|
25
|
+
only when `live === 0` or no eligible node has a positive weight. It is the **stateless** O(1) weighted
|
|
26
|
+
sampler (no accumulator to desync) for very large pools where SmoothWRR's O(cap) scan hurts -- trading
|
|
27
|
+
smoothness for sampling variance. `peerDependencies` stays `{}` (the Vose build is inlined; a lite-o1
|
|
28
|
+
`AliasTable` and a lite-logn Fenwick tree are deferred optional peers, imported by nothing).
|
|
29
|
+
([ADR 0012](./decisions/0012-weightedrandom.md)).
|
|
30
|
+
- **Fairness anchor** (`test/balance.mjs`) -- n=64, skewed weights [1..16], 8e6 seeded draws: every node's
|
|
31
|
+
observed share is within **2% relative** of `weight[i]/sum` (measured worst ~0.84%), and a cumsum-linear
|
|
32
|
+
O(n) foil matches the same fairness. The O(1) alias sample beats that O(n) foil by **~107x ops/ms** at
|
|
33
|
+
n=4096 (gate: >=3x). Under half the pool down (1e6 picks): **0 ineligible / 0 weight-0** returns,
|
|
34
|
+
survivor shares within **3% relative** of `weight[i]/sum(eligible)` (measured worst ~1.77%), and all-zero
|
|
35
|
+
weights -> `PICK_NONE`. Thresholds are the sampling-variance floor from a correct run (N sized so the
|
|
36
|
+
band holds with margin) -- the band is never widened to pass.
|
|
37
|
+
- **`GUIDE.md`** -- the "which of the ten strategies do I pick?" decision guide (a decision tree + table
|
|
38
|
+
keyed by keyed-vs-load-vs-latency-vs-weighted, O(1) vs O(cap), state owned, and when each wins),
|
|
39
|
+
distinct from `RECIPES.md` (how-to wiring). Added to the published `files[]` (the tarball is now 11 files).
|
|
40
|
+
- Gates extended for the new strategy: `test/WeightedRandom.test.js` boundary + behaviour suite;
|
|
41
|
+
`test/fuzz.mjs` keyed-agnostic subject + `checkWeightedRandom` (structural + no-weight-0-column + the
|
|
42
|
+
sum-reconstruction invariant, after every op) + a 1000-flap **0-rebuild** anti-flap assertion;
|
|
43
|
+
`test/torture.mjs` retention + a `pick()` 0 B/op phase (phase 14); `test/perf/PerfGate.test.mjs`
|
|
44
|
+
`weightedRandomPick` zero-alloc scenario + a boxed `mustFail` tooth; `test/witness.mjs` 'const'
|
|
45
|
+
flat-work subject; `benchmark/Matrix.mjs` throughput + fairness subject; `benchmark/Report.mjs`
|
|
46
|
+
Weighted-random parity row now times `WeightedRandomBalancer` vs `wrr` (both O(1) weighted-random --
|
|
47
|
+
previously a pending SKIP); `Pick.d.ts` + `test/types/pick.test-d.ts` typed surface.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- **`Pick.js` header roster/count** nine -> **ten**, and the `VERSION` stamp `0.9.0` -> `1.0.0` (the
|
|
52
|
+
three-place sync: `package.json`, the `VERSION` const, `llms.txt`). This session appends **one** class;
|
|
53
|
+
every other strategy in `Pick.js` is byte-identical.
|
|
54
|
+
- **`package.json`** version `1.0.0`, the description roster gains WeightedRandom + a GUIDE.md pointer,
|
|
55
|
+
keywords gain `alias-method` / `vose` (`weighted-random` was already present), and `files[]` gains
|
|
56
|
+
`GUIDE.md`. `peerDependencies` stays `{}`.
|
|
57
|
+
- Sibling boundary documented explicitly (so 1.0.0's WeightedRandom does not look duplicative with
|
|
58
|
+
`@zakkster/lite-random`): lite-random is a **game RNG** returning an item, not eligibility-aware, no
|
|
59
|
+
reusable table; lite-pick WeightedRandom returns an endpoint index, honours the shared eligibility
|
|
60
|
+
bitmap (fail-closed), and owns a persistent alias table -- different domain, not a peer (GUIDE.md,
|
|
61
|
+
llms.txt, ADR 0012).
|
|
62
|
+
|
|
7
63
|
## [0.9.0] - 2026-09-23
|
|
8
64
|
|
|
9
65
|
### Added
|
package/GUIDE.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Which strategy? -- the lite-pick decision guide
|
|
2
|
+
|
|
3
|
+
`@zakkster/lite-pick` ships **ten** selection strategies. They are not ranked; each wins a different
|
|
4
|
+
job. This guide is how you CHOOSE one. It is deliberately distinct from [RECIPES.md](./RECIPES.md),
|
|
5
|
+
which shows how to WIRE a chosen strategy (dispatch/settle counters, health, the `/pool` layer).
|
|
6
|
+
|
|
7
|
+
Every strategy shares the same contract: a hot `pick()` returning an endpoint **index** over a fixed
|
|
8
|
+
pool, **0 B/op** steady-state, **fail-closed** (`PICK_NONE` = -1 when nothing is pickable, never a
|
|
9
|
+
dead pick), reading a **shared read-only eligibility bitmap** it never writes.
|
|
10
|
+
|
|
11
|
+
## The one question that splits everything: what fixes the primary choice?
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Is routing decided by a KEY (same key -> same backend, for cache/session affinity)?
|
|
15
|
+
|
|
|
16
|
+
+-- YES -> you want a CONSISTENT HASH.
|
|
17
|
+
| |
|
|
18
|
+
| +-- Do a few hot keys overload one backend?
|
|
19
|
+
| | NO -> ConsistentHash (sticky, minimal disruption, O(1))
|
|
20
|
+
| | YES -> BoundedLoad (sticky + an occupancy cap that overflows a hotspot, O(1))
|
|
21
|
+
|
|
|
22
|
+
+-- NO -> the choice is by LOAD / LATENCY / WEIGHT, not a key.
|
|
23
|
+
|
|
|
24
|
+
+-- Do you have a LATENCY signal (rtt) and want to steer around a slow-but-up node?
|
|
25
|
+
| YES -> PeakEWMA (latency-aware power-of-two-choices, O(1))
|
|
26
|
+
|
|
|
27
|
+
+-- Do you have live IN-FLIGHT counts (a closed dispatch/settle loop)?
|
|
28
|
+
| |
|
|
29
|
+
| +-- Want the EXACT least-loaded, and O(cap) is fine (dozens-hundreds of nodes)?
|
|
30
|
+
| | unweighted -> LeastConn (exact fewest-in-flight, O(cap))
|
|
31
|
+
| | weighted -> SED (minimizes (inflight+1)/weight, O(cap))
|
|
32
|
+
| | worker pool (idle-first) -> NQ (never-queue: idle node first, else SED, O(cap))
|
|
33
|
+
| |
|
|
34
|
+
| +-- Want O(1) at very large pools and can accept a tiny balance gap?
|
|
35
|
+
| -> P2C (power-of-two-choices, the ln ln n ceiling, O(1))
|
|
36
|
+
|
|
|
37
|
+
+-- No load signal -- just spread by a fixed WEIGHT (or evenly)?
|
|
38
|
+
|
|
|
39
|
+
+-- Equal weight, simple rotation -> RoundRobin (O(1) amortized)
|
|
40
|
+
+-- Weighted, want SMOOTH low-variance -> SmoothWRR (deterministic, O(cap))
|
|
41
|
+
+-- Weighted, want STATELESS O(1) at scale -> WeightedRandom (alias table, O(1))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The table
|
|
45
|
+
|
|
46
|
+
| Strategy | Decides by | Bound / pick | State the balancer owns | Wins when |
|
|
47
|
+
| --- | --- | --- | --- | --- |
|
|
48
|
+
| **RoundRobin** | rotation | O(1) amortized | a cursor | equal weight, no load signal, simplest fair spread |
|
|
49
|
+
| **SmoothWRR** | fixed weight | O(cap) | smoothing accumulators (`_current`) | weighted **and** you want deterministic, smooth, low-variance interleaving |
|
|
50
|
+
| **WeightedRandom** | fixed weight | **O(1)** | a Vose alias table (`_prob`/`_alias`) | weighted at **very large** pools where SmoothWRR's O(cap) scan hurts; can accept sampling variance |
|
|
51
|
+
| **P2C** | in-flight load | **O(1)** | just a PRNG | O(1) load-balancing at scale; the `ln ln n` peak ceiling, a tiny gap vs exact |
|
|
52
|
+
| **LeastConn** | in-flight load | O(cap) | none (reads live) | the **exact** least-loaded in a feedback loop; dozens-hundreds of nodes |
|
|
53
|
+
| **SED** | (inflight+1)/weight | O(cap) | none (reads live) | **weighted** exact least-loaded (load settles proportional to weight) |
|
|
54
|
+
| **NQ** | idle-first, else SED | O(cap) | none (reads live) | **worker pools** -- never queue while a server is idle |
|
|
55
|
+
| **PeakEWMA** | (inflight+1) x decayed rtt | **O(1)** | EWMA rtt state (`_ewma`/`_stamp`) | you have latency and want to steer around a **slow-but-up** node |
|
|
56
|
+
| **ConsistentHash** | key hash (Maglev) | **O(1)** | a Maglev lookup table | **sticky** cache/session affinity; minimal disruption on scale events (~1/N keys move) |
|
|
57
|
+
| **BoundedLoad** | key hash + occupancy cap | **O(1)** | Maglev table + a running `_total` | sticky routing **and** a few hot keys would otherwise overload one backend |
|
|
58
|
+
|
|
59
|
+
## SmoothWRR vs WeightedRandom -- the weighted fork, made explicit
|
|
60
|
+
|
|
61
|
+
Both send load proportional to a configured integer weight. They differ in HOW and in cost:
|
|
62
|
+
|
|
63
|
+
- **SmoothWRR** is DETERMINISTIC and SMOOTH: weights `[5,1,1]` yield `A A B A C A A`, not bursts. It
|
|
64
|
+
converges EXACTLY (counts == k x weight over a cycle) with the lowest variance. Cost: **O(cap) per
|
|
65
|
+
pick**, and it owns per-endpoint accumulator state that is maintained in lockstep.
|
|
66
|
+
- **WeightedRandom** is a STATELESS **O(1)** sample from a Vose alias table: one column draw + one
|
|
67
|
+
compare. It converges to the weight ratios by the law of large numbers (any single pick is random --
|
|
68
|
+
it pays SAMPLING VARIANCE). No accumulator to desync.
|
|
69
|
+
|
|
70
|
+
Rule of thumb: **small-to-medium pools or when smoothness matters -> SmoothWRR; very large pools where
|
|
71
|
+
the O(cap) scan hurts -> WeightedRandom.** For **frequently-changing** weights, a `@zakkster/lite-logn`
|
|
72
|
+
Fenwick tree (O(log n) update + sample) is the deferred dynamic-weight complement to WeightedRandom's
|
|
73
|
+
static alias table (rebuilt cold on reweight); see the roadmap.
|
|
74
|
+
|
|
75
|
+
## Not sure you even want lite-pick? -- the sibling boundary
|
|
76
|
+
|
|
77
|
+
- **`@zakkster/lite-random` is NOT a load balancer.** It is a GAME RNG (Mulberry32) for loot tables,
|
|
78
|
+
particle systems, and gaussian sampling; its `weighted(items, weights) -> T` returns an **item**
|
|
79
|
+
one-shot, is not eligibility-aware, and holds no reusable table. For **game loot tables use
|
|
80
|
+
lite-random**; **lite-pick WeightedRandom is the eligibility-aware LB selector** (returns an endpoint
|
|
81
|
+
INDEX, honours the shared eligibility bitmap, owns a persistent alias table rebuilt only on reweight,
|
|
82
|
+
fail-closed). Different domain, different contract -- lite-random is not a peer or a substrate here.
|
|
83
|
+
- lite-pick is the **in-process** hop selector: it consumes health/circuit state and returns an index;
|
|
84
|
+
it is never a proxy. It is complementary to AWS NLB/ALB (which balance the network hop AWS sees) --
|
|
85
|
+
ALB's `weighted_random` + anomaly mitigation is lite-pick's WeightedRandom + BoundedLoad, and NLB's
|
|
86
|
+
flow-hash is ConsistentHash, at the hop AWS never sees.
|
|
87
|
+
|
|
88
|
+
## Then wire it
|
|
89
|
+
|
|
90
|
+
Once you have picked a strategy, [RECIPES.md](./RECIPES.md) shows the real wiring: the shared
|
|
91
|
+
eligibility bitmap from a health source, the caller-owned in-flight / weight arrays, the `/pool`
|
|
92
|
+
dispatch/settle + failover layer, and the latency (`recordRtt`) / occupancy (`note`) / keyed
|
|
93
|
+
(`opts.key`) feedback hooks.
|
package/Pick.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-pick -- TypeScript declarations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* M10 (1.0.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
|
|
5
5
|
* (LeastConn/SED/NQ) + PeakEWMA (latency-aware P2C) + ConsistentHash (Maglev table) +
|
|
6
|
-
* BoundedLoad (
|
|
7
|
-
*
|
|
6
|
+
* BoundedLoad (consistent hashing with bounded loads) + WeightedRandom (O(1) Vose alias-table
|
|
7
|
+
* sampling). Roster complete for now (NOT closed: AZ-aware routing, hedging, subsetting post-1.0).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** The single source-of-truth version stamp. */
|
|
@@ -271,3 +271,34 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
|
|
|
271
271
|
/** Map an integer `keyHash` to a backend, honouring the occupancy cap (overflow past a hot home), or `PICK_NONE`. O(1). */
|
|
272
272
|
pick(keyHash?: number): number;
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* WeightedRandomBalancer -- O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (M10). `pick()`
|
|
277
|
+
* draws one column + one probability compare to return an endpoint proportional to its weight, with
|
|
278
|
+
* REJECTION-SAMPLING eligibility (retry an ineligible candidate up to a bounded count, then a 0-B/op
|
|
279
|
+
* rotated linear eligible scan). The alias table is built COLD over the eligible-INDEPENDENT weights
|
|
280
|
+
* (a weight-0 node is NEVER a column), so rejection renormalizes the weight distribution across the
|
|
281
|
+
* surviving eligible mass. `weights` is the caller-owned Uint32Array; the balancer is the SOLE writer of
|
|
282
|
+
* its derived table via cold `setWeight` / `rebuild` (direct weight mutation desyncs the table -- UB).
|
|
283
|
+
* An eligibility flap NEVER rebuilds. The stateless O(1) sample (no accumulators to desync) for VERY
|
|
284
|
+
* LARGE pools where SmoothWRR's O(cap) scan hurts -- trading smoothness for sampling variance. O(1),
|
|
285
|
+
* 0 B/op, never throws. Fails closed (`PICK_NONE`) IFF `live === 0` OR no eligible node has a positive
|
|
286
|
+
* weight. NOT `@zakkster/lite-random` (a game RNG returning an item; use lite-random for loot tables --
|
|
287
|
+
* this is the eligibility-aware LB index selector; see GUIDE.md / ADR 0012).
|
|
288
|
+
*/
|
|
289
|
+
export class WeightedRandomBalancer extends BalancerBase {
|
|
290
|
+
/**
|
|
291
|
+
* @param capacity endpoint count (fixed).
|
|
292
|
+
* @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
293
|
+
* @param weights caller-owned per-endpoint weights (length >= capacity); mutate only via setWeight
|
|
294
|
+
* (the balancer is the sole writer of the derived alias table -- direct mutation is UB).
|
|
295
|
+
* @param seed deterministic PRNG seed (default 0x9e3779b9); reproducible benches.
|
|
296
|
+
*/
|
|
297
|
+
constructor(capacity: number, eligible: Uint8Array, weights: Uint32Array, seed?: number);
|
|
298
|
+
/** Cold path: reconfigure endpoint `i`'s weight (uint32) and rebuild the alias table. */
|
|
299
|
+
setWeight(i: number, w: number): void;
|
|
300
|
+
/** Cold path: rebuild the alias table from the current caller weights (e.g. after a membership change). */
|
|
301
|
+
rebuild(): void;
|
|
302
|
+
/** Pick an endpoint index proportional to weight (eligibility by rejection sampling), or `PICK_NONE`. O(1). */
|
|
303
|
+
pick(): number;
|
|
304
|
+
}
|
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.
|
|
@@ -47,6 +48,16 @@
|
|
|
47
48
|
* stickiness + minimal disruption PLUS the hotspot protection plain CH lacks. It
|
|
48
49
|
* extends ConsistentHashBalancer (reusing its Maglev build + probe VERBATIM) and
|
|
49
50
|
* OWNS a running `_total` (sole writer: the warm note(i, delta) seam). O(1), 0 B/op.
|
|
51
|
+
* - WeightedRandomBalancer O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (one
|
|
52
|
+
* column draw + one probability compare -> a candidate), with rejection-sampling
|
|
53
|
+
* eligibility (retry an ineligible candidate up to a bounded count, then a 0-B/op
|
|
54
|
+
* rotated linear eligible scan). The alias table is built COLD over the eligible-
|
|
55
|
+
* INDEPENDENT weights (a weight-0 node is NEVER a column), so rejection over the
|
|
56
|
+
* bitmap renormalizes the weight distribution across the SURVIVING eligible mass.
|
|
57
|
+
* The balancer OWNS its derived table (_prob/_alias) and is its SOLE writer via cold
|
|
58
|
+
* setWeight/rebuild (the SmoothWRR precedent); an eligibility flap never rebuilds.
|
|
59
|
+
* The stateless O(1) sample (no accumulators to desync) for VERY LARGE pools where
|
|
60
|
+
* SmoothWRR's O(cap) scan hurts. O(1), 0 B/op. (Vose 1991 / Walker alias method.)
|
|
50
61
|
*
|
|
51
62
|
* The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
|
|
52
63
|
* It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
|
|
@@ -55,8 +66,11 @@
|
|
|
55
66
|
*
|
|
56
67
|
* Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
|
|
57
68
|
* P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash [M8], BoundedLoad [M9],
|
|
58
|
-
* WeightedRandom [
|
|
59
|
-
*
|
|
69
|
+
* WeightedRandom [M10] -- roster complete for now (NOT closed: AZ-aware routing, hedging, and
|
|
70
|
+
* subsetting are queued post-1.0). The EXACT-O(log n) fewest-in-flight variant is a deferred
|
|
71
|
+
* @zakkster/lite-logn BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan;
|
|
72
|
+
* a lite-logn Fenwick tree is the deferred DYNAMIC-weight complement to WeightedRandom's static
|
|
73
|
+
* alias table, and lite-o1 AliasTable a deferred duck-typed optional-peer upgrade for the build.
|
|
60
74
|
*
|
|
61
75
|
* M5 (0.5.0) adds the ergonomic request layer at the @zakkster/lite-pick/pool subpath (a
|
|
62
76
|
* SEPARATE file, Pool.js -- the async dispatch/settle counter wrapper + distinct-endpoint
|
|
@@ -67,7 +81,7 @@
|
|
|
67
81
|
*/
|
|
68
82
|
|
|
69
83
|
/** Version stamp. Synced across package.json and llms.txt (three-place rule). */
|
|
70
|
-
export const VERSION = '0.
|
|
84
|
+
export const VERSION = '1.0.0';
|
|
71
85
|
|
|
72
86
|
/**
|
|
73
87
|
* Fail-closed sentinel returned by pick() when no endpoint is eligible.
|
|
@@ -1118,3 +1132,177 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
|
|
|
1118
1132
|
return firstEligible; // -1 (PICK_NONE) iff NO eligible backend was reachable in the window
|
|
1119
1133
|
}
|
|
1120
1134
|
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* WeightedRandomBalancer -- O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (M10),
|
|
1138
|
+
* the roster-completing strategy.
|
|
1139
|
+
*
|
|
1140
|
+
* `pick()` draws ONE column uniformly (`prng.nextBelow(cap)`), compares one fresh uniform against
|
|
1141
|
+
* `_prob[col]`, and takes `col` or `_alias[col]` -- a constant handful of integer/float ops that
|
|
1142
|
+
* return an endpoint proportional to its weight. This is the STATELESS O(1) weighted selector: no
|
|
1143
|
+
* per-endpoint accumulator to desync (SmoothWRR's `_current`), just a static table sampled with a
|
|
1144
|
+
* PRNG -- the fit for VERY LARGE pools where SmoothWRR's O(cap)-per-pick scan hurts. It trades
|
|
1145
|
+
* SmoothWRR's deterministic low-variance smoothness for sampling variance (any single pick is
|
|
1146
|
+
* random; the LAW OF LARGE NUMBERS delivers the weight ratios over a run -- balance.mjs anchors it).
|
|
1147
|
+
*
|
|
1148
|
+
* ELIGIBILITY is REJECTION SAMPLING over the shared bitmap (the ADR 0005 / P2C discipline, not a
|
|
1149
|
+
* table rebuild): if the drawn candidate is ineligible, redraw up to a bounded 64 times, then fall
|
|
1150
|
+
* back to a 0-B/op rotated linear scan from a random start for the degenerate heavy-outage case.
|
|
1151
|
+
* Because the alias table is built over the ELIGIBLE-INDEPENDENT weights and a candidate is ALWAYS
|
|
1152
|
+
* a positive-weight node (a weight-0 node is never a column -- see _build), rejecting the ineligible
|
|
1153
|
+
* draws RENORMALIZES the weight distribution over the SURVIVING eligible mass: each eligible node's
|
|
1154
|
+
* long-run share converges to weight[i] / sum(eligible weights) (ADR 0012 Fork 1). The rare fallback
|
|
1155
|
+
* scan returns the first eligible positive-weight node from a random offset (unbiased first-after-
|
|
1156
|
+
* offset), a correctness net, not a proportional path.
|
|
1157
|
+
*
|
|
1158
|
+
* Ownership (ADR 0001, ADR 0004, ADR 0012): `weights` is the CALLER's Uint32Array (length >= capacity)
|
|
1159
|
+
* -- the SmoothWRR / SED weight seam -- and the balancer is the SOLE writer of its DERIVED alias table
|
|
1160
|
+
* (`_prob` Float64Array + `_alias` Int32Array, both balancer-owned) via the cold `setWeight` / `rebuild`
|
|
1161
|
+
* (which read `weights` and rebuild the table); mutating `weights` directly desyncs the table (UB, the
|
|
1162
|
+
* SmoothWRR asymmetry). The alias build reuses COLD scratch worklists allocated once in the ctor -- the
|
|
1163
|
+
* build allocates nothing per call, and pick() allocates nothing per call.
|
|
1164
|
+
*
|
|
1165
|
+
* Fail-closed (ADR 0012 Fork 2): `pick()` returns PICK_NONE (-1) IFF `live === 0` OR no eligible node
|
|
1166
|
+
* has a positive weight (all-zero weights, or every eligible node's weight is 0). NEVER a dead pick,
|
|
1167
|
+
* a weight-0 return, or an out-of-range index. `pick()` never throws.
|
|
1168
|
+
*
|
|
1169
|
+
* Bound: O(1) per pick (one column draw + one compare, expected O(1) rejection draws when eligibility
|
|
1170
|
+
* is dense), 0 B/op (integer/float locals only) -- proven by test/torture.mjs + test/perf/PerfGate.test.mjs.
|
|
1171
|
+
*
|
|
1172
|
+
* DEFERRED optional-peer seams (import NOTHING; peerDependencies STAYS `{}` until a shipped path imports
|
|
1173
|
+
* one): a `@zakkster/lite-o1` `AliasTable` as a duck-typed drop-in for the inline Vose build, and a
|
|
1174
|
+
* `@zakkster/lite-logn` Fenwick/BinaryIndexedTree for the DYNAMIC-weight case (O(log n) update + sample)
|
|
1175
|
+
* -- the mutable-weight complement to this static table's O(1) sample / O(cap) rebuild (ADR 0012).
|
|
1176
|
+
*
|
|
1177
|
+
* NOT `@zakkster/lite-random`: that sibling is a GAME RNG (Mulberry32; loot tables, particles, gaussian)
|
|
1178
|
+
* whose `weighted(items, weights)` returns an ITEM one-shot, is NOT eligibility-aware, holds no reusable
|
|
1179
|
+
* table, and uses a different PRNG. lite-pick's WeightedRandom returns an endpoint INDEX, honours the
|
|
1180
|
+
* shared eligibility bitmap (fail-closed), owns a persistent alias table rebuilt only on reweight, and
|
|
1181
|
+
* uses the in-repo xorshift32. Different domain + contract -- not a peer, not a substrate (ADR 0012 / GUIDE.md).
|
|
1182
|
+
*/
|
|
1183
|
+
export class WeightedRandomBalancer extends BalancerBase {
|
|
1184
|
+
/**
|
|
1185
|
+
* @param {number} capacity endpoint count (fixed; add/remove is a cold rebuild).
|
|
1186
|
+
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
1187
|
+
* @param {Uint32Array} weights caller-owned per-endpoint weights (length >= capacity); the balancer
|
|
1188
|
+
* is the sole writer of the DERIVED alias table via setWeight (direct mutation desyncs it -- UB).
|
|
1189
|
+
* @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
|
|
1190
|
+
*/
|
|
1191
|
+
constructor(capacity, eligible, weights, seed = 0x9e3779b9) {
|
|
1192
|
+
super(capacity, eligible);
|
|
1193
|
+
// Validate typeof-first, BEFORE allocating the owned table / scratch (fail closed early -- the
|
|
1194
|
+
// PeakEWMA / ConsistentHash / BoundedLoad discipline).
|
|
1195
|
+
if (!(weights instanceof Uint32Array) || weights.length < capacity) {
|
|
1196
|
+
throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
|
|
1197
|
+
}
|
|
1198
|
+
this._weights = weights;
|
|
1199
|
+
this._rng = new Prng(seed);
|
|
1200
|
+
// Balancer-owned derived table: _prob (the split probability per column) + _alias (the column's
|
|
1201
|
+
// alternate). A candidate is ALWAYS a positive-weight node (see _build), so pick() never returns
|
|
1202
|
+
// a weight-0 index.
|
|
1203
|
+
this._prob = new Float64Array(capacity);
|
|
1204
|
+
this._alias = new Int32Array(capacity);
|
|
1205
|
+
// COLD scratch worklists for the Vose build (small/large index stacks + the scaled probabilities),
|
|
1206
|
+
// allocated ONCE here and reused by every _build -- the build never allocates per call.
|
|
1207
|
+
this._small = new Int32Array(capacity);
|
|
1208
|
+
this._large = new Int32Array(capacity);
|
|
1209
|
+
this._scaled = new Float64Array(capacity);
|
|
1210
|
+
this._psum = 0; // sum of ALL weights (the eligible-independent normalizer); 0 => degenerate.
|
|
1211
|
+
this._builds = 0; // COLD rebuild counter (observability / the anti-flap gate: a flap adds 0).
|
|
1212
|
+
this._build();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* COLD: (re)build the Vose/Walker alias table from the current caller weights. The standard
|
|
1217
|
+
* small/large worklist over `scaled[i] = weights[i] * cap / total` (mean-1 normalization): pair a
|
|
1218
|
+
* deficient (< 1) column with a surplus (>= 1) one until one worklist empties, then drain the
|
|
1219
|
+
* residue (numerically ~1 full columns) to prob 1. A weight-0 node has scaled 0, so it is popped
|
|
1220
|
+
* once, assigned prob 0 + a POSITIVE-weight alias, and NEVER reaches the prob-1 drain -- it can
|
|
1221
|
+
* never be returned as its own column. All-zero weights (total 0) leaves _psum 0 and pick() fails
|
|
1222
|
+
* closed. Reuses the cold scratch worklists -- allocates nothing. ~15 lines (do NOT re-implement
|
|
1223
|
+
* lite-o1's AliasTable; this is the inline standard build, ADR 0012 Fork 0).
|
|
1224
|
+
*/
|
|
1225
|
+
_build() {
|
|
1226
|
+
this._builds++;
|
|
1227
|
+
const cap = this._cap, wt = this._weights, prob = this._prob, alias = this._alias;
|
|
1228
|
+
const scaled = this._scaled, small = this._small, large = this._large;
|
|
1229
|
+
let total = 0;
|
|
1230
|
+
for (let i = 0; i < cap; i++) total += wt[i];
|
|
1231
|
+
this._psum = total;
|
|
1232
|
+
if (total <= 0) {
|
|
1233
|
+
// Degenerate all-zero weights: no positive-weight column. pick() short-circuits on _psum===0
|
|
1234
|
+
// (PICK_NONE), so the table is never read -- fill it defensively (each column self-referential).
|
|
1235
|
+
for (let i = 0; i < cap; i++) { prob[i] = 0; alias[i] = i; }
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const scale = cap / total;
|
|
1239
|
+
let ns = 0, nl = 0; // small / large stack heights (indices into the scratch)
|
|
1240
|
+
for (let i = 0; i < cap; i++) {
|
|
1241
|
+
const v = wt[i] * scale;
|
|
1242
|
+
scaled[i] = v;
|
|
1243
|
+
if (v < 1) small[ns++] = i; else large[nl++] = i;
|
|
1244
|
+
}
|
|
1245
|
+
while (ns > 0 && nl > 0) {
|
|
1246
|
+
const s = small[--ns];
|
|
1247
|
+
const l = large[--nl];
|
|
1248
|
+
prob[s] = scaled[s];
|
|
1249
|
+
alias[s] = l; // l is surplus (scaled >= 1) => positive weight
|
|
1250
|
+
const rem = (scaled[l] + scaled[s]) - 1;
|
|
1251
|
+
scaled[l] = rem;
|
|
1252
|
+
if (rem < 1) small[ns++] = l; else large[nl++] = l;
|
|
1253
|
+
}
|
|
1254
|
+
while (nl > 0) { const l = large[--nl]; prob[l] = 1; alias[l] = l; } // full columns
|
|
1255
|
+
while (ns > 0) { const s = small[--ns]; prob[s] = 1; alias[s] = s; } // float residue ~1
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* COLD: reconfigure endpoint i's weight (uint32) and REBUILD the alias table from the new weights.
|
|
1260
|
+
* The balancer is the sole writer of the derived table (the SmoothWRR / ConsistentHash precedent).
|
|
1261
|
+
* @param {number} i
|
|
1262
|
+
* @param {number} w new weight (uint32)
|
|
1263
|
+
*/
|
|
1264
|
+
setWeight(i, w) {
|
|
1265
|
+
if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
|
|
1266
|
+
const nw = w >>> 0;
|
|
1267
|
+
if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
|
|
1268
|
+
if (nw === this._weights[i]) return;
|
|
1269
|
+
this._weights[i] = nw;
|
|
1270
|
+
this._build();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** COLD: rebuild the alias table from the current caller weights (e.g. after a membership change). */
|
|
1274
|
+
rebuild() {
|
|
1275
|
+
this._build();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Pick an endpoint index proportional to weight, or PICK_NONE (fail closed). O(1), 0 B/op, never
|
|
1280
|
+
* throws. One column draw + one probability compare yields a positive-weight candidate; an
|
|
1281
|
+
* ineligible candidate is rejection-redrawn up to 64 times (renormalizing the weight distribution
|
|
1282
|
+
* over the eligible mass), then a rotated linear scan from a random start returns the first eligible
|
|
1283
|
+
* positive-weight node. PICK_NONE IFF live === 0 OR no eligible node has a positive weight.
|
|
1284
|
+
* @returns {number}
|
|
1285
|
+
*/
|
|
1286
|
+
pick() {
|
|
1287
|
+
if (this._live === 0 || this._psum === 0) return PICK_NONE; // pool down / no positive weight
|
|
1288
|
+
const cap = this._cap, el = this._eligible, prob = this._prob, alias = this._alias, rng = this._rng;
|
|
1289
|
+
// Fast path: alias draw + rejection on eligibility. A candidate is always positive-weight, so
|
|
1290
|
+
// rejecting the ineligible ones renormalizes weight-proportionality over the surviving mass.
|
|
1291
|
+
for (let t = 0; t < 64; t++) {
|
|
1292
|
+
const col = rng.nextBelow(cap);
|
|
1293
|
+
const u = rng.next() / 4294967296; // fresh uniform in [0, 1)
|
|
1294
|
+
const cand = u < prob[col] ? col : alias[col];
|
|
1295
|
+
if (el[cand]) return cand;
|
|
1296
|
+
}
|
|
1297
|
+
// Degenerate (very sparse eligibility): scan from a random start for the first eligible,
|
|
1298
|
+
// positive-weight node. Zero-alloc; returns PICK_NONE only if none exists.
|
|
1299
|
+
const wt = this._weights;
|
|
1300
|
+
let i = rng.nextBelow(cap);
|
|
1301
|
+
for (let k = 0; k < cap; k++) {
|
|
1302
|
+
if (el[i] && wt[i] > 0) return i;
|
|
1303
|
+
i++;
|
|
1304
|
+
if (i >= cap) i = 0;
|
|
1305
|
+
}
|
|
1306
|
+
return PICK_NONE; // no eligible positive-weight node
|
|
1307
|
+
}
|
|
1308
|
+
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-pick
|
|
2
2
|
|
|
3
|
-
> Zero-GC load-balancing **selection kernel**: one hot `pick()` that returns an endpoint **index** over a fixed pool and allocates **0 B/op** on the steady-state path. A pure selector, never a proxy -- it consumes health and circuit state, it never owns them. **
|
|
3
|
+
> Zero-GC load-balancing **selection kernel**: one hot `pick()` that returns an endpoint **index** over a fixed pool and allocates **0 B/op** on the steady-state path. A pure selector, never a proxy -- it consumes health and circuit state, it never owns them. **v1.0.0 ships the complete ten-strategy roster -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, the latency-aware `PeakEwmaBalancer`, the sticky/affinity `ConsistentHashBalancer` (a Maglev lookup table), the hotspot-protecting `BoundedLoadBalancer` (consistent hashing with bounded loads -- sticky routing + an occupancy cap that overflows a hot backend to its neighbours), and `WeightedRandomBalancer` (O(1) Vose alias-table sampling with rejection-sampling eligibility)** -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view), plus a **`@zakkster/lite-pick/pool`** subpath: the async dispatch/settle counter layer with distinct-endpoint failover and a duck-typed query-cache fetcher. Not sure which strategy? See **[GUIDE.md](./GUIDE.md)**.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@zakkster/lite-pick)
|
|
6
6
|
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
@@ -21,7 +21,7 @@ The npm landscape has old algorithm libraries (`load-balancers`, `loadbalance`,
|
|
|
21
21
|
- **Two pieces of evidence, both shipped.** A **0 B/op** witness on the pick path (no object, closure, string, or array created per pick), and a measured **balance-quality anchor** -- peak-to-average load within the strategy's theoretical ceiling (for P2C, the Azar-Broder-Karlin-Upfal `ln ln n / ln 2` bound) and strictly better than a random foil.
|
|
22
22
|
- **A pure selector, not a proxy.** It **consumes** health and circuit state; it never owns them. Health is a shared read-only bitmap written by [`@zakkster/lite-di-health`](https://www.npmjs.com/package/@zakkster/lite-di-health); circuit state comes from [`@zakkster/lite-statechart`](https://www.npmjs.com/package/@zakkster/lite-statechart); load counters are caller-owned typed arrays. `pick()` only reads.
|
|
23
23
|
|
|
24
|
-
> **Status:
|
|
24
|
+
> **Status: M10 (v1.0.0) -- the roster-complete release.** Ships the substrate seams **plus all ten strategies: `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, the latency-aware `PeakEwmaBalancer`, the sticky/affinity `ConsistentHashBalancer` (a Maglev table), the hotspot-protecting `BoundedLoadBalancer` (consistent hashing with bounded loads), and `WeightedRandomBalancer` (O(1) Vose alias-table sampling with rejection-sampling eligibility)**, the **`@zakkster/lite-pick/pool`** request layer (with opt-in latency-feedback, occupancy-feedback, and keyed-routing hooks), the **benchmark suite** -- the balance anchor + GC blast-radius headlines, a seeded/version-stamped `results.json`, a `bench:verify` drift check with teeth, and the vs-AWS positioning (see *Evidence* below) -- and the **[GUIDE.md](./GUIDE.md)** strategy-selection capstone. This session APPENDS one class: the other strategies in `Pick.js` are byte-identical, only the header roster/count and the `VERSION` stamp change. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), **SED tracks weight within 1%**, **PeakEWMA steers around a 10x-slow node** (it takes <= 25% of P2C's share for it and cuts service p99), **ConsistentHash remaps only ~1.6% of keys on a scale event** (vs ~98% for naive modulo), **BoundedLoad tames a hotspot plain consistent hashing can't** (under a skewed key stream ConsistentHash spikes a hot backend to ~13x the mean occupancy while BoundedLoad's `(1+eps)` cap holds it near the mean by overflowing to neighbours), and **WeightedRandom holds every node's share within 2% of its weight** while its O(1) alias sample beats an O(n) cumsum foil by >=3x ops/ms at n=4096 -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. Roster complete **for now, not closed** (AZ-aware routing, hedging, subsetting are post-1.0). See [ROADMAP.md](./ROADMAP.md), and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), pool-adapter (0007), benchmark-suite (0008), PeakEWMA (0009), ConsistentHash (0010), BoundedLoad/CHBL (0011), and WeightedRandom (0012) design forks.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
npm install @zakkster/lite-pick
|
|
@@ -238,6 +238,38 @@ The proof (from `test/balance.mjs`, a Zipfian-skewed key stream over 64 backends
|
|
|
238
238
|
|
|
239
239
|
BoundedLoad caps the hot backend near `(1 + eps) x mean` while plain consistent hashing lets it run away, and both reroute only **~1.6%** of keys on a scale event (`test/balance.mjs`). See [`ConsistentHashBalancer`](#consistenthash--sticky--cache-affinity-routing-v080) above for the integer-key contract and the FNV-1a helper.
|
|
240
240
|
|
|
241
|
+
## WeightedRandom -- O(1) alias-table weighted selection (v1.0.0)
|
|
242
|
+
|
|
243
|
+
The weighted strategy for **very large pools**. Where `SmoothWRRBalancer` is deterministic and smooth but scans O(cap) per pick and owns per-endpoint accumulator state, `WeightedRandomBalancer` is a **stateless O(1) sample**: one draw from a precomputed **Vose/Walker alias table** (one column draw + one probability compare) returns an endpoint proportional to its weight. It converges to the weight ratios by the law of large numbers -- trading SmoothWRR's low-variance smoothness for sampling variance.
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
import { WeightedRandomBalancer, PICK_NONE } from '@zakkster/lite-pick';
|
|
247
|
+
|
|
248
|
+
const eligible = Uint8Array.from([1, 1, 1, 1]);
|
|
249
|
+
const weights = Uint32Array.from([1, 2, 3, 10]); // YOU own this; endpoint 3 gets ~10/16 of traffic
|
|
250
|
+
const wr = new WeightedRandomBalancer(4, eligible, weights);
|
|
251
|
+
|
|
252
|
+
wr.pick(); // -> a weighted-random eligible index (mostly 3, sometimes 0/1/2)
|
|
253
|
+
|
|
254
|
+
// Reweight is COLD (rebuilds the alias table); the balancer is the sole writer of its table.
|
|
255
|
+
wr.setWeight(3, 1); // now roughly uniform
|
|
256
|
+
wr.setEligible(1, false); // an eligibility flap is FREE -- it never rebuilds the table (anti-flap)
|
|
257
|
+
wr.pick(); // never returns endpoint 1 (down) or a weight-0 node
|
|
258
|
+
|
|
259
|
+
// Whole pool down, or every eligible node weight 0 -> fail closed.
|
|
260
|
+
for (let i = 0; i < 4; i++) wr.setEligible(i, false);
|
|
261
|
+
wr.pick() === PICK_NONE; // -> true
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- **O(1), 0 B/op, never throws.** One alias-column draw + one compare. The table is built **cold** in the constructor (and on `setWeight` / `rebuild`) with the standard Vose small/large worklist -- reusing scratch buffers, so a rebuild allocates nothing and `pick()` allocates nothing.
|
|
265
|
+
- **Eligibility by rejection sampling** (the same discipline as P2C, [ADR 0005](./decisions/0005-p2c-draw.md)): the table is built over the **eligible-independent** weights, so a **weight-0 node is never a column** (never returned). If a drawn candidate is ineligible, `pick()` redraws up to a bounded 64, then falls back to a 0-B/op rotated linear eligible scan. Because every candidate is a positive-weight node, rejecting the ineligible ones **renormalizes** the weight distribution over the surviving eligible mass -- each eligible node's share converges to `weight[i] / sum(eligible weights)`.
|
|
266
|
+
- **Sole writer of its table.** `weights` is your `Uint32Array` (the SmoothWRR/SED seam); the balancer owns the derived alias table and is its only writer via cold `setWeight` / `rebuild`. Mutate `weights` directly and the table desyncs (UB). An eligibility flap **never** rebuilds. `PICK_NONE` only when `live === 0` or no eligible node has a positive weight.
|
|
267
|
+
- **Not `@zakkster/lite-random`.** That is a *game RNG* (loot tables, particles) whose `weighted(items, weights)` returns an **item** one-shot and is not eligibility-aware. WeightedRandom returns an endpoint **index**, honours the shared eligibility bitmap, and owns a persistent table -- different domain (see [GUIDE.md](./GUIDE.md) / [ADR 0012](./decisions/0012-weightedrandom.md)).
|
|
268
|
+
|
|
269
|
+
The proof (from `test/balance.mjs`, n=64, skewed weights 1..16, 8e6 seeded draws): every node's observed share is within **2%** of `weight[i]/sum` (measured worst ~0.84%), a cumsum-linear O(n) foil matches the *same* fairness, and the O(1) alias sample beats that foil by **~107x ops/ms** at n=4096. Under half the pool down: **0 ineligible / 0 weight-0** returns and survivor shares within **3%** of the renormalized target.
|
|
270
|
+
|
|
271
|
+
> **Which weighted strategy?** Small-to-medium pools or when smoothness matters -> **SmoothWRR**; very large pools where the O(cap) scan hurts -> **WeightedRandom**. Full decision tree in **[GUIDE.md](./GUIDE.md)**.
|
|
272
|
+
|
|
241
273
|
## Evidence -- the two headlines (v0.6.0 benchmark suite)
|
|
242
274
|
|
|
243
275
|
> **Framing: parity on speed, superiority on the contract + balance + tail.** A trivial `i++ % n` round-robin -- or `wrr` -- *matches* P2C on raw ops/sec, so `lite-pick` does **not** claim "N times faster." Throughput is claimed at **parity**; the wins are **zero-GC**, **balance quality**, **tail latency** (GC blast-radius), and **never a dead pick**. Every number below is **seeded** and regenerated by `npm run bench:report`; `npm run bench:verify` fails CI if a README number drifts from a fresh run (algorithmic exact, timing within +/-15%). Node / CPU / OS / every PRNG seed are stamped into `benchmark/results.json`.
|
|
@@ -250,9 +282,9 @@ The real pinned npm incumbents (`load-balancers`, `loadbalance`, `wrr`) run thro
|
|
|
250
282
|
|
|
251
283
|
| family | lite-pick | lite-pick ops/ms | incumbent (npm) | incumbent ops/ms |
|
|
252
284
|
| --- | --- | --- | --- | --- |
|
|
253
|
-
| P2C (power-of-two-choices) | P2cBalancer |
|
|
254
|
-
| RoundRobin | RoundRobinBalancer |
|
|
255
|
-
| Weighted-random |
|
|
285
|
+
| P2C (power-of-two-choices) | P2cBalancer | 57019 | load-balancers@1.3.52 | 59778 |
|
|
286
|
+
| RoundRobin | RoundRobinBalancer | 229911 | loadbalance@1.0.0 | 261301 |
|
|
287
|
+
| Weighted-random | WeightedRandomBalancer | 73673 | wrr@1.0.0 | 172137 |
|
|
256
288
|
|
|
257
289
|
<!-- /bench:competitors -->
|
|
258
290
|
|
|
@@ -280,8 +312,8 @@ The point of zero-GC is **not** the pick's own latency -- a major GC pause freez
|
|
|
280
312
|
|
|
281
313
|
| lane | major GC | pick B/op | max GC pause (ms) |
|
|
282
314
|
| --- | --- | --- | --- |
|
|
283
|
-
| lite-pick | 0 | 0 | 0.
|
|
284
|
-
| allocating foil | 13 | allocates |
|
|
315
|
+
| lite-pick | 0 | 0 | 0.2 |
|
|
316
|
+
| allocating foil | 13 | allocates | 1.8 |
|
|
285
317
|
|
|
286
318
|
<!-- /bench:gc -->
|
|
287
319
|
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-pick
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
5
5
|
Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
|
|
6
6
|
Node: >= 18.
|
|
@@ -14,19 +14,42 @@ reading pre-allocated views that siblings or the caller write, and returning an
|
|
|
14
14
|
The complementary evidence lite-pick ships is a measured balance-quality anchor (peak-to-
|
|
15
15
|
average load vs the strategy's theoretical ceiling) alongside the 0 B/op pick witness.
|
|
16
16
|
|
|
17
|
-
0.
|
|
18
|
-
default), P2C (power-of-two-choices -- also the O(1) least-connections
|
|
19
|
-
EXACT LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
|
|
20
|
-
(a Maglev lookup table -- sticky/affinity routing),
|
|
21
|
-
Loads -- sticky routing + a per-backend occupancy cap that overflows a hotspot to
|
|
22
|
-
|
|
17
|
+
1.0.0 -- the ROSTER-COMPLETE release -- ships the substrate seams + TEN strategies: RoundRobin,
|
|
18
|
+
SmoothWRR (the weighted default), P2C (power-of-two-choices -- also the O(1) least-connections
|
|
19
|
+
APPROXIMATION), the EXACT LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
|
|
20
|
+
ConsistentHash (a Maglev lookup table -- sticky/affinity routing), BoundedLoad (Consistent Hashing
|
|
21
|
+
with Bounded Loads -- sticky routing + a per-backend occupancy cap that overflows a hotspot to
|
|
22
|
+
neighbours), and WeightedRandom (O(1) Vose alias-table sampling with rejection-sampling eligibility).
|
|
23
|
+
It exports `VERSION`, the fail-closed
|
|
23
24
|
sentinel `PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared
|
|
24
25
|
read-only eligibility seam + O(1) live count), `RoundRobinBalancer`, `SmoothWRRBalancer`,
|
|
25
26
|
`P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, `PeakEwmaBalancer`,
|
|
26
|
-
`ConsistentHashBalancer`, `BoundedLoadBalancer`, and the ConsistentHash
|
|
27
|
-
(65537) / `CH_PROBE_LIMIT` (64).
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
`ConsistentHashBalancer`, `BoundedLoadBalancer`, `WeightedRandomBalancer`, and the ConsistentHash
|
|
28
|
+
constants `CH_DEFAULT_M` (65537) / `CH_PROBE_LIMIT` (64). Roster complete FOR NOW, not closed:
|
|
29
|
+
AZ-aware routing, the lite-await hedging combinator, and subsetting are queued post-1.0 (ROADMAP.md).
|
|
30
|
+
GUIDE.md is the "which of the ten strategies do I pick?" decision guide. The EXACT-O(log n)
|
|
31
|
+
fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
|
|
32
|
+
(decisions/0006), not this exact-O(cap) scan.
|
|
33
|
+
|
|
34
|
+
M10 (1.0.0) adds WeightedRandomBalancer (decisions/0012): O(1) weighted-random selection via an inline
|
|
35
|
+
Vose/Walker ALIAS TABLE (one column draw + one probability compare -> a candidate), with REJECTION-
|
|
36
|
+
SAMPLING eligibility (retry an ineligible candidate up to a bounded 64, then a 0-B/op rotated linear
|
|
37
|
+
eligible scan) -- the ADR 0005 / P2C discipline. The alias table is built COLD over the ELIGIBLE-
|
|
38
|
+
INDEPENDENT weights, so a weight-0 node is NEVER a column (never returned) and rejecting the ineligible
|
|
39
|
+
draws RENORMALIZES the weight distribution over the SURVIVING eligible mass (each eligible node's share
|
|
40
|
+
converges to weight[i] / sum(eligible weights)). `weights` is the caller's Uint32Array (the SmoothWRR/
|
|
41
|
+
SED seam); the balancer is the SOLE writer of its DERIVED table (`_prob`/`_alias`) via cold setWeight/
|
|
42
|
+
rebuild -- an eligibility flap NEVER rebuilds (anti-flap). Fail-closed (PICK_NONE) IFF live === 0 OR no
|
|
43
|
+
eligible node has a positive weight. O(1), 0 B/op, never throws. It is the STATELESS O(1) weighted
|
|
44
|
+
sampler (no accumulator to desync) for VERY LARGE pools where SmoothWRR's O(cap) scan hurts -- trading
|
|
45
|
+
smoothness for sampling variance. `peerDependencies` STAYS `{}`: the Vose build is inlined; a lite-o1
|
|
46
|
+
`AliasTable` (duck-typed drop-in for the build) and a lite-logn Fenwick tree (the DYNAMIC-weight
|
|
47
|
+
complement -- O(log n) update + sample) are DEFERRED optional peers, imported by NOTHING. NOT
|
|
48
|
+
@zakkster/lite-random: that is a GAME RNG (Mulberry32; loot tables, particles, gaussian) whose
|
|
49
|
+
`weighted(items, weights)` returns an ITEM one-shot, is not eligibility-aware, and holds no reusable
|
|
50
|
+
table -- lite-pick's WeightedRandom returns an endpoint INDEX, honours the shared eligibility bitmap
|
|
51
|
+
(fail-closed), and owns a persistent alias table rebuilt only on reweight. Different domain + contract
|
|
52
|
+
-- not a peer, not a substrate (GUIDE.md / ADR 0012).
|
|
30
53
|
|
|
31
54
|
M9 (0.9.0) adds BoundedLoadBalancer (decisions/0011): Consistent Hashing with Bounded Loads (CHBL --
|
|
32
55
|
Mirrokni et al. / Google Research; Vimeo eps=0.25). It is `ConsistentHashBalancer` (the Maglev table)
|
|
@@ -236,6 +259,35 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
|
|
|
236
259
|
the mean occupancy while CHBL caps it near (1+eps) x mean by overflow; both keep ~1/N minimal
|
|
237
260
|
disruption. `Pool.run` mirrors dispatch/settle into `note` when the balancer duck-types it, and
|
|
238
261
|
`Pool.run(fn, { key })` drives `pick(key)` for keyed CHBL routing (both inert otherwise).
|
|
262
|
+
- `WeightedRandomBalancer extends BalancerBase` -- class. O(1) weighted-random via a Vose/Walker ALIAS
|
|
263
|
+
TABLE (M10, decisions/0012). The roster-completing strategy.
|
|
264
|
+
- `new WeightedRandomBalancer(capacity, eligible, weights, seed?=0x9e3779b9)` -- `weights` is the
|
|
265
|
+
CALLER's Uint32Array (length >= capacity, the SmoothWRR/SED seam); the balancer is the SOLE writer
|
|
266
|
+
of its DERIVED alias table (`_prob` Float64Array + `_alias` Int32Array) via cold setWeight/rebuild
|
|
267
|
+
(direct weight mutation desyncs the table -- UB). Validates typeof-first (RangeError) BEFORE
|
|
268
|
+
allocating the table. Builds the table COLD in the ctor (the standard small/large Vose worklist over
|
|
269
|
+
the eligible-INDEPENDENT weights, ~15 lines -- NOT a re-implementation of lite-o1's AliasTable). The
|
|
270
|
+
build reuses cold scratch worklists -- it allocates nothing per rebuild.
|
|
271
|
+
- `pick()` -> number. Draws one column (`prng.nextBelow(cap)`) + one probability compare against a
|
|
272
|
+
fresh `prng.next()` uniform -> a candidate (`col` or `_alias[col]`), which is ALWAYS a positive-
|
|
273
|
+
weight node (a weight-0 node is NEVER a column). REJECTION SAMPLING on eligibility: if the candidate
|
|
274
|
+
is ineligible, redraw up to a bounded 64, then fall back to a 0-B/op rotated linear scan from a
|
|
275
|
+
random start for the first eligible positive-weight node. Because a candidate is always positive-
|
|
276
|
+
weight, rejecting the ineligible draws RENORMALIZES the weight distribution over the SURVIVING
|
|
277
|
+
eligible mass (each eligible node's share converges to weight[i] / sum(eligible weights)). O(1),
|
|
278
|
+
0 B/op, NEVER throws. `PICK_NONE` IFF live === 0 OR no eligible node has a positive weight. NEVER a
|
|
279
|
+
dead pick, a weight-0 return, or an out-of-range index.
|
|
280
|
+
- `setWeight(i, w)` -> void. COLD. Set endpoint i's weight (uint32) and REBUILD the alias table (the
|
|
281
|
+
SmoothWRR sole-writer precedent). `rebuild()` -> void. COLD. Re-derive the table from the current
|
|
282
|
+
caller weights. An eligibility flap NEVER rebuilds (anti-flap; only setWeight/rebuild/membership).
|
|
283
|
+
- vs SmoothWRR: SmoothWRR is deterministic/smooth/low-variance but O(cap)/pick and owns accumulator
|
|
284
|
+
state; WeightedRandom is a STATELESS O(1) sample (no accumulator to desync) with sampling variance --
|
|
285
|
+
the fit for VERY LARGE pools where SmoothWRR's O(cap) scan hurts. vs @zakkster/lite-random: that is a
|
|
286
|
+
GAME RNG (Mulberry32; loot tables) returning an ITEM one-shot, not eligibility-aware, no reusable
|
|
287
|
+
table; WeightedRandom returns an endpoint INDEX, honours the shared eligibility bitmap (fail-closed),
|
|
288
|
+
and owns a persistent alias table -- different domain, NOT a peer (see GUIDE.md / ADR 0012). DEFERRED
|
|
289
|
+
optional-peer seams (import NOTHING; `peerDependencies` STAYS `{}`): a lite-o1 `AliasTable` duck-typed
|
|
290
|
+
drop-in for the Vose build, and a lite-logn Fenwick/BinaryIndexedTree for DYNAMIC weights.
|
|
239
291
|
|
|
240
292
|
## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
|
|
241
293
|
|
|
@@ -293,7 +345,10 @@ duck-typed and imports NOTHING from lite-query.
|
|
|
293
345
|
|
|
294
346
|
@zakkster/lite-di-health (eligibility writer), lite-statechart (breaker), lite-o1
|
|
295
347
|
(RandomSet / AliasTable / RingLog substrate), lite-logn (exact least-conn heap / Fenwick
|
|
296
|
-
weights
|
|
348
|
+
dynamic weights -- the mutable-weight complement to WeightedRandom's static alias table),
|
|
349
|
+
lite-random (a SEPARATE domain -- a GAME RNG for loot tables / particles that returns an ITEM,
|
|
350
|
+
NOT an eligibility-aware LB index selector; use lite-pick WeightedRandom for load balancing),
|
|
351
|
+
lite-lru (sticky affinity), lite-fastbit32 (optional small-pool bitset peer),
|
|
297
352
|
lite-query (the fetcher adapter), lite-await (hedging), lite-worker-pool (in-process
|
|
298
353
|
consumer), lite-di-signal / lite-signal-decorators (observability). None is a HARD
|
|
299
354
|
dependency -- each is an OPTIONAL PEER dep (peerDependenciesMeta.optional, the LiteQuery
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-pick",
|
|
3
3
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, PeakEWMA (latency-aware peak-EWMA), ConsistentHash (Maglev sticky/affinity routing, minimal disruption),
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster of ten strategies: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, PeakEWMA (latency-aware peak-EWMA), ConsistentHash (Maglev sticky/affinity routing, minimal disruption), BoundedLoad (consistent hashing with bounded loads -- sticky routing with a per-backend occupancy cap that overflows a hotspot to neighbours), and WeightedRandom (O(1) Vose alias-table sampling with rejection-sampling eligibility); the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher. See GUIDE.md to choose a strategy.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./Pick.js",
|
|
8
8
|
"module": "./Pick.js",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"llms.txt",
|
|
30
30
|
"README.md",
|
|
31
31
|
"RECIPES.md",
|
|
32
|
+
"GUIDE.md",
|
|
32
33
|
"CHANGELOG.md",
|
|
33
34
|
"LICENSE"
|
|
34
35
|
],
|
|
@@ -79,6 +80,8 @@
|
|
|
79
80
|
"maglev",
|
|
80
81
|
"bounded-load",
|
|
81
82
|
"weighted-random",
|
|
83
|
+
"alias-method",
|
|
84
|
+
"vose",
|
|
82
85
|
"sticky-routing",
|
|
83
86
|
"client-side-load-balancing",
|
|
84
87
|
"in-process",
|