@zakkster/lite-pick 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Pool.d.ts CHANGED
@@ -8,13 +8,31 @@
8
8
  /** The source-of-truth version stamp (re-exported from the core). */
9
9
  export const VERSION: string;
10
10
 
11
- /** The minimal balancer shape Pool drives (any lite-pick strategy satisfies it). */
11
+ /**
12
+ * The minimal abort-signal shape Pool reads (L15). A structural type -- NOT the global DOM
13
+ * `AbortSignal` -- so a consumer compiling with `lib: ["ES2022"]` only (no DOM, no @types/node)
14
+ * still type-checks. A real `AbortSignal` (DOM or node:) satisfies it.
15
+ */
16
+ export interface AbortLike {
17
+ readonly aborted: boolean;
18
+ readonly reason?: unknown;
19
+ throwIfAborted?(): void;
20
+ }
21
+
22
+ /**
23
+ * The minimal balancer shape Pool drives. A KEYED strategy (ConsistentHash / BoundedLoad) exposes a
24
+ * static `KEYED === true` and takes `pick(keyHash)`; a LATENCY strategy (PeakEWMA) exposes a static
25
+ * `LATENCY === true` and takes `pick(now)`; a plain strategy takes `pick()`. All lite-pick strategies
26
+ * satisfy this (their required-arg `pick` is bivariant-compatible with the zero-arg method here).
27
+ */
12
28
  export interface Balancer {
13
- /** `now` (PeakEwma clock) or `keyHash` (ConsistentHash/BoundedLoad) when supplied via opts. */
29
+ /** `now` (latency clock) or `keyHash` (keyed) when the strategy requires it, else no argument. */
14
30
  pick(arg?: number): number;
15
31
  readonly capacity: number;
16
32
  readonly live: number;
17
- /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is supplied. */
33
+ /** True iff endpoint `i` is currently pickable (used by distinct failover's untried scan). */
34
+ isEligible?(i: number): boolean;
35
+ /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is in use. */
18
36
  recordRtt?(i: number, sampleNs: number, now: number): void;
19
37
  /** Optional occupancy sink (BoundedLoadBalancer); fed +1 on dispatch, -1 on settle. */
20
38
  note?(i: number, delta: number): void;
@@ -22,21 +40,34 @@ export interface Balancer {
22
40
 
23
41
  /** Options for `Pool.run`. */
24
42
  export interface RunOptions {
25
- /** Passed to `fn`; when already aborted after a failure, stops failover (abort propagates). */
26
- signal?: AbortSignal;
43
+ /** Passed to `fn`; when already aborted, stops dispatch/failover (the abort propagates). */
44
+ signal?: AbortLike;
27
45
  /** Max distinct-endpoint attempts (default 1 = no failover). */
28
46
  tries?: number;
29
47
  /**
30
- * A caller-owned nanosecond clock. When present it drives `pick(now)` and the opt-in
31
- * `recordRtt` latency feedback for a latency-aware balancer (PeakEwma); otherwise inert.
48
+ * A caller-owned nanosecond clock. REQUIRED for a latency-aware balancer (PeakEWMA): `run`
49
+ * validates each reading is finite, drives `pick(now)`, and feeds `recordRtt` on settle (and a
50
+ * failure penalty on a throw). For a non-latency balancer it is optional and only feeds
51
+ * `recordRtt` if the balancer duck-types it; otherwise inert. Omitting it for a latency balancer
52
+ * is a runtime error.
32
53
  */
33
54
  clock?: () => number;
34
55
  /**
35
- * An integer routing key for a keyed balancer (ConsistentHash / BoundedLoad). When present,
36
- * `run` calls `pick(key)`; the opt-in `note` occupancy hook is driven on dispatch/settle for
37
- * a bounded-load balancer. Ignored by non-keyed strategies.
56
+ * An integer routing key. REQUIRED for a keyed balancer (ConsistentHash / BoundedLoad): `run`
57
+ * drives `pick(key)` (sticky / bounded-load routing). Omitting it for a keyed balancer is a
58
+ * runtime error. It is NOT passed to a latency balancer as `now`, and is ignored by non-keyed,
59
+ * non-latency strategies. The `note` occupancy hook is driven whenever the balancer duck-types
60
+ * `note`, independently of `key`.
38
61
  */
39
62
  key?: number;
63
+ /**
64
+ * The minimum rtt penalty (nanoseconds) a thrown attempt feeds a latency-aware balancer via
65
+ * `recordRtt(i, max(elapsed, failurePenaltyNs), done)`, so a fast-failing endpoint stops being the
66
+ * cheapest pick. Finite, > 0. Default 1e9 (1 s). RECOVERY: the penalized estimate decays back to
67
+ * competitive after roughly `tauNs * ln(failurePenaltyNs / healthyRttNs)`, so the node is
68
+ * periodically RE-PROBED at that cadence (recovery works) while its steady-state share stays low.
69
+ */
70
+ failurePenaltyNs?: number;
40
71
  }
41
72
 
42
73
  /**
@@ -56,31 +87,46 @@ export class Pool {
56
87
  /** The shared in-flight view Pool increments on dispatch and decrements on settle. */
57
88
  readonly inflight: Uint32Array;
58
89
  /**
59
- * Run `fn` against a chosen endpoint (in-flight incremented on dispatch, decremented on
60
- * settle), with up to `opts.tries` distinct-endpoint failover attempts on a throw. Rejects
61
- * with a `LITE_PICK_NONE`-coded error when no endpoint is eligible, or the last error when
62
- * every attempt fails.
90
+ * Run `fn` against a chosen endpoint (in-flight incremented on dispatch, decremented on settle),
91
+ * with up to `opts.tries` genuinely DISTINCT-endpoint failover attempts on a throw (failover
92
+ * targets are spread across keys for a keyed run and cursor-rotated for an unkeyed run). A keyed
93
+ * balancer requires `opts.key` and a latency balancer requires `opts.clock` (a `LITE_PICK_KEY_REQUIRED`
94
+ * / `LITE_PICK_CLOCK_REQUIRED`-coded error otherwise).
95
+ *
96
+ * Rejections: `LITE_PICK_NONE` when no endpoint is eligible; the last error when every attempt
97
+ * fails; an already-aborted `signal` dispatches NOTHING and rejects (the signal's `reason`, or a
98
+ * `LITE_PICK_ABORTED`-coded error). Feedback is loud and never re-runs `fn`: if SETTLE-time feedback
99
+ * after a SUCCESS fails (clock throws / non-finite, or `recordRtt` throws), `run` rejects with a
100
+ * `LITE_PICK_FEEDBACK`-coded error carrying `.cause` (the feedback error) and `.result` (fn's
101
+ * resolved value). If PENALTY feedback after a FAILURE fails, `run` throws fn's error object
102
+ * unchanged (identity preserved) with the feedback error attached as a non-enumerable
103
+ * `liteFeedbackError`. A backwards-stepping but finite clock reading records NO rtt sample and
104
+ * resolves normally (a failed attempt still records the full penalty). `opts` may be omitted or `null`.
63
105
  */
64
- run<T>(fn: (endpoint: number, signal?: AbortSignal) => Promise<T> | T, opts?: RunOptions): Promise<T>;
106
+ run<T>(fn: (endpoint: number, signal?: AbortLike) => Promise<T> | T, opts?: RunOptions | null): Promise<T>;
65
107
  }
66
108
 
67
109
  /** Context passed to the per-endpoint fetcher. */
68
110
  export interface PerEndpointContext {
69
111
  endpoint: number;
70
112
  key: any;
71
- signal?: AbortSignal;
113
+ signal?: AbortLike;
72
114
  }
73
115
 
74
116
  /** Context a query cache passes to the produced fetcher (lite-query's fetcher shape). */
75
117
  export interface FetcherContext {
76
118
  key: any;
77
- signal?: AbortSignal;
119
+ signal?: AbortLike;
78
120
  }
79
121
 
80
122
  /** Options for `liteQueryFetcher`. */
81
123
  export interface FetcherOptions {
82
124
  /** Spatial failover attempts across the pool (default 1). */
83
125
  tries?: number;
126
+ /** A nanosecond clock forwarded to a latency-aware balancer (PeakEWMA); otherwise inert. */
127
+ clock?: () => number;
128
+ /** Minimum rtt penalty a thrown attempt feeds a latency-aware balancer; forwarded to `pool.run`. */
129
+ failurePenaltyNs?: number;
84
130
  }
85
131
 
86
132
  /**
package/Pool.js CHANGED
@@ -28,6 +28,53 @@ import { VERSION, PICK_NONE } from './Pick.js';
28
28
  /** Re-exported so a /pool-only importer can read the version without importing the core. */
29
29
  export { VERSION };
30
30
 
31
+ /**
32
+ * Distinct-endpoint re-pick bound (M2): after a failed attempt Pool re-picks up to this many times
33
+ * while the strategy keeps returning an already-tried endpoint, before falling back to a linear scan
34
+ * for an eligible UNTRIED endpoint. Small: `tries` is small, and a keyed/deterministic strategy that
35
+ * always returns the same backend hits the scan after this bound rather than spinning.
36
+ */
37
+ const REPICK_LIMIT = 8;
38
+
39
+ /**
40
+ * Cold failover fallback (M2): the first ELIGIBLE endpoint NOT already tried this run, scanning the
41
+ * capacity from a rotating start (`from`) so failover does not always favour low indices. Uses the
42
+ * balancer's own `isEligible` (duck-typed; if absent, no scan is possible -> PICK_NONE). Returns
43
+ * PICK_NONE when every eligible endpoint has already been tried -- the caller then stops failing over
44
+ * rather than re-dispatching to an endpoint that already failed this run.
45
+ * @param {{ capacity: number, isEligible?(i: number): boolean }} b
46
+ * @param {number[]} tried endpoints already dispatched this run
47
+ * @param {number} from rotating scan start
48
+ * @returns {number}
49
+ */
50
+ function _scanUntried(b, tried, from) {
51
+ if (typeof b.isEligible !== 'function') return PICK_NONE;
52
+ const cap = b.capacity;
53
+ for (let s = 0; s < cap; s++) {
54
+ let idx = from + s;
55
+ if (idx >= cap) idx -= cap;
56
+ if (b.isEligible(idx) && tried.indexOf(idx) < 0) return idx;
57
+ }
58
+ return PICK_NONE;
59
+ }
60
+
61
+ /**
62
+ * Attach a feedback error to fn's error WITHOUT replacing it (identity is preserved): when `err` is
63
+ * an extensible object, define a NON-enumerable `liteFeedbackError` property carrying `fe`. Never
64
+ * throws (a frozen/sealed or primitive `err` is left untouched) -- the caller still gets fn's error.
65
+ * @param {unknown} err the value fn threw (returned unchanged)
66
+ * @param {unknown} fe the feedback error to attach
67
+ */
68
+ function _attachFeedback(err, fe) {
69
+ if (err !== null && (typeof err === 'object' || typeof err === 'function')) {
70
+ try {
71
+ Object.defineProperty(err, 'liteFeedbackError', {
72
+ value: fe, enumerable: false, configurable: true, writable: true,
73
+ });
74
+ } catch { /* frozen/sealed: leave fn's error untouched -- identity preserved */ }
75
+ }
76
+ }
77
+
31
78
  /**
32
79
  * Pool -- wraps a balancer + the caller-owned in-flight view with the dispatch/settle counter
33
80
  * ergonomics and distinct-endpoint failover. The balancer is duck-typed (anything with
@@ -51,6 +98,7 @@ export class Pool {
51
98
  }
52
99
  this._b = balancer;
53
100
  this._inflight = inflight;
101
+ this._scanCursor = 0; // rotating failover-scan start for UNKEYED runs (spreads across runs)
54
102
  }
55
103
 
56
104
  /** The wrapped balancer. */
@@ -64,99 +112,247 @@ export class Pool {
64
112
  }
65
113
 
66
114
  /**
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).
115
+ * Run `fn` against a chosen endpoint, incrementing its in-flight on dispatch and decrementing on
116
+ * settle. On a thrown error, keep the failed endpoint's count ELEVATED and fail over to a
117
+ * genuinely DISTINCT endpoint -- up to `tries` attempts, then throw the last error. Every count
118
+ * this run raised (and every applied `note(+1)`) is released before returning or throwing
119
+ * (net-zero on every path: success, throw, abort, feedback error).
72
120
  *
73
- * A LATENCY-AWARE balancer (PeakEwmaBalancer -- anything duck-typing `recordRtt`) is fed on
74
- * settle WHEN a `clock` is supplied: Pool reads `clock()` (caller-owned nanoseconds) before the
75
- * attempt, passes it to `pick(now)`, and on a SUCCESSFUL settle records `recordRtt(i, elapsed,
76
- * now2)`. Without a `clock`, or against a balancer with no `recordRtt`, the hook is INERT --
77
- * Pool stays generic, the in-flight counter stays net-zero, and abort/failover are unaffected.
78
- * The kernel `pick()` remains 0 B/op; this wrapper is not held to that bar.
121
+ * DISTINCT FAILOVER (M2): the endpoints already tried this run are tracked; after a failure Pool
122
+ * re-picks while the strategy repeats a tried endpoint (bounded by REPICK_LIMIT), then falls back
123
+ * to a scan for an eligible UNTRIED endpoint (`balancer.isEligible`). The scan start is derived
124
+ * from the KEY for a keyed run (deterministic per key -> a key's failover target is stable and
125
+ * cache-friendly, but spread ACROSS keys so one failing backend does not funnel every key onto a
126
+ * single neighbour) and from a rotating per-Pool cursor for an unkeyed run. If no untried eligible
127
+ * endpoint exists it stops failing over -- never re-dispatching to an endpoint that already failed
128
+ * this run.
79
129
  *
80
- * An OCCUPANCY-AWARE balancer (BoundedLoadBalancer -- anything duck-typing `note`) has each
81
- * dispatch mirrored as `note(i, +1)` and each settle as `note(i, -1)`, so its owned running mean
82
- * stays current; against a balancer with no `note` the hook is fully INERT (same net-zero,
83
- * generic behaviour). The two hooks are independent -- a balancer may duck-type neither, one, or
84
- * both.
130
+ * CHANNELS (M3), read off the strategy's static markers so Pool stays duck-typed:
131
+ * - A KEYED balancer (`constructor.KEYED === true`: ConsistentHash / BoundedLoad) REQUIRES a
132
+ * numeric `opts.key`; Pool drives `pick(key)`. Missing/non-numeric key -> a clear error. A
133
+ * clock reading is NEVER passed to a keyed pick.
134
+ * - A LATENCY balancer (`constructor.LATENCY === true`: PeakEWMA) REQUIRES an `opts.clock`;
135
+ * Pool reads `clock()` (validated finite BEFORE dispatch) and drives `pick(now)`.
136
+ * - Otherwise, when a `clock` is supplied Pool passes its reading to `pick(now)` too (a
137
+ * non-latency built-in ignores the argument; a duck-typed latency balancer that omitted the
138
+ * marker still gets `now`, preserving 1.0.0 behaviour). The KEY still reaches ONLY a keyed pick.
85
139
  *
86
- * A KEYED balancer (ConsistentHashBalancer / BoundedLoadBalancer -- CHBL) routes by an INTEGER
87
- * key. When `opts.key` is supplied, Pool drives `pick(key)` (sticky / bounded-load routing);
88
- * failover re-picks with the SAME key, and because the failed backend's occupancy stays elevated
89
- * (its `note(+1)` held across attempts) a CHBL re-pick naturally OVERFLOWS to the next backend.
90
- * Without `opts.key`, `pick()` / `pick(now)` behaviour is unchanged.
140
+ * FEEDBACK IS LOUD, never silent, and never re-runs fn (M4): the occupancy hook `note(i, +1/-1)`
141
+ * mirrors dispatch/settle, and the latency hook `recordRtt` is fed on settle when a clock is in use.
142
+ * - On SUCCESS the settle feedback (read `clock()`, then `recordRtt(i, done - now, done)`; a
143
+ * BACKWARDS finite reading `done < now` records NO sample -- neither a rejection nor a fake 0 ns
144
+ * rtt; `done === now` is a real 0 reading and is recorded) runs OUTSIDE the attempt's try/catch, so it can never re-dispatch fn. If it fails (clock
145
+ * throws or returns non-finite, or `recordRtt` throws) `run` REJECTS with a `LITE_PICK_FEEDBACK`
146
+ * -coded error whose `.cause` is the feedback error and whose `.result` is fn's resolved value
147
+ * (the caller loses nothing). fn ran exactly once.
148
+ * - On FAILURE the penalty feedback `recordRtt(i, max(elapsed, failurePenaltyNs), done)` (H1)
149
+ * runs in its OWN try/catch so fn's error object is preserved by identity as the thrown value.
150
+ * If the penalty feedback fails (throwing/non-finite clock, throwing `recordRtt`) Pool stops
151
+ * failing over (a broken clock would throw on the next attempt anyway), attaches the feedback
152
+ * error to fn's error as a NON-enumerable `liteFeedbackError`, and throws fn's error unchanged.
153
+ * - H1 recovery: a penalized node's estimate decays back to competitive after roughly
154
+ * `tauNs * ln(failurePenaltyNs / healthyRttNs)`, so it is periodically RE-PROBED at that
155
+ * cadence (recovery works) while its steady-state share stays low.
91
156
  *
92
157
  * @template T
93
- * @param {(endpoint: number, signal?: AbortSignal) => (Promise<T>|T)} fn the per-endpoint work.
94
- * @param {{ signal?: AbortSignal, tries?: number, clock?: () => number, key?: number }} [opts]
95
- * `tries` (default 1 = no failover) is the max number of distinct-endpoint attempts; `signal` is
96
- * passed to `fn` and, when already aborted after a failure, stops failover (the abort
97
- * propagates, no re-pick); `clock` is a caller-owned nanosecond source that, when present,
98
- * drives `pick(now)` and the opt-in `recordRtt` latency feedback for a latency-aware balancer;
99
- * `key` is a caller-supplied INTEGER key that, when present, drives `pick(key)` for a keyed
100
- * balancer (sticky / CHBL routing).
158
+ * @param {(endpoint: number, signal?: { readonly aborted: boolean }) => (Promise<T>|T)} fn
159
+ * @param {{ signal?: { readonly aborted: boolean, reason?: unknown, throwIfAborted?(): void },
160
+ * tries?: number, clock?: () => number, key?: number, failurePenaltyNs?: number } | null} [opts]
161
+ * `tries` (default 1 = no failover) is the max distinct-endpoint attempts; `signal` is passed to
162
+ * `fn` and, when already aborted, dispatches NOTHING (the abort always propagates); `clock`
163
+ * (REQUIRED for a latency balancer) is a caller-owned nanosecond source driving `pick(now)` +
164
+ * `recordRtt`; `key` (REQUIRED for a keyed balancer) is a caller-supplied INTEGER routing key;
165
+ * `failurePenaltyNs` (default 1e9) is the minimum rtt penalty a thrown attempt feeds a
166
+ * latency-aware balancer.
101
167
  * @returns {Promise<T>}
102
168
  */
103
169
  async run(fn, opts) {
104
170
  if (typeof fn !== 'function') throw new TypeError('[lite-pick] Pool.run needs a function');
105
- const rawTries = opts && opts.tries != null ? (opts.tries | 0) : 1;
171
+ const o = opts != null ? opts : undefined; // L1: run(fn, null) / run(fn) are valid
172
+ const rawTries = o && o.tries != null ? (o.tries | 0) : 1;
106
173
  const tries = rawTries > 0 ? rawTries : 1;
107
- const signal = opts ? opts.signal : undefined;
108
- const clock = opts && typeof opts.clock === 'function' ? opts.clock : undefined;
109
- // A keyed balancer (ConsistentHash / BoundedLoad -- CHBL) picks by an INTEGER key. When
110
- // `opts.key` is supplied, pick(key) drives selection; otherwise the existing pick()/pick(now)
111
- // behaviour is unchanged. `keyed` is true exactly when a key was passed.
112
- const keyed = opts !== undefined && opts.key !== undefined;
113
- const key = keyed ? opts.key : undefined;
114
- const inflight = this._inflight, b = this._b;
115
- // Opt-in latency feedback: only when BOTH a clock is supplied AND the balancer duck-types
116
- // recordRtt. Otherwise inert -- Pool stays generic and byte-for-byte behaviour is unchanged.
174
+ const signal = o ? o.signal : undefined;
175
+ const b = this._b, inflight = this._inflight;
176
+
177
+ // M3: separate, REQUIRED channels, read off the strategy's static markers (duck-typed --
178
+ // Pool imports nothing new). Validate BEFORE any dispatch (fail closed).
179
+ const ctor = b.constructor;
180
+ const keyed = !!(ctor && ctor.KEYED === true);
181
+ const latency = !!(ctor && ctor.LATENCY === true);
182
+
183
+ let key;
184
+ if (keyed) {
185
+ const k = o ? o.key : undefined;
186
+ if (typeof k !== 'number' || !Number.isFinite(k)) {
187
+ const e = new Error('[lite-pick] a keyed balancer (ConsistentHash/BoundedLoad) requires a numeric opts.key');
188
+ e.code = 'LITE_PICK_KEY_REQUIRED';
189
+ throw e;
190
+ }
191
+ key = k;
192
+ }
193
+
194
+ let clock;
195
+ if (latency) {
196
+ const c = o ? o.clock : undefined;
197
+ if (typeof c !== 'function') {
198
+ const e = new Error('[lite-pick] a latency-aware balancer (PeakEWMA) requires an opts.clock function');
199
+ e.code = 'LITE_PICK_CLOCK_REQUIRED';
200
+ throw e;
201
+ }
202
+ clock = c;
203
+ } else {
204
+ clock = o && typeof o.clock === 'function' ? o.clock : undefined;
205
+ }
206
+
207
+ // H1: a thrown attempt feeds a latency penalty so a fast-failing endpoint stops being the
208
+ // cheapest pick. Default 1 s; validated finite > 0 (fail closed -- never silently ignored).
209
+ let failurePenaltyNs = 1e9;
210
+ if (o && o.failurePenaltyNs !== undefined) {
211
+ const fp = o.failurePenaltyNs;
212
+ if (typeof fp !== 'number' || !Number.isFinite(fp) || fp <= 0) {
213
+ throw new RangeError('[lite-pick] failurePenaltyNs must be a finite number > 0');
214
+ }
215
+ failurePenaltyNs = fp;
216
+ }
217
+
218
+ // Opt-in feedback (duck-typed, independent): latency (recordRtt, only with a clock) and
219
+ // occupancy (note). Inert when the balancer does not duck-type the method. `useNow` is true
220
+ // when the clock reading is passed to pick() (any clocked run that is not keyed).
117
221
  const rtt = clock !== undefined && typeof b.recordRtt === 'function';
118
- // Opt-in occupancy feedback (BoundedLoadBalancer): when the balancer duck-types note(), Pool
119
- // mirrors each dispatch(+1)/settle(-1) into it so the balancer's owned _total mean stays
120
- // O(1)-current. Otherwise inert -- Pool stays generic, in-flight stays net-zero, and
121
- // abort/failover are unchanged. Follows the exact opt-in shape the recordRtt hook uses.
122
222
  const notes = typeof b.note === 'function';
123
- const held = []; // endpoints incremented this run (kept elevated across failover)
223
+ const useNow = clock !== undefined && !keyed;
224
+
225
+ const held = []; // endpoints incremented this run == the endpoints TRIED (kept elevated)
226
+ const noteApplied = []; // per-held: whether note(+1) actually landed (so finally never unpairs)
124
227
  let lastErr;
125
228
  try {
126
229
  for (let attempt = 0; attempt < tries; attempt++) {
127
- const now = clock !== undefined ? clock() : undefined;
128
- // A keyed pick (opts.key) takes precedence -- sticky/CHBL routing; else pick(now)/pick().
129
- const i = keyed ? b.pick(key) : b.pick(now);
230
+ // Item 5: abort before EVERY attempt -- an already-aborted signal dispatches NOTHING and
231
+ // ALWAYS throws (throwIfAborted is optional on the structural signal: call it when it is
232
+ // a function, then always throw the reason, else a coded abort error).
233
+ if (signal && signal.aborted) {
234
+ if (typeof signal.throwIfAborted === 'function') signal.throwIfAborted();
235
+ if (signal.reason !== undefined) throw signal.reason;
236
+ const e = new Error('[lite-pick] run aborted before dispatch');
237
+ e.code = 'LITE_PICK_ABORTED';
238
+ throw e;
239
+ }
240
+
241
+ // Validate the clock reading BEFORE dispatch so a broken clock fails closed early.
242
+ let now;
243
+ if (clock !== undefined) {
244
+ now = clock();
245
+ if (!Number.isFinite(now)) {
246
+ throw new Error('[lite-pick] clock() must return a finite number, got ' + now);
247
+ }
248
+ }
249
+
250
+ // Choose an endpoint: the key goes ONLY to a keyed pick; a clock reading (now) never
251
+ // reaches a keyed pick but does drive pick(now) for any other clocked run (M3 + nit 7).
252
+ let i = keyed ? b.pick(key) : (useNow ? b.pick(now) : b.pick());
253
+ if (attempt > 0) {
254
+ // M2: genuinely DISTINCT failover. Re-pick while the result repeats a tried
255
+ // endpoint (bounded), then a scan for an eligible UNTRIED endpoint from a start that
256
+ // is key-derived (keyed: stable per key, spread across keys) or cursor-rotated.
257
+ for (let g = 0; i !== PICK_NONE && held.indexOf(i) >= 0 && g < REPICK_LIMIT; g++) {
258
+ i = keyed ? b.pick(key) : (useNow ? b.pick(now) : b.pick());
259
+ }
260
+ if (i === PICK_NONE || held.indexOf(i) >= 0) {
261
+ const cap = b.capacity;
262
+ const from = keyed
263
+ ? (Math.imul(key >>> 0, 0x9e3779b1) >>> 0) % cap
264
+ : (this._scanCursor = (this._scanCursor + 1) & 0x3fffffff) % cap; // stays an SMI
265
+ i = _scanUntried(b, held, from);
266
+ }
267
+ }
130
268
  if (i === PICK_NONE) {
131
269
  if (attempt === 0) {
132
270
  const e = new Error('[lite-pick] no eligible endpoint');
133
271
  e.code = 'LITE_PICK_NONE';
134
272
  throw e;
135
273
  }
136
- break; // pool went fully down mid-failover: surface the last error
274
+ break; // no distinct untried endpoint left: surface the last error
137
275
  }
276
+
138
277
  inflight[i] = (inflight[i] + 1) >>> 0;
139
278
  held.push(i);
140
- if (notes) b.note(i, 1); // mirror the dispatch into the balancer's occupancy sum
279
+ noteApplied.push(false);
280
+ if (notes) {
281
+ // A throwing note(+1) propagates (loud); noteApplied stays false so the finally
282
+ // never sends an UNPAIRED note(-1) for this dispatch (nit 8). inflight is released.
283
+ b.note(i, 1);
284
+ noteApplied[noteApplied.length - 1] = true;
285
+ }
286
+
287
+ let out;
288
+ let ok = false;
141
289
  try {
142
- const out = await fn(i, signal);
143
- if (rtt) { // successful settle: feed the measured rtt back to the balancer
144
- const done = clock();
145
- b.recordRtt(i, done > now ? done - now : 0, done);
146
- }
147
- return out;
290
+ out = await fn(i, signal);
291
+ ok = true;
148
292
  } catch (err) {
149
293
  lastErr = err;
294
+ if (rtt) {
295
+ // H1 penalty feedback in its OWN try/catch: fn's error identity is preserved.
296
+ // Boolean flag, not a null sentinel: a hook that throws `null` is still a failure.
297
+ let feFailed = false, feErr;
298
+ try {
299
+ const done = clock();
300
+ if (!Number.isFinite(done)) {
301
+ throw new Error('[lite-pick] clock() must return a finite number, got ' + done);
302
+ }
303
+ let elapsed = done - now;
304
+ if (!(elapsed >= 0)) elapsed = 0; // clamp (NaN-safe): backwards finite clock
305
+ const pen = elapsed > failurePenaltyNs ? elapsed : failurePenaltyNs;
306
+ b.recordRtt(i, pen, done);
307
+ } catch (fe) {
308
+ feFailed = true;
309
+ feErr = fe;
310
+ }
311
+ if (feFailed) {
312
+ // Broken clock/feedback: stop failing over (the pre-dispatch check would
313
+ // throw next attempt anyway) and throw fn's error, unchanged, with the
314
+ // feedback error attached non-enumerably (never replaced).
315
+ _attachFeedback(err, feErr);
316
+ throw err;
317
+ }
318
+ }
150
319
  if (signal && signal.aborted) throw err; // abort: stop failover, propagate
320
+ continue; // keep inflight[i] elevated, re-pick distinct
321
+ }
322
+ if (ok) {
323
+ if (rtt) {
324
+ // Settle feedback runs OUTSIDE the attempt's try/catch (never re-runs fn). If it
325
+ // fails, REJECT loudly with LITE_PICK_FEEDBACK carrying .cause and .result.
326
+ try {
327
+ const done = clock();
328
+ if (!Number.isFinite(done)) {
329
+ throw new Error('[lite-pick] clock() must return a finite number, got ' + done);
330
+ }
331
+ // A backwards clock reading records NO sample: a fabricated 0 ns rtt would
332
+ // make the node look instant (cost 0 while idle) and drag down the pool mean.
333
+ // done === now is a real 0 reading from a coarse clock and IS recorded.
334
+ if (done >= now) b.recordRtt(i, done - now, done);
335
+ } catch (fe) {
336
+ const e = new Error('[lite-pick] settle-time feedback failed after a successful call');
337
+ e.code = 'LITE_PICK_FEEDBACK';
338
+ e.cause = fe;
339
+ e.result = out;
340
+ throw e;
341
+ }
342
+ }
343
+ return out;
151
344
  }
152
- // keep inflight[i] elevated so the next pick() steers to a different endpoint
153
345
  }
154
346
  throw lastErr;
155
347
  } finally {
156
348
  for (let k = 0; k < held.length; k++) {
157
349
  const j = held[k];
158
350
  inflight[j] = inflight[j] > 0 ? inflight[j] - 1 : 0;
159
- if (notes) b.note(j, -1); // net-zero settle -- keeps _total in lockstep per run
351
+ // Settle note(-1) ONLY for a dispatch whose note(+1) landed (nit 8), and swallow a
352
+ // cleanup-time throw so it never masks the error being thrown (identity preserved).
353
+ if (notes && noteApplied[k]) {
354
+ try { b.note(j, -1); } catch { /* best-effort net-zero cleanup */ }
355
+ }
160
356
  }
161
357
  }
162
358
  }
@@ -173,11 +369,18 @@ export class Pool {
173
369
  * The query cache owns TEMPORAL retry/backoff/staleness; the Pool owns SPATIAL failover across
174
370
  * the pool (`tries`). Wiring both is deliberate layering, never double-ownership (ADR 0007).
175
371
  *
372
+ * `ctx.key` is the QUERY-CACHE key (arbitrary), passed through to `perEndpoint`; it is NOT the
373
+ * integer ROUTING key a keyed balancer needs, so this generic adapter does not drive `pick(key)` --
374
+ * a keyed balancer wired through it fails closed (supply routing keys via `pool.run` directly). A
375
+ * `clock` (for a latency balancer) is forwarded when supplied in `opts`.
376
+ *
176
377
  * @template T
177
378
  * @param {Pool} pool
178
- * @param {(ctx: { endpoint: number, key: any, signal?: AbortSignal }) => (Promise<T>|T)} perEndpoint
179
- * @param {{ tries?: number }} [opts] spatial failover attempts (default 1).
180
- * @returns {(ctx: { key: any, signal?: AbortSignal }) => Promise<T>}
379
+ * @param {(ctx: { endpoint: number, key: any, signal?: { readonly aborted: boolean } }) => (Promise<T>|T)} perEndpoint
380
+ * @param {{ tries?: number, clock?: () => number, failurePenaltyNs?: number }} [opts] spatial failover
381
+ * attempts (default 1), an optional nanosecond clock, and the failure penalty -- all forwarded to
382
+ * `pool.run` (the clock/penalty feed a latency-aware balancer).
383
+ * @returns {(ctx: { key: any, signal?: { readonly aborted: boolean } }) => Promise<T>}
181
384
  */
182
385
  export function liteQueryFetcher(pool, perEndpoint, opts) {
183
386
  if (!(pool instanceof Pool)) throw new TypeError('[lite-pick] liteQueryFetcher needs a Pool');
@@ -185,9 +388,19 @@ export function liteQueryFetcher(pool, perEndpoint, opts) {
185
388
  throw new TypeError('[lite-pick] liteQueryFetcher needs a per-endpoint function');
186
389
  }
187
390
  const tries = opts && opts.tries != null ? opts.tries : 1;
391
+ const clock = opts && typeof opts.clock === 'function' ? opts.clock : undefined;
392
+ const failurePenaltyNs = opts && opts.failurePenaltyNs !== undefined ? opts.failurePenaltyNs : undefined;
393
+ // Validate once at creation (fail closed early), not on every fetch.
394
+ if (failurePenaltyNs !== undefined &&
395
+ (typeof failurePenaltyNs !== 'number' || !Number.isFinite(failurePenaltyNs) || failurePenaltyNs <= 0)) {
396
+ throw new RangeError('[lite-pick] failurePenaltyNs must be a finite number > 0');
397
+ }
188
398
  return function fetcher(ctx) {
189
399
  const key = ctx ? ctx.key : undefined;
190
400
  const signal = ctx ? ctx.signal : undefined;
191
- return pool.run((endpoint, sig) => perEndpoint({ endpoint, key, signal: sig }), { signal, tries });
401
+ return pool.run(
402
+ (endpoint, sig) => perEndpoint({ endpoint, key, signal: sig }),
403
+ { signal, tries, clock, failurePenaltyNs },
404
+ );
192
405
  };
193
406
  }