@zakkster/lite-pick 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/Pick.d.ts +30 -3
- package/Pick.js +169 -4
- package/Pool.d.ts +8 -1
- package/Pool.js +24 -5
- package/README.md +50 -8
- package/llms.txt +49 -11
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,76 @@ 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.7.1] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- PeakEWMA cold-start / unsampled-node scoring under real large-magnitude clocks. An unsampled node
|
|
12
|
+
now scores at its UNDECAYED baseline (`_stamp` initialized to a negative sentinel, read as the
|
|
13
|
+
1.0 baseline) = graceful least-connections, instead of `exp(-now/tau)` underflowing to 0 and
|
|
14
|
+
collapsing a cold pool to random selection. The FIRST `recordRtt` sample now initializes the EWMA
|
|
15
|
+
EXACTLY to the sample (clock-magnitude-independent); the Finagle peak rule applies from the second
|
|
16
|
+
sample on. `pick()` stays a pure 0 B/op read (a per-candidate sentinel compare, no allocation).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Completes the 0.7.0 packaging: synced the `llms.txt` version stamp, added the PeakEWMA
|
|
21
|
+
README / CHANGELOG sections + `decisions/0009-peakewma.md`, and regenerated the benchmark
|
|
22
|
+
`results.json`. Documented that `pick(now)` / `recordRtt` require a FINITE `now` -- `recordRtt`
|
|
23
|
+
throws on a non-finite argument; `pick(now)` never throws (fail-closed) and degrades a non-finite
|
|
24
|
+
`now` to P2C-random selection. No API or behavior change beyond the cold-start fix.
|
|
25
|
+
|
|
26
|
+
## [0.7.0] - 2026-09-23
|
|
27
|
+
|
|
28
|
+
M7: `PeakEwmaBalancer` -- latency-aware power-of-two-choices (Twitter Finagle's peak-EWMA). A
|
|
29
|
+
STRATEGY-APPEND session: one class is added to `Pick.js`; the other strategies are byte-identical,
|
|
30
|
+
only the header roster/count and the `VERSION` stamp change. `peerDependencies` stays `{}`.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `PeakEwmaBalancer extends BalancerBase` (`Pick.js`, `Pick.d.ts`) -- `new PeakEwmaBalancer(capacity,
|
|
35
|
+
eligible, inflight, tauNs, seed?)`. `pick(now)` draws two distinct eligible endpoints (reusing
|
|
36
|
+
`P2cBalancer`'s rejection-sampling `_draw`) and returns the lower `cost = (inflight + 1) x
|
|
37
|
+
ewmaAt(now)`, tie to the first draw; `O(d)=O(1)`. `ewmaAt(i, now)` decays ON READ
|
|
38
|
+
(`_ewma[i] * exp(-(now - _stamp[i]) / tau)`), so `pick()` never writes and is **0 B/op**.
|
|
39
|
+
`recordRtt(i, sampleNs, now)` is the warm feedback path (the Finagle peak rule: snap up to a
|
|
40
|
+
larger sample, decay down over `~tau`), also **0 B/op** on the success path. `now` / `sampleNs`
|
|
41
|
+
are caller-supplied nanoseconds. The EWMA state (`_ewma` / `_stamp`, `Float64Array`) is
|
|
42
|
+
balancer-owned; `inflight` is the caller's `Uint32Array` read live. Cold start seeds the EWMA to
|
|
43
|
+
`1.0` -> graceful least-connections, never `NaN`. Constructor and `recordRtt` validate
|
|
44
|
+
typeof-first, before allocation. Anti-flap = the half-life, no extra dwell.
|
|
45
|
+
- `Pool.run` opt-in latency feedback (`Pool.js`, `Pool.d.ts`): when `opts.clock` (a caller-owned
|
|
46
|
+
nanosecond source) is supplied AND the balancer duck-types `recordRtt`, Pool drives `pick(now)`
|
|
47
|
+
and records the settled rtt on success. Otherwise the hook is inert -- Pool stays generic, the
|
|
48
|
+
in-flight counter stays net-zero, and abort/failover are unchanged.
|
|
49
|
+
- `test/PeakEWMA.test.js` -- the boundary suite (cold-start valid + never-NaN, snap-up, decay to
|
|
50
|
+
sample/e at dt=tau within ~1%, slow-node avoidance, fail-closed, tie-break to the first draw =
|
|
51
|
+
identical to P2C on the same seed, constructor + recordRtt validation throws, flap churn).
|
|
52
|
+
- `test/balance.mjs` -- the LATENCY ANCHOR: a closed-loop single-server-per-node queue with one node
|
|
53
|
+
at 10x service time. Measured: PeakEWMA slow-node share ~0.007% vs P2C ~1.47% (<= 25% of P2C);
|
|
54
|
+
PeakEWMA service p99 ~1950ns vs P2C ~14500ns (>= 20% lower); random foil worse than both.
|
|
55
|
+
- `test/torture.mjs` -- PeakEWMA retention + `pick(now)` and `recordRtt()` 0 B/op phases.
|
|
56
|
+
- `test/perf/PerfGate.test.mjs` -- `PeakEwmaBalancer.pick(now)` + `recordRtt()` zero-alloc scenarios
|
|
57
|
+
and a `pick(now)`-boxed-into-a-fresh-array `mustFail` tooth.
|
|
58
|
+
- `test/witness.mjs` -- PeakEWMA subject, `const` (O(d)=O(1)) flat flag; work-rate flatness ~0.89
|
|
59
|
+
(the `Math.exp` runs ~2x/pick and stays flat -- the cached 2^-k decay-table fallback was NOT
|
|
60
|
+
needed).
|
|
61
|
+
- `test/fuzz.mjs` -- PeakEWMA subject: `_ewma` / `_stamp` stay finite and `PICK_NONE` holds iff the
|
|
62
|
+
pickable mass is 0 under a `recordRtt` / `pick(now)` / `setEligible` barrage.
|
|
63
|
+
- `test/types/pick.test-d.ts` -- PeakEwmaBalancer type-surface smoke.
|
|
64
|
+
- `benchmark/Matrix.mjs` PeakEWMA throughput subject; PeakEWMA lanes in `benchmark/GcBlastRadius.mjs`
|
|
65
|
+
(same maxMajor 0 / 0 B/op / bounded-pause contract) and `benchmark/Fairness.mjs` (latency
|
|
66
|
+
steering); `benchmark/results.json` regenerated (version 0.7.0), `bench:verify` green.
|
|
67
|
+
- `decisions/0009-peakewma.md` -- the ADR (latency-aware P2C, decay-on-read, the Finagle peak rule,
|
|
68
|
+
caller-supplied clock, balancer-owned state, deferred DDSketch-p99, anti-flap = half-life).
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- `Pick.js` header roster/count (six -> seven strategies), `VERSION` 0.6.0 -> 0.7.0; `package.json`
|
|
73
|
+
version + description; `llms.txt` version + PeakEWMA surface + the FE-profile note + the deferred
|
|
74
|
+
DDSketch-p99 note; `README.md` PeakEWMA section + FE profile + the AWS anomaly-mitigation mapping
|
|
75
|
+
row.
|
|
76
|
+
|
|
7
77
|
## [0.6.0] - 2026-09-23
|
|
8
78
|
|
|
9
79
|
M6: the benchmark suite (ROADMAP.md M6). An EVIDENCE session -- no API change. `Pick.js` and
|
package/Pick.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-pick -- TypeScript declarations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* (LeastConn/SED/NQ). The remaining strategy classes
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
54
|
+
export const VERSION = '0.7.1';
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
57
|
* Fail-closed sentinel returned by pick() when no endpoint is eligible.
|
|
@@ -574,3 +579,163 @@ 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 a NEGATIVE "unsampled" sentinel (-1). While a
|
|
606
|
+
* node is unsampled `ewmaAt` returns the baseline 1.0 UNDECAYED, so before any sample cost(i) =
|
|
607
|
+
* (inflight[i] + 1) x 1 and PeakEWMA degrades GRACEFULLY to plain least-connections (P2C-over-
|
|
608
|
+
* inflight) REGARDLESS of the caller's clock magnitude -- a plain `_stamp = 0` would decay as
|
|
609
|
+
* exp(-now/tau) -> 0 under a real large clock and collapse a cold pool to random. The first
|
|
610
|
+
* `recordRtt` initializes the EWMA EXACTLY to the sample (clock-independent); the peak rule applies
|
|
611
|
+
* only from the second sample on. It is never NaN.
|
|
612
|
+
*
|
|
613
|
+
* Contract: `now` (in `pick(now)` / `recordRtt`) and `sampleNs` MUST be FINITE numbers. `recordRtt`
|
|
614
|
+
* throws on a non-finite argument (the warm path); `pick(now)` never throws (the fail-closed
|
|
615
|
+
* contract), so a non-finite `now` yields P2C-random selection rather than an error.
|
|
616
|
+
*
|
|
617
|
+
* Anti-flap (ADR 0002, ADR 0009): the EWMA half-life IS the smoothing -- a single slow sample
|
|
618
|
+
* snaps the cost up instantly and it decays back over ~tau, so there is NO extra dwell/hysteresis.
|
|
619
|
+
*
|
|
620
|
+
* Deferred (ADR 0009 / llms.txt): a p99-aware variant scoring inflight x p99Rtt via a per-node
|
|
621
|
+
* @zakkster/lite-sketch `DDSketch` (optional peer, 0 B/op `add`). EWMA-mean is the shipped,
|
|
622
|
+
* zero-peer default; `peerDependencies` stays empty until a shipped path imports the sketch.
|
|
623
|
+
*
|
|
624
|
+
* Bound: O(d) = O(1) per pick (two expected-O(1) rejection draws + two exp() + a compare),
|
|
625
|
+
* 0 B/op on BOTH `pick()` and `recordRtt()` (torture + PerfGate). Fails closed (PICK_NONE) when
|
|
626
|
+
* the whole pool is down.
|
|
627
|
+
*/
|
|
628
|
+
export class PeakEwmaBalancer extends BalancerBase {
|
|
629
|
+
/**
|
|
630
|
+
* @param {number} capacity endpoint count (fixed).
|
|
631
|
+
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
632
|
+
* @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
|
|
633
|
+
* caller-owned and only READ here.
|
|
634
|
+
* @param {number} tauNs the EWMA time-constant / half-life in nanoseconds (> 0, finite):
|
|
635
|
+
* larger tau = slower decay = longer memory of a latency spike.
|
|
636
|
+
* @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
|
|
637
|
+
*/
|
|
638
|
+
constructor(capacity, eligible, inflight, tauNs, seed = 0x9e3779b9) {
|
|
639
|
+
super(capacity, eligible);
|
|
640
|
+
// Validate typeof-first, BEFORE allocating the owned Float64 state (fail closed early).
|
|
641
|
+
if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
|
|
642
|
+
throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
|
|
643
|
+
}
|
|
644
|
+
if (typeof tauNs !== 'number') {
|
|
645
|
+
throw new TypeError('[lite-pick] tauNs must be a number');
|
|
646
|
+
}
|
|
647
|
+
if (!Number.isFinite(tauNs) || tauNs <= 0) {
|
|
648
|
+
throw new RangeError('[lite-pick] tauNs must be a finite number > 0');
|
|
649
|
+
}
|
|
650
|
+
this._inflight = inflight;
|
|
651
|
+
this._tau = tauNs;
|
|
652
|
+
this._rng = new Prng(seed);
|
|
653
|
+
this._ewma = new Float64Array(capacity);
|
|
654
|
+
this._stamp = new Float64Array(capacity);
|
|
655
|
+
// Cold start: _ewma seeds to 1.0 and _stamp to a NEGATIVE "unsampled" sentinel (-1). The
|
|
656
|
+
// sentinel makes ewmaAt read the baseline UNDECAYED (graceful LeastConn) regardless of the
|
|
657
|
+
// caller's clock magnitude -- a plain _stamp=0 would decay as exp(-now/tau) -> 0 under a
|
|
658
|
+
// real large-magnitude clock and collapse a cold pool to random selection.
|
|
659
|
+
for (let i = 0; i < capacity; i++) { this._ewma[i] = 1.0; this._stamp[i] = -1; }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* A uniformly random ELIGIBLE index, or PICK_NONE if none. Reuses P2cBalancer's exact
|
|
664
|
+
* rejection-sampling draw (ADR 0005) verbatim -- same `_rng` / `_eligible` / `_live` fields,
|
|
665
|
+
* no re-implementation, no owned draw-set. Internal, zero-alloc.
|
|
666
|
+
* @returns {number}
|
|
667
|
+
*/
|
|
668
|
+
_draw() {
|
|
669
|
+
return P2cBalancer.prototype._draw.call(this);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The decayed EWMA rtt estimate for endpoint i at time `now` (ns). Pure READ -- exponential
|
|
674
|
+
* decay applied on read, never written. An UNSAMPLED node (`_stamp < 0`) reads its baseline
|
|
675
|
+
* 1.0 UNDECAYED (graceful LeastConn), so a cold pool never underflows to 0 under a real
|
|
676
|
+
* large-magnitude clock. `now` must be finite. Zero-alloc.
|
|
677
|
+
* @param {number} i
|
|
678
|
+
* @param {number} now caller-supplied nanoseconds
|
|
679
|
+
* @returns {number}
|
|
680
|
+
*/
|
|
681
|
+
ewmaAt(i, now) {
|
|
682
|
+
const s = this._stamp[i];
|
|
683
|
+
if (s < 0) return this._ewma[i]; // unsampled: undecayed baseline, clock-magnitude-independent
|
|
684
|
+
return this._ewma[i] * Math.exp(-(now - s) / this._tau);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Record an rtt SAMPLE for endpoint i at time `now` (the warm feedback path -- NOT the hot
|
|
689
|
+
* pick path). The FIRST sample (an unsampled node, `_stamp < 0`) initializes the EWMA EXACTLY
|
|
690
|
+
* to the sample, clock-magnitude-independent. Thereafter the Finagle peak rule applies: decay
|
|
691
|
+
* the stored estimate to `now`, then SNAP UP to the sample if it is larger (a spike is felt
|
|
692
|
+
* instantly) else ease toward it (it decays back over ~tau). The balancer is the SOLE writer of
|
|
693
|
+
* `_ewma` / `_stamp`. Zero-alloc on the success path.
|
|
694
|
+
* @param {number} i endpoint index
|
|
695
|
+
* @param {number} sampleNs observed rtt in nanoseconds (finite, >= 0)
|
|
696
|
+
* @param {number} now caller-supplied nanoseconds (finite), consistent with pick(now)
|
|
697
|
+
*/
|
|
698
|
+
recordRtt(i, sampleNs, now) {
|
|
699
|
+
if (typeof i !== 'number' || typeof sampleNs !== 'number' || typeof now !== 'number') {
|
|
700
|
+
throw new TypeError('[lite-pick] recordRtt(i, sampleNs, now) requires numbers');
|
|
701
|
+
}
|
|
702
|
+
if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
|
|
703
|
+
if (!Number.isFinite(sampleNs) || sampleNs < 0) {
|
|
704
|
+
throw new RangeError('[lite-pick] sampleNs must be a finite number >= 0');
|
|
705
|
+
}
|
|
706
|
+
if (!Number.isFinite(now)) throw new RangeError('[lite-pick] now must be a finite number');
|
|
707
|
+
if (this._stamp[i] < 0) {
|
|
708
|
+
this._ewma[i] = sampleNs; // first sample: exact init, no decay (clock-independent)
|
|
709
|
+
} else {
|
|
710
|
+
const w = Math.exp(-(now - this._stamp[i]) / this._tau);
|
|
711
|
+
const e = this._ewma[i] * w;
|
|
712
|
+
this._ewma[i] = sampleNs > e ? sampleNs : e + (sampleNs - e) * (1 - w);
|
|
713
|
+
}
|
|
714
|
+
this._stamp[i] = now;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Pick by latency-aware power-of-two-choices: two distinct eligible draws, lower cost =
|
|
719
|
+
* (inflight+1) x ewmaAt(now) wins; a tie goes to the first draw. PICK_NONE (fail closed) iff
|
|
720
|
+
* the whole pool is down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call).
|
|
721
|
+
* @param {number} now caller-supplied nanoseconds (consistent with recordRtt)
|
|
722
|
+
* @returns {number}
|
|
723
|
+
*/
|
|
724
|
+
pick(now) {
|
|
725
|
+
const a = this._draw();
|
|
726
|
+
if (a < 0) return PICK_NONE; // whole pool down: fail closed
|
|
727
|
+
if (this._live === 1) return a; // only one eligible: it is both choices
|
|
728
|
+
// A DISTINCT second draw, bounded (the ADR 0005 rationale): at live>=2 each redraw misses
|
|
729
|
+
// with probability <= 1/2, so 32 tries leaves a ~2^-32 collision chance, expected-O(1), 0 B/op.
|
|
730
|
+
let b = this._draw();
|
|
731
|
+
for (let t = 0; b === a && t < 32; t++) b = this._draw();
|
|
732
|
+
if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
|
|
733
|
+
const inf = this._inflight, ewma = this._ewma, stamp = this._stamp, tau = this._tau;
|
|
734
|
+
// Decay-on-read with the unsampled sentinel: `_stamp < 0` reads the undecayed baseline
|
|
735
|
+
// (graceful LeastConn), else exponential decay. A cheap per-candidate compare, no alloc.
|
|
736
|
+
const sa = stamp[a], sb = stamp[b];
|
|
737
|
+
const costA = (inf[a] + 1) * (sa < 0 ? ewma[a] : ewma[a] * Math.exp(-(now - sa) / tau));
|
|
738
|
+
const costB = (inf[b] + 1) * (sb < 0 ? ewma[b] : ewma[b] * Math.exp(-(now - sb) / tau));
|
|
739
|
+
return costB < costA ? b : a; // lower cost wins; tie -> the first draw
|
|
740
|
+
}
|
|
741
|
+
}
|
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`
|
|
76
|
-
* is the max number of distinct-endpoint attempts; `signal` is
|
|
77
|
-
* already aborted after a failure, stops failover (the abort
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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. **v0.
|
|
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. **v0.7.0 ships seven strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, and the latency-aware `PeakEwmaBalancer`** -- 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. The rest of the roster -- ConsistentHash, BoundedLoad, WeightedRandom -- lands one per session.
|
|
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: M7 (v0.7.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, and the latency-aware `PeakEwmaBalancer`**, the **`@zakkster/lite-pick/pool`** request layer (now with an opt-in latency-feedback hook), and 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). 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%**, and **PeakEWMA steers around a 10x-slow node** (it takes <= 25% of P2C's share for it and cuts service p99) -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M7 -> 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), benchmark-suite (0008), and PeakEWMA (0009) design forks.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
npm install @zakkster/lite-pick
|
|
@@ -139,6 +139,47 @@ 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
|
+
## PeakEWMA -- latency-aware P2C (v0.7.0)
|
|
143
|
+
|
|
144
|
+
When endpoints differ in **latency**, not just queue depth, count-based strategies keep re-probing a slow-but-up node: it drains its queue between visits, so its in-flight looks attractive again. `PeakEwmaBalancer` (Twitter Finagle's *peak-EWMA*) is power-of-two-choices over a **latency cost** -- `cost = (inflight + 1) x decayed-EWMA(rtt)` -- so a degraded node is avoided **even while idle** ([ADR 0009](./decisions/0009-peakewma.md)). It is `O(d) = O(1)` per pick and **0 B/op** on both the pick path and the feedback path.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { PeakEwmaBalancer } from '@zakkster/lite-pick';
|
|
148
|
+
|
|
149
|
+
const eligible = Uint8Array.from([1, 1, 1, 1]);
|
|
150
|
+
const inflight = new Uint32Array(4); // YOU own this; read live by pick()
|
|
151
|
+
const TAU_NS = 30e6; // EWMA half-life: 30ms of latency memory
|
|
152
|
+
|
|
153
|
+
// `now` and rtt samples are CALLER-supplied nanoseconds -- deterministic, testable, zero-GC.
|
|
154
|
+
const pe = new PeakEwmaBalancer(4, eligible, inflight, TAU_NS);
|
|
155
|
+
|
|
156
|
+
const now = perfNs(); // your monotonic ns clock
|
|
157
|
+
const i = pe.pick(now); // two random eligibles, lower latency-cost wins
|
|
158
|
+
inflight[i]++; // dispatch
|
|
159
|
+
// ... await the request ...
|
|
160
|
+
inflight[i]--; // settle
|
|
161
|
+
pe.recordRtt(i, perfNs() - now, perfNs()); // feed the observed rtt back (the warm path)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Decay-on-read.** `pick()` *never writes* -- it applies exponential decay when it reads (`ewmaAt(i, now) = _ewma[i] x exp(-(now - _stamp[i]) / tau)`), so the hot path is a pure read and allocates nothing.
|
|
165
|
+
- **The peak rule.** `recordRtt` *snaps the cost up* to a larger sample instantly (a spike is felt on the next pick) and *decays it down* over `~tau`. The half-life **is** the anti-flap smoothing -- no extra dwell ([ADR 0002](./decisions/0002-anti-flapping.md)).
|
|
166
|
+
- **Balancer-owned state.** `inflight` is your live-read `Uint32Array`; the EWMA arrays are owned by the balancer and written *only* by `recordRtt`. Cold start seeds the EWMA to `1.0` with an *unsampled* sentinel (`_stamp = -1`): an unsampled node scores at its undecayed baseline, so before any sample PeakEWMA degrades gracefully to least-connections **regardless of your clock's magnitude** -- never underflowing to `0` (which a plain `_stamp = 0` would, as `exp(-now/tau)`, under a real large clock) and never `NaN`. The first `recordRtt` initializes the EWMA *exactly* to the sample; the peak rule applies from the second sample on.
|
|
167
|
+
- **`now` must be finite.** `now` (for `pick(now)` / `recordRtt`) and `sampleNs` must be finite numbers. `recordRtt` throws on a non-finite argument; `pick(now)` never throws (the fail-closed contract), so a non-finite `now` yields P2C-random selection rather than an error.
|
|
168
|
+
|
|
169
|
+
The proof (from `test/balance.mjs`, a closed-loop queue sim with one node at 10x service time):
|
|
170
|
+
|
|
171
|
+
| lane | slow-node share | service p99 |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| **PeakEWMA** | **~0.01%** (learns and avoids it) | **lowest** |
|
|
174
|
+
| P2C (in-flight only) | ~1.5% (keeps re-probing) | ~7x PeakEWMA's |
|
|
175
|
+
| random foil | ~6% (blind) | saturates the slow node |
|
|
176
|
+
|
|
177
|
+
PeakEWMA sends the slow node **<= 25% of P2C's share** for it and cuts service p99 **>= 20% below** P2C-over-inflight; the random foil is worse than both.
|
|
178
|
+
|
|
179
|
+
### FE profile -- PeakEWMA + health, nothing else
|
|
180
|
+
|
|
181
|
+
For a **front-end / browser client** -- a handful of picks per second across origins/regions, not a zero-GC hot loop -- the recommended profile is **PeakEWMA + the eligibility bitmap only**: latency-aware choice with a fail-closed health view, and **none** of the server-side bounded-load / availability-zone / occupancy machinery. It is the smallest honest latency-aware client balancer. *(Deferred: a tail-aware `inflight x p99Rtt` variant via an optional-peer `@zakkster/lite-sketch` `DDSketch`; the EWMA-mean score is the shipped zero-peer default, and `peerDependencies` stays `{}` -- [ADR 0009](./decisions/0009-peakewma.md).)*
|
|
182
|
+
|
|
142
183
|
## Evidence -- the two headlines (v0.6.0 benchmark suite)
|
|
143
184
|
|
|
144
185
|
> **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`.
|
|
@@ -151,9 +192,9 @@ The real pinned npm incumbents (`load-balancers`, `loadbalance`, `wrr`) run thro
|
|
|
151
192
|
|
|
152
193
|
| family | lite-pick | lite-pick ops/ms | incumbent (npm) | incumbent ops/ms |
|
|
153
194
|
| --- | --- | --- | --- | --- |
|
|
154
|
-
| P2C (power-of-two-choices) | P2cBalancer |
|
|
155
|
-
| RoundRobin | RoundRobinBalancer |
|
|
156
|
-
| Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 |
|
|
195
|
+
| P2C (power-of-two-choices) | P2cBalancer | 53426 | load-balancers@1.3.52 | 61749 |
|
|
196
|
+
| RoundRobin | RoundRobinBalancer | 246108 | loadbalance@1.0.0 | 282939 |
|
|
197
|
+
| Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 | 151557 |
|
|
157
198
|
|
|
158
199
|
<!-- /bench:competitors -->
|
|
159
200
|
|
|
@@ -181,8 +222,8 @@ The point of zero-GC is **not** the pick's own latency -- a major GC pause freez
|
|
|
181
222
|
|
|
182
223
|
| lane | major GC | pick B/op | max GC pause (ms) |
|
|
183
224
|
| --- | --- | --- | --- |
|
|
184
|
-
| lite-pick | 0 | 0 | 0.
|
|
185
|
-
| allocating foil | 13 | allocates |
|
|
225
|
+
| lite-pick | 0 | 0 | 0.3 |
|
|
226
|
+
| allocating foil | 13 | allocates | 1.9 |
|
|
186
227
|
|
|
187
228
|
<!-- /bench:gc -->
|
|
188
229
|
|
|
@@ -221,6 +262,7 @@ On a scale event (add / remove a node), what fraction of keys keep their node? T
|
|
|
221
262
|
| AWS edge feature (managed, billed) | `lite-pick` equivalent (in-process, zero-GC) |
|
|
222
263
|
| --- | --- |
|
|
223
264
|
| ALB `least_outstanding_requests` (LOR) | `LeastConnBalancer` / `P2cBalancer` |
|
|
265
|
+
| ALB anomaly mitigation / latency-aware shedding | `PeakEwmaBalancer` (latency-aware P2C) |
|
|
224
266
|
| ALB `weighted_random` + anomaly mitigation | `WeightedRandom` + `BoundedLoad` (M9/M10) |
|
|
225
267
|
| NLB flow-hash (5-tuple) | `ConsistentHash` (Maglev, M8) |
|
|
226
268
|
|
|
@@ -254,7 +296,7 @@ PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead
|
|
|
254
296
|
VERSION; // -> '0.6.0'
|
|
255
297
|
```
|
|
256
298
|
|
|
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.
|
|
299
|
+
`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`, `PeakEwmaBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
|
|
258
300
|
|
|
259
301
|
## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
|
|
260
302
|
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-pick
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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,15 +14,25 @@ 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 APPROXIMATION),
|
|
19
|
-
EXACT LeastConn family (LeastConn, SED, NQ)
|
|
20
|
-
`PICK_NONE` (-1), a deterministic `Prng` (xorshift32),
|
|
21
|
-
eligibility seam + O(1) live count), `RoundRobinBalancer`,
|
|
22
|
-
`LeastConnBalancer`, `SedBalancer`,
|
|
23
|
-
session (see ROADMAP.md):
|
|
24
|
-
n) fewest-in-flight variant is a
|
|
25
|
-
(decisions/0006), not this
|
|
17
|
+
0.7.0 ships the substrate seams + seven strategies: RoundRobin, SmoothWRR (the weighted
|
|
18
|
+
default), P2C (power-of-two-choices -- also the O(1) least-connections APPROXIMATION), the
|
|
19
|
+
EXACT LeastConn family (LeastConn, SED, NQ), and PeakEWMA (latency-aware P2C). It exports
|
|
20
|
+
`VERSION`, the fail-closed sentinel `PICK_NONE` (-1), a deterministic `Prng` (xorshift32),
|
|
21
|
+
`BalancerBase` (the shared read-only eligibility seam + O(1) live count), `RoundRobinBalancer`,
|
|
22
|
+
`SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, and
|
|
23
|
+
`PeakEwmaBalancer`. The remaining strategies land one per session (see ROADMAP.md):
|
|
24
|
+
ConsistentHash, BoundedLoad, WeightedRandom. The EXACT-O(log n) fewest-in-flight variant is a
|
|
25
|
+
deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam (decisions/0006), not this
|
|
26
|
+
exact-O(cap) scan.
|
|
27
|
+
|
|
28
|
+
M7 (0.7.0) adds PeakEwmaBalancer (decisions/0009). The FE PROFILE: for a browser / front-end
|
|
29
|
+
client (a handful of picks per second, not a zero-GC hot loop), the recommended profile is
|
|
30
|
+
PeakEWMA + health/eligibility ONLY -- no bounded-load / AZ / occupancy machinery -- latency-aware
|
|
31
|
+
choice across origins with a fail-closed eligibility view. DEFERRED tail-aware variant: a p99-aware
|
|
32
|
+
score (inflight x p99Rtt) via a per-node @zakkster/lite-sketch `DDSketch` (published 0.3.0; `add`
|
|
33
|
+
is O(1) / 0 B/op, `quantile` carries a hard relative-error bound) is an OPTIONAL PEER complement;
|
|
34
|
+
the EWMA-mean score is the shipped, zero-peer default and `peerDependencies` STAYS `{}` until a
|
|
35
|
+
shipped code path imports the sketch.
|
|
26
36
|
|
|
27
37
|
M6 (0.6.0) adds the BENCHMARK SUITE -- an evidence session, no API change: Pick.js / Pool.js are
|
|
28
38
|
byte-identical to 0.5.0 apart from the VERSION stamp. It lives entirely under benchmark/ (NOT in
|
|
@@ -116,6 +126,30 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
|
|
|
116
126
|
- `pick()` -> number. Returns the FIRST idle eligible positive-weight node (in-flight 0) if one
|
|
117
127
|
exists -- never queueing while a server is free -- else the SED minimum, else `PICK_NONE`.
|
|
118
128
|
O(cap) worst case, O(1) when an early node is idle. 0 B/op.
|
|
129
|
+
- `PeakEwmaBalancer extends BalancerBase` -- class. Latency-aware P2C (M7, Finagle peak-EWMA).
|
|
130
|
+
- `new PeakEwmaBalancer(capacity, eligible, inflight, tauNs, seed?=0x9e3779b9)` -- `inflight` is a
|
|
131
|
+
caller-owned Uint32Array (length >= capacity) read LIVE; `tauNs` is the EWMA time-constant /
|
|
132
|
+
half-life in nanoseconds (finite, > 0). The per-endpoint EWMA state (`_ewma` / `_stamp`,
|
|
133
|
+
Float64) is BALANCER-OWNED and written ONLY by `recordRtt`. Validates typeof-first BEFORE
|
|
134
|
+
allocating (RangeError/TypeError). Cold start seeds the EWMA to 1.0 with an UNSAMPLED sentinel
|
|
135
|
+
(`_stamp = -1`), so an unsampled node scores at its undecayed baseline -> graceful
|
|
136
|
+
least-connections regardless of clock magnitude (never underflows to 0).
|
|
137
|
+
- `pick(now)` -> number. Draws two DISTINCT eligible endpoints (ADR 0005's rejection sampling,
|
|
138
|
+
reused) and returns the lower `cost = (inflight + 1) * ewmaAt(now)`; a tie goes to the first
|
|
139
|
+
draw. `now` is caller-supplied nanoseconds. Decays ON READ (never writes) -> O(d)=O(1), 0 B/op.
|
|
140
|
+
`PICK_NONE` when the whole pool is down. A slow-but-up node (high EWMA rtt) is avoided even while
|
|
141
|
+
idle -- the difference from P2C-over-inflight (latency anchor in balance.mjs).
|
|
142
|
+
- `ewmaAt(i, now)` -> number. The EWMA rtt estimate for endpoint i at `now`. Pure read. An
|
|
143
|
+
unsampled node (`_stamp < 0`) returns the baseline 1.0 undecayed; otherwise exponential decay.
|
|
144
|
+
- `recordRtt(i, sampleNs, now)` -> void. WARM feedback path (not the hot pick path): the FIRST
|
|
145
|
+
sample initializes the EWMA EXACTLY to `sampleNs` (clock-magnitude-independent); thereafter the
|
|
146
|
+
Finagle peak rule -- the cost SNAPS UP to a larger sample instantly and DECAYS DOWN over ~tau.
|
|
147
|
+
`now` / `sampleNs` are caller-supplied nanoseconds, consistent with `pick(now)`. Validates
|
|
148
|
+
typeof-first; 0 B/op on the success path. Anti-flap = the half-life, no extra dwell (ADR 0002,
|
|
149
|
+
ADR 0009).
|
|
150
|
+
- CONTRACT: `now` and `sampleNs` MUST be FINITE numbers. `recordRtt` THROWS on a non-finite
|
|
151
|
+
argument (warm path); `pick(now)` NEVER throws (fail-closed contract), so a non-finite `now`
|
|
152
|
+
yields P2C-random selection, not an error.
|
|
119
153
|
|
|
120
154
|
## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
|
|
121
155
|
|
|
@@ -140,7 +174,11 @@ duck-typed and imports NOTHING from lite-query.
|
|
|
140
174
|
once aborted after a failure, failover stops and the abort propagates. NOT a 0 B/op path (the
|
|
141
175
|
kernel `pick()` is): a normal async wrapper adding O(1) counter ops + one small per-run array.
|
|
142
176
|
BOUNDARY: Pool owns SPATIAL failover (across the pool); the caller / query cache owns TEMPORAL
|
|
143
|
-
retry (backoff, staleness). Never double-owned (decisions/0007).
|
|
177
|
+
retry (backoff, staleness). Never double-owned (decisions/0007). OPT-IN latency feedback: when
|
|
178
|
+
`opts.clock` (a caller-owned nanosecond source) is supplied AND the balancer duck-types
|
|
179
|
+
`recordRtt` (PeakEwmaBalancer), Pool drives `pick(now)` and records the settled rtt on success;
|
|
180
|
+
otherwise the hook is inert -- Pool stays generic, in-flight stays net-zero, abort/failover
|
|
181
|
+
unchanged.
|
|
144
182
|
- `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
|
|
145
183
|
query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
|
|
146
184
|
key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
|
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,
|
|
4
|
+
"version": "0.7.1",
|
|
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",
|