@zakkster/lite-pick 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.9.0
3
+ Version: 1.0.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,23 +14,49 @@ 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.9.0 ships the substrate seams + nine 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), PeakEWMA (latency-aware P2C), ConsistentHash
20
- (a Maglev lookup table -- sticky/affinity routing), and BoundedLoad (Consistent Hashing with Bounded
21
- Loads -- sticky routing + a per-backend occupancy cap that overflows a hotspot to neighbours). It
22
- exports `VERSION`, the fail-closed
17
+ 1.0.1 -- the audit bug-fix release (CHANGELOG [1.0.1], decisions/0013) on top of
18
+ 1.0.0 -- the ROSTER-COMPLETE release -- which ships the substrate seams + TEN strategies: RoundRobin,
19
+ SmoothWRR (the weighted default), P2C (power-of-two-choices -- also the O(1) least-connections
20
+ APPROXIMATION), the EXACT LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
21
+ ConsistentHash (a Maglev lookup table -- sticky/affinity routing), BoundedLoad (Consistent Hashing
22
+ with Bounded Loads -- sticky routing + a per-backend occupancy cap that overflows a hotspot to
23
+ neighbours), and WeightedRandom (O(1) Vose alias-table sampling with rejection-sampling eligibility).
24
+ It exports `VERSION`, the fail-closed
23
25
  sentinel `PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared
24
26
  read-only eligibility seam + O(1) live count), `RoundRobinBalancer`, `SmoothWRRBalancer`,
25
27
  `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, `PeakEwmaBalancer`,
26
- `ConsistentHashBalancer`, `BoundedLoadBalancer`, and the ConsistentHash constants `CH_DEFAULT_M`
27
- (65537) / `CH_PROBE_LIMIT` (64). The remaining strategy lands next session (see ROADMAP.md):
28
- WeightedRandom. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
29
- `BinaryHeap` optional-peer seam (decisions/0006), not this exact-O(cap) scan.
28
+ `ConsistentHashBalancer`, `BoundedLoadBalancer`, `WeightedRandomBalancer`, and the ConsistentHash
29
+ constants `CH_DEFAULT_M` (65537) / `CH_PROBE_LIMIT` (64). Roster complete FOR NOW, not closed:
30
+ AZ-aware routing, the lite-await hedging combinator, and subsetting are queued post-1.0 (ROADMAP.md).
31
+ GUIDE.md is the "which of the ten strategies do I pick?" decision guide. The EXACT-O(log n)
32
+ fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
33
+ (decisions/0006), not this exact-O(cap) scan.
34
+
35
+ M10 (1.0.0) adds WeightedRandomBalancer (decisions/0012): O(1) weighted-random selection via an inline
36
+ Vose/Walker ALIAS TABLE (one column draw + one probability compare -> a candidate), with REJECTION-
37
+ SAMPLING eligibility (retry an ineligible candidate up to a bounded 64, then a 0-B/op rotated linear
38
+ eligible scan) -- the ADR 0005 / P2C discipline. The alias table is built COLD over the ELIGIBLE-
39
+ INDEPENDENT weights, so a weight-0 node is NEVER a column (never returned) and rejecting the ineligible
40
+ draws RENORMALIZES the weight distribution over the SURVIVING eligible mass (each eligible node's share
41
+ converges to weight[i] / sum(eligible weights)). `weights` is the caller's Uint32Array (the SmoothWRR/
42
+ SED seam); the balancer is the SOLE writer of its DERIVED table (`_prob`/`_alias`) via cold setWeight/
43
+ rebuild -- an eligibility flap NEVER rebuilds (anti-flap). Fail-closed (PICK_NONE) IFF live === 0 OR no
44
+ eligible node has a positive weight. O(1), 0 B/op, never throws. It is the STATELESS O(1) weighted
45
+ sampler (no accumulator to desync) for VERY LARGE pools where SmoothWRR's O(cap) scan hurts -- trading
46
+ smoothness for sampling variance. `peerDependencies` STAYS `{}`: the Vose build is inlined; a lite-o1
47
+ `AliasTable` (duck-typed drop-in for the build) and a lite-logn Fenwick tree (the DYNAMIC-weight
48
+ complement -- O(log n) update + sample) are DEFERRED optional peers, imported by NOTHING. NOT
49
+ @zakkster/lite-random: that is a GAME RNG (Mulberry32; loot tables, particles, gaussian) whose
50
+ `weighted(items, weights)` returns an ITEM one-shot, is not eligibility-aware, and holds no reusable
51
+ table -- lite-pick's WeightedRandom returns an endpoint INDEX, honours the shared eligibility bitmap
52
+ (fail-closed), and owns a persistent alias table rebuilt only on reweight. Different domain + contract
53
+ -- not a peer, not a substrate (GUIDE.md / ADR 0012).
30
54
 
