@zakkster/lite-pick 0.5.0 → 0.7.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 CHANGED
@@ -4,6 +4,68 @@ 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
+ ## [0.6.0] - 2026-09-23
8
+
9
+ M6: the benchmark suite (ROADMAP.md M6). An EVIDENCE session -- no API change. `Pick.js` and
10
+ `Pool.js` are byte-identical to 0.5.0 apart from the `VERSION` stamp; the kernel gates
11
+ (torture / PerfGate / witness / balance / fuzz) are unchanged and green. Everything added lives
12
+ under `benchmark/` and is NOT in the published tarball (`files[]` unchanged).
13
+
14
+ ### Added
15
+
16
+ - `benchmark/GcBlastRadius.mjs` -- the GC blast-radius headline (dimension 3). The same sustained
17
+ mixed workload (n=1024, 2,000,000 requests) through two lanes: the lite-pick lane (`P2cBalancer`
18
+ over caller-owned typed arrays) measures `major=0`, pick `B/op=0`, `maxPause` ~0.1-0.3ms; the
19
+ allocating foil lane (collect-candidates-sort idiom with a retained in-flight request context)
20
+ measures `major=13-14`, `maxPause` ~2.5-4ms. GC sampled via `@zakkster/lite-gc-profiler`
21
+ (`GcProfiler` + `checkNoGc`, the torture.mjs machinery); `B/op` via `measureAllocs`.
22
+ - `benchmark/Fairness.mjs` -- weighted convergence + burstiness (dimension 8). SmoothWRR weights
23
+ [10,3,2,1]: exact fairness (counts == k*weight) over 500 cycles, max-run 3 vs the bursty
24
+ weight-expansion foil's 10. SED weights [1,2,3,4,6,8,12,16]: worst share drift < 0.0001 from the
25
+ weight target; weighted-imbalance ~0.00 vs a weight-blind random foil's ~5.5.
26
+ - `benchmark/Disruption.mjs` -- consistent-hash disruption (dimension 7). Naive-modulo foil over
27
+ 100,000 seeded keys remaps 98.4% (remove node 64->63) / 98.5% (add node 64->65) of keys vs a good
28
+ consistent hash's ~1.6% / ~1.5% ideal. Explicit SKIP row for `ConsistentHash` (Maglev, M8) -- no
29
+ stub in `Pick.js`.
30
+ - `benchmark/Report.mjs` -- emits `benchmark/results.json` stamping Node version, V8, CPU model,
31
+ core count, arch, OS, and every PRNG seed alongside the measured numbers; renders the README
32
+ `<!-- bench:ID -->` fenced tables from it. `--verify` mode is the `bench:verify` drift check:
33
+ ALGORITHMIC numbers (balance peak-gap, disruption remap %) recomputed FRESH and compared EXACT;
34
+ TIMING numbers (GC pauses) compared to `results.json` within +/-15%. Exits non-zero on any drift.
35
+ (Fixes the previously-broken `bench:report` script, which pointed at an absent `Report.mjs`.)
36
+ - `benchmark/Soak.mjs` -- the endurance-soak scaffold (post-1.0 #8): ONE P2C lane, continuous
37
+ mixed-chaos load (flap storms + whole-pool-down troughs + load feedback), emits a JSONL
38
+ time-series to `benchmark/soak.jsonl`, and RUNS `test/invariants.mjs:checkBase` at every
39
+ checkpoint. `tracker.size()` returns to 0 after each cycle; invariants green at all checkpoints.
40
+ `SOAK_CYCLES=0` runs forever -- the harness the `caffeinate -i` overnight burn-in plugs into.
41
+ - `benchmark/Matrix.mjs` -- extended (not rewritten): each SUBJECT gained a `dims` flag, and the
42
+ file now exports the SHARED SEEDED workload matrix (`buildWorkload` over uniform / skewed-weight
43
+ / skewed-cost), `SEEDS`, `SUBJECTS`, `SIZES`, and `measureThroughput`, reused by every dimension
44
+ file. The standalone throughput runner is behind a main guard so importing it runs no sweep.
45
+ - `decisions/0008-benchmark-suite.md` -- the ADR: the parity framing, the real-competitors + foils
46
+ baseline, the drift-check teeth, and the ConsistentHash SKIP.
47
+ - README: the *Evidence* section -- balance anchor + GC blast-radius headlines (fenced), the
48
+ consistent-hash disruption trust gate, the honest COLD-path cost table, and the vs-AWS NLB/ALB
49
+ "complementary, not a competitor" positioning.
50
+ - devDependencies: `load-balancers@1.3.52`, `loadbalance@1.0.0`, `wrr@1.0.0` -- REAL npm
51
+ competitors, pinned to exact versions, loaded via `createRequire` (all three are CommonJS) and
52
+ timed into `results.json` beside our in-repo foils; a package that fails to load becomes a
53
+ labeled `unavailable` row rather than being silently dropped. Each incumbent is rendered side by
54
+ side with the SAME-complexity lite-pick strategy on the SAME n=1024 pool in the README
55
+ `<!-- bench:competitors -->` throughput fence. Parity is claimed only on EQUAL-work rows: P2C is
56
+ parity (~60k vs ~61k ops/ms); RoundRobin is ~22% slower (~260k vs ~335k) and the gap is OWNED --
57
+ `loadbalance` is a bare `i++ % n` with no liveness, while lite-pick's `RoundRobinBalancer`
58
+ forward-scans the eligibility bitmap to skip down nodes (never a dead pick), and that scan is the
59
+ constant-factor cost of a guarantee the incumbents do not offer. The weighted-random row is a
60
+ disclosed SKIP -- lite-pick's O(1) `WeightedRandom` (alias table) lands at M10, so `wrr` is not
61
+ raced against our O(cap) `SmoothWRRBalancer` (a different complexity class). No "faster"/"Nx".
62
+ - Scripts: `bench:gc`, `bench:fairness`, `bench:disruption`, `bench:verify`, `soak`.
63
+
64
+ ### Changed
65
+
66
+ - Version bumped to 0.6.0 in the three sync sites (`package.json`, `Pick.js` `VERSION`, `llms.txt`).
67
+ `peerDependencies` stays `{}`.
68
+
7
69
  ## [0.5.0] - 2026-09-23
8
70
 
9
71
  M5: the ergonomic request layer at the `@zakkster/lite-pick/pool` subpath -- dispatch/settle
package/Pick.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- TypeScript declarations.
3
3
  *
4
- * M4 (0.4.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
5
- * (LeastConn/SED/NQ). The remaining strategy classes (PeakEWMA, ConsistentHash, BoundedLoad,
6
- * WeightedRandom) are added one per session.
4
+ * M7 (0.7.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
5
+ * (LeastConn/SED/NQ) + PeakEWMA (latency-aware P2C). The remaining strategy classes
6
+ * (ConsistentHash, BoundedLoad, WeightedRandom) are added one per session.
7
7
  */
8
8
 
9
9
  /** The single source-of-truth version stamp. */
@@ -160,3 +160,30 @@ export class NqBalancer extends BalancerBase {
160
160
  /** The first idle eligible node, else the SED minimum, or `PICK_NONE`. O(cap). */
161
161
  pick(): number;
162
162
  }
163
+
164
+ /**
165
+ * PeakEwmaBalancer -- latency-aware power-of-two-choices (M7, Twitter Finagle's peak-EWMA).
166
+ * Draws two distinct eligible endpoints and returns the lower cost = `(inflight + 1) * ewmaAt(now)`;
167
+ * a slow endpoint (high decayed EWMA rtt) is avoided even with a short queue. `inflight` is the
168
+ * caller-owned Uint32Array read LIVE; the EWMA state (`_ewma` / `_stamp`, Float64) is BALANCER-OWNED
169
+ * and written ONLY by `recordRtt` (the warm feedback path). `pick(now)` decays on READ -- never
170
+ * writes -- so it is 0 B/op, as is `recordRtt`. `now` / `sampleNs` are caller-supplied nanoseconds.
171
+ * Cold start seeds the EWMA to 1.0 -> graceful least-connections, never NaN. O(d)=O(1). Fails
172
+ * closed (`PICK_NONE`) when the whole pool is down.
173
+ */
174
+ export class PeakEwmaBalancer extends BalancerBase {
175
+ /**
176
+ * @param capacity endpoint count (fixed).
177
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
178
+ * @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
179
+ * @param tauNs the EWMA time-constant / half-life in nanoseconds (finite, > 0).
180
+ * @param seed deterministic PRNG seed (default 0x9e3779b9); reproducible benches.
181
+ */
182
+ constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, tauNs: number, seed?: number);
183
+ /** The decayed EWMA rtt estimate for endpoint `i` at time `now` (ns). Pure read, zero-alloc. */
184
+ ewmaAt(i: number, now: number): number;
185
+ /** Warm feedback path: record an rtt sample (ns) for endpoint `i` at time `now` (ns). 0 B/op. */
186
+ recordRtt(i: number, sampleNs: number, now: number): void;
187
+ /** Pick by latency-aware power-of-two-choices at time `now` (ns), or `PICK_NONE`. O(d)=O(1). */
188
+ pick(now?: number): number;
189
+ }
package/Pick.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
3
3
  *
4
- * M4 (0.4.0): substrate seams + six strategies -- RoundRobin, SmoothWRR, P2C, and the
5
- * exact LeastConn family (LeastConn, SED, NQ). This file ships:
4
+ * M7 (0.7.0): substrate seams + seven strategies -- RoundRobin, SmoothWRR, P2C, the exact
5
+ * LeastConn family (LeastConn, SED, NQ), and PeakEWMA (latency-aware P2C). This file ships:
6
6
  *
7
7
  * - VERSION the single source-of-truth version stamp (3-place sync).
8
8
  * - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
@@ -26,6 +26,11 @@
26
26
  * charges the NEW request's marginal cost. O(cap)/pick, 0 B/op.
27
27
  * - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
28
28
  * exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
29
+ * - PeakEwmaBalancer latency-aware P2C (Twitter Finagle's peak-EWMA): draws two distinct
30
+ * eligible endpoints and takes the lower cost = (inflight+1) x decayed EWMA(rtt).
31
+ * Decay-on-READ (pick() never writes -> 0 B/op); the balancer OWNS the Float64
32
+ * _ewma/_stamp state and is its SOLE writer via the warm recordRtt() feedback
33
+ * path (also 0 B/op). Caller-supplied nanosecond clock. O(d)=O(1)/pick.
29
34
  *
30
35
  * The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
31
36
  * It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
@@ -33,7 +38,7 @@
33
38
  * counters live OUTSIDE the kernel. The steady-state pick path allocates 0 B/op.
34
39
  *
35
40
  * Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
36
- * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom
41
+ * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash, BoundedLoad, WeightedRandom
37
42
  * [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
38
43
  * BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
39
44
  *
@@ -46,7 +51,7 @@
46
51
  */
47
52
 
48
53
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
49
- export const VERSION = '0.5.0';
54
+ export const VERSION = '0.7.0';
50
55
 
51
56
  /**
52
57
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
@@ -574,3 +579,138 @@ export class NqBalancer extends BalancerBase {
574
579
  return best; // -1 when every eligible node has weight 0
575
580
  }
576
581
  }
582
+
583
+ /**
584
+ * PeakEwmaBalancer -- latency-aware power-of-two-choices (M7), Twitter Finagle's peak-EWMA.
585
+ *
586
+ * `pick(now)` draws TWO distinct eligible endpoints (the same rejection-sampling machinery as
587
+ * P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST,
588
+ * where cost(i) = (inflight[i] + 1) x ewmaAt(i, now). It is P2C over a LATENCY signal instead of
589
+ * raw in-flight count: a slow endpoint (high EWMA rtt) is avoided even when its queue is short,
590
+ * so the pool steers around a degraded-but-up node -- the strategy the multi-region FE case wants.
591
+ * O(d) = O(1) per pick.
592
+ *
593
+ * Ownership (ADR 0001, ADR 0009): `inflight` is the CALLER's Uint32Array, read LIVE (the P2C /
594
+ * LeastConn seam). The EWMA state -- `_ewma` (the decayed rtt estimate) and `_stamp` (the ns
595
+ * timestamp of each node's last update), both Float64Array -- is BALANCER-OWNED (the SmoothWRR
596
+ * precedent: a strategy may own algorithm state), and the balancer is its SOLE writer, via the
597
+ * warm `recordRtt()` feedback path. `pick()` NEVER writes: it decays ON READ, so the hot path
598
+ * stays a pure read -> 0 B/op.
599
+ *
600
+ * Decay-on-read: ewmaAt(i, now) = _ewma[i] x exp(-(now - _stamp[i]) / tau). No write, no clock
601
+ * call on the gated path -- `now` (and the rtt sample) are CALLER-supplied nanoseconds, consistent
602
+ * between `pick(now)` and `recordRtt(i, sampleNs, now)`, so the whole strategy is deterministic
603
+ * and testable and allocates nothing.
604
+ *
605
+ * Cold start: `_ewma` seeds to 1.0 and `_stamp` to 0 at construction, so before any sample
606
+ * cost(i) ~ (inflight[i] + 1) x 1 and PeakEWMA degrades GRACEFULLY to plain least-connections
607
+ * (P2C-over-inflight). It is never NaN.
608
+ *
609
+ * Anti-flap (ADR 0002, ADR 0009): the EWMA half-life IS the smoothing -- a single slow sample
610
+ * snaps the cost up instantly and it decays back over ~tau, so there is NO extra dwell/hysteresis.
611
+ *
612
+ * Deferred (ADR 0009 / llms.txt): a p99-aware variant scoring inflight x p99Rtt via a per-node
613
+ * @zakkster/lite-sketch `DDSketch` (optional peer, 0 B/op `add`). EWMA-mean is the shipped,
614
+ * zero-peer default; `peerDependencies` stays empty until a shipped path imports the sketch.
615
+ *
616
+ * Bound: O(d) = O(1) per pick (two expected-O(1) rejection draws + two exp() + a compare),
617
+ * 0 B/op on BOTH `pick()` and `recordRtt()` (torture + PerfGate). Fails closed (PICK_NONE) when
618
+ * the whole pool is down.
619
+ */
620
+ export class PeakEwmaBalancer extends BalancerBase {
621
+ /**
622
+ * @param {number} capacity endpoint count (fixed).
623
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
624
+ * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
625
+ * caller-owned and only READ here.
626
+ * @param {number} tauNs the EWMA time-constant / half-life in nanoseconds (> 0, finite):
627
+ * larger tau = slower decay = longer memory of a latency spike.
628
+ * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
629
+ */
630
+ constructor(capacity, eligible, inflight, tauNs, seed = 0x9e3779b9) {
631
+ super(capacity, eligible);
632
+ // Validate typeof-first, BEFORE allocating the owned Float64 state (fail closed early).
633
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
634
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
635
+ }
636
+ if (typeof tauNs !== 'number') {
637
+ throw new TypeError('[lite-pick] tauNs must be a number');
638
+ }
639
+ if (!Number.isFinite(tauNs) || tauNs <= 0) {
640
+ throw new RangeError('[lite-pick] tauNs must be a finite number > 0');
641
+ }
642
+ this._inflight = inflight;
643
+ this._tau = tauNs;
644
+ this._rng = new Prng(seed);
645
+ this._ewma = new Float64Array(capacity);
646
+ this._stamp = new Float64Array(capacity); // all-zero: last-update timestamp
647
+ for (let i = 0; i < capacity; i++) this._ewma[i] = 1.0; // cold start -> graceful LeastConn
648
+ }
649
+
650
+ /**
651
+ * A uniformly random ELIGIBLE index, or PICK_NONE if none. Reuses P2cBalancer's exact
652
+ * rejection-sampling draw (ADR 0005) verbatim -- same `_rng` / `_eligible` / `_live` fields,
653
+ * no re-implementation, no owned draw-set. Internal, zero-alloc.
654
+ * @returns {number}
655
+ */
656
+ _draw() {
657
+ return P2cBalancer.prototype._draw.call(this);
658
+ }
659
+
660
+ /**
661
+ * The decayed EWMA rtt estimate for endpoint i at time `now` (ns). Pure READ -- exponential
662
+ * decay applied on read, never written. Cold (unsampled) nodes read ~1.0. Zero-alloc.
663
+ * @param {number} i
664
+ * @param {number} now caller-supplied nanoseconds
665
+ * @returns {number}
666
+ */
667
+ ewmaAt(i, now) {
668
+ return this._ewma[i] * Math.exp(-(now - this._stamp[i]) / this._tau);
669
+ }
670
+
671
+ /**
672
+ * Record an rtt SAMPLE for endpoint i at time `now` (the warm feedback path -- NOT the hot
673
+ * pick path). The Finagle peak rule: decay the stored estimate to `now`, then SNAP UP to the
674
+ * sample if it is larger (a spike is felt instantly) else ease toward it (it decays back over
675
+ * ~tau). The balancer is the SOLE writer of `_ewma` / `_stamp`. Zero-alloc on the success path.
676
+ * @param {number} i endpoint index
677
+ * @param {number} sampleNs observed rtt in nanoseconds (finite, >= 0)
678
+ * @param {number} now caller-supplied nanoseconds (finite), consistent with pick(now)
679
+ */
680
+ recordRtt(i, sampleNs, now) {
681
+ if (typeof i !== 'number' || typeof sampleNs !== 'number' || typeof now !== 'number') {
682
+ throw new TypeError('[lite-pick] recordRtt(i, sampleNs, now) requires numbers');
683
+ }
684
+ if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
685
+ if (!Number.isFinite(sampleNs) || sampleNs < 0) {
686
+ throw new RangeError('[lite-pick] sampleNs must be a finite number >= 0');
687
+ }
688
+ if (!Number.isFinite(now)) throw new RangeError('[lite-pick] now must be a finite number');
689
+ const w = Math.exp(-(now - this._stamp[i]) / this._tau);
690
+ const e = this._ewma[i] * w;
691
+ this._ewma[i] = sampleNs > e ? sampleNs : e + (sampleNs - e) * (1 - w);
692
+ this._stamp[i] = now;
693
+ }
694
+
695
+ /**
696
+ * Pick by latency-aware power-of-two-choices: two distinct eligible draws, lower cost =
697
+ * (inflight+1) x ewmaAt(now) wins; a tie goes to the first draw. PICK_NONE (fail closed) iff
698
+ * the whole pool is down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call).
699
+ * @param {number} now caller-supplied nanoseconds (consistent with recordRtt)
700
+ * @returns {number}
701
+ */
702
+ pick(now) {
703
+ const a = this._draw();
704
+ if (a < 0) return PICK_NONE; // whole pool down: fail closed
705
+ if (this._live === 1) return a; // only one eligible: it is both choices
706
+ // A DISTINCT second draw, bounded (the ADR 0005 rationale): at live>=2 each redraw misses
707
+ // with probability <= 1/2, so 32 tries leaves a ~2^-32 collision chance, expected-O(1), 0 B/op.
708
+ let b = this._draw();
709
+ for (let t = 0; b === a && t < 32; t++) b = this._draw();
710
+ if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
711
+ const inf = this._inflight, ewma = this._ewma, stamp = this._stamp, tau = this._tau;
712
+ const costA = (inf[a] + 1) * (ewma[a] * Math.exp(-(now - stamp[a]) / tau));
713
+ const costB = (inf[b] + 1) * (ewma[b] * Math.exp(-(now - stamp[b]) / tau));
714
+ return costB < costA ? b : a; // lower cost wins; tie -> the first draw
715
+ }
716
+ }
package/Pool.d.ts CHANGED
@@ -10,9 +10,11 @@ export const VERSION: string;
10
10
 
11
11
  /** The minimal balancer shape Pool drives (any lite-pick strategy satisfies it). */
12
12
  export interface Balancer {
13
- pick(): number;
13
+ pick(now?: number): number;
14
14
  readonly capacity: number;
15
15
  readonly live: number;
16
+ /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is supplied. */
17
+ recordRtt?(i: number, sampleNs: number, now: number): void;
16
18
  }
17
19
 
18
20
  /** Options for `Pool.run`. */
@@ -21,6 +23,11 @@ export interface RunOptions {
21
23
  signal?: AbortSignal;
22
24
  /** Max distinct-endpoint attempts (default 1 = no failover). */
23
25
  tries?: number;
26
+ /**
27
+ * A caller-owned nanosecond clock. When present it drives `pick(now)` and the opt-in
28
+ * `recordRtt` latency feedback for a latency-aware balancer (PeakEwma); otherwise inert.
29
+ */
30
+ clock?: () => number;
24
31
  }
25
32
 
26
33
  /**
package/Pool.js CHANGED
@@ -70,11 +70,20 @@ export class Pool {
70
70
  * DIFFERENT endpoint -- up to `tries` attempts, then throw the last error. All counts this run
71
71
  * raised are released before returning or throwing (net-zero per run).
72
72
  *
73
+ * A LATENCY-AWARE balancer (PeakEwmaBalancer -- anything duck-typing `recordRtt`) is fed on
74
+ * settle WHEN a `clock` is supplied: Pool reads `clock()` (caller-owned nanoseconds) before the
75
+ * attempt, passes it to `pick(now)`, and on a SUCCESSFUL settle records `recordRtt(i, elapsed,
76
+ * now2)`. Without a `clock`, or against a balancer with no `recordRtt`, the hook is INERT --
77
+ * Pool stays generic, the in-flight counter stays net-zero, and abort/failover are unaffected.
78
+ * The kernel `pick()` remains 0 B/op; this wrapper is not held to that bar.
79
+ *
73
80
  * @template T
74
81
  * @param {(endpoint: number, signal?: AbortSignal) => (Promise<T>|T)} fn the per-endpoint work.
75
- * @param {{ signal?: AbortSignal, tries?: number }} [opts] `tries` (default 1 = no failover)
76
- * is the max number of distinct-endpoint attempts; `signal` is passed to `fn` and, when
77
- * already aborted after a failure, stops failover (the abort propagates, no re-pick).
82
+ * @param {{ signal?: AbortSignal, tries?: number, clock?: () => number }} [opts] `tries`
83
+ * (default 1 = no failover) is the max number of distinct-endpoint attempts; `signal` is
84
+ * passed to `fn` and, when already aborted after a failure, stops failover (the abort
85
+ * propagates, no re-pick); `clock` is a caller-owned nanosecond source that, when present,
86
+ * drives `pick(now)` and the opt-in `recordRtt` latency feedback for a latency-aware balancer.
78
87
  * @returns {Promise<T>}
79
88
  */
80
89
  async run(fn, opts) {
@@ -82,12 +91,17 @@ export class Pool {
82
91
  const rawTries = opts && opts.tries != null ? (opts.tries | 0) : 1;
83
92
  const tries = rawTries > 0 ? rawTries : 1;
84
93
  const signal = opts ? opts.signal : undefined;
94
+ const clock = opts && typeof opts.clock === 'function' ? opts.clock : undefined;
85
95
  const inflight = this._inflight, b = this._b;
96
+ // Opt-in latency feedback: only when BOTH a clock is supplied AND the balancer duck-types
97
+ // recordRtt. Otherwise inert -- Pool stays generic and byte-for-byte behaviour is unchanged.
98
+ const rtt = clock !== undefined && typeof b.recordRtt === 'function';
86
99
  const held = []; // endpoints incremented this run (kept elevated across failover)
87
100
  let lastErr;
88
101
  try {
89
102
  for (let attempt = 0; attempt < tries; attempt++) {
90
- const i = b.pick();
103
+ const now = clock !== undefined ? clock() : undefined;
104
+ const i = b.pick(now);
91
105
  if (i === PICK_NONE) {
92
106
  if (attempt === 0) {
93
107
  const e = new Error('[lite-pick] no eligible endpoint');
@@ -99,7 +113,12 @@ export class Pool {
99
113
  inflight[i] = (inflight[i] + 1) >>> 0;
100
114
  held.push(i);
101
115
  try {
102
- return await fn(i, signal);
116
+ const out = await fn(i, signal);
117
+ if (rtt) { // successful settle: feed the measured rtt back to the balancer
118
+ const done = clock();
119
+ b.recordRtt(i, done > now ? done - now : 0, done);
120
+ }
121
+ return out;
103
122
  } catch (err) {
104
123
  lastErr = err;
105
124
  if (signal && signal.aborted) throw err; // abort: stop failover, propagate
package/README.md CHANGED
@@ -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: M5 (v0.5.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**, and the **`@zakkster/lite-pick/pool`** request layer. 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), and **SED tracks weight within 1%** -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M5 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), and pool-adapter (0007) design forks.
24
+ > **Status: M6 (v0.6.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**, the **`@zakkster/lite-pick/pool`** request layer, and now 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). The kernel `Pick.js` / `Pool.js` are byte-identical to v0.5.0 apart from the version stamp. 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), and **SED tracks weight within 1%** -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M6 -> M10 path to 1.0.0, 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), and benchmark-suite (0008) design forks.
25
25
 
26
26
  ```bash
27
27
  npm install @zakkster/lite-pick
@@ -139,6 +139,93 @@ The proof (from `test/balance.mjs`):
139
139
 
140
140
  Each `pick()` is **0 B/op** and **O(cap)** (NQ is O(1) when an early node is idle). Because these are the state-heaviest strategies so far, M4 also introduces the **invariant fuzzer** (`npm run fuzz`): a seeded state-machine attack that, after *every* `pick` / `setEligible` / weight / load op, asserts the chosen endpoint is the *exact* optimum, `live` stays exact, and `PICK_NONE` holds *iff* nothing is pickable -- printing the seed on any failure for byte-for-byte replay.
141
141
 
142
+ ## Evidence -- the two headlines (v0.6.0 benchmark suite)
143
+
144
+ > **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`.
145
+
146
+ ### Throughput parity vs the incumbents (ops/ms, same pool)
147
+
148
+ The real pinned npm incumbents (`load-balancers`, `loadbalance`, `wrr`) run through the **same** harness on the **same** `n=1024` pool as the matching `lite-pick` strategy of the **same complexity class** -- ops/ms side by side, not a winner. On the same-work P2C row `lite-pick` holds parity (59591 vs 60956). On the RoundRobin row `lite-pick` is ~22% slower (260168 vs 334541), and that gap is owned, not hidden: `loadbalance@1.0.0` is a bare `i++ % n` with no liveness, while `lite-pick`'s `RoundRobinBalancer` forward-scans the eligibility bitmap to skip down nodes -- so it never returns a dead pick. That scan is the constant-factor cost of a guarantee none of these incumbents offer. `lite-pick` claims parity only where the work is equal; where it is slower, it is slower for the liveness contract, and the balance + tail wins above are the reason to pay it. The weighted-random row is a disclosed **SKIP**: `lite-pick`'s O(1) weighted-random (`WeightedRandom`, alias table) lands at **M10**, so it is not raced against here -- our shipped `SmoothWRRBalancer` is O(cap) *smooth* weighted round-robin (a different, stronger-smoothness guarantee), whose throughput is measured by `npm run witness` and `npm run bench`, not force-fit into this parity table.
149
+
150
+ <!-- bench:competitors -->
151
+
152
+ | family | lite-pick | lite-pick ops/ms | incumbent (npm) | incumbent ops/ms |
153
+ | --- | --- | --- | --- | --- |
154
+ | P2C (power-of-two-choices) | P2cBalancer | 59591 | load-balancers@1.3.52 | 60956 |
155
+ | RoundRobin | RoundRobinBalancer | 260168 | loadbalance@1.0.0 | 334541 |
156
+ | Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 | 158395 |
157
+
158
+ <!-- /bench:competitors -->
159
+
160
+ ### Balance quality -- the anchor (headline #1)
161
+
162
+ The canonical balls-into-bins experiment (throw `m = 32*n` balls into `n` bins): P2C's peak load stays within an additive `ln ln n / ln 2` of the mean, while a single random draw's gap grows with the pool. **Peak-gap** = heaviest bin minus the mean (32); the ceiling column is `4*lnln(n)/ln2 + 4`.
163
+
164
+ <!-- bench:balance -->
165
+
166
+ | pool n | P2C peak-gap | random foil peak-gap | ceiling |
167
+ | --- | --- | --- | --- |
168
+ | 64 | 3 | 13 | 12.2 |
169
+ | 1024 | 2 | 21 | 15.2 |
170
+ | 4096 | 2 | 25 | 16.2 |
171
+
172
+ <!-- /bench:balance -->
173
+
174
+ P2C's peak-gap stays a small `ln ln n` constant while the random foil's grows with `n` -- the exponential improvement one extra probe buys, measured not asserted ([ADR 0008](./decisions/0008-benchmark-suite.md)).
175
+
176
+ ### GC blast-radius -- the tail (headline #2)
177
+
178
+ The point of zero-GC is **not** the pick's own latency -- a major GC pause freezes **every in-flight request at once**, so an allocating balancer inflates the *service-level* tail. The **same** sustained mixed workload (`n=1024`, 2M requests) runs through two lanes: the zero-GC `lite-pick` lane vs the ordinary "collect candidates, sort, take the best" idiom that allocates a request context per pick (promoted to old gen while in flight -- exactly the garbage that forces mark-sweep).
179
+
180
+ <!-- bench:gc -->
181
+
182
+ | lane | major GC | pick B/op | max GC pause (ms) |
183
+ | --- | --- | --- | --- |
184
+ | lite-pick | 0 | 0 | 0.1 |
185
+ | allocating foil | 13 | allocates | 2.9 |
186
+
187
+ <!-- /bench:gc -->
188
+
189
+ The `lite-pick` lane holds **0 major GC / 0 B/op** on the pick path; the allocating foil's promoted request state forces mark-sweep pauses that stall the whole service. **That contrast is the headline**, not ops/sec.
190
+
191
+ ### Consistent-hash disruption -- a trust gate
192
+
193
+ On a scale event (add / remove a node), what fraction of keys keep their node? The **naive-modulo** trap (`key % n`) reshuffles almost everything -- blowing every downstream cache -- while a good consistent hash moves only ~`1/n`. The real `ConsistentHash` (Maglev) lands at **M8**; it is disclosed here as an explicit **SKIP**, not a stub:
194
+
195
+ <!-- bench:disruption -->
196
+
197
+ | scale event | naive-modulo remap | good consistent hash |
198
+ | --- | --- | --- |
199
+ | node removed | 98.4% | 1.6% |
200
+ | node added | 98.5% | 1.5% |
201
+ | ConsistentHash (Maglev) | SKIP -- ships in a later milestone | -- |
202
+
203
+ <!-- /bench:disruption -->
204
+
205
+ ### Honest cold-path cost (so the 0 B/op HOT-path claim stays bounded)
206
+
207
+ `pick()` is **0 B/op** and that is the whole point -- so the cold-path costs are **named**, not hidden:
208
+
209
+ | operation | when | allocates |
210
+ | --- | --- | --- |
211
+ | `new <Strategy>Balancer(...)` | construction, once | the balancer object + its owned accumulators (SmoothWRR's Float64 `current`). The eligibility / inflight / weight views are **caller-owned**, never copied |
212
+ | `setEligible(i, up)` | cold, on a health flip | **0** -- one byte write + an O(1) live-count adjust |
213
+ | `setWeight(i, w)` (SmoothWRR) | cold, on reweight | **0** -- one array write + an O(1) eligible-total adjust |
214
+ | `pick()` | **HOT**, per request | **0 B/op** -- proven by `torture` + `test:perf`, measured by `bench:gc` |
215
+ | `Pool.run(fn)` (`/pool`) | per request | a promise + one small `held` array -- an async wrapper, **not** the kernel path ([ADR 0007](./decisions/0007-pool-adapter.md)) |
216
+
217
+ ### Complementary to AWS NLB / ALB (not a competitor)
218
+
219
+ `lite-pick` does not replace a managed **ALB/NLB** -- it governs the *inner* hop those never see (your service fanning out to downstreams / shards / replicas / workers), bringing the **same algorithm family AWS bills for at the edge** to a zero-GC in-process selector you own:
220
+
221
+ | AWS edge feature (managed, billed) | `lite-pick` equivalent (in-process, zero-GC) |
222
+ | --- | --- |
223
+ | ALB `least_outstanding_requests` (LOR) | `LeastConnBalancer` / `P2cBalancer` |
224
+ | ALB `weighted_random` + anomaly mitigation | `WeightedRandom` + `BoundedLoad` (M9/M10) |
225
+ | NLB flow-hash (5-tuple) | `ConsistentHash` (Maglev, M8) |
226
+
227
+ The composition: inbound traffic still enters through your **ALB/NLB -> service** (the edge hop AWS owns and bills); `lite-pick` governs the fan-out **after** that, the hop no AWS load balancer touches. Complementary, not a replacement -- "the hop your ALB/NLB never sees."
228
+
142
229
  ## The substrate (under every strategy)
143
230
 
144
231
  ```js
@@ -164,7 +251,7 @@ rng.nextBelow(4); // -> a uint32 in [0, 4)
164
251
  rng.reset(); // replays the exact stream
165
252
 
166
253
  PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead pick)
167
- VERSION; // -> '0.4.0'
254
+ VERSION; // -> '0.6.0'
168
255
  ```
169
256
 
170
257
  `BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index. Every shipped strategy (`RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.5.0
3
+ Version: 0.6.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,7 +14,7 @@ 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.4.0 ships the substrate seams + six strategies: RoundRobin, SmoothWRR (the weighted
17
+ 0.6.0 ships the substrate seams + six strategies: RoundRobin, SmoothWRR (the weighted
18
18
  default), P2C (power-of-two-choices -- also the O(1) least-connections APPROXIMATION), and the
19
19
  EXACT LeastConn family (LeastConn, SED, NQ). It exports `VERSION`, the fail-closed sentinel
20
20
  `PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared read-only
@@ -24,6 +24,16 @@ session (see ROADMAP.md): PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom.
24
24
  n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
25
25
  (decisions/0006), not this exact-O(cap) scan.
26
26
 
27
+ M6 (0.6.0) adds the BENCHMARK SUITE -- an evidence session, no API change: Pick.js / Pool.js are
28
+ byte-identical to 0.5.0 apart from the VERSION stamp. It lives entirely under benchmark/ (NOT in
29
+ the published tarball): the balance anchor + GC blast-radius headlines, weighted-fairness and
30
+ consistent-hash-disruption trust gates, a seeded + Node/CPU/OS-stamped results.json, a
31
+ bench:verify drift check (algorithmic EXACT, timing +/-15%), the naive-modulo disruption foil
32
+ with an explicit ConsistentHash SKIP row (Maglev lands at M8), the endurance Soak.mjs scaffold
33
+ (reuses test/invariants.mjs:checkBase), and REAL pinned npm competitors (load-balancers,
34
+ loadbalance, wrr) as devDeps beside our in-repo foils. Framing: parity on speed, superiority on
35
+ the contract + balance + tail -- never an "N times faster" headline (decisions/0008).
36
+
27
37
  ## Design ownership (decisions/0001, 0002)
28
38
 
29
39
  - IN-PROCESS first (workers, DI services); remote HTTP is a thin optional adapter.
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.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, plus PeakEWMA and consistent hashing; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
4
+ "version": "0.7.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), plus consistent hashing; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
6
6
  "type": "module",
7
7
  "main": "./Pick.js",
8
8
  "module": "./Pick.js",
@@ -40,7 +40,12 @@
40
40
  "fuzz": "node test/fuzz.mjs",
41
41
  "test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
42
42
  "bench": "node benchmark/Matrix.mjs",
43
- "bench:report": "node benchmark/Matrix.mjs && node benchmark/Report.mjs",
43
+ "bench:gc": "node --expose-gc benchmark/GcBlastRadius.mjs",
44
+ "bench:fairness": "node benchmark/Fairness.mjs",
45
+ "bench:disruption": "node benchmark/Disruption.mjs",
46
+ "bench:report": "node benchmark/Matrix.mjs && node --expose-gc benchmark/Report.mjs",
47
+ "bench:verify": "node benchmark/Report.mjs --verify",
48
+ "soak": "node --expose-gc benchmark/Soak.mjs",
44
49
  "demo": "node demo/fanout.mjs",
45
50
  "verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run fuzz && npm run test:perf"
46
51
  },
@@ -103,7 +108,10 @@
103
108
  "@zakkster/lite-gc-profiler": "^1.16.0",
104
109
  "@zakkster/lite-leak": "^1.10.0",
105
110
  "@zakkster/lite-perf-gate": "^1.4.2",
106
- "typescript": "^7.0.2"
111
+ "load-balancers": "1.3.52",
112
+ "loadbalance": "1.0.0",
113
+ "typescript": "^7.0.2",
114
+ "wrr": "1.0.0"
107
115
  },
108
116
  "peerDependencies": {},
109
117
  "peerDependenciesMeta": {},