@zakkster/lite-pick 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/Pick.d.ts +57 -2
- package/Pick.js +196 -5
- package/Pool.d.ts +78 -0
- package/Pool.js +147 -0
- package/README.md +73 -4
- package/llms.txt +66 -8
- package/package.json +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,95 @@ 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.5.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
M5: the ergonomic request layer at the `@zakkster/lite-pick/pool` subpath -- dispatch/settle
|
|
10
|
+
in-flight counters + distinct-endpoint failover + a duck-typed query-cache fetcher (ROADMAP.md M5).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `@zakkster/lite-pick/pool` (`Pool.js`) -- a new SUBPATH export (the kernel `Pick.js` stays a
|
|
15
|
+
single 0 B/op file; the async layer lives outside it, the lite-query `/stream` + `/await`
|
|
16
|
+
precedent, ADR 0007).
|
|
17
|
+
- `Pool` -- wraps a balancer + the caller-owned in-flight view. `run(fn, opts?)` picks an endpoint,
|
|
18
|
+
increments in-flight on dispatch, awaits `fn(endpoint, signal)`, decrements on settle (in a
|
|
19
|
+
`finally` -- net-zero per run, even on throw). On a thrown error it keeps the failed endpoint's
|
|
20
|
+
count ELEVATED and re-picks, so a load-aware strategy (P2C/LeastConn/SED/NQ) steers the next
|
|
21
|
+
attempt to a DISTINCT endpoint -- up to `opts.tries` attempts (default 1 = no failover), then
|
|
22
|
+
rejects with the last error. Rejects a `code:'LITE_PICK_NONE'` error when no endpoint is eligible;
|
|
23
|
+
`opts.signal` is passed to `fn` and, once aborted after a failure, stops failover. NOT a 0 B/op
|
|
24
|
+
path (the kernel `pick()` is) -- a normal async wrapper, disclosed.
|
|
25
|
+
- `liteQueryFetcher(pool, perEndpoint, opts?)` -- returns a `({ key, signal }) => Promise` fetcher
|
|
26
|
+
for a query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). Imports NOTHING from
|
|
27
|
+
lite-query -- duck-typed, so `peerDependencies` stays empty. `opts.tries` is the spatial failover
|
|
28
|
+
count. BOUNDARY: Pool owns SPATIAL failover across the pool; the cache owns TEMPORAL retry/backoff.
|
|
29
|
+
- `test/Pool.test.js` -- 12 tests: dispatch/settle in-flight balance (success AND throw), fail-closed
|
|
30
|
+
coding, distinct-endpoint failover, tries exhaustion (last error), abort-stops-failover, signal
|
|
31
|
+
passthrough, a 200-way CONCURRENT-consistency check (in-flight drains to all-zero -- no leak), and
|
|
32
|
+
the duck-typed fetcher.
|
|
33
|
+
- `Pool.d.ts` + `test/types/pool.test-d.ts` -- the typed surface (the type-test tsconfig gains the
|
|
34
|
+
`DOM` lib for `AbortSignal`).
|
|
35
|
+
- `demo/fanout.mjs` (`npm run demo`) -- the integration moat: least-connections fan-out over a flaky
|
|
36
|
+
pool with a replica killed mid-run, proving 0 dead picks + 0 leaked in-flight + live failover, and
|
|
37
|
+
showing the lite-query fetcher wiring. (`demo/` is not in `files[]`.)
|
|
38
|
+
- `decisions/0007-pool-adapter.md` -- the /pool-subpath home, spatial-vs-temporal retry ownership,
|
|
39
|
+
the explicit 0 B/op boundary, and the duck-typed (zero-peer) fetcher.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Version 0.4.0 -> 0.5.0 across `package.json`, `Pick.js` `VERSION` (re-exported by `Pool.js`), and
|
|
44
|
+
`llms.txt`. `exports` gains `./pool`; `files[]` gains `Pool.js` + `Pool.d.ts`.
|
|
45
|
+
- `peerDependencies` stays `{}` -- the fetcher adapter is duck-typed (ADR 0007 Fork 4).
|
|
46
|
+
|
|
47
|
+
## [0.4.0] - 2026-09-23
|
|
48
|
+
|
|
49
|
+
M4: the exact LeastConn family (IPVS `lc` / `sed` / `nq` made zero-GC) + the seeded invariant
|
|
50
|
+
fuzzer (ROADMAP.md M4).
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- `LeastConnBalancer extends BalancerBase` -- EXACT fewest-in-flight (IPVS `lc`). A full O(cap)
|
|
55
|
+
scan of the caller-owned in-flight view returning the eligible node with the lowest count
|
|
56
|
+
(lowest index on a tie); the deterministic complement to P2C's O(1) approximation. In-flight
|
|
57
|
+
is read LIVE (no `setWeight`, no derived aggregate -- the caller may mutate it directly).
|
|
58
|
+
0 B/op. Fails closed (`PICK_NONE`) when the whole pool is down.
|
|
59
|
+
- `SedBalancer extends BalancerBase` -- shortest-expected-delay (IPVS `sed`). Returns the
|
|
60
|
+
eligible, positive-weight node minimizing `(inflight + 1) / weight`. BOTH inflight and weights
|
|
61
|
+
are caller-owned, read live. A weight-0 eligible node is not a candidate; all-zero-weight fails
|
|
62
|
+
closed even with the pool up. O(cap), 0 B/op.
|
|
63
|
+
- `NqBalancer extends BalancerBase` -- never-queue (IPVS `nq`). Returns the FIRST idle eligible
|
|
64
|
+
positive-weight node (in-flight 0) if one exists, else the SED minimum -- the worker-pool fit.
|
|
65
|
+
O(cap) worst case, O(1) when an early node is idle, 0 B/op.
|
|
66
|
+
- **The invariant fuzzer** (`test/fuzz.mjs` + the reusable `test/invariants.mjs` checker): a
|
|
67
|
+
seeded, property-based state-machine attack asserting STATE-SYNCHRONISATION invariants after
|
|
68
|
+
EVERY op (strict mode) per strategy -- `live` and (SmoothWRR) `_totalEligibleWeight` stay EXACT
|
|
69
|
+
vs a manual recompute, owned Float64 accumulators stay finite, `PICK_NONE` holds IFF the
|
|
70
|
+
pickable mass is 0, and LeastConn/SED/NQ return the true optimum (NQ its idle-first rule). Prints
|
|
71
|
+
the seed on failure for byte-for-byte replay; CI runs a fixed seed + a random seed + a regression
|
|
72
|
+
corpus + a pathological corpus (max-weight 0xFFFFFFFF summed, all-zero-weight while live>0,
|
|
73
|
+
single-node). Retrofits M2 SmoothWRR. Wired into `npm run fuzz` and `npm run verify`.
|
|
74
|
+
- `test/LeastConn.test.js` (10), `test/SED.test.js` (8), `test/NQ.test.js` (10) -- boundary +
|
|
75
|
+
behaviour suites (exact minimum, weight-0 exclusion, feedback-loop balance, idle-first fan-out,
|
|
76
|
+
never-a-down-index under 200k churned picks).
|
|
77
|
+
- Balance anchors (`test/balance.mjs`): LeastConn is greedy-perfect (max-minus-min <= 1, tighter
|
|
78
|
+
than P2C's gap; peak <= P2C's on the same run); SED converges to load proportional-to-weight
|
|
79
|
+
(< 1% drift; weighted-imbalance far below a random foil); NQ fans the first n dispatches out to
|
|
80
|
+
n distinct idle workers.
|
|
81
|
+
- Gates extended for all three: torture (retention + 0 B/op `pick()` phases 6-8), PerfGate
|
|
82
|
+
(three `zgcSuite` scenarios + three `mustFail` teeth-checks), witness (`linear` complexity ->
|
|
83
|
+
flat work-rate), benchmark matrix (LeastConn/SED/NQ subjects + a per-pick-allocating
|
|
84
|
+
`lc-array` foil).
|
|
85
|
+
- `decisions/0006-leastconn-family.md` -- P2C-is-already-least-conn (no redundant alias), exact-
|
|
86
|
+
O(cap)-scan-first, caller-owned live-read counters (the documented asymmetry with SmoothWRR),
|
|
87
|
+
and the confirmed-but-deferred lite-logn `BinaryHeap` exact-O(log n) peer seam.
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
|
|
91
|
+
- Version 0.3.0 -> 0.4.0 across `package.json`, `Pick.js` `VERSION`, and `llms.txt`.
|
|
92
|
+
- Folded the invariant-fuzz testing methodology (RESEARCH s3, ROADMAP s3/s0) into an adopted,
|
|
93
|
+
shipped gate: vectors 2 (flap chaos) + 3 (zero-GC soak) were already covered; the net-new
|
|
94
|
+
seeded state-synchronisation fuzzer is now `test/fuzz.mjs`.
|
|
95
|
+
|
|
7
96
|
## [0.3.0] - 2026-09-23
|
|
8
97
|
|
|
9
98
|
M3: P2C (power-of-two-choices), the headline strategy -- and the balance-quality anchor
|
package/Pick.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-pick -- TypeScript declarations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* remaining strategy classes (
|
|
4
|
+
* M4 (0.4.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
|
|
5
|
+
* (LeastConn/SED/NQ). The remaining strategy classes (PeakEWMA, ConsistentHash, BoundedLoad,
|
|
6
6
|
* WeightedRandom) are added one per session.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -105,3 +105,58 @@ export class P2cBalancer extends BalancerBase {
|
|
|
105
105
|
/** Pick by power-of-two-choices (lower in-flight of two random eligibles), or `PICK_NONE`. */
|
|
106
106
|
pick(): number;
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* LeastConnBalancer -- EXACT fewest-in-flight (M4, IPVS `lc`). A full O(cap) scan of the
|
|
111
|
+
* caller-owned in-flight view returning the eligible node with the lowest count (lowest index
|
|
112
|
+
* on a tie) -- the deterministic complement to P2C's O(1) approximation. In-flight counts are
|
|
113
|
+
* caller-owned and read LIVE (no `setWeight`, no derived aggregate). 0 B/op. Fails closed
|
|
114
|
+
* (`PICK_NONE`) when the whole pool is down.
|
|
115
|
+
*/
|
|
116
|
+
export class LeastConnBalancer extends BalancerBase {
|
|
117
|
+
/**
|
|
118
|
+
* @param capacity endpoint count (fixed).
|
|
119
|
+
* @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
120
|
+
* @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
|
|
121
|
+
*/
|
|
122
|
+
constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array);
|
|
123
|
+
/** The eligible node with the fewest in-flight requests, or `PICK_NONE`. O(cap). */
|
|
124
|
+
pick(): number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* SedBalancer -- shortest-expected-delay (M4, IPVS `sed`). Returns the eligible, positive-weight
|
|
129
|
+
* node minimizing `(inflight + 1) / weight`; converges to load proportional-to-weight. BOTH
|
|
130
|
+
* inflight and weights are caller-owned Uint32Arrays, read LIVE (no `setWeight`, no derived
|
|
131
|
+
* aggregate). A weight-0 eligible node is not a candidate. O(cap), 0 B/op. Fails closed
|
|
132
|
+
* (`PICK_NONE`) when no eligible node has a positive weight.
|
|
133
|
+
*/
|
|
134
|
+
export class SedBalancer extends BalancerBase {
|
|
135
|
+
/**
|
|
136
|
+
* @param capacity endpoint count (fixed).
|
|
137
|
+
* @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
138
|
+
* @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
|
|
139
|
+
* @param weights per-endpoint weights (length >= capacity), caller-owned, read live.
|
|
140
|
+
*/
|
|
141
|
+
constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, weights: Uint32Array);
|
|
142
|
+
/** The eligible node minimizing (inflight+1)/weight, or `PICK_NONE`. O(cap). */
|
|
143
|
+
pick(): number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* NqBalancer -- never-queue (M4, IPVS `nq`). Returns the first idle eligible positive-weight
|
|
148
|
+
* node (in-flight 0) if one exists, else the SED minimum -- the worker-pool fit. BOTH inflight
|
|
149
|
+
* and weights are caller-owned, read LIVE. O(cap) worst case, O(1) when an early node is idle,
|
|
150
|
+
* 0 B/op. Fails closed (`PICK_NONE`) when no eligible node has a positive weight.
|
|
151
|
+
*/
|
|
152
|
+
export class NqBalancer extends BalancerBase {
|
|
153
|
+
/**
|
|
154
|
+
* @param capacity endpoint count (fixed).
|
|
155
|
+
* @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
156
|
+
* @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
|
|
157
|
+
* @param weights per-endpoint weights (length >= capacity), caller-owned, read live.
|
|
158
|
+
*/
|
|
159
|
+
constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, weights: Uint32Array);
|
|
160
|
+
/** The first idle eligible node, else the SED minimum, or `PICK_NONE`. O(cap). */
|
|
161
|
+
pick(): number;
|
|
162
|
+
}
|
package/Pick.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* M4 (0.4.0): substrate seams + six strategies -- RoundRobin, SmoothWRR, P2C, and the
|
|
5
|
+
* exact LeastConn family (LeastConn, SED, NQ). This file ships:
|
|
5
6
|
*
|
|
6
7
|
* - VERSION the single source-of-truth version stamp (3-place sync).
|
|
7
8
|
* - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
|
|
@@ -15,8 +16,16 @@
|
|
|
15
16
|
* the eligibility view, skipping down nodes, O(1) amortized, 0 B/op.
|
|
16
17
|
* - SmoothWRRBalancer the weighted default: nginx smooth weighted round-robin over
|
|
17
18
|
* caller-configured integer weights, O(cap)/pick, 0 B/op.
|
|
18
|
-
* - P2cBalancer
|
|
19
|
-
*
|
|
19
|
+
* - P2cBalancer power-of-two-choices over caller-owned in-flight counts; the ln ln n
|
|
20
|
+
* balance ceiling, O(1)/pick, 0 B/op. This IS the O(1) least-connections
|
|
21
|
+
* APPROXIMATION ("P2C-least-conn") -- the LeastConn family below is exact.
|
|
22
|
+
* - LeastConnBalancer EXACT fewest-in-flight (IPVS `lc`): a full O(cap) scan of the
|
|
23
|
+
* caller-owned in-flight view, 0 B/op. The deterministic complement to
|
|
24
|
+
* P2C's O(1) approximation.
|
|
25
|
+
* - SedBalancer shortest-expected-delay (IPVS `sed`): minimizes (inflight+1)/weight --
|
|
26
|
+
* charges the NEW request's marginal cost. O(cap)/pick, 0 B/op.
|
|
27
|
+
* - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
|
|
28
|
+
* exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
|
|
20
29
|
*
|
|
21
30
|
* The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
|
|
22
31
|
* It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
|
|
@@ -24,13 +33,20 @@
|
|
|
24
33
|
* counters live OUTSIDE the kernel. The steady-state pick path allocates 0 B/op.
|
|
25
34
|
*
|
|
26
35
|
* Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
|
|
27
|
-
* P2C [M3], LeastConn/SED/NQ, PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom
|
|
36
|
+
* P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom
|
|
37
|
+
* [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
|
|
38
|
+
* BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
|
|
39
|
+
*
|
|
40
|
+
* M5 (0.5.0) adds the ergonomic request layer at the @zakkster/lite-pick/pool subpath (a
|
|
41
|
+
* SEPARATE file, Pool.js -- the async dispatch/settle counter wrapper + distinct-endpoint
|
|
42
|
+
* failover + a duck-typed query-cache fetcher). This kernel file stays PURE and 0 B/op; the
|
|
43
|
+
* async Pool lives outside it (decisions/0007, the lite-query /stream + /await subpath precedent).
|
|
28
44
|
*
|
|
29
45
|
* Zero runtime dependencies. node:test only. ESM, single file, tree-shakeable.
|
|
30
46
|
*/
|
|
31
47
|
|
|
32
48
|
/** Version stamp. Synced across package.json and llms.txt (three-place rule). */
|
|
33
|
-
export const VERSION = '0.
|
|
49
|
+
export const VERSION = '0.5.0';
|
|
34
50
|
|
|
35
51
|
/**
|
|
36
52
|
* Fail-closed sentinel returned by pick() when no endpoint is eligible.
|
|
@@ -383,3 +399,178 @@ export class P2cBalancer extends BalancerBase {
|
|
|
383
399
|
return this._inflight[b] < this._inflight[a] ? b : a;
|
|
384
400
|
}
|
|
385
401
|
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* LeastConnBalancer -- EXACT fewest-in-flight (M4), IPVS `lc` made zero-GC.
|
|
405
|
+
*
|
|
406
|
+
* `pick()` scans the whole pool and returns the eligible endpoint with the lowest in-flight
|
|
407
|
+
* count -- the deterministic, exact complement to P2cBalancer's O(1) two-choice APPROXIMATION
|
|
408
|
+
* of the same objective. Where P2C trades a tiny balance gap for O(1), LeastConn pays O(cap)
|
|
409
|
+
* for the exact minimum: in a closed feedback loop (the caller increments inflight on dispatch
|
|
410
|
+
* and decrements on settle) it is the greedy-optimal assignment -- max-minus-min load stays
|
|
411
|
+
* within 1 (test/balance.mjs proves the perfect balance, tighter than P2C's ln ln n gap).
|
|
412
|
+
*
|
|
413
|
+
* Ownership (ADR 0001, ADR 0006): in-flight counts live in the CALLER's Uint32Array, read-only
|
|
414
|
+
* to `pick()`. LeastConn owns NO derived state beyond the base `_live` -- it reads inflight
|
|
415
|
+
* live each scan, so (unlike SmoothWRR's weights) the caller may mutate the inflight view
|
|
416
|
+
* directly between picks; that is the whole point of the shared-counter seam.
|
|
417
|
+
*
|
|
418
|
+
* Bound: O(cap) per pick (one scan). Steady-state pick(): integer compares + one index write,
|
|
419
|
+
* no object/closure/array created -- 0 B/op. Tie-break is the lowest index (deterministic);
|
|
420
|
+
* the feedback loop breaks a startup all-zero tie by raising the picked node's count. Fails
|
|
421
|
+
* closed (PICK_NONE) when the whole pool is down.
|
|
422
|
+
*
|
|
423
|
+
* NOTE: without a feedback loop (inflight never changes) LeastConn returns the same lowest-load
|
|
424
|
+
* index every call -- correct by contract (it IS the least-loaded), but the caller must feed
|
|
425
|
+
* load back for it to distribute. The M5 lite-query adapter provides that increment/decrement.
|
|
426
|
+
*/
|
|
427
|
+
export class LeastConnBalancer extends BalancerBase {
|
|
428
|
+
/**
|
|
429
|
+
* @param {number} capacity endpoint count (fixed).
|
|
430
|
+
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
431
|
+
* @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
|
|
432
|
+
* caller-owned and only READ here.
|
|
433
|
+
*/
|
|
434
|
+
constructor(capacity, eligible, inflight) {
|
|
435
|
+
super(capacity, eligible);
|
|
436
|
+
if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
|
|
437
|
+
throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
|
|
438
|
+
}
|
|
439
|
+
this._inflight = inflight;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The eligible endpoint with the fewest in-flight requests, or PICK_NONE (fail closed).
|
|
444
|
+
* O(cap), zero-alloc. Lowest index on a tie.
|
|
445
|
+
* @returns {number}
|
|
446
|
+
*/
|
|
447
|
+
pick() {
|
|
448
|
+
if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
|
|
449
|
+
const cap = this._cap, el = this._eligible, inf = this._inflight;
|
|
450
|
+
let best = -1, bestLoad = 0;
|
|
451
|
+
for (let i = 0; i < cap; i++) {
|
|
452
|
+
if (el[i]) {
|
|
453
|
+
const c = inf[i];
|
|
454
|
+
if (best < 0 || c < bestLoad) { best = i; bestLoad = c; }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return best; // best >= 0 guaranteed while _live > 0
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* SedBalancer -- shortest-expected-delay (M4), IPVS `sed` made zero-GC.
|
|
463
|
+
*
|
|
464
|
+
* `pick()` returns the eligible endpoint that minimizes `(inflight + 1) / weight` -- the
|
|
465
|
+
* expected delay if the NEW request were placed there (the +1 charges the request itself).
|
|
466
|
+
* Higher-weight endpoints absorb proportionally more load; SED converges to inflight/weight
|
|
467
|
+
* equal across the pool (test/balance.mjs proves the weighted fairness). It is the weighted
|
|
468
|
+
* generalization of least-connections: with all weights equal, SED and LeastConn agree.
|
|
469
|
+
*
|
|
470
|
+
* Ownership (ADR 0001, ADR 0006): BOTH inflight AND weights are caller-owned Uint32Arrays,
|
|
471
|
+
* read-only to `pick()`. SED (like LeastConn, unlike SmoothWRR) owns NO derived weight
|
|
472
|
+
* aggregate -- it reads weights live each scan, so there is no `setWeight` and no total to
|
|
473
|
+
* desync: the caller may retune weights directly between picks. An eligible endpoint whose
|
|
474
|
+
* weight is 0 is NOT a candidate (its expected delay is infinite); if every eligible endpoint
|
|
475
|
+
* has weight 0, `pick()` fails closed.
|
|
476
|
+
*
|
|
477
|
+
* Bound: O(cap) per pick (one scan, one Float64 division per eligible node), 0 B/op. Lowest
|
|
478
|
+
* index on a tie. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
|
|
479
|
+
*/
|
|
480
|
+
export class SedBalancer extends BalancerBase {
|
|
481
|
+
/**
|
|
482
|
+
* @param {number} capacity endpoint count (fixed).
|
|
483
|
+
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
484
|
+
* @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity), caller-owned.
|
|
485
|
+
* @param {Uint32Array} weights per-endpoint weights (length >= capacity), caller-owned; read live.
|
|
486
|
+
*/
|
|
487
|
+
constructor(capacity, eligible, inflight, weights) {
|
|
488
|
+
super(capacity, eligible);
|
|
489
|
+
if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
|
|
490
|
+
throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
|
|
491
|
+
}
|
|
492
|
+
if (!(weights instanceof Uint32Array) || weights.length < capacity) {
|
|
493
|
+
throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
|
|
494
|
+
}
|
|
495
|
+
this._inflight = inflight;
|
|
496
|
+
this._weights = weights;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The eligible endpoint minimizing (inflight + 1) / weight, or PICK_NONE (fail closed).
|
|
501
|
+
* O(cap), zero-alloc. Lowest index on a tie; weight-0 nodes are not candidates.
|
|
502
|
+
* @returns {number}
|
|
503
|
+
*/
|
|
504
|
+
pick() {
|
|
505
|
+
if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
|
|
506
|
+
const cap = this._cap, el = this._eligible, inf = this._inflight, wt = this._weights;
|
|
507
|
+
let best = -1, bestScore = Infinity;
|
|
508
|
+
for (let i = 0; i < cap; i++) {
|
|
509
|
+
if (el[i]) {
|
|
510
|
+
const w = wt[i];
|
|
511
|
+
if (w > 0) {
|
|
512
|
+
const score = (inf[i] + 1) / w;
|
|
513
|
+
if (score < bestScore) { bestScore = score; best = i; }
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return best; // -1 when every eligible node has weight 0
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* NqBalancer -- never-queue (M4), IPVS `nq` made zero-GC.
|
|
523
|
+
*
|
|
524
|
+
* `pick()` returns an IDLE eligible endpoint (in-flight 0, positive weight) the instant one
|
|
525
|
+
* exists -- never leaving a free server idle while queueing elsewhere -- and otherwise falls
|
|
526
|
+
* back to SED (`(inflight + 1) / weight`). This is the best fit for the in-process worker-pool
|
|
527
|
+
* case: spin up idle capacity first, only weigh expected delay once everyone is busy.
|
|
528
|
+
*
|
|
529
|
+
* Ownership (ADR 0001, ADR 0006): identical to SED -- caller-owned inflight + weights, read
|
|
530
|
+
* live, no derived aggregate. The first idle eligible node (lowest index, in-flight 0, weight
|
|
531
|
+
* > 0) short-circuits the scan.
|
|
532
|
+
*
|
|
533
|
+
* Bound: O(cap) worst case (no idle node -> a full SED scan); O(1) when a low-index endpoint is
|
|
534
|
+
* idle. 0 B/op. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
|
|
535
|
+
*/
|
|
536
|
+
export class NqBalancer extends BalancerBase {
|
|
537
|
+
/**
|
|
538
|
+
* @param {number} capacity endpoint count (fixed).
|
|
539
|
+
* @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
|
|
540
|
+
* @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity), caller-owned.
|
|
541
|
+
* @param {Uint32Array} weights per-endpoint weights (length >= capacity), caller-owned; read live.
|
|
542
|
+
*/
|
|
543
|
+
constructor(capacity, eligible, inflight, weights) {
|
|
544
|
+
super(capacity, eligible);
|
|
545
|
+
if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
|
|
546
|
+
throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
|
|
547
|
+
}
|
|
548
|
+
if (!(weights instanceof Uint32Array) || weights.length < capacity) {
|
|
549
|
+
throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
|
|
550
|
+
}
|
|
551
|
+
this._inflight = inflight;
|
|
552
|
+
this._weights = weights;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The first idle eligible endpoint (in-flight 0, weight > 0), else the SED minimum, else
|
|
557
|
+
* PICK_NONE (fail closed). O(cap) worst case, O(1) when an early node is idle. Zero-alloc.
|
|
558
|
+
* @returns {number}
|
|
559
|
+
*/
|
|
560
|
+
pick() {
|
|
561
|
+
if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
|
|
562
|
+
const cap = this._cap, el = this._eligible, inf = this._inflight, wt = this._weights;
|
|
563
|
+
let best = -1, bestScore = Infinity;
|
|
564
|
+
for (let i = 0; i < cap; i++) {
|
|
565
|
+
if (el[i]) {
|
|
566
|
+
const w = wt[i];
|
|
567
|
+
if (w > 0) {
|
|
568
|
+
if (inf[i] === 0) return i; // idle: never queue -- take it immediately
|
|
569
|
+
const score = (inf[i] + 1) / w;
|
|
570
|
+
if (score < bestScore) { bestScore = score; best = i; }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return best; // -1 when every eligible node has weight 0
|
|
575
|
+
}
|
|
576
|
+
}
|
package/Pool.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-pick/pool -- TypeScript declarations (M5).
|
|
3
|
+
*
|
|
4
|
+
* The async request layer over the 0 B/op kernel: dispatch/settle in-flight counter
|
|
5
|
+
* ergonomics + distinct-endpoint failover, plus a duck-typed query-cache fetcher adapter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The source-of-truth version stamp (re-exported from the core). */
|
|
9
|
+
export const VERSION: string;
|
|
10
|
+
|
|
11
|
+
/** The minimal balancer shape Pool drives (any lite-pick strategy satisfies it). */
|
|
12
|
+
export interface Balancer {
|
|
13
|
+
pick(): number;
|
|
14
|
+
readonly capacity: number;
|
|
15
|
+
readonly live: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Options for `Pool.run`. */
|
|
19
|
+
export interface RunOptions {
|
|
20
|
+
/** Passed to `fn`; when already aborted after a failure, stops failover (abort propagates). */
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
/** Max distinct-endpoint attempts (default 1 = no failover). */
|
|
23
|
+
tries?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pool -- wraps a balancer + the caller-owned in-flight view with dispatch/settle counter
|
|
28
|
+
* ergonomics and distinct-endpoint failover. `run` increments in-flight on dispatch, decrements
|
|
29
|
+
* on settle, and on a thrown error keeps the failed endpoint elevated so a load-aware strategy
|
|
30
|
+
* steers the next attempt elsewhere. NOT a 0 B/op path (the kernel `pick()` is).
|
|
31
|
+
*/
|
|
32
|
+
export class Pool {
|
|
33
|
+
/**
|
|
34
|
+
* @param balancer a lite-pick strategy (or duck-compatible) with `pick()`, `capacity`, `live`.
|
|
35
|
+
* @param inflight the SAME caller-owned in-flight view the balancer reads (length >= capacity).
|
|
36
|
+
*/
|
|
37
|
+
constructor(balancer: Balancer, inflight: Uint32Array);
|
|
38
|
+
/** The wrapped balancer. */
|
|
39
|
+
readonly balancer: Balancer;
|
|
40
|
+
/** The shared in-flight view Pool increments on dispatch and decrements on settle. */
|
|
41
|
+
readonly inflight: Uint32Array;
|
|
42
|
+
/**
|
|
43
|
+
* Run `fn` against a chosen endpoint (in-flight incremented on dispatch, decremented on
|
|
44
|
+
* settle), with up to `opts.tries` distinct-endpoint failover attempts on a throw. Rejects
|
|
45
|
+
* with a `LITE_PICK_NONE`-coded error when no endpoint is eligible, or the last error when
|
|
46
|
+
* every attempt fails.
|
|
47
|
+
*/
|
|
48
|
+
run<T>(fn: (endpoint: number, signal?: AbortSignal) => Promise<T> | T, opts?: RunOptions): Promise<T>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Context passed to the per-endpoint fetcher. */
|
|
52
|
+
export interface PerEndpointContext {
|
|
53
|
+
endpoint: number;
|
|
54
|
+
key: any;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Context a query cache passes to the produced fetcher (lite-query's fetcher shape). */
|
|
59
|
+
export interface FetcherContext {
|
|
60
|
+
key: any;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Options for `liteQueryFetcher`. */
|
|
65
|
+
export interface FetcherOptions {
|
|
66
|
+
/** Spatial failover attempts across the pool (default 1). */
|
|
67
|
+
tries?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Adapt a Pool into a `({ key, signal }) => Promise` fetcher for a query cache (lite-query, or
|
|
72
|
+
* any fetcher-shaped consumer). Imports nothing from lite-query -- duck-typed, zero peers.
|
|
73
|
+
*/
|
|
74
|
+
export function liteQueryFetcher<T>(
|
|
75
|
+
pool: Pool,
|
|
76
|
+
perEndpoint: (ctx: PerEndpointContext) => Promise<T> | T,
|
|
77
|
+
opts?: FetcherOptions,
|
|
78
|
+
): (ctx: FetcherContext) => Promise<T>;
|
package/Pool.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-pick/pool -- the ergonomic request wrapper (M5).
|
|
3
|
+
*
|
|
4
|
+
* import { Pool, liteQueryFetcher } from '@zakkster/lite-pick/pool';
|
|
5
|
+
*
|
|
6
|
+
* The kernel (Pick.js) is a PURE, 0 B/op selector: pick() -> index. Real callers also need
|
|
7
|
+
* the counter ergonomics ADR 0001 always promised -- increment in-flight on DISPATCH,
|
|
8
|
+
* decrement on SETTLE, and on a failure re-pick a DIFFERENT endpoint. That layer is async
|
|
9
|
+
* (it wraps the request lifecycle), so it lives OUTSIDE the single-file 0 B/op kernel, in
|
|
10
|
+
* this separate subpath file (the lite-query precedent: /stream, /await are subpath entries).
|
|
11
|
+
*
|
|
12
|
+
* BOUNDARY (decisions/0007): Pool owns SPATIAL failover -- try up to `tries` DISTINCT endpoints,
|
|
13
|
+
* once each, on a thrown error. It does NOT own TEMPORAL retry (backoff, staleness) -- that
|
|
14
|
+
* belongs to the caller / a query cache (lite-query's `retry`). The two never double-own: Pool
|
|
15
|
+
* moves ACROSS the pool once; the caller retries the whole operation over TIME.
|
|
16
|
+
*
|
|
17
|
+
* ZERO-GC boundary: the kernel `pick()` is 0 B/op; `Pool.run` is a NORMAL async wrapper -- the
|
|
18
|
+
* request it wraps already allocates a promise -- adding only O(1) integer counter ops per
|
|
19
|
+
* attempt plus one small per-run bookkeeping array. It is NOT held to the kernel's 0 B/op bar.
|
|
20
|
+
*
|
|
21
|
+
* Duck-typed, zero HARD deps, zero peers: `liteQueryFetcher` returns a value shaped like
|
|
22
|
+
* lite-query's `fetcher` (`({ key, signal }) => Promise`) WITHOUT importing lite-query, so
|
|
23
|
+
* `peerDependencies` stays empty and the same helper serves any fetcher-shaped consumer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { VERSION, PICK_NONE } from './Pick.js';
|
|
27
|
+
|
|
28
|
+
/** Re-exported so a /pool-only importer can read the version without importing the core. */
|
|
29
|
+
export { VERSION };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pool -- wraps a balancer + the caller-owned in-flight view with the dispatch/settle counter
|
|
33
|
+
* ergonomics and distinct-endpoint failover. The balancer is duck-typed (anything with
|
|
34
|
+
* `pick() -> number`, `capacity`, and `live`), so a Pool can drive any lite-pick strategy or a
|
|
35
|
+
* compatible custom one.
|
|
36
|
+
*/
|
|
37
|
+
export class Pool {
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ pick(): number, capacity: number, live: number }} balancer a lite-pick
|
|
40
|
+
* strategy (RoundRobin / SmoothWRR / P2C / LeastConn / SED / NQ) or a duck-compatible one.
|
|
41
|
+
* @param {Uint32Array} inflight the SAME caller-owned in-flight view the balancer reads
|
|
42
|
+
* (length >= balancer.capacity). Pool is the increment/decrement authority around run().
|
|
43
|
+
*/
|
|
44
|
+
constructor(balancer, inflight) {
|
|
45
|
+
if (!balancer || typeof balancer.pick !== 'function' ||
|
|
46
|
+
typeof balancer.capacity !== 'number' || typeof balancer.live !== 'number') {
|
|
47
|
+
throw new TypeError('[lite-pick] Pool needs a balancer with pick(), capacity, and live');
|
|
48
|
+
}
|
|
49
|
+
if (!(inflight instanceof Uint32Array) || inflight.length < balancer.capacity) {
|
|
50
|
+
throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= balancer.capacity');
|
|
51
|
+
}
|
|
52
|
+
this._b = balancer;
|
|
53
|
+
this._inflight = inflight;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The wrapped balancer. */
|
|
57
|
+
get balancer() {
|
|
58
|
+
return this._b;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The shared in-flight view Pool increments on dispatch and decrements on settle. */
|
|
62
|
+
get inflight() {
|
|
63
|
+
return this._inflight;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run `fn` against a chosen endpoint, incrementing its in-flight on dispatch and decrementing
|
|
68
|
+
* on settle. On a thrown error, keep the failed endpoint's count ELEVATED and re-pick -- so a
|
|
69
|
+
* load-aware strategy (P2C / LeastConn / SED / NQ) naturally steers the next attempt to a
|
|
70
|
+
* DIFFERENT endpoint -- up to `tries` attempts, then throw the last error. All counts this run
|
|
71
|
+
* raised are released before returning or throwing (net-zero per run).
|
|
72
|
+
*
|
|
73
|
+
* @template T
|
|
74
|
+
* @param {(endpoint: number, signal?: AbortSignal) => (Promise<T>|T)} fn the per-endpoint work.
|
|
75
|
+
* @param {{ signal?: AbortSignal, tries?: number }} [opts] `tries` (default 1 = no failover)
|
|
76
|
+
* is the max number of distinct-endpoint attempts; `signal` is passed to `fn` and, when
|
|
77
|
+
* already aborted after a failure, stops failover (the abort propagates, no re-pick).
|
|
78
|
+
* @returns {Promise<T>}
|
|
79
|
+
*/
|
|
80
|
+
async run(fn, opts) {
|
|
81
|
+
if (typeof fn !== 'function') throw new TypeError('[lite-pick] Pool.run needs a function');
|
|
82
|
+
const rawTries = opts && opts.tries != null ? (opts.tries | 0) : 1;
|
|
83
|
+
const tries = rawTries > 0 ? rawTries : 1;
|
|
84
|
+
const signal = opts ? opts.signal : undefined;
|
|
85
|
+
const inflight = this._inflight, b = this._b;
|
|
86
|
+
const held = []; // endpoints incremented this run (kept elevated across failover)
|
|
87
|
+
let lastErr;
|
|
88
|
+
try {
|
|
89
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
90
|
+
const i = b.pick();
|
|
91
|
+
if (i === PICK_NONE) {
|
|
92
|
+
if (attempt === 0) {
|
|
93
|
+
const e = new Error('[lite-pick] no eligible endpoint');
|
|
94
|
+
e.code = 'LITE_PICK_NONE';
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
break; // pool went fully down mid-failover: surface the last error
|
|
98
|
+
}
|
|
99
|
+
inflight[i] = (inflight[i] + 1) >>> 0;
|
|
100
|
+
held.push(i);
|
|
101
|
+
try {
|
|
102
|
+
return await fn(i, signal);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
lastErr = err;
|
|
105
|
+
if (signal && signal.aborted) throw err; // abort: stop failover, propagate
|
|
106
|
+
}
|
|
107
|
+
// keep inflight[i] elevated so the next pick() steers to a different endpoint
|
|
108
|
+
}
|
|
109
|
+
throw lastErr;
|
|
110
|
+
} finally {
|
|
111
|
+
for (let k = 0; k < held.length; k++) {
|
|
112
|
+
const j = held[k];
|
|
113
|
+
inflight[j] = inflight[j] > 0 ? inflight[j] - 1 : 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* liteQueryFetcher -- adapt a Pool into a fetcher for a query cache (lite-query's `fetcher`, or
|
|
121
|
+
* any `({ key, signal }) => Promise` consumer). Duck-typed: imports NOTHING from lite-query.
|
|
122
|
+
*
|
|
123
|
+
* const fetcher = liteQueryFetcher(pool, ({ endpoint, key, signal }) =>
|
|
124
|
+
* fetch(urls[endpoint] + '/' + key[0], { signal }).then(r => r.json()), { tries: 2 });
|
|
125
|
+
* query(qc, { key: ['users'], fetcher });
|
|
126
|
+
*
|
|
127
|
+
* The query cache owns TEMPORAL retry/backoff/staleness; the Pool owns SPATIAL failover across
|
|
128
|
+
* the pool (`tries`). Wiring both is deliberate layering, never double-ownership (ADR 0007).
|
|
129
|
+
*
|
|
130
|
+
* @template T
|
|
131
|
+
* @param {Pool} pool
|
|
132
|
+
* @param {(ctx: { endpoint: number, key: any, signal?: AbortSignal }) => (Promise<T>|T)} perEndpoint
|
|
133
|
+
* @param {{ tries?: number }} [opts] spatial failover attempts (default 1).
|
|
134
|
+
* @returns {(ctx: { key: any, signal?: AbortSignal }) => Promise<T>}
|
|
135
|
+
*/
|
|
136
|
+
export function liteQueryFetcher(pool, perEndpoint, opts) {
|
|
137
|
+
if (!(pool instanceof Pool)) throw new TypeError('[lite-pick] liteQueryFetcher needs a Pool');
|
|
138
|
+
if (typeof perEndpoint !== 'function') {
|
|
139
|
+
throw new TypeError('[lite-pick] liteQueryFetcher needs a per-endpoint function');
|
|
140
|
+
}
|
|
141
|
+
const tries = opts && opts.tries != null ? opts.tries : 1;
|
|
142
|
+
return function fetcher(ctx) {
|
|
143
|
+
const key = ctx ? ctx.key : undefined;
|
|
144
|
+
const signal = ctx ? ctx.signal : undefined;
|
|
145
|
+
return pool.run((endpoint, sig) => perEndpoint({ endpoint, key, signal: sig }), { signal, tries });
|
|
146
|
+
};
|
|
147
|
+
}
|
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.5.0 ships six strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family** -- 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 -- PeakEWMA, 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: M5 (v0.5.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**, and the **`@zakkster/lite-pick/pool`** request layer. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), and **SED tracks weight within 1%** -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M5 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), and pool-adapter (0007) design forks.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
npm install @zakkster/lite-pick
|
|
@@ -102,6 +102,43 @@ The proof (from `test/balance.mjs`, the library's analytical anchor):
|
|
|
102
102
|
|
|
103
103
|
P2C's gap stays a small `ln ln n` constant while the random foil's grows with the pool. `pick()` is **0 B/op** and **O(1)** (two expected-O(1) rejection draws + a compare); in-flight counts are your caller-owned `Uint32Array` ([ADR 0005](./decisions/0005-p2c-draw.md)).
|
|
104
104
|
|
|
105
|
+
## LeastConn / SED / NQ -- the exact load-aware family (v0.4.0)
|
|
106
|
+
|
|
107
|
+
P2C above is the **O(1) approximation** of least-connections. When you want the **exact** least-loaded endpoint -- and the weighted (SED) and worker-pool (NQ) variants -- M4 ships the IPVS `lc` / `sed` / `nq` cohort, made zero-GC. All three read your caller-owned `inflight` (and, for SED/NQ, `weights`) **live** -- no `setWeight`, no derived total, so you mutate the counters directly in your feedback loop ([ADR 0006](./decisions/0006-leastconn-family.md)).
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
import { LeastConnBalancer, SedBalancer, NqBalancer } from '@zakkster/lite-pick';
|
|
111
|
+
|
|
112
|
+
const eligible = Uint8Array.from([1, 1, 1, 1]);
|
|
113
|
+
const inflight = new Uint32Array(4); // YOU own this; increment on dispatch, decrement on settle
|
|
114
|
+
|
|
115
|
+
// LeastConn: the EXACT fewest-in-flight endpoint (O(cap) scan). In a feedback loop it is
|
|
116
|
+
// greedy-optimal -- load spreads within 1 of the mean (tighter than P2C's ln ln n gap).
|
|
117
|
+
const lc = new LeastConnBalancer(4, eligible, inflight);
|
|
118
|
+
const a = lc.pick(); inflight[a]++;
|
|
119
|
+
|
|
120
|
+
// SED (shortest-expected-delay): minimizes (inflight + 1) / weight -- higher weight absorbs
|
|
121
|
+
// proportionally more load. A weight-0 eligible node is never a candidate.
|
|
122
|
+
const weights = Uint32Array.from([1, 2, 3, 4]);
|
|
123
|
+
const sed = new SedBalancer(4, eligible, inflight, weights);
|
|
124
|
+
const b = sed.pick(); inflight[b]++;
|
|
125
|
+
|
|
126
|
+
// NQ (never-queue): jump to an IDLE endpoint (in-flight 0) the instant one exists, else SED.
|
|
127
|
+
// The best fit for a worker pool -- fill free workers before queueing anywhere.
|
|
128
|
+
const nq = new NqBalancer(4, eligible, inflight, weights);
|
|
129
|
+
const c = nq.pick(); inflight[c]++;
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The proof (from `test/balance.mjs`):
|
|
133
|
+
|
|
134
|
+
| strategy | claim | measured |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| **LeastConn** | exact greedy balance | **max-minus-min load <= 1** (peak/avg 1.00), tighter than P2C; peak <= P2C's on the same run |
|
|
137
|
+
| **SED** | load proportional to weight | **< 1% share drift** from each node's weight fraction; weighted-imbalance far below a random foil |
|
|
138
|
+
| **NQ** | never queue while idle | first *n* dispatches hit **n distinct idle workers**, then falls back to SED |
|
|
139
|
+
|
|
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
|
+
|
|
105
142
|
## The substrate (under every strategy)
|
|
106
143
|
|
|
107
144
|
```js
|
|
@@ -127,10 +164,41 @@ rng.nextBelow(4); // -> a uint32 in [0, 4)
|
|
|
127
164
|
rng.reset(); // replays the exact stream
|
|
128
165
|
|
|
129
166
|
PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead pick)
|
|
130
|
-
VERSION; // -> '0.
|
|
167
|
+
VERSION; // -> '0.4.0'
|
|
131
168
|
```
|
|
132
169
|
|
|
133
|
-
`BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index. Every shipped strategy (`RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
|
|
170
|
+
`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.
|
|
171
|
+
|
|
172
|
+
## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
|
|
173
|
+
|
|
174
|
+
The kernel gives you `pick() -> index`. Real callers also need the counter ergonomics: **increment in-flight on dispatch, decrement on settle, and re-pick a *different* endpoint on failure.** That layer is async (it wraps the request), so it lives in a separate subpath -- `@zakkster/lite-pick/pool` -- and the kernel stays 0 B/op.
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
import { LeastConnBalancer } from '@zakkster/lite-pick';
|
|
178
|
+
import { Pool, liteQueryFetcher } from '@zakkster/lite-pick/pool';
|
|
179
|
+
|
|
180
|
+
const eligible = Uint8Array.from([1, 1, 1, 1]);
|
|
181
|
+
const inflight = new Uint32Array(4);
|
|
182
|
+
const balancer = new LeastConnBalancer(4, eligible, inflight);
|
|
183
|
+
const pool = new Pool(balancer, inflight); // Pool is the inc/dec authority around run()
|
|
184
|
+
|
|
185
|
+
// run(): pick -> inflight++ -> await fn -> inflight-- (in a finally). tries=2 re-picks a
|
|
186
|
+
// DIFFERENT endpoint if the first throws (a load-aware strategy steers off the failed node).
|
|
187
|
+
const res = await pool.run((i, signal) => fetch(urls[i], { signal }), { tries: 2 });
|
|
188
|
+
|
|
189
|
+
// Drop-in for a query cache (lite-query, or any `({ key, signal }) => Promise` fetcher).
|
|
190
|
+
// Duck-typed -- imports NOTHING from lite-query, so peerDependencies stays empty.
|
|
191
|
+
const fetcher = liteQueryFetcher(pool,
|
|
192
|
+
({ endpoint, key, signal }) => fetch(urls[endpoint] + '/' + key[0], { signal }).then(r => r.json()),
|
|
193
|
+
{ tries: 2 });
|
|
194
|
+
// query(qc, { key: ['users'], fetcher });
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Two layers, no overlap** ([ADR 0007](./decisions/0007-pool-adapter.md)): the pool owns **spatial** failover (try a different endpoint *now*); your query cache owns **temporal** retry (backoff, staleness). `run()` is a normal async wrapper -- it adds O(1) counter ops per attempt, **it is not held to the kernel's 0 B/op bar** (that's `pick()`). See it end-to-end -- least-conn fan-out over a flaky pool with a node killed mid-run, proving 0 dead picks and 0 leaked in-flight:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm run demo
|
|
201
|
+
```
|
|
134
202
|
|
|
135
203
|
## Design ownership (ratified before any strategy)
|
|
136
204
|
|
|
@@ -161,6 +229,7 @@ npm run torture # lite-leak retention + lite-gc-profiler 0 B/op (needs --expo
|
|
|
161
229
|
npm run test:perf # lite-perf-gate HARD zero-alloc gate + a mustFail teeth-check
|
|
162
230
|
npm run witness # pick throughput flatness across a pool-size sweep
|
|
163
231
|
npm run balance # peak-to-average vs the strategy ceiling + random foil (the anchor)
|
|
232
|
+
npm run fuzz # seeded invariant fuzzer: state-sync invariants after every op
|
|
164
233
|
npm run verify # all of the above
|
|
165
234
|
```
|
|
166
235
|
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-pick
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
5
5
|
Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
|
|
6
6
|
Node: >= 18.
|
|
@@ -14,12 +14,15 @@ 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),
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
0.4.0 ships the substrate seams + six strategies: RoundRobin, SmoothWRR (the weighted
|
|
18
|
+
default), P2C (power-of-two-choices -- also the O(1) least-connections APPROXIMATION), and the
|
|
19
|
+
EXACT LeastConn family (LeastConn, SED, NQ). It exports `VERSION`, the fail-closed sentinel
|
|
20
|
+
`PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared read-only
|
|
21
|
+
eligibility seam + O(1) live count), `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`,
|
|
22
|
+
`LeastConnBalancer`, `SedBalancer`, and `NqBalancer`. The remaining strategies land one per
|
|
23
|
+
session (see ROADMAP.md): PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom. The EXACT-O(log
|
|
24
|
+
n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
|
|
25
|
+
(decisions/0006), not this exact-O(cap) scan.
|
|
23
26
|
|
|
24
27
|
## Design ownership (decisions/0001, 0002)
|
|
25
28
|
|
|
@@ -78,7 +81,60 @@ PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom.
|
|
|
78
81
|
- `pick()` -> number. Draws two DISTINCT eligible endpoints uniformly at random (rejection
|
|
79
82
|
sampling over the bitmap, no peer) and returns the one with the lower in-flight load; ties
|
|
80
83
|
to the first draw. The `ln ln n / ln 2` peak-load ceiling (ADR 0005). O(1) per pick, 0 B/op.
|
|
81
|
-
`PICK_NONE` when the whole pool is down.
|
|
84
|
+
`PICK_NONE` when the whole pool is down. This IS the O(1) least-connections APPROXIMATION;
|
|
85
|
+
`LeastConnBalancer` below is the exact-O(cap) complement.
|
|
86
|
+
- `LeastConnBalancer extends BalancerBase` -- class. EXACT fewest-in-flight (M4, IPVS `lc`).
|
|
87
|
+
- `new LeastConnBalancer(capacity, eligible, inflight)` -- `inflight` a caller-owned Uint32Array
|
|
88
|
+
(length >= capacity), read LIVE each pick. No `setWeight`, no derived aggregate: the caller may
|
|
89
|
+
mutate inflight directly between picks (that is the shared-counter seam).
|
|
90
|
+
- `pick()` -> number. Full O(cap) scan returning the eligible node with the fewest in-flight
|
|
91
|
+
requests (lowest index on a tie), or `PICK_NONE` when the whole pool is down. 0 B/op. In a
|
|
92
|
+
feedback loop (increment on dispatch, decrement on settle) it is greedy-optimal: max-minus-min
|
|
93
|
+
load stays within 1 (balance.mjs). Without feedback it returns the same lowest-load index --
|
|
94
|
+
correct by contract; the M5 lite-query adapter provides the increment/decrement.
|
|
95
|
+
- `SedBalancer extends BalancerBase` -- class. Shortest-expected-delay (M4, IPVS `sed`).
|
|
96
|
+
- `new SedBalancer(capacity, eligible, inflight, weights)` -- `inflight` AND `weights` are
|
|
97
|
+
caller-owned Uint32Arrays (length >= capacity), read LIVE (no `setWeight`, no derived total --
|
|
98
|
+
the caller may retune weights directly, UNLIKE SmoothWRR).
|
|
99
|
+
- `pick()` -> number. O(cap) scan returning the eligible, positive-weight node minimizing
|
|
100
|
+
`(inflight + 1) / weight` (the new request's marginal expected delay), lowest index on a tie,
|
|
101
|
+
or `PICK_NONE` when no eligible node has a positive weight. A weight-0 eligible node is NOT a
|
|
102
|
+
candidate. Converges to load proportional-to-weight (balance.mjs). 0 B/op.
|
|
103
|
+
- `NqBalancer extends BalancerBase` -- class. Never-queue (M4, IPVS `nq`); the worker-pool fit.
|
|
104
|
+
- `new NqBalancer(capacity, eligible, inflight, weights)` -- same caller-owned, read-live
|
|
105
|
+
inflight + weights contract as SED.
|
|
106
|
+
- `pick()` -> number. Returns the FIRST idle eligible positive-weight node (in-flight 0) if one
|
|
107
|
+
exists -- never queueing while a server is free -- else the SED minimum, else `PICK_NONE`.
|
|
108
|
+
O(cap) worst case, O(1) when an early node is idle. 0 B/op.
|
|
109
|
+
|
|
110
|
+
## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
|
|
111
|
+
|
|
112
|
+
The kernel is a PURE 0 B/op selector; `/pool` is the ASYNC layer over it -- dispatch/settle
|
|
113
|
+
in-flight counter ergonomics + distinct-endpoint failover + a duck-typed query-cache fetcher.
|
|
114
|
+
It is a SEPARATE subpath file (the lite-query /stream + /await precedent), NOT part of the
|
|
115
|
+
0 B/op single-file kernel (decisions/0007). Zero HARD deps, zero peers: the fetcher adapter is
|
|
116
|
+
duck-typed and imports NOTHING from lite-query.
|
|
117
|
+
|
|
118
|
+
- `VERSION` -- string. Re-exported from the core (same three-place stamp).
|
|
119
|
+
- `Pool` -- class. Wraps a balancer + the caller-owned in-flight view.
|
|
120
|
+
- `new Pool(balancer, inflight)` -- `balancer` is duck-typed (any `{ pick(): number, capacity,
|
|
121
|
+
live }` -- every lite-pick strategy qualifies); `inflight` is the SAME caller-owned Uint32Array
|
|
122
|
+
the balancer reads (length >= balancer.capacity). Throws on a bad balancer / undersized view.
|
|
123
|
+
- `balancer` / `inflight` -- readonly getters.
|
|
124
|
+
- `run(fn, opts?)` -> Promise. Picks an endpoint, increments its in-flight on DISPATCH, awaits
|
|
125
|
+
`fn(endpoint, signal)`, decrements on SETTLE (in a finally -- net-zero per run, even on throw).
|
|
126
|
+
On a thrown error it keeps the failed endpoint's count ELEVATED and re-picks, so a load-aware
|
|
127
|
+
strategy (P2C/LeastConn/SED/NQ) steers the next attempt to a DIFFERENT endpoint -- up to
|
|
128
|
+
`opts.tries` attempts (default 1 = no failover), then rejects with the LAST error. Rejects with
|
|
129
|
+
a `code:'LITE_PICK_NONE'` error when no endpoint is eligible. `opts.signal` is passed to `fn`;
|
|
130
|
+
once aborted after a failure, failover stops and the abort propagates. NOT a 0 B/op path (the
|
|
131
|
+
kernel `pick()` is): a normal async wrapper adding O(1) counter ops + one small per-run array.
|
|
132
|
+
BOUNDARY: Pool owns SPATIAL failover (across the pool); the caller / query cache owns TEMPORAL
|
|
133
|
+
retry (backoff, staleness). Never double-owned (decisions/0007).
|
|
134
|
+
- `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
|
|
135
|
+
query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
|
|
136
|
+
key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
|
|
137
|
+
Imports nothing from lite-query -- duck-typed, so `peerDependencies` stays empty.
|
|
82
138
|
|
|
83
139
|
## Gates (every session)
|
|
84
140
|
|
|
@@ -88,6 +144,8 @@ PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom.
|
|
|
88
144
|
lite-perf-gate `zgcSuite` HARD zero-alloc gate + a `mustFail` teeth-check.
|
|
89
145
|
- `npm run witness` -- pick throughput flatness across a pool-size sweep.
|
|
90
146
|
- `npm run balance` -- peak-to-average load vs the strategy ceiling + random foil (the anchor).
|
|
147
|
+
- `npm run fuzz` -- the seeded invariant fuzzer (the state-machine attack): strict-mode
|
|
148
|
+
state-synchronisation invariants after every op, per strategy; prints the seed on failure.
|
|
91
149
|
- `npm test` / `npm run test:types` -- node:test boundary suite + tsc type-surface check.
|
|
92
150
|
|
|
93
151
|
## Composes with
|
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,
|
|
4
|
+
"version": "0.5.0",
|
|
5
|
+
"description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, plus PeakEWMA and consistent hashing; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./Pick.js",
|
|
8
8
|
"module": "./Pick.js",
|
|
@@ -13,11 +13,19 @@
|
|
|
13
13
|
"node": "./Pick.js",
|
|
14
14
|
"import": "./Pick.js",
|
|
15
15
|
"default": "./Pick.js"
|
|
16
|
+
},
|
|
17
|
+
"./pool": {
|
|
18
|
+
"types": "./Pool.d.ts",
|
|
19
|
+
"node": "./Pool.js",
|
|
20
|
+
"import": "./Pool.js",
|
|
21
|
+
"default": "./Pool.js"
|
|
16
22
|
}
|
|
17
23
|
},
|
|
18
24
|
"files": [
|
|
19
25
|
"Pick.js",
|
|
20
26
|
"Pick.d.ts",
|
|
27
|
+
"Pool.js",
|
|
28
|
+
"Pool.d.ts",
|
|
21
29
|
"llms.txt",
|
|
22
30
|
"README.md",
|
|
23
31
|
"CHANGELOG.md",
|
|
@@ -29,10 +37,12 @@
|
|
|
29
37
|
"torture": "node --expose-gc test/torture.mjs",
|
|
30
38
|
"witness": "node test/witness.mjs",
|
|
31
39
|
"balance": "node test/balance.mjs",
|
|
40
|
+
"fuzz": "node test/fuzz.mjs",
|
|
32
41
|
"test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
|
|
33
42
|
"bench": "node benchmark/Matrix.mjs",
|
|
34
43
|
"bench:report": "node benchmark/Matrix.mjs && node benchmark/Report.mjs",
|
|
35
|
-
"
|
|
44
|
+
"demo": "node demo/fanout.mjs",
|
|
45
|
+
"verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run fuzz && npm run test:perf"
|
|
36
46
|
},
|
|
37
47
|
"keywords": [
|
|
38
48
|
"load-balancer",
|