31
55
  M9 (0.9.0) adds BoundedLoadBalancer (decisions/0011): Consistent Hashing with Bounded Loads (CHBL --
32
56
  Mirrokni et al. / Google Research; Vimeo eps=0.25). It is `ConsistentHashBalancer` (the Maglev table)
33
- PLUS a per-backend occupancy cap `cap = (1 + eps) x _total / live`: `pick(keyHash)` sticks a key to its
57
+ PLUS a per-backend occupancy cap `cap = ceil((1 + eps) x (_total + 1) / live)` (the +1 counts the INCOMING
58
+ request -- Mirrokni-Thorup-Zadimoghaddam per-bin capacity, so cap >= 1; NOT HAProxy's global-slot-by-weight
59
+ definition, which is stricter): `pick(keyHash)` sticks a key to its
34
60
  hashed home UNLESS that backend is over cap, in which case the request OVERFLOWS along the same bounded
35
61
  probe to the next eligible under-cap backend -- consistent hashing's stickiness + minimal disruption
36
62
  PLUS the HOTSPOT protection plain CH lacks. If none in the window is under cap it FALLS BACK to the
@@ -38,8 +64,9 @@ first eligible (sticky wins; PICK_NONE is pool-down ONLY, never for over-cap); `
38
64
  cap -> pure ConsistentHash. It extends ConsistentHashBalancer (reusing the Maglev build + probe +
39
65
  setWeight/rebuild/tableSize VERBATIM) and OWNS a running `_total` whose SOLE writer is the warm
40
66
  `note(i, delta)` seam (dispatch +1 / settle -1); `inflight` is the caller's Uint32Array read LIVE as
41
- the per-backend occupancy. `pick()` and `note()` are both O(1) / 0 B/op. CONTRACT: when using
42
- BoundedLoad the mirrored counter is mutated ONLY through `note()` / /pool -- direct mutation desyncs
67
+ the per-backend occupancy. `pick()` and `note()` are both O(1) / 0 B/op. CONTRACT: update `inflight[i]`
68
+ AND call `note(i, +/-1)` in LOCKSTEP (or drive it through /pool, which does both). `note` maintains
69
+ `_total`; it does NOT write `inflight`. A direct `inflight` write without the matching `note` desyncs
43
70
  `_total` (UB, the SmoothWRR-weights asymmetry). THE PIVOT (decisions/0011): P2C-over-inflight with a
44
71
  `(1+eps) x mean` cap is byte-identical to plain P2C (an under-cap draw always has lower inflight than an
45
72
  over-cap one), so the cap is only LOAD-BEARING when the primary choice is a HASH -- CHBL is that. The
@@ -78,8 +105,12 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
78
105
  ## Design ownership (decisions/0001, 0002)
79
106
 
80
107
  - IN-PROCESS first (workers, DI services); remote HTTP is a thin optional adapter.
81
- - Eligibility is a SHARED read-only `Uint8Array` (1 = pickable, 0 = down), WRITTEN by
82
- @zakkster/lite-di-health / circuit breakers, only READ by `pick()`. Zero-copy.
108
+ - Eligibility is a `Uint8Array` (1 = pickable, 0 = down) READ by `pick()`. At runtime it is
109
+ flipped ONLY through `setEligible(i, up)` (the sole supported writer, which keeps the cached
110
+ `live` count -- and SmoothWRR's eligible-weight total -- exact); @zakkster/lite-di-health /
111
+ circuit breakers DRIVE that call. A direct byte write desyncs the cache (fail-closed picks,
112
+ wrong ratios) -- UB. Each balancer needs its OWN eligibility array (a shared `Eligibility`
113
+ value object is deferred to 2.0; ADR 0001, amended 1.0.1). No second internal copy.
83
114
  - Load counters are CALLER-OWNED typed arrays (`Uint32Array` inflight, `Float64Array`
84
115
  rtt/EWMA); the kernel holds no request state. `pick()` is pure-read.
85
116
  - The circuit breaker is CONSUMED (@zakkster/lite-statechart), never built in.
@@ -97,14 +128,15 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
97
128
  - `nextBelow(n)` -> uint32 in [0, n).
98
129
  - `reset()` -> void. Replays the original seed's stream (reproducible benches).
99
130
  - `BalancerBase` -- class. The shared eligibility seam; strategies (M1+) subclass it.
100
- - `new BalancerBase(capacity, eligible)` -- `eligible` is a shared `Uint8Array`
101
- (length >= capacity) owned/written externally; throws `RangeError` on a bad capacity
102
- or an undersized / non-Uint8Array view.
131
+ - `new BalancerBase(capacity, eligible)` -- `eligible` is a `Uint8Array` (length >= capacity),
132
+ per-balancer (not shared across balancers); throws `RangeError` on a bad capacity or an
133
+ undersized / non-Uint8Array view. Flip it ONLY through `setEligible` after construction.
103
134
  - `capacity` -- readonly number. Fixed endpoint count.
104
135
  - `live` -- readonly number. Currently eligible endpoints (O(1), cold-path maintained).
105
- - `isEligible(i)` -> boolean. O(1); out-of-range is `false`, never a throw.
106
- - `setEligible(i, up)` -> void. Cold path; keeps `live` exact and is idempotent; throws
107
- `RangeError` on an out-of-range index.
136
+ - `isEligible(i)` -> boolean. O(1); out-of-range OR a non-integer (1.5, NaN) is `false`, never a throw.
137
+ - `setEligible(i, up)` -> void. Cold path; the ONLY supported eligibility writer -- keeps `live`
138
+ exact and is idempotent; throws `RangeError` on a non-integer or out-of-range index (incl. a
139
+ numeric string like '2'). A direct `eligible[i]` write desyncs `live` (UB).
108
140
  - `pick()` -> number. ABSTRACT in the base (throws); overridden by each strategy.
109
141
  - `RoundRobinBalancer extends BalancerBase` -- class. The baseline strategy (M1).
110
142
  - `new RoundRobinBalancer(capacity, eligible)` -- same shared-eligibility contract as the base.
@@ -139,7 +171,8 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
139
171
  (length >= capacity), read LIVE each pick. No `setWeight`, no derived aggregate: the caller may
140
172
  mutate inflight directly between picks (that is the shared-counter seam).
141
173
  - `pick()` -> number. Full O(cap) scan returning the eligible node with the fewest in-flight
142
- requests (lowest index on a tie), or `PICK_NONE` when the whole pool is down. 0 B/op. In a
174
+ requests (tie order UNSPECIFIED in 1.0.1 -- deterministic but do not depend on it; a rotating
175
+ tie-break is planned for 1.1.0), or `PICK_NONE` when the whole pool is down. 0 B/op. In a
143
176
  feedback loop (increment on dispatch, decrement on settle) it is greedy-optimal: max-minus-min
144
177
  load stays within 1 (balance.mjs). Without feedback it returns the same lowest-load index --
145
178
  correct by contract; the M5 lite-query adapter provides the increment/decrement.
@@ -148,7 +181,8 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
148
181
  caller-owned Uint32Arrays (length >= capacity), read LIVE (no `setWeight`, no derived total --
149
182
  the caller may retune weights directly, UNLIKE SmoothWRR).
150
183
  - `pick()` -> number. O(cap) scan returning the eligible, positive-weight node minimizing
151
- `(inflight + 1) / weight` (the new request's marginal expected delay), lowest index on a tie,
184
+ `(inflight + 1) / weight` (the new request's marginal expected delay), tie order UNSPECIFIED
185
+ (1.0.1; rotating tie-break planned for 1.1.0),
152
186
  or `PICK_NONE` when no eligible node has a positive weight. A weight-0 eligible node is NOT a
153
187
  candidate. Converges to load proportional-to-weight (balance.mjs). 0 B/op.
154
188
  - `NqBalancer extends BalancerBase` -- class. Never-queue (M4, IPVS `nq`); the worker-pool fit.
@@ -159,25 +193,33 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
159
193
  O(cap) worst case, O(1) when an early node is idle. 0 B/op.
160
194
  - `PeakEwmaBalancer extends BalancerBase` -- class. Latency-aware P2C (M7, Finagle peak-EWMA).
161
195
  - `new PeakEwmaBalancer(capacity, eligible, inflight, tauNs, seed?=0x9e3779b9)` -- `inflight` is a
162
- caller-owned Uint32Array (length >= capacity) read LIVE; `tauNs` is the EWMA time-constant /
163
- half-life in nanoseconds (finite, > 0). The per-endpoint EWMA state (`_ewma` / `_stamp`,
164
- Float64) is BALANCER-OWNED and written ONLY by `recordRtt`. Validates typeof-first BEFORE
165
- allocating (RangeError/TypeError). Cold start seeds the EWMA to 1.0 with an UNSAMPLED sentinel
166
- (`_stamp = -1`), so an unsampled node scores at its undecayed baseline -> graceful
167
- least-connections regardless of clock magnitude (never underflows to 0).
196
+ caller-owned Uint32Array (length >= capacity) read LIVE; `tauNs` is the EWMA TIME CONSTANT in
197
+ nanoseconds (finite, > 0; half-life = tauNs x ln2). The per-endpoint EWMA state (`_ewma` /
198
+ `_stamp`, Float64) plus a lifetime running-mean-of-samples pair are BALANCER-OWNED and written
199
+ ONLY by `recordRtt`. Cold start seeds `_ewma` to 1.0 with an UNSAMPLED sentinel (`_stamp = -1`).
200
+ LATENCY marker (`static LATENCY = true`): /pool REQUIRES `opts.clock` for this strategy.
168
201
  - `pick(now)` -> number. Draws two DISTINCT eligible endpoints (ADR 0005's rejection sampling,
169
- reused) and returns the lower `cost = (inflight + 1) * ewmaAt(now)`; a tie goes to the first
170
- draw. `now` is caller-supplied nanoseconds. Decays ON READ (never writes) -> O(d)=O(1), 0 B/op.
171
- `PICK_NONE` when the whole pool is down. A slow-but-up node (high EWMA rtt) is avoided even while
172
- idle -- the difference from P2C-over-inflight (latency anchor in balance.mjs).
173
- - `ewmaAt(i, now)` -> number. The EWMA rtt estimate for endpoint i at `now`. Pure read. An
174
- unsampled node (`_stamp < 0`) returns the baseline 1.0 undecayed; otherwise exponential decay.
202
+ reused), lower COST wins; a tie goes to the first draw. Per-candidate cost (pure read, 0 B/op):
203
+ an unsampled node costs 0 WHILE IDLE (graceful least-connections; holds one probe in flight
204
+ until its first sample) and `(inflight+1) x lifetime-mean-sampled-rtt ONCE BUSY` (so a
205
+ cold-but-busy node is NOT the old 1.0 ns black hole); a sampled node costs
206
+ `(inflight+1) x max(decayedEWMA, dt)` while busy (a hung node -- dt grows, no completion -- gets
207
+ MORE expensive), else `(inflight+1) x decayedEWMA`. `dt = max(now - stamp, 0)` (clamped, L6).
208
+ `now` is caller-supplied nanoseconds. Decays ON READ -> O(d)=O(1), 0 B/op. `PICK_NONE` when the
209
+ whole pool is down. CAVEAT: an idle-then-busy node is priced by time-since-last-response until
210
+ that response completes (exact busy-since stamp = 1.1.0). A node that fails fast and records
211
+ nothing keeps winning while idle -- record failures (the /pool failure penalty does).
212
+ - `ewmaAt(i, now)` -> number. The decayed EWMA rtt estimate for endpoint i at `now`. Pure read. An
213
+ unsampled node (`_stamp < 0`) returns the baseline 1.0 undecayed; otherwise
214
+ `_ewma[i] x exp(-max(now - stamp, 0)/tau)`.
175
215
  - `recordRtt(i, sampleNs, now)` -> void. WARM feedback path (not the hot pick path): the FIRST
176
216
  sample initializes the EWMA EXACTLY to `sampleNs` (clock-magnitude-independent); thereafter the
177
- Finagle peak rule -- the cost SNAPS UP to a larger sample instantly and DECAYS DOWN over ~tau.
178
- `now` / `sampleNs` are caller-supplied nanoseconds, consistent with `pick(now)`. Validates
179
- typeof-first; 0 B/op on the success path. Anti-flap = the half-life, no extra dwell (ADR 0002,
180
- ADR 0009).
217
+ Finagle peak rule -- the cost SNAPS UP to a larger sample instantly and DECAYS DOWN over ~tau --
218
+ and it folds the sample into the lifetime mean. `now` / `sampleNs` are caller-supplied
219
+ nanoseconds, consistent with `pick(now)`. THROWS `RangeError` on a non-integer / out-of-range
220
+ index (incl. a numeric string like '2' -- which used to work; a string index now throws
221
+ RangeError, not TypeError) or a non-finite/negative `sampleNs`; 0 B/op on the success path.
222
+ Anti-flap = the time constant, no extra dwell (ADR 0002, ADR 0009).
181
223
  - CONTRACT: `now` and `sampleNs` MUST be FINITE numbers. `recordRtt` THROWS on a non-finite
182
224
  argument (warm path); `pick(now)` NEVER throws (fail-closed contract), so a non-finite `now`
183
225
  yields P2C-random selection, not an error.
@@ -214,10 +256,13 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
214
256
  BEFORE super() allocates the Maglev table (TypeError non-number eps, RangeError non-finite / <= 0).
215
257
  `weights` / `m` / `seed` are the ConsistentHash args (copied weights, prime m >= capacity, COLD
216
258
  build). The running occupancy sum `_total` is BALANCER-OWNED (starts at 0) and written SOLELY by
217
- `note`; when using BoundedLoad, the mirrored inflight counter is mutated ONLY through `note` / /pool
218
- (direct mutation desyncs `_total` -- UB, the SmoothWRR-weights asymmetry).
259
+ `note`; when using BoundedLoad, update `inflight[i]` AND call `note(i, +/-1)` in LOCKSTEP (or drive
260
+ it through /pool). `note` maintains `_total`; it does NOT write `inflight`. A direct `inflight` write
261
+ without the matching `note` desyncs `_total` -- UB, the SmoothWRR-weights asymmetry.
219
262
  - `pick(keyHash)` -> number. slot = (keyHash >>> 0) % M; walk the probe window (home + CH_PROBE_LIMIT
220
- slots) and return the FIRST backend that is ELIGIBLE AND under `cap = (1 + eps) * _total / live`
263
+ slots) and return the FIRST backend that is ELIGIBLE AND under `cap = ceil((1 + eps) * (_total + 1)
264
+ / live)` (the +1 counts the incoming request -- MTZ per-bin capacity, cap >= 1; NOT HAProxy's
265
+ stricter global-slot-by-weight definition)
221
266
  (a hot home OVERFLOWS to a neighbour). If none in the window is under cap, fall back to the FIRST
222
267
  eligible seen (sticky wins; the cap is a soft preference). `_total === 0` skips the cap -> pure
223
268
  ConsistentHash. O(1), 0 B/op, NEVER throws. `PICK_NONE` ONLY when no eligible backend is reachable
@@ -236,6 +281,35 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
236
281
  the mean occupancy while CHBL caps it near (1+eps) x mean by overflow; both keep ~1/N minimal
237
282
  disruption. `Pool.run` mirrors dispatch/settle into `note` when the balancer duck-types it, and
238
283
  `Pool.run(fn, { key })` drives `pick(key)` for keyed CHBL routing (both inert otherwise).
284
+ - `WeightedRandomBalancer extends BalancerBase` -- class. O(1) weighted-random via a Vose/Walker ALIAS
285
+ TABLE (M10, decisions/0012). The roster-completing strategy.
286
+ - `new WeightedRandomBalancer(capacity, eligible, weights, seed?=0x9e3779b9)` -- `weights` is the
287
+ CALLER's Uint32Array (length >= capacity, the SmoothWRR/SED seam); the balancer is the SOLE writer
288
+ of its DERIVED alias table (`_prob` Float64Array + `_alias` Int32Array) via cold setWeight/rebuild
289
+ (direct weight mutation desyncs the table -- UB). Validates typeof-first (RangeError) BEFORE
290
+ allocating the table. Builds the table COLD in the ctor (the standard small/large Vose worklist over
291
+ the eligible-INDEPENDENT weights, ~15 lines -- NOT a re-implementation of lite-o1's AliasTable). The
292
+ build reuses cold scratch worklists -- it allocates nothing per rebuild.
293
+ - `pick()` -> number. Draws one column (`prng.nextBelow(cap)`) + one probability compare against a
294
+ fresh `prng.next()` uniform -> a candidate (`col` or `_alias[col]`), which is ALWAYS a positive-
295
+ weight node (a weight-0 node is NEVER a column). REJECTION SAMPLING on eligibility: if the candidate
296
+ is ineligible, redraw up to a bounded 64, then fall back to a 0-B/op rotated linear scan from a
297
+ random start for the first eligible positive-weight node. Because a candidate is always positive-
298
+ weight, rejecting the ineligible draws RENORMALIZES the weight distribution over the SURVIVING
299
+ eligible mass (each eligible node's share converges to weight[i] / sum(eligible weights)). O(1),
300
+ 0 B/op, NEVER throws. `PICK_NONE` IFF live === 0 OR no eligible node has a positive weight. NEVER a
301
+ dead pick, a weight-0 return, or an out-of-range index.
302
+ - `setWeight(i, w)` -> void. COLD. Set endpoint i's weight (uint32) and REBUILD the alias table (the
303
+ SmoothWRR sole-writer precedent). `rebuild()` -> void. COLD. Re-derive the table from the current
304
+ caller weights. An eligibility flap NEVER rebuilds (anti-flap; only setWeight/rebuild/membership).
305
+ - vs SmoothWRR: SmoothWRR is deterministic/smooth/low-variance but O(cap)/pick and owns accumulator
306
+ state; WeightedRandom is a STATELESS O(1) sample (no accumulator to desync) with sampling variance --
307
+ the fit for VERY LARGE pools where SmoothWRR's O(cap) scan hurts. vs @zakkster/lite-random: that is a
308
+ GAME RNG (Mulberry32; loot tables) returning an ITEM one-shot, not eligibility-aware, no reusable
309
+ table; WeightedRandom returns an endpoint INDEX, honours the shared eligibility bitmap (fail-closed),
310
+ and owns a persistent alias table -- different domain, NOT a peer (see GUIDE.md / ADR 0012). DEFERRED
311
+ optional-peer seams (import NOTHING; `peerDependencies` STAYS `{}`): a lite-o1 `AliasTable` duck-typed
312
+ drop-in for the Vose build, and a lite-logn Fenwick/BinaryIndexedTree for DYNAMIC weights.
239
313
 
240
314
  ## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
241
315
 
@@ -251,38 +325,52 @@ duck-typed and imports NOTHING from lite-query.
251
325
  live }` -- every lite-pick strategy qualifies); `inflight` is the SAME caller-owned Uint32Array
