@zakkster/lite-pick 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/GUIDE.md +105 -0
- package/Pick.d.ts +85 -28
- package/Pick.js +364 -62
- package/Pool.d.ts +63 -17
- package/Pool.js +275 -62
- package/README.md +73 -36
- package/RECIPES.md +132 -36
- package/llms.txt +166 -75
- package/package.json +22 -5
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-pick
|
|
2
2
|
|
|
3
|
-
Version: 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.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
27
|
-
(65537) / `CH_PROBE_LIMIT` (64).
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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:
|
|
42
|
-
|
|
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
|
|
82
|
-
|
|
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
|
|
101
|
-
(
|
|
102
|
-
|
|
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;
|
|
107
|
-
`RangeError` on
|
|
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 (
|
|
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),
|
|
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
|
|
163
|
-
|
|
164
|
-
Float64)
|
|
165
|
-
|
|
166
|
-
(`
|
|
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)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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,
|
|
218
|
-
|
|
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
|
|
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
|
|
255
|
-
`fn(endpoint, signal)`, decrements on SETTLE (in a finally --
|
|
256
|
-
On a thrown error it keeps the failed endpoint's count ELEVATED
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
284
|
-
- `npm run test:perf` -- `node --expose-gc --max-semi-space-size=
|
|
285
|
-
lite-perf-gate `zgcSuite`
|
|
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 (
|
|
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
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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.
|
|
5
|
-
"description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, PeakEWMA (latency-aware peak-EWMA), ConsistentHash (Maglev sticky/affinity routing, minimal disruption),
|
|
4
|
+
"version": "1.0.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
|
|
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=
|
|
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",
|