@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 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.3.27",
3
+ "version": "7.4.0",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",