@zakkster/lite-pick 0.4.0 → 0.6.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 +102 -0
- package/Pick.js +6 -1
- package/Pool.d.ts +78 -0
- package/Pool.js +147 -0
- package/README.md +121 -3
- package/llms.txt +41 -2
- package/package.json +21 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,108 @@ All notable changes to `@zakkster/lite-pick` are documented here. The format fol
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
M6: the benchmark suite (ROADMAP.md M6). An EVIDENCE session -- no API change. `Pick.js` and
|
|
10
|
+
`Pool.js` are byte-identical to 0.5.0 apart from the `VERSION` stamp; the kernel gates
|
|
11
|
+
(torture / PerfGate / witness / balance / fuzz) are unchanged and green. Everything added lives
|
|
12
|
+
under `benchmark/` and is NOT in the published tarball (`files[]` unchanged).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `benchmark/GcBlastRadius.mjs` -- the GC blast-radius headline (dimension 3). The same sustained
|
|
17
|
+
mixed workload (n=1024, 2,000,000 requests) through two lanes: the lite-pick lane (`P2cBalancer`
|
|
18
|
+
over caller-owned typed arrays) measures `major=0`, pick `B/op=0`, `maxPause` ~0.1-0.3ms; the
|
|
19
|
+
allocating foil lane (collect-candidates-sort idiom with a retained in-flight request context)
|
|
20
|
+
measures `major=13-14`, `maxPause` ~2.5-4ms. GC sampled via `@zakkster/lite-gc-profiler`
|
|
21
|
+
(`GcProfiler` + `checkNoGc`, the torture.mjs machinery); `B/op` via `measureAllocs`.
|
|
22
|
+
- `benchmark/Fairness.mjs` -- weighted convergence + burstiness (dimension 8). SmoothWRR weights
|
|
23
|
+
[10,3,2,1]: exact fairness (counts == k*weight) over 500 cycles, max-run 3 vs the bursty
|
|
24
|
+
weight-expansion foil's 10. SED weights [1,2,3,4,6,8,12,16]: worst share drift < 0.0001 from the
|
|
25
|
+
weight target; weighted-imbalance ~0.00 vs a weight-blind random foil's ~5.5.
|
|
26
|
+
- `benchmark/Disruption.mjs` -- consistent-hash disruption (dimension 7). Naive-modulo foil over
|
|
27
|
+
100,000 seeded keys remaps 98.4% (remove node 64->63) / 98.5% (add node 64->65) of keys vs a good
|
|
28
|
+
consistent hash's ~1.6% / ~1.5% ideal. Explicit SKIP row for `ConsistentHash` (Maglev, M8) -- no
|
|
29
|
+
stub in `Pick.js`.
|
|
30
|
+
- `benchmark/Report.mjs` -- emits `benchmark/results.json` stamping Node version, V8, CPU model,
|
|
31
|
+
core count, arch, OS, and every PRNG seed alongside the measured numbers; renders the README
|
|
32
|
+
`<!-- bench:ID -->` fenced tables from it. `--verify` mode is the `bench:verify` drift check:
|
|
33
|
+
ALGORITHMIC numbers (balance peak-gap, disruption remap %) recomputed FRESH and compared EXACT;
|
|
34
|
+
TIMING numbers (GC pauses) compared to `results.json` within +/-15%. Exits non-zero on any drift.
|
|
35
|
+
(Fixes the previously-broken `bench:report` script, which pointed at an absent `Report.mjs`.)
|
|
36
|
+
- `benchmark/Soak.mjs` -- the endurance-soak scaffold (post-1.0 #8): ONE P2C lane, continuous
|
|
37
|
+
mixed-chaos load (flap storms + whole-pool-down troughs + load feedback), emits a JSONL
|
|
38
|
+
time-series to `benchmark/soak.jsonl`, and RUNS `test/invariants.mjs:checkBase` at every
|
|
39
|
+
checkpoint. `tracker.size()` returns to 0 after each cycle; invariants green at all checkpoints.
|
|
40
|
+
`SOAK_CYCLES=0` runs forever -- the harness the `caffeinate -i` overnight burn-in plugs into.
|
|
41
|
+
- `benchmark/Matrix.mjs` -- extended (not rewritten): each SUBJECT gained a `dims` flag, and the
|
|
42
|
+
file now exports the SHARED SEEDED workload matrix (`buildWorkload` over uniform / skewed-weight
|
|
43
|
+
/ skewed-cost), `SEEDS`, `SUBJECTS`, `SIZES`, and `measureThroughput`, reused by every dimension
|
|
44
|
+
file. The standalone throughput runner is behind a main guard so importing it runs no sweep.
|
|
45
|
+
- `decisions/0008-benchmark-suite.md` -- the ADR: the parity framing, the real-competitors + foils
|
|
46
|
+
baseline, the drift-check teeth, and the ConsistentHash SKIP.
|
|
47
|
+
- README: the *Evidence* section -- balance anchor + GC blast-radius headlines (fenced), the
|
|
48
|
+
consistent-hash disruption trust gate, the honest COLD-path cost table, and the vs-AWS NLB/ALB
|
|
49
|
+
"complementary, not a competitor" positioning.
|
|
50
|
+
- devDependencies: `load-balancers@1.3.52`, `loadbalance@1.0.0`, `wrr@1.0.0` -- REAL npm
|
|
51
|
+
competitors, pinned to exact versions, loaded via `createRequire` (all three are CommonJS) and
|
|
52
|
+
timed into `results.json` beside our in-repo foils; a package that fails to load becomes a
|
|
53
|
+
labeled `unavailable` row rather than being silently dropped. Each incumbent is rendered side by
|
|
54
|
+
side with the SAME-complexity lite-pick strategy on the SAME n=1024 pool in the README
|
|
55
|
+
`<!-- bench:competitors -->` throughput fence. Parity is claimed only on EQUAL-work rows: P2C is
|
|
56
|
+
parity (~60k vs ~61k ops/ms); RoundRobin is ~22% slower (~260k vs ~335k) and the gap is OWNED --
|
|
57
|
+
`loadbalance` is a bare `i++ % n` with no liveness, while lite-pick's `RoundRobinBalancer`
|
|
58
|
+
forward-scans the eligibility bitmap to skip down nodes (never a dead pick), and that scan is the
|
|
59
|
+
constant-factor cost of a guarantee the incumbents do not offer. The weighted-random row is a
|
|
60
|
+
disclosed SKIP -- lite-pick's O(1) `WeightedRandom` (alias table) lands at M10, so `wrr` is not
|
|
61
|
+
raced against our O(cap) `SmoothWRRBalancer` (a different complexity class). No "faster"/"Nx".
|
|
62
|
+
- Scripts: `bench:gc`, `bench:fairness`, `bench:disruption`, `bench:verify`, `soak`.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- Version bumped to 0.6.0 in the three sync sites (`package.json`, `Pick.js` `VERSION`, `llms.txt`).
|
|
67
|
+
`peerDependencies` stays `{}`.
|
|
68
|
+
|
|
69
|
+
## [0.5.0] - 2026-09-23
|
|
70
|
+
|
|
71
|
+
M5: the ergonomic request layer at the `@zakkster/lite-pick/pool` subpath -- dispatch/settle
|
|
72
|
+
in-flight counters + distinct-endpoint failover + a duck-typed query-cache fetcher (ROADMAP.md M5).
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- `@zakkster/lite-pick/pool` (`Pool.js`) -- a new SUBPATH export (the kernel `Pick.js` stays a
|
|
77
|
+
single 0 B/op file; the async layer lives outside it, the lite-query `/stream` + `/await`
|
|
78
|
+
precedent, ADR 0007).
|
|
79
|
+
- `Pool` -- wraps a balancer + the caller-owned in-flight view. `run(fn, opts?)` picks an endpoint,
|
|
80
|
+
increments in-flight on dispatch, awaits `fn(endpoint, signal)`, decrements on settle (in a
|
|
81
|
+
`finally` -- net-zero per run, even on throw). On a thrown error it keeps the failed endpoint's
|
|
82
|
+
count ELEVATED and re-picks, so a load-aware strategy (P2C/LeastConn/SED/NQ) steers the next
|
|
83
|
+
attempt to a DISTINCT endpoint -- up to `opts.tries` attempts (default 1 = no failover), then
|
|
84
|
+
rejects with the last error. Rejects a `code:'LITE_PICK_NONE'` error when no endpoint is eligible;
|
|
85
|
+
`opts.signal` is passed to `fn` and, once aborted after a failure, stops failover. NOT a 0 B/op
|
|
86
|
+
path (the kernel `pick()` is) -- a normal async wrapper, disclosed.
|
|
87
|
+
- `liteQueryFetcher(pool, perEndpoint, opts?)` -- returns a `({ key, signal }) => Promise` fetcher
|
|
88
|
+
for a query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). Imports NOTHING from
|
|
89
|
+
lite-query -- duck-typed, so `peerDependencies` stays empty. `opts.tries` is the spatial failover
|
|
90
|
+
count. BOUNDARY: Pool owns SPATIAL failover across the pool; the cache owns TEMPORAL retry/backoff.
|
|
91
|
+
- `test/Pool.test.js` -- 12 tests: dispatch/settle in-flight balance (success AND throw), fail-closed
|
|
92
|
+
coding, distinct-endpoint failover, tries exhaustion (last error), abort-stops-failover, signal
|
|
93
|
+
passthrough, a 200-way CONCURRENT-consistency check (in-flight drains to all-zero -- no leak), and
|
|
94
|
+
the duck-typed fetcher.
|
|
95
|
+
- `Pool.d.ts` + `test/types/pool.test-d.ts` -- the typed surface (the type-test tsconfig gains the
|
|
96
|
+
`DOM` lib for `AbortSignal`).
|
|
97
|
+
- `demo/fanout.mjs` (`npm run demo`) -- the integration moat: least-connections fan-out over a flaky
|
|
98
|
+
pool with a replica killed mid-run, proving 0 dead picks + 0 leaked in-flight + live failover, and
|
|
99
|
+
showing the lite-query fetcher wiring. (`demo/` is not in `files[]`.)
|
|
100
|
+
- `decisions/0007-pool-adapter.md` -- the /pool-subpath home, spatial-vs-temporal retry ownership,
|
|
101
|
+
the explicit 0 B/op boundary, and the duck-typed (zero-peer) fetcher.
|
|
102
|
+
|
|
103
|
+
### Changed
|
|
104
|
+
|
|
105
|
+
- Version 0.4.0 -> 0.5.0 across `package.json`, `Pick.js` `VERSION` (re-exported by `Pool.js`), and
|
|
106
|
+
`llms.txt`. `exports` gains `./pool`; `files[]` gains `Pool.js` + `Pool.d.ts`.
|
|
107
|
+
- `peerDependencies` stays `{}` -- the fetcher adapter is duck-typed (ADR 0007 Fork 4).
|
|
108
|
+
|
|
7
109
|
## [0.4.0] - 2026-09-23
|
|
8
110
|
|
|
9
111
|
M4: the exact LeastConn family (IPVS `lc` / `sed` / `nq` made zero-GC) + the seeded invariant
|
package/Pick.js
CHANGED
|
@@ -37,11 +37,16 @@
|
|
|
37
37
|
* [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
|
|
38
38
|
* BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
|
|
39
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).
|
|
44
|
+
*
|
|
40
45
|
* Zero runtime dependencies. node:test only. ESM, single file, tree-shakeable.
|
|
41
46
|
*/
|
|
42
47
|
|
|
43
48
|
/** Version stamp. Synced across package.json and llms.txt (three-place rule). */
|
|
44
|
-
export const VERSION = '0.
|
|
49
|
+
export const VERSION = '0.6.0';
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
52
|
* Fail-closed sentinel returned by pick() when no endpoint is eligible.
|
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: M6 (v0.6.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**, the **`@zakkster/lite-pick/pool`** request layer, and now the **benchmark suite** -- the balance anchor + GC blast-radius headlines, a seeded/version-stamped `results.json`, a `bench:verify` drift check with teeth, and the vs-AWS positioning (see *Evidence* below). The kernel `Pick.js` / `Pool.js` are byte-identical to v0.5.0 apart from the version stamp. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), and **SED tracks weight within 1%** -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M6 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), pool-adapter (0007), and benchmark-suite (0008) design forks.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
npm install @zakkster/lite-pick
|
|
@@ -139,6 +139,93 @@ The proof (from `test/balance.mjs`):
|
|
|
139
139
|
|
|
140
140
|
Each `pick()` is **0 B/op** and **O(cap)** (NQ is O(1) when an early node is idle). Because these are the state-heaviest strategies so far, M4 also introduces the **invariant fuzzer** (`npm run fuzz`): a seeded state-machine attack that, after *every* `pick` / `setEligible` / weight / load op, asserts the chosen endpoint is the *exact* optimum, `live` stays exact, and `PICK_NONE` holds *iff* nothing is pickable -- printing the seed on any failure for byte-for-byte replay.
|
|
141
141
|
|
|
142
|
+
## Evidence -- the two headlines (v0.6.0 benchmark suite)
|
|
143
|
+
|
|
144
|
+
> **Framing: parity on speed, superiority on the contract + balance + tail.** A trivial `i++ % n` round-robin -- or `wrr` -- *matches* P2C on raw ops/sec, so `lite-pick` does **not** claim "N times faster." Throughput is claimed at **parity**; the wins are **zero-GC**, **balance quality**, **tail latency** (GC blast-radius), and **never a dead pick**. Every number below is **seeded** and regenerated by `npm run bench:report`; `npm run bench:verify` fails CI if a README number drifts from a fresh run (algorithmic exact, timing within +/-15%). Node / CPU / OS / every PRNG seed are stamped into `benchmark/results.json`.
|
|
145
|
+
|
|
146
|
+
### Throughput parity vs the incumbents (ops/ms, same pool)
|
|
147
|
+
|
|
148
|
+
The real pinned npm incumbents (`load-balancers`, `loadbalance`, `wrr`) run through the **same** harness on the **same** `n=1024` pool as the matching `lite-pick` strategy of the **same complexity class** -- ops/ms side by side, not a winner. On the same-work P2C row `lite-pick` holds parity (59591 vs 60956). On the RoundRobin row `lite-pick` is ~22% slower (260168 vs 334541), and that gap is owned, not hidden: `loadbalance@1.0.0` is a bare `i++ % n` with no liveness, while `lite-pick`'s `RoundRobinBalancer` forward-scans the eligibility bitmap to skip down nodes -- so it never returns a dead pick. That scan is the constant-factor cost of a guarantee none of these incumbents offer. `lite-pick` claims parity only where the work is equal; where it is slower, it is slower for the liveness contract, and the balance + tail wins above are the reason to pay it. The weighted-random row is a disclosed **SKIP**: `lite-pick`'s O(1) weighted-random (`WeightedRandom`, alias table) lands at **M10**, so it is not raced against here -- our shipped `SmoothWRRBalancer` is O(cap) *smooth* weighted round-robin (a different, stronger-smoothness guarantee), whose throughput is measured by `npm run witness` and `npm run bench`, not force-fit into this parity table.
|
|
149
|
+
|
|
150
|
+
<!-- bench:competitors -->
|
|
151
|
+
|
|
152
|
+
| family | lite-pick | lite-pick ops/ms | incumbent (npm) | incumbent ops/ms |
|
|
153
|
+
| --- | --- | --- | --- | --- |
|
|
154
|
+
| P2C (power-of-two-choices) | P2cBalancer | 59591 | load-balancers@1.3.52 | 60956 |
|
|
155
|
+
| RoundRobin | RoundRobinBalancer | 260168 | loadbalance@1.0.0 | 334541 |
|
|
156
|
+
| Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 | 158395 |
|
|
157
|
+
|
|
158
|
+
<!-- /bench:competitors -->
|
|
159
|
+
|
|
160
|
+
### Balance quality -- the anchor (headline #1)
|
|
161
|
+
|
|
162
|
+
The canonical balls-into-bins experiment (throw `m = 32*n` balls into `n` bins): P2C's peak load stays within an additive `ln ln n / ln 2` of the mean, while a single random draw's gap grows with the pool. **Peak-gap** = heaviest bin minus the mean (32); the ceiling column is `4*lnln(n)/ln2 + 4`.
|
|
163
|
+
|
|
164
|
+
<!-- bench:balance -->
|
|
165
|
+
|
|
166
|
+
| pool n | P2C peak-gap | random foil peak-gap | ceiling |
|
|
167
|
+
| --- | --- | --- | --- |
|
|
168
|
+
| 64 | 3 | 13 | 12.2 |
|
|
169
|
+
| 1024 | 2 | 21 | 15.2 |
|
|
170
|
+
| 4096 | 2 | 25 | 16.2 |
|
|
171
|
+
|
|
172
|
+
<!-- /bench:balance -->
|
|
173
|
+
|
|
174
|
+
P2C's peak-gap stays a small `ln ln n` constant while the random foil's grows with `n` -- the exponential improvement one extra probe buys, measured not asserted ([ADR 0008](./decisions/0008-benchmark-suite.md)).
|
|
175
|
+
|
|
176
|
+
### GC blast-radius -- the tail (headline #2)
|
|
177
|
+
|
|
178
|
+
The point of zero-GC is **not** the pick's own latency -- a major GC pause freezes **every in-flight request at once**, so an allocating balancer inflates the *service-level* tail. The **same** sustained mixed workload (`n=1024`, 2M requests) runs through two lanes: the zero-GC `lite-pick` lane vs the ordinary "collect candidates, sort, take the best" idiom that allocates a request context per pick (promoted to old gen while in flight -- exactly the garbage that forces mark-sweep).
|
|
179
|
+
|
|
180
|
+
<!-- bench:gc -->
|
|
181
|
+
|
|
182
|
+
| lane | major GC | pick B/op | max GC pause (ms) |
|
|
183
|
+
| --- | --- | --- | --- |
|
|
184
|
+
| lite-pick | 0 | 0 | 0.1 |
|
|
185
|
+
| allocating foil | 13 | allocates | 2.9 |
|
|
186
|
+
|
|
187
|
+
<!-- /bench:gc -->
|
|
188
|
+
|
|
189
|
+
The `lite-pick` lane holds **0 major GC / 0 B/op** on the pick path; the allocating foil's promoted request state forces mark-sweep pauses that stall the whole service. **That contrast is the headline**, not ops/sec.
|
|
190
|
+
|
|
191
|
+
### Consistent-hash disruption -- a trust gate
|
|
192
|
+
|
|
193
|
+
On a scale event (add / remove a node), what fraction of keys keep their node? The **naive-modulo** trap (`key % n`) reshuffles almost everything -- blowing every downstream cache -- while a good consistent hash moves only ~`1/n`. The real `ConsistentHash` (Maglev) lands at **M8**; it is disclosed here as an explicit **SKIP**, not a stub:
|
|
194
|
+
|
|
195
|
+
<!-- bench:disruption -->
|
|
196
|
+
|
|
197
|
+
| scale event | naive-modulo remap | good consistent hash |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| node removed | 98.4% | 1.6% |
|
|
200
|
+
| node added | 98.5% | 1.5% |
|
|
201
|
+
| ConsistentHash (Maglev) | SKIP -- ships in a later milestone | -- |
|
|
202
|
+
|
|
203
|
+
<!-- /bench:disruption -->
|
|
204
|
+
|
|
205
|
+
### Honest cold-path cost (so the 0 B/op HOT-path claim stays bounded)
|
|
206
|
+
|
|
207
|
+
`pick()` is **0 B/op** and that is the whole point -- so the cold-path costs are **named**, not hidden:
|
|
208
|
+
|
|
209
|
+
| operation | when | allocates |
|
|
210
|
+
| --- | --- | --- |
|
|
211
|
+
| `new <Strategy>Balancer(...)` | construction, once | the balancer object + its owned accumulators (SmoothWRR's Float64 `current`). The eligibility / inflight / weight views are **caller-owned**, never copied |
|
|
212
|
+
| `setEligible(i, up)` | cold, on a health flip | **0** -- one byte write + an O(1) live-count adjust |
|
|
213
|
+
| `setWeight(i, w)` (SmoothWRR) | cold, on reweight | **0** -- one array write + an O(1) eligible-total adjust |
|
|
214
|
+
| `pick()` | **HOT**, per request | **0 B/op** -- proven by `torture` + `test:perf`, measured by `bench:gc` |
|
|
215
|
+
| `Pool.run(fn)` (`/pool`) | per request | a promise + one small `held` array -- an async wrapper, **not** the kernel path ([ADR 0007](./decisions/0007-pool-adapter.md)) |
|
|
216
|
+
|
|
217
|
+
### Complementary to AWS NLB / ALB (not a competitor)
|
|
218
|
+
|
|
219
|
+
`lite-pick` does not replace a managed **ALB/NLB** -- it governs the *inner* hop those never see (your service fanning out to downstreams / shards / replicas / workers), bringing the **same algorithm family AWS bills for at the edge** to a zero-GC in-process selector you own:
|
|
220
|
+
|
|
221
|
+
| AWS edge feature (managed, billed) | `lite-pick` equivalent (in-process, zero-GC) |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| ALB `least_outstanding_requests` (LOR) | `LeastConnBalancer` / `P2cBalancer` |
|
|
224
|
+
| ALB `weighted_random` + anomaly mitigation | `WeightedRandom` + `BoundedLoad` (M9/M10) |
|
|
225
|
+
| NLB flow-hash (5-tuple) | `ConsistentHash` (Maglev, M8) |
|
|
226
|
+
|
|
227
|
+
The composition: inbound traffic still enters through your **ALB/NLB -> service** (the edge hop AWS owns and bills); `lite-pick` governs the fan-out **after** that, the hop no AWS load balancer touches. Complementary, not a replacement -- "the hop your ALB/NLB never sees."
|
|
228
|
+
|
|
142
229
|
## The substrate (under every strategy)
|
|
143
230
|
|
|
144
231
|
```js
|
|
@@ -164,11 +251,42 @@ rng.nextBelow(4); // -> a uint32 in [0, 4)
|
|
|
164
251
|
rng.reset(); // replays the exact stream
|
|
165
252
|
|
|
166
253
|
PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead pick)
|
|
167
|
-
VERSION; // -> '0.
|
|
254
|
+
VERSION; // -> '0.6.0'
|
|
168
255
|
```
|
|
169
256
|
|
|
170
257
|
`BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index. Every shipped strategy (`RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
|
|
171
258
|
|
|
259
|
+
## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
import { LeastConnBalancer } from '@zakkster/lite-pick';
|
|
265
|
+
import { Pool, liteQueryFetcher } from '@zakkster/lite-pick/pool';
|
|
266
|
+
|
|
267
|
+
const eligible = Uint8Array.from([1, 1, 1, 1]);
|
|
268
|
+
const inflight = new Uint32Array(4);
|
|
269
|
+
const balancer = new LeastConnBalancer(4, eligible, inflight);
|
|
270
|
+
const pool = new Pool(balancer, inflight); // Pool is the inc/dec authority around run()
|
|
271
|
+
|
|
272
|
+
// run(): pick -> inflight++ -> await fn -> inflight-- (in a finally). tries=2 re-picks a
|
|
273
|
+
// DIFFERENT endpoint if the first throws (a load-aware strategy steers off the failed node).
|
|
274
|
+
const res = await pool.run((i, signal) => fetch(urls[i], { signal }), { tries: 2 });
|
|
275
|
+
|
|
276
|
+
// Drop-in for a query cache (lite-query, or any `({ key, signal }) => Promise` fetcher).
|
|
277
|
+
// Duck-typed -- imports NOTHING from lite-query, so peerDependencies stays empty.
|
|
278
|
+
const fetcher = liteQueryFetcher(pool,
|
|
279
|
+
({ endpoint, key, signal }) => fetch(urls[endpoint] + '/' + key[0], { signal }).then(r => r.json()),
|
|
280
|
+
{ tries: 2 });
|
|
281
|
+
// query(qc, { key: ['users'], fetcher });
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**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:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
npm run demo
|
|
288
|
+
```
|
|
289
|
+
|
|
172
290
|
## Design ownership (ratified before any strategy)
|
|
173
291
|
|
|
174
292
|
lite-pick owns **no mutable state it can avoid owning** ([ADR 0001](./decisions/0001-selection-kernel-boundary.md)):
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-pick
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
5
5
|
Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
|
|
6
6
|
Node: >= 18.
|
|
@@ -14,7 +14,7 @@ reading pre-allocated views that siblings or the caller write, and returning an
|
|
|
14
14
|
The complementary evidence lite-pick ships is a measured balance-quality anchor (peak-to-
|
|
15
15
|
average load vs the strategy's theoretical ceiling) alongside the 0 B/op pick witness.
|
|
16
16
|
|
|
17
|
-
0.
|
|
17
|
+
0.6.0 ships the substrate seams + six strategies: RoundRobin, SmoothWRR (the weighted
|
|
18
18
|
default), P2C (power-of-two-choices -- also the O(1) least-connections APPROXIMATION), and the
|
|
19
19
|
EXACT LeastConn family (LeastConn, SED, NQ). It exports `VERSION`, the fail-closed sentinel
|
|
20
20
|
`PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared read-only
|
|
@@ -24,6 +24,16 @@ session (see ROADMAP.md): PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom.
|
|
|
24
24
|
n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
|
|
25
25
|
(decisions/0006), not this exact-O(cap) scan.
|
|
26
26
|
|
|
27
|
+
M6 (0.6.0) adds the BENCHMARK SUITE -- an evidence session, no API change: Pick.js / Pool.js are
|
|
28
|
+
byte-identical to 0.5.0 apart from the VERSION stamp. It lives entirely under benchmark/ (NOT in
|
|
29
|
+
the published tarball): the balance anchor + GC blast-radius headlines, weighted-fairness and
|
|
30
|
+
consistent-hash-disruption trust gates, a seeded + Node/CPU/OS-stamped results.json, a
|
|
31
|
+
bench:verify drift check (algorithmic EXACT, timing +/-15%), the naive-modulo disruption foil
|
|
32
|
+
with an explicit ConsistentHash SKIP row (Maglev lands at M8), the endurance Soak.mjs scaffold
|
|
33
|
+
(reuses test/invariants.mjs:checkBase), and REAL pinned npm competitors (load-balancers,
|
|
34
|
+
loadbalance, wrr) as devDeps beside our in-repo foils. Framing: parity on speed, superiority on
|
|
35
|
+
the contract + balance + tail -- never an "N times faster" headline (decisions/0008).
|
|
36
|
+
|
|
27
37
|
## Design ownership (decisions/0001, 0002)
|
|
28
38
|
|
|
29
39
|
- IN-PROCESS first (workers, DI services); remote HTTP is a thin optional adapter.
|
|
@@ -107,6 +117,35 @@ n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optio
|
|
|
107
117
|
exists -- never queueing while a server is free -- else the SED minimum, else `PICK_NONE`.
|
|
108
118
|
O(cap) worst case, O(1) when an early node is idle. 0 B/op.
|
|
109
119
|
|
|
120
|
+
## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
|
|
121
|
+
|
|
122
|
+
The kernel is a PURE 0 B/op selector; `/pool` is the ASYNC layer over it -- dispatch/settle
|
|
123
|
+
in-flight counter ergonomics + distinct-endpoint failover + a duck-typed query-cache fetcher.
|
|
124
|
+
It is a SEPARATE subpath file (the lite-query /stream + /await precedent), NOT part of the
|
|
125
|
+
0 B/op single-file kernel (decisions/0007). Zero HARD deps, zero peers: the fetcher adapter is
|
|
126
|
+
duck-typed and imports NOTHING from lite-query.
|
|
127
|
+
|
|
128
|
+
- `VERSION` -- string. Re-exported from the core (same three-place stamp).
|
|
129
|
+
- `Pool` -- class. Wraps a balancer + the caller-owned in-flight view.
|
|
130
|
+
- `new Pool(balancer, inflight)` -- `balancer` is duck-typed (any `{ pick(): number, capacity,
|
|
131
|
+
live }` -- every lite-pick strategy qualifies); `inflight` is the SAME caller-owned Uint32Array
|
|
132
|
+
the balancer reads (length >= balancer.capacity). Throws on a bad balancer / undersized view.
|
|
133
|
+
- `balancer` / `inflight` -- readonly getters.
|
|
134
|
+
- `run(fn, opts?)` -> Promise. Picks an endpoint, increments its in-flight on DISPATCH, awaits
|
|
135
|
+
`fn(endpoint, signal)`, decrements on SETTLE (in a finally -- net-zero per run, even on throw).
|
|
136
|
+
On a thrown error it keeps the failed endpoint's count ELEVATED and re-picks, so a load-aware
|
|
137
|
+
strategy (P2C/LeastConn/SED/NQ) steers the next attempt to a DIFFERENT endpoint -- up to
|
|
138
|
+
`opts.tries` attempts (default 1 = no failover), then rejects with the LAST error. Rejects with
|
|
139
|
+
a `code:'LITE_PICK_NONE'` error when no endpoint is eligible. `opts.signal` is passed to `fn`;
|
|
140
|
+
once aborted after a failure, failover stops and the abort propagates. NOT a 0 B/op path (the
|
|
141
|
+
kernel `pick()` is): a normal async wrapper adding O(1) counter ops + one small per-run array.
|
|
142
|
+
BOUNDARY: Pool owns SPATIAL failover (across the pool); the caller / query cache owns TEMPORAL
|
|
143
|
+
retry (backoff, staleness). Never double-owned (decisions/0007).
|
|
144
|
+
- `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
|
|
145
|
+
query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
|
|
146
|
+
key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
|
|
147
|
+
Imports nothing from lite-query -- duck-typed, so `peerDependencies` stays empty.
|
|
148
|
+
|
|
110
149
|
## Gates (every session)
|
|
111
150
|
|
|
112
151
|
- `npm run torture` -- `node --expose-gc test/torture.mjs`: lite-leak retention +
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-pick",
|
|
3
3
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, plus PeakEWMA and consistent hashing.",
|
|
4
|
+
"version": "0.6.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",
|
|
@@ -32,7 +40,13 @@
|
|
|
32
40
|
"fuzz": "node test/fuzz.mjs",
|
|
33
41
|
"test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
|
|
34
42
|
"bench": "node benchmark/Matrix.mjs",
|
|
35
|
-
"bench:
|
|
43
|
+
"bench:gc": "node --expose-gc benchmark/GcBlastRadius.mjs",
|
|
44
|
+
"bench:fairness": "node benchmark/Fairness.mjs",
|
|
45
|
+
"bench:disruption": "node benchmark/Disruption.mjs",
|
|
46
|
+
"bench:report": "node benchmark/Matrix.mjs && node --expose-gc benchmark/Report.mjs",
|
|
47
|
+
"bench:verify": "node benchmark/Report.mjs --verify",
|
|
48
|
+
"soak": "node --expose-gc benchmark/Soak.mjs",
|
|
49
|
+
"demo": "node demo/fanout.mjs",
|
|
36
50
|
"verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run fuzz && npm run test:perf"
|
|
37
51
|
},
|
|
38
52
|
"keywords": [
|
|
@@ -94,7 +108,10 @@
|
|
|
94
108
|
"@zakkster/lite-gc-profiler": "^1.16.0",
|
|
95
109
|
"@zakkster/lite-leak": "^1.10.0",
|
|
96
110
|
"@zakkster/lite-perf-gate": "^1.4.2",
|
|
97
|
-
"
|
|
111
|
+
"load-balancers": "1.3.52",
|
|
112
|
+
"loadbalance": "1.0.0",
|
|
113
|
+
"typescript": "^7.0.2",
|
|
114
|
+
"wrr": "1.0.0"
|
|
98
115
|
},
|
|
99
116
|
"peerDependencies": {},
|
|
100
117
|
"peerDependenciesMeta": {},
|