@zakkster/lite-pick 0.9.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/GUIDE.md +105 -0
- package/Pick.d.ts +85 -28
- package/Pick.js +364 -62
- package/Pool.d.ts +63 -17
- package/Pool.js +275 -62
- package/README.md +73 -36
- package/RECIPES.md +132 -36
- package/llms.txt +166 -75
- package/package.json +22 -5
package/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
|
-
/**
|
|
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` (
|
|
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
|
-
/**
|
|
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
|
|
26
|
-
signal?:
|
|
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.
|
|
31
|
-
*
|
|
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)
|
|
36
|
-
*
|
|
37
|
-
* a
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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?:
|
|
94
|
-
* @param {{ signal?:
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* `key` is a caller-supplied INTEGER
|
|
100
|
-
*
|
|
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
|
|
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 =
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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?:
|
|
179
|
-
* @param {{ tries?: number }} [opts] spatial failover
|
|
180
|
-
*
|
|
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(
|
|
401
|
+
return pool.run(
|
|
402
|
+
(endpoint, sig) => perEndpoint({ endpoint, key, signal: sig }),
|
|
403
|
+
{ signal, tries, clock, failurePenaltyNs },
|
|
404
|
+
);
|
|
192
405
|
};
|
|
193
406
|
}
|