@zakkster/lite-pick 0.7.1 → 0.7.2

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 CHANGED
@@ -4,6 +4,19 @@ All notable changes to `@zakkster/lite-pick` are documented here. The format fol
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.7.2] - 2026-09-23
8
+
9
+ ### Added
10
+
11
+ - `RECIPES.md` -- a beginner-to-advanced usage guide that builds the selection kernel up into a
12
+ real load balancer (health/eligibility wiring, caller-owned in-flight counters, the
13
+ dispatch/settle loop, `/pool` failover, PeakEWMA rtt feedback, the FE profile, a strategy
14
+ decision table, suite composition, zero-GC discipline, and gotchas). Added to the published
15
+ package (`files[]`) and linked from the README.
16
+
17
+ Docs-only release: no source or behavior change from 0.7.1 (the `VERSION` stamp is bumped for the
18
+ three-place sync).
19
+
7
20
  ## [0.7.1] - 2026-09-23
8
21
 
9
22
  ### Fixed
package/Pick.js CHANGED
@@ -51,7 +51,7 @@
51
51
  */
52
52
 
53
53
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
54
- export const VERSION = '0.7.1';
54
+ export const VERSION = '0.7.2';
55
55
 
56
56
  /**
57
57
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
package/README.md CHANGED
@@ -300,6 +300,8 @@ VERSION; // -> '0.6.0'
300
300
 
301
301
  ## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
302
302
 
303
+ > **New to lite-pick as a load balancer?** [**RECIPES.md**](./RECIPES.md) is a beginner-to-advanced guide: it builds the kernel up into a real balancer step by step -- health/eligibility, load counters, the dispatch/settle loop, failover, latency feedback (PeakEWMA), the FE profile, and choosing a strategy. Start there; the sections below are the reference.
304
+
303
305
  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.
304
306
 
305
307
  ```js
package/RECIPES.md ADDED
@@ -0,0 +1,329 @@
1
+ # lite-pick recipes -- from a one-liner to a real load balancer
2
+
3
+ `@zakkster/lite-pick` is a SELECTION KERNEL, not a proxy. It answers one question --
4
+ "which endpoint should this request go to?" -- and returns an integer index (or
5
+ `PICK_NONE` = -1 when nothing is eligible). A *real* load balancer is that kernel plus
6
+ the wiring around it:
7
+
8
+ - **eligibility** -- who is up? (a health check writes a shared bitmap)
9
+ - **load counters** -- how busy is each endpoint? (you own an `inflight` array)
10
+ - **the dispatch/settle loop** -- increment on send, decrement on finish
11
+ - **failover** -- if a call fails, try a different endpoint
12
+ - **latency feedback** -- for latency-aware routing, feed measured rtt back
13
+
14
+ These recipes build that wiring up, one layer at a time. Every array is preallocated
15
+ once and reused -- the pick path allocates 0 bytes.
16
+
17
+ Install: `npm i @zakkster/lite-pick` (zero runtime dependencies; ESM; Node >= 18)
18
+
19
+ ---
20
+
21
+ ## 1. The 30-second version -- round-robin over a fixed pool
22
+
23
+ ```js
24
+ import { RoundRobinBalancer, PICK_NONE } from '@zakkster/lite-pick';
25
+
26
+ const CAP = 4; // fixed pool size
27
+ const eligible = new Uint8Array(CAP).fill(1); // 1 = up, 0 = down (all up here)
28
+ const lb = new RoundRobinBalancer(CAP, eligible);
29
+
30
+ const endpoints = ['a.svc:8080', 'b.svc:8080', 'c.svc:8080', 'd.svc:8080'];
31
+
32
+ for (let r = 0; r < 6; r++) {
33
+ const i = lb.pick(); // -> next eligible index, round-robin
34
+ if (i === PICK_NONE) throw new Error('pool is down');
35
+ send(endpoints[i]); // your transport; lite-pick never opens a socket
36
+ }
37
+ // picks: a, b, c, d, a, b
38
+ ```
39
+
40
+ `pick()` is the whole kernel. Everything below adds a capability around it.
41
+
42
+ ---
43
+
44
+ ## 2. Fail closed -- always handle PICK_NONE
45
+
46
+ lite-pick never returns a down endpoint and never guesses. When the whole pool is
47
+ ineligible, `pick()` returns `PICK_NONE` (-1). Treat it as a first-class outcome:
48
+
49
+ ```js
50
+ const i = lb.pick();
51
+ if (i === PICK_NONE) {
52
+ // shed load, return 503, or fall back -- your policy. Never index endpoints[-1].
53
+ return respond503();
54
+ }
55
+ send(endpoints[i]);
56
+ ```
57
+
58
+ `lb.live` is an O(1) count of eligible endpoints if you want to check before picking.
59
+
60
+ ---
61
+
62
+ ## 3. Wire health -> eligibility (the shared bitmap)
63
+
64
+ Eligibility is a shared `Uint8Array` (1 = pickable, 0 = down). Something else writes it
65
+ -- a health checker, a circuit breaker, or `@zakkster/lite-di-health` -- and `pick()`
66
+ only reads it. Two ways to flip a node:
67
+
68
+ ```js
69
+ // (a) write the bitmap directly if you own it elsewhere (zero-copy, pick sees it live):
70
+ eligible[2] = 0; // endpoint c is down
71
+
72
+ // (b) go through the balancer so its O(1) `live` count stays exact (recommended):
73
+ lb.setEligible(2, false); // COLD path; idempotent; keeps `live` correct
74
+ lb.setEligible(2, true); // back up
75
+
76
+ lb.isEligible(2); // -> boolean, HOT, out-of-range is false (never throws)
77
+ ```
78
+
79
+ Prefer `setEligible` when you rely on `live`. Health flapping is the writer's problem:
80
+ apply hysteresis/dwell in the health layer -- `pick()` stays greedy and stateless.
81
+
82
+ ---
83
+
84
+ ## 4. Weighted pools -- SmoothWRR
85
+
86
+ When endpoints have different capacities, weight them. `SmoothWRRBalancer` owns its
87
+ weight state; it is the SOLE writer -- always go through `setWeight`, never mutate the
88
+ array directly (direct mutation desyncs the internal total = undefined behavior).
89
+
90
+ ```js
91
+ import { SmoothWRRBalancer } from '@zakkster/lite-pick';
92
+
93
+ const weights = new Uint32Array(CAP); // the balancer manages these
94
+ const lb = new SmoothWRRBalancer(CAP, eligible, weights);
95
+ lb.setWeight(0, 5); // a is 5x
96
+ lb.setWeight(1, 1);
97
+ lb.setWeight(2, 1);
98
+ lb.setWeight(3, 1);
99
+ // pick() interleaves smoothly (nginx smooth WRR): a a b a c a a d ... not a a a a a b c d
100
+ ```
101
+
102
+ Use SmoothWRR when weights are known/config-driven and change rarely.
103
+
104
+ ---
105
+
106
+ ## 5. Load-aware selection -- you own the `inflight` counters
107
+
108
+ P2C, LeastConn, SED, and NQ route by *current load*. That load lives in a
109
+ caller-owned `Uint32Array` you increment on dispatch and decrement on settle. If you
110
+ don't maintain it, these strategies are blind (they see every node at 0).
111
+
112
+ ```js
113
+ import { P2cBalancer } from '@zakkster/lite-pick';
114
+
115
+ const inflight = new Uint32Array(CAP); // YOURS to maintain
116
+ const lb = new P2cBalancer(CAP, eligible, inflight);
117
+
118
+ async function handle(req) {
119
+ const i = lb.pick();
120
+ if (i === PICK_NONE) return respond503();
121
+ inflight[i]++; // DISPATCH
122
+ try {
123
+ return await send(endpoints[i], req);
124
+ } finally {
125
+ inflight[i]--; // SETTLE (always, even on error)
126
+ }
127
+ }
128
+ ```
129
+
130
+ - **P2C** -- two random draws, pick the lighter. O(1), the scalable default; peak load
131
+ hugs the `ln ln n` band. Great from ~8 endpoints up.
132
+ - **LeastConn** -- exact fewest-in-flight (O(cap) scan). Best balance for small pools.
133
+ - **SED** / **NQ** -- weighted least-conn: pass a `weights` Uint32Array too;
134
+ `new SedBalancer(CAP, eligible, inflight, weights)`. NQ sends to an idle node first.
135
+
136
+ Maintaining the dispatch/settle loop by hand is easy to get wrong. Recipe 6 does it for
137
+ you.
138
+
139
+ ---
140
+
141
+ ## 6. The real request loop -- `@zakkster/lite-pick/pool`
142
+
143
+ The `/pool` subpath wraps the kernel with the async dispatch/settle ergonomics so you
144
+ don't hand-maintain `inflight`. The kernel `pick()` stays 0 B/op; `Pool.run` is a normal
145
+ async wrapper on top.
146
+
147
+ ```js
148
+ import { P2cBalancer } from '@zakkster/lite-pick';
149
+ import { Pool } from '@zakkster/lite-pick/pool';
150
+
151
+ const inflight = new Uint32Array(CAP);
152
+ const lb = new P2cBalancer(CAP, eligible, inflight);
153
+ const pool = new Pool(lb, inflight); // SAME inflight array the balancer reads
154
+
155
+ // Pool does pick -> inflight++ -> await fn -> inflight-- (in a finally) for you:
156
+ const body = await pool.run((i, signal) => fetchFrom(endpoints[i], { signal }));
157
+ ```
158
+
159
+ `run(fn, opts?)` rejects with a `code: 'LITE_PICK_NONE'` error when the pool is down.
160
+ `fn(endpoint, signal)` receives the chosen index and the (optional) AbortSignal.
161
+
162
+ ---
163
+
164
+ ## 7. Failover -- try a different endpoint on error
165
+
166
+ Set `tries > 1`. On a thrown error, Pool keeps the failed node's in-flight count
167
+ elevated and re-picks -- so a load-aware strategy naturally steers to a DIFFERENT
168
+ endpoint -- up to `tries` attempts, then rejects with the last error.
169
+
170
+ ```js
171
+ const body = await pool.run(
172
+ (i, signal) => fetchFrom(endpoints[i], { signal }),
173
+ { tries: 3, signal: req.signal } // up to 3 distinct endpoints
174
+ );
175
+ ```
176
+
177
+ Boundary: Pool owns **spatial** failover (move across the pool, once each, in-process).
178
+ The caller or your query cache owns **temporal** retry (backoff, staleness, dedup).
179
+ Don't double-own them. If `signal` aborts after a failure, failover stops and the abort
180
+ propagates.
181
+
182
+ ---
183
+
184
+ ## 8. Latency-aware routing -- PeakEWMA with rtt feedback
185
+
186
+ PeakEWMA (latency-aware P2C, Finagle's peak-EWMA) steers away from *slow* endpoints,
187
+ not just busy ones. It scores each candidate `(inflight + 1) x ewma(rtt)`, so a node
188
+ that got slow gets less traffic even if its connection count looks fine. It needs two
189
+ things you didn't need before: a **clock** (`now`, caller-supplied nanoseconds) and
190
+ **rtt feedback** (`recordRtt`).
191
+
192
+ Manual loop:
193
+
194
+ ```js
195
+ import { PeakEwmaBalancer } from '@zakkster/lite-pick';
196
+
197
+ const TAU_NS = 30_000_000; // 30ms half-life for the EWMA decay
198
+ const inflight = new Uint32Array(CAP);
199
+ const lb = new PeakEwmaBalancer(CAP, eligible, inflight, TAU_NS);
200
+ const nowNs = () => Number(process.hrtime.bigint());
201
+
202
+ async function handle(req) {
203
+ const now = nowNs();
204
+ const i = lb.pick(now); // decay-on-read, 0 B/op
205
+ if (i === PICK_NONE) return respond503();
206
+ inflight[i]++;
207
+ const start = nowNs();
208
+ try {
209
+ return await send(endpoints[i], req);
210
+ } finally {
211
+ inflight[i]--;
212
+ lb.recordRtt(i, nowNs() - start, nowNs()); // FEEDBACK: measured rtt, snaps up / decays down
213
+ }
214
+ }
215
+ ```
216
+
217
+ Or let Pool do the feedback for you -- pass a `clock`; Pool drives `pick(now)` and calls
218
+ `recordRtt` on a successful settle when the balancer supports it:
219
+
220
+ ```js
221
+ import { Pool } from '@zakkster/lite-pick/pool';
222
+ const pool = new Pool(lb, inflight);
223
+ const body = await pool.run(
224
+ (i, signal) => fetchFrom(endpoints[i], { signal }),
225
+ { clock: () => Number(process.hrtime.bigint()), tries: 2 }
226
+ );
227
+ ```
228
+
229
+ Notes:
230
+ - **Cold start** (no samples yet) degrades gracefully to least-connections -- never NaN.
231
+ - `now` must be a FINITE number. A non-finite `now` degrades to P2C-random (no throw).
232
+ - Pick `tauNs` around your p50-p90 rtt: smaller = reacts faster to a slowdown, larger =
233
+ steadier. It IS the anti-flap smoothing; no extra dwell needed.
234
+ - Measured effect: with one node at 10x latency, PeakEWMA sends it a tiny fraction of
235
+ the traffic P2C-over-inflight would, and cuts service p99 sharply.
236
+
237
+ ---
238
+
239
+ ## 9. Wire it into a query cache (lite-query, or any fetcher)
240
+
241
+ `liteQueryFetcher` adapts a Pool into a `({ key, signal }) => Promise` fetcher -- the
242
+ shape lite-query (or any cache/route-loader) expects. It imports nothing from lite-query
243
+ (duck-typed), so it works with any fetcher-shaped consumer.
244
+
245
+ ```js
246
+ import { Pool, liteQueryFetcher } from '@zakkster/lite-pick/pool';
247
+
248
+ const pool = new Pool(lb, inflight);
249
+ const fetcher = liteQueryFetcher(
250
+ pool,
251
+ ({ endpoint, key, signal }) => fetchFrom(endpoints[endpoint], { key, signal }),
252
+ { tries: 2 }
253
+ );
254
+ // hand `fetcher` to your query cache; each cache miss fans out across the pool with failover.
255
+ ```
256
+
257
+ ---
258
+
259
+ ## 10. Choosing a strategy
260
+
261
+ | Strategy | Route by | Cost | Reach for it when |
262
+ |---------------|---------------------|-------------|-------------------|
263
+ | RoundRobin | position | O(1) amort. | uniform endpoints, no load signal |
264
+ | SmoothWRR | static weight | O(cap) | known/config capacities, smooth interleave |
265
+ | P2C | in-flight (approx) | O(1) | the scalable default from ~8 nodes up |
266
+ | LeastConn | in-flight (exact) | O(cap) | small pools, tightest connection balance |
267
+ | SED | in-flight / weight | O(cap) | weighted least-conn |
268
+ | NQ | idle-first else SED | O(cap)/O(1) | worker pools -- never queue while a worker is free |
269
+ | PeakEWMA | in-flight x ewma(rtt)| O(1) | heterogeneous / flaky backends; steer around slow nodes |
270
+
271
+ All read the SAME `inflight` array live, so you can swap strategies without rewiring.
272
+
273
+ ---
274
+
275
+ ## 11. The "FE profile" -- a browser / front-end client
276
+
277
+ For a front-end client picking among origins a handful of times per second (not a
278
+ zero-GC hot loop), the recommended profile is **PeakEWMA + health/eligibility only** --
279
+ latency-aware choice across origins with a fail-closed eligibility view -- and skip the
280
+ bounded-load / AZ / occupancy machinery. Feed rtt from your `fetch` timings via
281
+ `recordRtt`.
282
+
283
+ ---
284
+
285
+ ## 12. Compose with the suite (all optional, all duck-typed)
286
+
287
+ lite-pick declares ZERO hard dependencies and an EMPTY `peerDependencies`. Each seam is
288
+ a shared TypedArray or a duck-typed shape, so you wire in a sibling only if you use it:
289
+
290
+ - `@zakkster/lite-di-health` -- writes the eligibility bitmap from health checks.
291
+ - `@zakkster/lite-statechart` -- a circuit breaker that flips eligibility.
292
+ - `@zakkster/lite-query` -- the cache behind `liteQueryFetcher` (recipe 9).
293
+ - `@zakkster/lite-sketch` -- `DDSketch` for a p99-aware PeakEWMA variant (deferred).
294
+ - `@zakkster/lite-await` -- hedging (race the P2C second choice past a percentile).
295
+
296
+ None is required; the kernel runs over raw TypedArrays with nothing installed.
297
+
298
+ ---
299
+
300
+ ## 13. Zero-GC discipline (why the pick path stays 0 B/op)
301
+
302
+ - Allocate `eligible` / `inflight` / `weights` ONCE at startup and reuse them. Never
303
+ build arrays per pick.
304
+ - The counters are YOURS -- mutate them in place (`inflight[i]++/--`), don't replace them.
305
+ - `pick()` / `pick(now)` and `recordRtt` allocate nothing. The only async allocation is
306
+ the promise your own `fn` already creates (disclosed; `Pool.run` adds O(1) integer ops
307
+ plus one small per-run array).
308
+ - Fixed capacity: the pool size is set at construction and the backing arrays never
309
+ reallocate.
310
+
311
+ ---
312
+
313
+ ## 14. Gotchas
314
+
315
+ - **PICK_NONE (-1)** is always possible -- handle it before indexing (recipe 2).
316
+ - **SmoothWRR weights** must go through `setWeight`; direct array mutation is UB.
317
+ - **Load-aware strategies need the dispatch/settle loop** -- forget the `inflight--` in
318
+ a `finally` and load leaks upward forever. Use `/pool` (recipe 6) to avoid it.
319
+ - **PeakEWMA needs a finite `now`** and rtt feedback -- without `recordRtt` it behaves
320
+ like LeastConn (cold-start baseline).
321
+ - **Eligibility is read-only to `pick()`** -- the health layer writes it; the balancer
322
+ only reads (or maintains `live` via `setEligible`).
323
+ - **lite-pick is not a proxy** -- it returns an index; you own transport, retries/backoff
324
+ (temporal), health checking, and the socket.
325
+
326
+ ---
327
+
328
+ See also: `README.md` (overview + gates), `llms.txt` (full API surface),
329
+ `decisions/` (the ADRs behind each design call), `ROADMAP.md` (what's next).
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.7.1
3
+ Version: 0.7.2
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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-pick",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "0.7.1",
4
+ "version": "0.7.2",
5
5
  "description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, PeakEWMA (latency-aware peak-EWMA), plus consistent hashing; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
6
6
  "type": "module",
7
7
  "main": "./Pick.js",
@@ -28,6 +28,7 @@
28
28
  "Pool.d.ts",
29
29
  "llms.txt",
30
30
  "README.md",
31
+ "RECIPES.md",
31
32
  "CHANGELOG.md",
32
33
  "LICENSE"
33
34
  ],