@nxtedition/nxt-undici 7.3.27 → 7.4.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/lib/index.d.ts +56 -0
- package/lib/index.js +1 -0
- package/lib/interceptor/pressure.js +317 -0
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -137,6 +137,61 @@ export interface LogInterceptorOptions {
|
|
|
137
137
|
bindings?: Record<string, unknown>
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
export interface PressureInterceptorOptions {
|
|
141
|
+
/** Sampling interval for the internal EWMA loop, in ms (default 200).
|
|
142
|
+
* Set to 0 to disable the internal timer and drive sampling yourself via
|
|
143
|
+
* `sample()` from a loop you already run. */
|
|
144
|
+
sampleInterval?: number
|
|
145
|
+
/** EWMA time-constant in ms — the smoothing/desensitizing window (default 10000). */
|
|
146
|
+
tau?: number
|
|
147
|
+
/** Schmitt-trigger dead-band for `some` (shed discretionary work). */
|
|
148
|
+
someHi?: number
|
|
149
|
+
someLo?: number
|
|
150
|
+
/** Schmitt-trigger dead-band for `full` (pause the producer). */
|
|
151
|
+
fullHi?: number
|
|
152
|
+
fullLo?: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PressureStats {
|
|
156
|
+
/** Gauge: requests dispatched but not yet connected (waiting for a slot). */
|
|
157
|
+
pending: number
|
|
158
|
+
/** Gauge: requests connected and in-flight. */
|
|
159
|
+
running: number
|
|
160
|
+
/** Counter: cumulative settled requests (onComplete + onError). */
|
|
161
|
+
completed: number
|
|
162
|
+
/** EWMA in [0,1]: fraction of recent time the origin had a connection backlog. */
|
|
163
|
+
some: number
|
|
164
|
+
/** EWMA in [0,1]: fraction of recent time the origin made zero progress under backlog. */
|
|
165
|
+
full: number
|
|
166
|
+
/** Latched: shed discretionary work (engaged when `some` crosses `someHi`). */
|
|
167
|
+
shed: boolean
|
|
168
|
+
/** Latched: pause the producer (engaged when `full` crosses `fullHi`). */
|
|
169
|
+
paused: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface PressureReading {
|
|
173
|
+
some: number
|
|
174
|
+
full: number
|
|
175
|
+
shed: boolean
|
|
176
|
+
paused: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface PressureInterceptor {
|
|
180
|
+
(dispatch: DispatchFn): DispatchFn
|
|
181
|
+
/** Per-origin snapshot, or — with no argument — an array over every tracked origin. */
|
|
182
|
+
stats(origin: string): PressureStats | undefined
|
|
183
|
+
stats(): Array<PressureStats & { origin: string }>
|
|
184
|
+
/** Smoothed pressure for an origin (zeroed/false for an untracked origin). */
|
|
185
|
+
pressure(origin: string): PressureReading
|
|
186
|
+
/** `full` pauses everything; `some` sheds only discretionary (low-priority) work. */
|
|
187
|
+
shouldBackoff(origin: string, priority?: Priority): boolean
|
|
188
|
+
/** Manually tick the EWMA loop (for `sampleInterval: 0`). */
|
|
189
|
+
sample(): void
|
|
190
|
+
/** Stop the internal timer and drop all tracked origins. */
|
|
191
|
+
close(): void
|
|
192
|
+
[Symbol.dispose](): void
|
|
193
|
+
}
|
|
194
|
+
|
|
140
195
|
export interface CacheKey {
|
|
141
196
|
origin: string
|
|
142
197
|
method: string
|
|
@@ -230,6 +285,7 @@ export const interceptors: {
|
|
|
230
285
|
dns: () => Interceptor
|
|
231
286
|
lookup: () => Interceptor
|
|
232
287
|
priority: () => Interceptor
|
|
288
|
+
pressure: (opts?: PressureInterceptorOptions) => PressureInterceptor
|
|
233
289
|
}
|
|
234
290
|
|
|
235
291
|
export const cache: {
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export const interceptors = {
|
|
|
21
21
|
dns: (await import('./interceptor/dns.js')).default,
|
|
22
22
|
lookup: (await import('./interceptor/lookup.js')).default,
|
|
23
23
|
priority: (await import('./interceptor/priority.js')).default,
|
|
24
|
+
pressure: (await import('./interceptor/pressure.js')).default,
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export const cache = {
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { parsePriority, Scheduler } from '@nxtedition/scheduler'
|
|
2
|
+
import { DecoratorHandler } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
// Reconstruct a PSI-style pressure signal per origin from the request lifecycle
|
|
5
|
+
// this interceptor observes, then expose latched flags so producers can back
|
|
6
|
+
// off. See @nxtedition/scheduler's README ("Pressure & backoff: utilization is
|
|
7
|
+
// the wrong trigger"): a resource can be 100% utilized with ~0 pressure — what
|
|
8
|
+
// you must throttle on is the fraction of time runnable work is *stalled*, not
|
|
9
|
+
// how busy you are.
|
|
10
|
+
//
|
|
11
|
+
// The HTTP analogue of the scheduler's `pending > 0 && saturated` is simply
|
|
12
|
+
// `pending > 0`: undici leaves a request un-connected (`pending`, in our
|
|
13
|
+
// accounting: dispatched but no `onConnect` yet) *only* when it has no free
|
|
14
|
+
// connection slot to place it on. The instant a slot frees, the queued request
|
|
15
|
+
// connects. So a standing `pending > 0` already means "backlog AND no free
|
|
16
|
+
// capacity" — a busy-but-keeping-up origin drains `pending` back to 0 between
|
|
17
|
+
// samples and scores 0, exactly the healthy-saturation false positive the naive
|
|
18
|
+
// `running/concurrency` recipe fires on.
|
|
19
|
+
//
|
|
20
|
+
// - some (PSI "some"): at least one request waiting for a connection -> shed
|
|
21
|
+
// discretionary work.
|
|
22
|
+
// - full (PSI "full"): a backlog AND zero completions this window -> nothing is
|
|
23
|
+
// making progress (origin hung / all sockets stuck) -> pause the producer.
|
|
24
|
+
//
|
|
25
|
+
// Two independent defenses give "sensitive to real overload, not so twitchy it
|
|
26
|
+
// flaps": the predicate filters healthy saturation before it enters the signal,
|
|
27
|
+
// and the EWMA window + Schmitt-trigger dead-band (engage high, release low) is
|
|
28
|
+
// the oomd-style sustained-duration requirement.
|
|
29
|
+
|
|
30
|
+
const EPS = 1e-3
|
|
31
|
+
|
|
32
|
+
// Smallest priority that is still "discretionary" — sheddable under `some`
|
|
33
|
+
// pressure. low/lower/lowest (<= -1); normal and above are never shed.
|
|
34
|
+
function isDiscretionary(priority) {
|
|
35
|
+
if (priority == null) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return parsePriority(priority) <= Scheduler.LOW
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class PressureMonitor {
|
|
46
|
+
#origins = new Map()
|
|
47
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
48
|
+
#timer = null
|
|
49
|
+
|
|
50
|
+
#sampleInterval
|
|
51
|
+
#tau
|
|
52
|
+
#someHi
|
|
53
|
+
#someLo
|
|
54
|
+
#fullHi
|
|
55
|
+
#fullLo
|
|
56
|
+
|
|
57
|
+
constructor({
|
|
58
|
+
sampleInterval = 200,
|
|
59
|
+
tau = 10_000,
|
|
60
|
+
someHi = 0.5,
|
|
61
|
+
someLo = 0.2,
|
|
62
|
+
fullHi = 0.3,
|
|
63
|
+
fullLo = 0.1,
|
|
64
|
+
} = {}) {
|
|
65
|
+
this.#sampleInterval = sampleInterval
|
|
66
|
+
this.#tau = tau
|
|
67
|
+
this.#someHi = someHi
|
|
68
|
+
this.#someLo = someLo
|
|
69
|
+
this.#fullHi = fullHi
|
|
70
|
+
this.#fullLo = fullLo
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Called from the interceptor on each dispatch to get (or lazily create) the
|
|
74
|
+
// per-origin record the handler mutates as the request progresses. The
|
|
75
|
+
// `pending` increment is done by the Handler constructor, not here, so it is
|
|
76
|
+
// tied to a *successfully* constructed handler: a handler that fails
|
|
77
|
+
// validation (DecoratorHandler throws on a non-object handler) never leaves a
|
|
78
|
+
// phantom pending count wedging the origin under pressure. A record created
|
|
79
|
+
// here but never incremented is harmless — it reports no pressure and is
|
|
80
|
+
// evicted on the next idle tick.
|
|
81
|
+
track(key) {
|
|
82
|
+
let rec = this.#origins.get(key)
|
|
83
|
+
if (rec == null) {
|
|
84
|
+
rec = {
|
|
85
|
+
pending: 0, // gauge: dispatched, awaiting onConnect (waiting for a slot)
|
|
86
|
+
running: 0, // gauge: connected and in-flight
|
|
87
|
+
completed: 0, // counter: cumulative settled (onComplete + onError)
|
|
88
|
+
prevCompleted: 0, // snapshot of `completed` at the previous sample
|
|
89
|
+
some: 0, // EWMA: fraction of recent time `someNow` held
|
|
90
|
+
full: 0, // EWMA: fraction of recent time `fullNow` held
|
|
91
|
+
shed: false, // latched: shed discretionary work
|
|
92
|
+
paused: false, // latched: pause the producer
|
|
93
|
+
lastSample: performance.now(),
|
|
94
|
+
}
|
|
95
|
+
this.#origins.set(key, rec)
|
|
96
|
+
}
|
|
97
|
+
this.#ensureTimer()
|
|
98
|
+
return rec
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// One sample -> instantaneous "stalled right now?" per level, smoothed into
|
|
102
|
+
// loadavg-shaped EWMAs with a dt-aware gain (keeps the time-constant honest
|
|
103
|
+
// under a jittery loop). Counters (completed) carry the `full` decision so a
|
|
104
|
+
// burst that fills and drains between two samples can't alias it away.
|
|
105
|
+
#sample(rec, now) {
|
|
106
|
+
const dt = now - rec.lastSample
|
|
107
|
+
if (dt <= 0) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
rec.lastSample = now
|
|
111
|
+
|
|
112
|
+
const someNow = rec.pending > 0
|
|
113
|
+
const progressed = rec.completed - rec.prevCompleted > 0
|
|
114
|
+
const fullNow = someNow && !progressed
|
|
115
|
+
rec.prevCompleted = rec.completed
|
|
116
|
+
|
|
117
|
+
const a = 1 - Math.exp(-dt / this.#tau)
|
|
118
|
+
rec.some += a * ((someNow ? 1 : 0) - rec.some)
|
|
119
|
+
rec.full += a * ((fullNow ? 1 : 0) - rec.full)
|
|
120
|
+
|
|
121
|
+
// Hysteresis: engage high, release low.
|
|
122
|
+
if (!rec.shed && rec.some > this.#someHi) {
|
|
123
|
+
rec.shed = true
|
|
124
|
+
} else if (rec.shed && rec.some < this.#someLo) {
|
|
125
|
+
rec.shed = false
|
|
126
|
+
}
|
|
127
|
+
if (!rec.paused && rec.full > this.#fullHi) {
|
|
128
|
+
rec.paused = true
|
|
129
|
+
} else if (rec.paused && rec.full < this.#fullLo) {
|
|
130
|
+
rec.paused = false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Tick every tracked origin, then evict any that is fully idle and has decayed
|
|
135
|
+
// back to ~0 on both levels, so a client touching many origins doesn't
|
|
136
|
+
// accumulate records forever. Stop the timer once nothing is tracked.
|
|
137
|
+
#tick() {
|
|
138
|
+
const now = performance.now()
|
|
139
|
+
for (const [key, rec] of this.#origins) {
|
|
140
|
+
this.#sample(rec, now)
|
|
141
|
+
if (rec.pending === 0 && rec.running === 0 && rec.some < EPS && rec.full < EPS) {
|
|
142
|
+
this.#origins.delete(key)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (this.#origins.size === 0 && this.#timer != null) {
|
|
146
|
+
clearInterval(this.#timer)
|
|
147
|
+
this.#timer = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#ensureTimer() {
|
|
152
|
+
if (this.#timer == null && this.#sampleInterval > 0 && this.#origins.size > 0) {
|
|
153
|
+
this.#timer = setInterval(() => this.#tick(), this.#sampleInterval)
|
|
154
|
+
// Never keep the event loop alive just to sample — the producer's own
|
|
155
|
+
// activity is what matters.
|
|
156
|
+
this.#timer.unref?.()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Manual tick, for callers that disable the internal timer (sampleInterval: 0)
|
|
161
|
+
// and drive sampling from a loop they already run (health check, metrics
|
|
162
|
+
// scrape) — the scheduler README's preferred "the loop is YOURS" pattern.
|
|
163
|
+
sample() {
|
|
164
|
+
this.#tick()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#snapshot(rec) {
|
|
168
|
+
return {
|
|
169
|
+
pending: rec.pending,
|
|
170
|
+
running: rec.running,
|
|
171
|
+
completed: rec.completed,
|
|
172
|
+
some: rec.some,
|
|
173
|
+
full: rec.full,
|
|
174
|
+
shed: rec.shed,
|
|
175
|
+
paused: rec.paused,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// No arg -> array of { origin, ...snapshot } for every tracked origin (a
|
|
180
|
+
// metrics scrape). With an origin -> that origin's snapshot, or undefined if
|
|
181
|
+
// it isn't being tracked (never seen, or evicted after going idle).
|
|
182
|
+
stats(origin) {
|
|
183
|
+
if (origin == null) {
|
|
184
|
+
const out = []
|
|
185
|
+
for (const [key, rec] of this.#origins) {
|
|
186
|
+
out.push({ origin: key, ...this.#snapshot(rec) })
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
const rec = this.#origins.get(origin)
|
|
191
|
+
return rec ? this.#snapshot(rec) : undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The smoothed pressure for an origin. An untracked origin is, by definition,
|
|
195
|
+
// not under pressure.
|
|
196
|
+
pressure(origin) {
|
|
197
|
+
const rec = this.#origins.get(origin)
|
|
198
|
+
return rec
|
|
199
|
+
? { some: rec.some, full: rec.full, shed: rec.shed, paused: rec.paused }
|
|
200
|
+
: { some: 0, full: 0, shed: false, paused: false }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Producer-side gate mirroring the README's `submit` recipe: `full` pauses
|
|
204
|
+
// everything; `some` sheds only discretionary (low-priority) work.
|
|
205
|
+
shouldBackoff(origin, priority) {
|
|
206
|
+
const rec = this.#origins.get(origin)
|
|
207
|
+
if (rec == null) {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
if (rec.paused) {
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
if (rec.shed) {
|
|
214
|
+
return isDiscretionary(priority)
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
close() {
|
|
220
|
+
if (this.#timer != null) {
|
|
221
|
+
clearInterval(this.#timer)
|
|
222
|
+
this.#timer = null
|
|
223
|
+
}
|
|
224
|
+
this.#origins.clear()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
[Symbol.dispose]() {
|
|
228
|
+
this.close()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
class Handler extends DecoratorHandler {
|
|
233
|
+
#rec
|
|
234
|
+
// 'pending' (awaiting onConnect) -> 'running' (connected) -> 'done' (settled).
|
|
235
|
+
// Tracked here so each transition fires exactly once regardless of how many
|
|
236
|
+
// times onConnect is invoked (e.g. a retry handler upstream) or which
|
|
237
|
+
// terminal callback fires.
|
|
238
|
+
#state = 'pending'
|
|
239
|
+
|
|
240
|
+
constructor(handler, rec) {
|
|
241
|
+
// super() validates the handler and throws on a non-object before we touch
|
|
242
|
+
// the gauge, so a rejected handler can't leak a pending count.
|
|
243
|
+
super(handler)
|
|
244
|
+
this.#rec = rec
|
|
245
|
+
rec.pending += 1
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
onConnect(abort) {
|
|
249
|
+
if (this.#state === 'pending') {
|
|
250
|
+
this.#state = 'running'
|
|
251
|
+
this.#rec.pending -= 1
|
|
252
|
+
this.#rec.running += 1
|
|
253
|
+
}
|
|
254
|
+
super.onConnect(abort)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
onComplete(trailers) {
|
|
258
|
+
this.#settle()
|
|
259
|
+
super.onComplete(trailers)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
onError(err) {
|
|
263
|
+
this.#settle()
|
|
264
|
+
super.onError(err)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#settle() {
|
|
268
|
+
if (this.#state === 'done') {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (this.#state === 'pending') {
|
|
272
|
+
this.#rec.pending -= 1
|
|
273
|
+
} else {
|
|
274
|
+
this.#rec.running -= 1
|
|
275
|
+
}
|
|
276
|
+
this.#rec.completed += 1
|
|
277
|
+
this.#state = 'done'
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default (opts) => {
|
|
282
|
+
const monitor = new PressureMonitor(opts)
|
|
283
|
+
|
|
284
|
+
const interceptor = (dispatch) => (opts, handler) => {
|
|
285
|
+
if (!opts.origin) {
|
|
286
|
+
return dispatch(opts, handler)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Key on opts.origin — the logical origin the caller knows and queries by.
|
|
290
|
+
// Compose this interceptor *ahead of* (outer to) `dns()` so opts.origin is
|
|
291
|
+
// still the logical host rather than a rotating resolved IP.
|
|
292
|
+
const key = opts.origin
|
|
293
|
+
|
|
294
|
+
const rec = monitor.track(key)
|
|
295
|
+
// Construct before the try: a handler that fails validation throws straight
|
|
296
|
+
// to the caller (as with any DecoratorHandler-based interceptor) and, since
|
|
297
|
+
// the constructor increments `pending` only on success, leaks nothing.
|
|
298
|
+
const pressureHandler = new Handler(handler, rec)
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return dispatch(opts, pressureHandler)
|
|
302
|
+
} catch (err) {
|
|
303
|
+
pressureHandler.onError(err)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The monitor is owned by the interceptor instance; surface its read API on
|
|
308
|
+
// the composed function so callers hold a single handle.
|
|
309
|
+
interceptor.stats = (origin) => monitor.stats(origin)
|
|
310
|
+
interceptor.pressure = (origin) => monitor.pressure(origin)
|
|
311
|
+
interceptor.shouldBackoff = (origin, priority) => monitor.shouldBackoff(origin, priority)
|
|
312
|
+
interceptor.sample = () => monitor.sample()
|
|
313
|
+
interceptor.close = () => monitor.close()
|
|
314
|
+
interceptor[Symbol.dispose] = () => monitor.close()
|
|
315
|
+
|
|
316
|
+
return interceptor
|
|
317
|
+
}
|