252
326
  the balancer reads (length >= balancer.capacity). Throws on a bad balancer / undersized view.
253
327
  - `balancer` / `inflight` -- readonly getters.
254
- - `run(fn, opts?)` -> Promise. Picks an endpoint, increments its in-flight on DISPATCH, awaits
255
- `fn(endpoint, signal)`, decrements on SETTLE (in a finally -- net-zero per run, even on throw).
256
- On a thrown error it keeps the failed endpoint's count ELEVATED and re-picks, so a load-aware
257
- strategy (P2C/LeastConn/SED/NQ) steers the next attempt to a DIFFERENT endpoint -- up to
258
- `opts.tries` attempts (default 1 = no failover), then rejects with the LAST error. Rejects with
259
- a `code:'LITE_PICK_NONE'` error when no endpoint is eligible. `opts.signal` is passed to `fn`;
260
- once aborted after a failure, failover stops and the abort propagates. NOT a 0 B/op path (the
261
- kernel `pick()` is): a normal async wrapper adding O(1) counter ops + one small per-run array.
262
- BOUNDARY: Pool owns SPATIAL failover (across the pool); the caller / query cache owns TEMPORAL
263
- retry (backoff, staleness). Never double-owned (decisions/0007). OPT-IN latency feedback: when
264
- `opts.clock` (a caller-owned nanosecond source) is supplied AND the balancer duck-types
265
- `recordRtt` (PeakEwmaBalancer), Pool drives `pick(now)` and records the settled rtt on success;
266
- otherwise the hook is inert -- Pool stays generic, in-flight stays net-zero, abort/failover
267
- unchanged. OPT-IN occupancy feedback: when the balancer duck-types `note` (BoundedLoadBalancer),
268
- Pool mirrors each dispatch as `note(i, +1)` and each settle as `note(i, -1)` so its owned mean
269
- stays current; inert otherwise. The hooks are independent -- a balancer may duck-type neither,
270
- one, or both; a BoundedLoad + Pool round is net-zero on BOTH the inflight array and `_total`.
271
- OPT-IN keyed routing: `opts.key` (a caller INTEGER) drives `pick(key)` for a keyed balancer
272
- (ConsistentHash / BoundedLoad -- CHBL); failover re-picks with the SAME key, and because the failed
273
- backend's occupancy stays elevated a CHBL re-pick naturally OVERFLOWS to the next backend. Without
274
- `opts.key`, `pick()` / `pick(now)` behaviour is unchanged.
328
+ - `run(fn, opts?)` -> Promise. `opts` may be omitted or `null`. Picks an endpoint, increments its
329
+ in-flight on DISPATCH, awaits `fn(endpoint, signal)`, decrements on SETTLE (in a finally --
330
+ net-zero per run, even on throw). On a thrown error it keeps the failed endpoint's count ELEVATED
331
+ and fails over to a GENUINELY DISTINCT endpoint (M2): re-pick while the strategy repeats a tried
332
+ endpoint (bounded), then scan for an eligible UNTRIED one (key-derived start for a keyed run,
333
+ cursor-rotated otherwise). It STOPS with the last error as soon as no untried eligible endpoint
334
+ remains -- so a 1-node pool with `tries: 3` makes ONE attempt (Pool owns SPATIAL failover, not
335
+ temporal retry). `opts.tries` default 1 = no failover. Rejects `code:'LITE_PICK_NONE'` when no
336
+ endpoint is eligible.
337
+ ABORT (M-Item5): `opts.signal` is checked before EVERY attempt -- an already-aborted signal
338
+ dispatches NOTHING and rejects with the signal's `reason`, else `code:'LITE_PICK_ABORTED'`; an
339
+ abort after a failure stops failover and propagates.
340
+ CHANNELS (M3), read off static markers (Pool imports nothing new): a KEYED balancer
341
+ (`constructor.KEYED === true`: ConsistentHash/BoundedLoad) REQUIRES a numeric `opts.key`
342
+ (`code:'LITE_PICK_KEY_REQUIRED'` otherwise) and Pool drives `pick(key)`; a LATENCY balancer
343
+ (`constructor.LATENCY === true`: PeakEWMA) REQUIRES an `opts.clock`
344
+ (`code:'LITE_PICK_CLOCK_REQUIRED'` otherwise), read (validated finite) BEFORE dispatch and passed
345
+ as `pick(now)`. The KEY reaches ONLY a keyed pick; the CLOCK reading NEVER reaches a keyed pick; a
346
+ non-keyed clocked run passes the reading to `pick(now)`.
347
+ FEEDBACK is loud and never re-runs fn (M4): occupancy `note(i, +/-1)` mirrors dispatch/settle for
348
+ a note-duck-typed balancer; latency `recordRtt` is fed on settle when a clock is in use. A SUCCESS
349
+ whose settle feedback fails (clock throws / non-finite, or `recordRtt` throws) rejects
350
+ `code:'LITE_PICK_FEEDBACK'` carrying `.cause` and `.result` (fn's value -- fn ran once, nothing
351
+ lost). A FAILURE feeds a penalty `recordRtt(i, max(elapsed, failurePenaltyNs), done)` (H1); if THAT
352
+ feedback fails, Pool throws fn's original error unchanged (identity) with a non-enumerable
353
+ `liteFeedbackError` attached. A non-finite clock reading throws BEFORE dispatch; a
354
+ backwards-stepping finite reading records NO sample and resolves normally. `opts.failurePenaltyNs`
355
+ (finite > 0, default 1e9) is the minimum penalty rtt; a penalized node is re-probed roughly every
356
+ `tauNs x ln(failurePenaltyNs / healthyRttNs)`. NOT a 0 B/op path (the kernel `pick()` is): a normal
357
+ async wrapper adding O(1) counter ops + one small per-run array. BOUNDARY: Pool owns SPATIAL
358
+ failover; the caller / query cache owns TEMPORAL retry (decisions/0007).
275
359
  - `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
276
360
  query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
277
- key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
278
- Imports nothing from lite-query -- duck-typed, so `peerDependencies` stays empty.
361
+ key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count;
362
+ `opts.clock` and `opts.failurePenaltyNs` are forwarded to `pool.run` (they feed a latency-aware
363
+ balancer). `ctx.key` is the query-cache key (arbitrary), NOT the integer routing key -- so this
364
+ generic adapter does not drive `pick(key)`; supply routing keys via `pool.run` directly. Imports
365
+ nothing from lite-query -- duck-typed, so `peerDependencies` stays empty.
279
366
 
280
367
  ## Gates (every session)
281
368
 
282
369
  - `npm run torture` -- `node --expose-gc test/torture.mjs`: lite-leak retention +
283
- lite-gc-profiler 0 B/op on the hot path.
284
- - `npm run test:perf` -- `node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs`:
285
- lite-perf-gate `zgcSuite` HARD zero-alloc gate + a `mustFail` teeth-check.
370
+ lite-gc-profiler -- proves `pick()` RETAINS 0 B/op (reports the retained B/op).
371
+ - `npm run test:perf` -- `node --expose-gc --min-semi-space-size=1 --max-semi-space-size=1 --test
372
+ test/perf/PerfGate.test.mjs`: lite-perf-gate `zgcSuite` -- proves `pick()` ALLOCATES 0 B/op by
373
+ scavenge counting at a pinned 1 MB semi-space + a `mustFail` teeth-check.
286
374
  - `npm run witness` -- pick throughput flatness across a pool-size sweep.
287
375
  - `npm run balance` -- peak-to-average load vs the strategy ceiling + random foil (the anchor).
288
376
  - `npm run fuzz` -- the seeded invariant fuzzer (the state-machine attack): strict-mode
@@ -291,11 +379,14 @@ duck-typed and imports NOTHING from lite-query.
291
379
 
292
380
  ## Composes with
293
381
 
294
- @zakkster/lite-di-health (eligibility writer), lite-statechart (breaker), lite-o1
382
+ @zakkster/lite-di-health (drives setEligible), lite-statechart (breaker), lite-o1
295
383
  (RandomSet / AliasTable / RingLog substrate), lite-logn (exact least-conn heap / Fenwick
296
- weights), lite-lru (sticky affinity), lite-fastbit32 (optional small-pool bitset peer),
384
+ dynamic weights -- the mutable-weight complement to WeightedRandom's static alias table),
385
+ lite-random (a SEPARATE domain -- a GAME RNG for loot tables / particles that returns an ITEM,
386
+ NOT an eligibility-aware LB index selector; use lite-pick WeightedRandom for load balancing),
387
+ lite-lru (sticky affinity), lite-fastbit32 (optional small-pool bitset peer),
297
388
  lite-query (the fetcher adapter), lite-await (hedging), lite-worker-pool (in-process
298
- consumer), lite-di-signal / lite-signal-decorators (observability). None is a HARD
299
- dependency -- each is an OPTIONAL PEER dep (peerDependenciesMeta.optional, the LiteQuery
300
- model), every seam is duck-typed over a shared TypedArray, and the kernel runs with zero
301
- peers installed. A peer is declared only when a shipped code path imports it.
389
+ consumer), lite-di-signal / lite-signal-decorators (observability). None is a dependency of
390
+ any kind -- `dependencies`, `peerDependencies` AND `peerDependenciesMeta` are all `{}`. Every
391
+ seam is duck-typed over a shared TypedArray, and the kernel runs with nothing else installed.
392
+ A peer would be declared only if a shipped code path imported it (none does today).
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "@zakkster/lite-pick",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "0.9.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), and BoundedLoad (consistent hashing with bounded loads -- sticky routing with a per-backend occupancy cap that overflows a hotspot to neighbours); the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
4
+ "version": "1.0.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 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",
9
9
  "types": "./Pick.d.ts",
10
+ "typesVersions": {
11
+ "*": {
12
+ "pool": [
13
+ "./Pool.d.ts"
14
+ ]
15
+ }
16
+ },
10
17
  "exports": {
11
18
  ".": {
12
19
  "types": "./Pick.d.ts",
@@ -29,17 +36,18 @@
29
36
  "llms.txt",
30
37
  "README.md",
31
38
  "RECIPES.md",
39
+ "GUIDE.md",
32
40
  "CHANGELOG.md",
33
41
  "LICENSE"
34
42
  ],
35
43
  "scripts": {
36
- "test": "node --test test/*.test.js",
37
- "test:types": "tsc -p test/types/tsconfig.json",
44
+ "test": "node --test test/Base.test.js test/BoundedLoad.test.js test/ConsistentHash.test.js test/LeastConn.test.js test/NQ.test.js test/P2C.test.js test/PeakEWMA.test.js test/Pool.test.js test/RoundRobin.test.js test/SED.test.js test/SmoothWRR.test.js test/WeightedRandom.test.js test/suite-list.test.js",
45
+ "test:types": "tsc -p test/types/tsconfig.json && tsc -p test/types/tsconfig.es2022.json",
38
46
  "torture": "node --expose-gc test/torture.mjs",
39
47
  "witness": "node test/witness.mjs",
40
48
  "balance": "node test/balance.mjs",
41
49
  "fuzz": "node test/fuzz.mjs",
42
- "test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
50
+ "test:perf": "node --expose-gc --min-semi-space-size=1 --max-semi-space-size=1 --test test/perf/PerfGate.test.mjs",
43
51
  "bench": "node benchmark/Matrix.mjs",
44
52
  "bench:gc": "node --expose-gc benchmark/GcBlastRadius.mjs",
45
53
  "bench:fairness": "node benchmark/Fairness.mjs",
@@ -48,6 +56,9 @@
48
56
  "bench:verify": "node benchmark/Report.mjs --verify",
49
57
  "soak": "node --expose-gc benchmark/Soak.mjs",
50
58
  "demo": "node demo/fanout.mjs",
59
+ "scope": "node --expose-gc demo/pool-scope/tui.mjs",
60
+ "scope:frames": "node --expose-gc demo/pool-scope/tui.mjs --frames 40 --scenario flapstorm",
61
+ "scope:web": "node demo/pool-scope/web/serve.mjs",
51
62
  "verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run fuzz && npm run test:perf"
52
63
  },
53
64
  "keywords": [
@@ -79,6 +90,8 @@
79
90
  "maglev",
80
91
  "bounded-load",
81
92
  "weighted-random",
93
+ "alias-method",
94
+ "vose",
82
95
  "sticky-routing",
83
96
  "client-side-load-balancing",
84
97
  "in-process",
@@ -108,9 +121,13 @@
108
121
  "access": "public"
109
122
  },
110
123
  "devDependencies": {
124
+ "@zakkster/lite-adaptive": "^1.0.0",
125
+ "@zakkster/lite-charts": "^1.24.0",
111
126
  "@zakkster/lite-gc-profiler": "^1.16.0",
112
127
  "@zakkster/lite-leak": "^1.10.0",
113
128
  "@zakkster/lite-perf-gate": "^1.4.2",
129
+ "@zakkster/lite-signal": "^1.5.2",
130
+ "@zakkster/lite-sketch": "^1.1.2",
114
131
  "load-balancers": "1.3.52",
115
132
  "loadbalance": "1.0.0",
116
133
  "typescript": "^7.0.2",