@nxtedition/nxt-undici 7.4.3 → 7.5.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 +11 -0
- package/lib/index.js +15 -0
- package/lib/interceptor/cache.js +243 -23
- package/lib/interceptor/dns.js +101 -2
- package/lib/interceptor/log.js +126 -59
- package/lib/interceptor/lookup.js +53 -1
- package/lib/interceptor/pressure.js +86 -3
- package/lib/interceptor/priority.js +73 -3
- package/lib/interceptor/redirect.js +26 -0
- package/lib/interceptor/response-retry.js +32 -2
- package/lib/interceptor/response-verify.js +36 -0
- package/lib/trace.js +87 -0
- package/package.json +2 -1
package/lib/interceptor/dns.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import net from 'node:net'
|
|
2
2
|
import * as dns from 'node:dns'
|
|
3
3
|
import { DecoratorHandler, getFastNow } from '../utils.js'
|
|
4
|
+
import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
|
|
4
5
|
import xxhash from 'xxhash-wasm'
|
|
5
6
|
|
|
6
7
|
let HASHER
|
|
@@ -119,8 +120,16 @@ export default () => (dispatch) => {
|
|
|
119
120
|
function resolve(hostname, { ttl, negativeTTL, lookup }) {
|
|
120
121
|
let promise = promises.get(hostname)
|
|
121
122
|
if (!promise) {
|
|
123
|
+
// A synchronous lookup callback (custom resolvers answering from a local
|
|
124
|
+
// cache) runs inside the Promise executor, BEFORE the `promises.set`
|
|
125
|
+
// below — its cleanup delete would be a no-op and the settled promise
|
|
126
|
+
// would be retained forever, turning every later resolve() for the
|
|
127
|
+
// hostname (misses and pre-emptive refreshes alike) into a stale no-op.
|
|
128
|
+
// Track settlement so an already-settled promise is never registered.
|
|
129
|
+
let settled = false
|
|
122
130
|
promise = new Promise((resolve) => {
|
|
123
131
|
lookup(hostname, { all: true }, (err, records) => {
|
|
132
|
+
settled = true
|
|
124
133
|
promises.delete(hostname)
|
|
125
134
|
|
|
126
135
|
if (err) {
|
|
@@ -153,7 +162,9 @@ export default () => (dispatch) => {
|
|
|
153
162
|
}
|
|
154
163
|
})
|
|
155
164
|
})
|
|
156
|
-
|
|
165
|
+
if (!settled) {
|
|
166
|
+
promises.set(hostname, promise)
|
|
167
|
+
}
|
|
157
168
|
}
|
|
158
169
|
return promise
|
|
159
170
|
}
|
|
@@ -190,10 +201,47 @@ export default () => (dispatch) => {
|
|
|
190
201
|
// served from cache.
|
|
191
202
|
const negative = negatives.get(hostname)
|
|
192
203
|
if (negative != null && negative.expires >= now) {
|
|
204
|
+
// Cold path (fail-fast) — resolve the writer per emission, like
|
|
205
|
+
// response-retry's traceRetry.
|
|
206
|
+
const write = traceWrite(opts.trace)
|
|
207
|
+
if (write !== null) {
|
|
208
|
+
traceSafe(
|
|
209
|
+
write,
|
|
210
|
+
{
|
|
211
|
+
id: opts.id ?? null,
|
|
212
|
+
url: traceUrl(opts),
|
|
213
|
+
source: 'negative',
|
|
214
|
+
durationMs: 0,
|
|
215
|
+
records: null,
|
|
216
|
+
err: traceErr(negative.err),
|
|
217
|
+
},
|
|
218
|
+
'undici:dns',
|
|
219
|
+
)
|
|
220
|
+
}
|
|
193
221
|
throw makeLookupError(negative.err)
|
|
194
222
|
}
|
|
195
223
|
|
|
224
|
+
// Cold path (cache miss) — this request synchronously awaits the
|
|
225
|
+
// (possibly shared in-flight) resolution, so attribute the wait to it;
|
|
226
|
+
// concurrent awaiters each emit their own doc. The url tag is taken
|
|
227
|
+
// from the logical opts, before the origin is rewritten to the IP.
|
|
228
|
+
const write = traceWrite(opts.trace)
|
|
229
|
+
const started = write !== null ? performance.now() : 0
|
|
196
230
|
const [err, val] = await resolve(hostname, { ttl, negativeTTL, lookup })
|
|
231
|
+
if (write !== null) {
|
|
232
|
+
traceSafe(
|
|
233
|
+
write,
|
|
234
|
+
{
|
|
235
|
+
id: opts.id ?? null,
|
|
236
|
+
url: traceUrl(opts),
|
|
237
|
+
source: 'miss',
|
|
238
|
+
durationMs: Math.round(performance.now() - started),
|
|
239
|
+
records: err ? null : val.length,
|
|
240
|
+
err: err ? traceErr(err) : null,
|
|
241
|
+
},
|
|
242
|
+
'undici:dns',
|
|
243
|
+
)
|
|
244
|
+
}
|
|
197
245
|
if (err) {
|
|
198
246
|
throw makeLookupError(err)
|
|
199
247
|
}
|
|
@@ -242,7 +290,35 @@ export default () => (dispatch) => {
|
|
|
242
290
|
// refreshed records land in cache for the next request, smoothing
|
|
243
291
|
// out DNS lookup latency. `resolve()` dedupes via `promises`.
|
|
244
292
|
if (records.some((x) => x.expires < now + ttl / 2)) {
|
|
245
|
-
|
|
293
|
+
// Only the request that actually initiates the refresh observes it
|
|
294
|
+
// (checked against `promises` BEFORE resolve() registers the new
|
|
295
|
+
// in-flight promise): concurrent requests in the half-TTL window join
|
|
296
|
+
// the same deduped lookup and would otherwise emit one doc each, with
|
|
297
|
+
// attach-relative durations — the doc denotes the background lookup
|
|
298
|
+
// itself, not a per-request wait (nothing awaits a refresh).
|
|
299
|
+
const write = traceWrite(opts.trace)
|
|
300
|
+
const initiated = write !== null && !promises.has(hostname)
|
|
301
|
+
const promise = resolve(hostname, { ttl, negativeTTL, lookup })
|
|
302
|
+
if (initiated) {
|
|
303
|
+
// Side-observe a copy of the chain: resolve() never rejects (it
|
|
304
|
+
// settles with an [err, val] tuple) and .then returns a new promise,
|
|
305
|
+
// so the shared in-flight promise's other consumers are unaffected.
|
|
306
|
+
const started = performance.now()
|
|
307
|
+
promise.then(([err, val]) => {
|
|
308
|
+
traceSafe(
|
|
309
|
+
write,
|
|
310
|
+
{
|
|
311
|
+
id: opts.id ?? null,
|
|
312
|
+
url: traceUrl(opts),
|
|
313
|
+
source: 'refresh',
|
|
314
|
+
durationMs: Math.round(performance.now() - started),
|
|
315
|
+
records: err ? null : val.length,
|
|
316
|
+
err: err ? traceErr(err) : null,
|
|
317
|
+
},
|
|
318
|
+
'undici:dns',
|
|
319
|
+
)
|
|
320
|
+
})
|
|
321
|
+
}
|
|
246
322
|
}
|
|
247
323
|
|
|
248
324
|
url.hostname = net.isIPv6(record.address) ? `[${record.address}]` : record.address
|
|
@@ -265,6 +341,29 @@ export default () => (dispatch) => {
|
|
|
265
341
|
if (err.code != null && CONNECTION_ERROR_CODES.has(err.code)) {
|
|
266
342
|
// The IP is bad/unreachable — drop it from rotation immediately.
|
|
267
343
|
record.expires = 0
|
|
344
|
+
|
|
345
|
+
// Cold path (connection error) — opts is the dispatch closure's;
|
|
346
|
+
// eviction stays allocation-free while tracing is off.
|
|
347
|
+
const write = traceWrite(opts.trace)
|
|
348
|
+
if (write !== null) {
|
|
349
|
+
const evictedAt = getFastNow()
|
|
350
|
+
let siblings = 0
|
|
351
|
+
for (const x of records) {
|
|
352
|
+
if (x.expires >= evictedAt) {
|
|
353
|
+
siblings++
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
traceSafe(
|
|
357
|
+
write,
|
|
358
|
+
{
|
|
359
|
+
hostname: hostname.slice(0, 256),
|
|
360
|
+
address: String(record.address).slice(0, 256),
|
|
361
|
+
err: traceErr(err),
|
|
362
|
+
siblings,
|
|
363
|
+
},
|
|
364
|
+
'undici:dns-evict',
|
|
365
|
+
)
|
|
366
|
+
}
|
|
268
367
|
} else {
|
|
269
368
|
// Reachable IP, request failed for an unrelated reason (timeout
|
|
270
369
|
// mid-stream, size mismatch, ...) — penalize softly, don't evict.
|
package/lib/interceptor/log.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DecoratorHandler, parseHeaders } from '../utils.js'
|
|
2
|
+
import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
|
|
2
3
|
|
|
3
4
|
const kGlobalIndex = Symbol.for('@nxtedition/nxt-undici#globalIndex')
|
|
4
5
|
const kGlobalArray = Symbol.for('@nxtedition/nxt-undici#globalArray')
|
|
@@ -141,6 +142,18 @@ class Handler extends DecoratorHandler {
|
|
|
141
142
|
#ureq
|
|
142
143
|
#logger
|
|
143
144
|
|
|
145
|
+
// Trace emission (op 'undici:request') lives in this handler alongside
|
|
146
|
+
// logging: both observe the same lifecycle (start, status, bytes, terminal
|
|
147
|
+
// event, sync-dispatch-throw finalization), so a second decorator layer
|
|
148
|
+
// would duplicate the bookkeeping. `#write` is the trace fn resolved once
|
|
149
|
+
// per request (capture-once: the same fn emits both the start and the end
|
|
150
|
+
// doc, so a writer flipping mid-request cannot break the pairing) and null
|
|
151
|
+
// when tracing is off. Logging and tracing are independently optional; the
|
|
152
|
+
// dispatch entry only constructs this handler when at least one is active.
|
|
153
|
+
#write
|
|
154
|
+
#traceUrl
|
|
155
|
+
#upgraded = false
|
|
156
|
+
|
|
144
157
|
#abort
|
|
145
158
|
#aborted = false
|
|
146
159
|
#pos = 0
|
|
@@ -153,20 +166,39 @@ class Handler extends DecoratorHandler {
|
|
|
153
166
|
end: -1,
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
#opts
|
|
156
170
|
#statusCode
|
|
157
171
|
#headers
|
|
158
172
|
|
|
159
|
-
constructor(logOpts, opts, { handler }) {
|
|
173
|
+
constructor(write, logOpts, opts, { handler }) {
|
|
160
174
|
super(handler)
|
|
161
175
|
|
|
162
|
-
this.#
|
|
163
|
-
this.#
|
|
176
|
+
this.#opts = opts
|
|
177
|
+
this.#write = write
|
|
178
|
+
|
|
179
|
+
if (write !== null) {
|
|
180
|
+
this.#traceUrl = traceUrl(opts)
|
|
164
181
|
|
|
165
|
-
|
|
166
|
-
|
|
182
|
+
traceSafe(
|
|
183
|
+
write,
|
|
184
|
+
{ phase: 'start', id: opts.id ?? null, method: opts.method ?? null, url: this.#traceUrl },
|
|
185
|
+
'undici:request',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (opts.logger) {
|
|
190
|
+
this.#ureq = sanitizeRequest(opts)
|
|
191
|
+
this.#logger = opts.logger.child({ ureq: this.#ureq })
|
|
192
|
+
|
|
193
|
+
if (logOpts?.bindings) {
|
|
194
|
+
this.#logger = this.#logger.child(logOpts?.bindings)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.#logger.debug('upstream request started')
|
|
198
|
+
} else {
|
|
199
|
+
this.#logger = null
|
|
167
200
|
}
|
|
168
201
|
|
|
169
|
-
this.#logger.debug('upstream request started')
|
|
170
202
|
this.#timing.created = this.#created + performance.timeOrigin
|
|
171
203
|
|
|
172
204
|
this[kGlobalArray] = globalThis[kGlobalArray] ??= []
|
|
@@ -190,8 +222,13 @@ class Handler extends DecoratorHandler {
|
|
|
190
222
|
|
|
191
223
|
onUpgrade(statusCode, headers, socket) {
|
|
192
224
|
this.#timing.headers = performance.now() - this.#created
|
|
225
|
+
this.#statusCode = statusCode
|
|
226
|
+
// After an upgrade the socket is handed over and no onComplete/onError
|
|
227
|
+
// will ever arrive — close of the upgraded socket is the end of the
|
|
228
|
+
// request. Bytes are not tracked on an upgraded socket.
|
|
229
|
+
this.#upgraded = true
|
|
193
230
|
|
|
194
|
-
this.#logger
|
|
231
|
+
this.#logger?.debug(
|
|
195
232
|
{
|
|
196
233
|
ures: { statusCode, headers: sanitizeHeaders(headers) },
|
|
197
234
|
elapsedTime: this.#timing.headers,
|
|
@@ -200,8 +237,8 @@ class Handler extends DecoratorHandler {
|
|
|
200
237
|
)
|
|
201
238
|
|
|
202
239
|
socket.on('close', () => {
|
|
203
|
-
this.#logger
|
|
204
|
-
this.onDone()
|
|
240
|
+
this.#logger?.debug('upstream request socket closed')
|
|
241
|
+
this.onDone(null)
|
|
205
242
|
})
|
|
206
243
|
|
|
207
244
|
super.onUpgrade(statusCode, headers, socket)
|
|
@@ -230,30 +267,32 @@ class Handler extends DecoratorHandler {
|
|
|
230
267
|
onComplete(trailers) {
|
|
231
268
|
this.#timing.end = performance.now() - this.#created
|
|
232
269
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
if (this.#logger) {
|
|
271
|
+
const data = {
|
|
272
|
+
ureq: this.#ureq,
|
|
273
|
+
ures: {
|
|
274
|
+
statusCode: this.#statusCode,
|
|
275
|
+
headers: this.#headers,
|
|
276
|
+
timing: this.#timing,
|
|
277
|
+
bytesRead: this.#pos,
|
|
278
|
+
bytesReadPerSecond:
|
|
279
|
+
this.#timing.data >= 0 && this.#timing.end > this.#timing.data
|
|
280
|
+
? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
|
|
281
|
+
: 0,
|
|
282
|
+
},
|
|
283
|
+
elapsedTime: this.#timing.end,
|
|
284
|
+
}
|
|
247
285
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
286
|
+
if (this.#statusCode >= 500) {
|
|
287
|
+
this.#logger.error(data, 'upstream request completed')
|
|
288
|
+
} else if (this.#statusCode >= 400) {
|
|
289
|
+
this.#logger.warn(data, 'upstream request completed')
|
|
290
|
+
} else {
|
|
291
|
+
this.#logger.debug(data, 'upstream request completed')
|
|
292
|
+
}
|
|
254
293
|
}
|
|
255
294
|
|
|
256
|
-
this.onDone()
|
|
295
|
+
this.onDone(null)
|
|
257
296
|
|
|
258
297
|
super.onComplete(trailers)
|
|
259
298
|
}
|
|
@@ -261,33 +300,40 @@ class Handler extends DecoratorHandler {
|
|
|
261
300
|
onError(err) {
|
|
262
301
|
this.#timing.end = performance.now() - this.#created
|
|
263
302
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
if (this.#logger) {
|
|
304
|
+
const data = {
|
|
305
|
+
ures: {
|
|
306
|
+
statusCode: this.#statusCode || undefined,
|
|
307
|
+
headers: this.#headers,
|
|
308
|
+
timing: this.#timing,
|
|
309
|
+
bytesRead: this.#pos,
|
|
310
|
+
bytesReadPerSecond:
|
|
311
|
+
this.#timing.data >= 0 && this.#timing.end > this.#timing.data
|
|
312
|
+
? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
|
|
313
|
+
: 0,
|
|
314
|
+
},
|
|
315
|
+
elapsedTime: this.#timing.end,
|
|
316
|
+
err,
|
|
317
|
+
}
|
|
278
318
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
319
|
+
if (this.#aborted) {
|
|
320
|
+
this.#logger.debug(data, 'upstream request aborted')
|
|
321
|
+
} else {
|
|
322
|
+
this.#logger.error(data, 'upstream request failed')
|
|
323
|
+
}
|
|
283
324
|
}
|
|
284
325
|
|
|
285
|
-
this.onDone()
|
|
326
|
+
this.onDone(err)
|
|
286
327
|
|
|
287
328
|
super.onError(err)
|
|
288
329
|
}
|
|
289
330
|
|
|
290
|
-
|
|
331
|
+
// Terminal finalization, reached exactly once per request from every end
|
|
332
|
+
// path (onComplete, onError, upgraded-socket close, onDispatchError): the
|
|
333
|
+
// in-flight registry entry is the once-guard. Deregistration happens BEFORE
|
|
334
|
+
// the trace end doc is emitted so a misbehaving writer can never observe —
|
|
335
|
+
// or re-enter — a handler that still looks in flight.
|
|
336
|
+
onDone(err) {
|
|
291
337
|
if (this[kGlobalIndex] !== -1) {
|
|
292
338
|
const tmp = this[kGlobalArray].pop()
|
|
293
339
|
if (tmp !== this) {
|
|
@@ -295,16 +341,33 @@ class Handler extends DecoratorHandler {
|
|
|
295
341
|
tmp[kGlobalIndex] = this[kGlobalIndex]
|
|
296
342
|
}
|
|
297
343
|
this[kGlobalIndex] = -1
|
|
344
|
+
|
|
345
|
+
if (this.#write !== null) {
|
|
346
|
+
traceSafe(
|
|
347
|
+
this.#write,
|
|
348
|
+
{
|
|
349
|
+
phase: 'end',
|
|
350
|
+
id: this.#opts.id ?? null,
|
|
351
|
+
method: this.#opts.method ?? null,
|
|
352
|
+
url: this.#traceUrl,
|
|
353
|
+
statusCode: this.#statusCode ?? null,
|
|
354
|
+
durationMs: Math.round(performance.now() - this.#created),
|
|
355
|
+
bytes: this.#upgraded ? null : this.#pos,
|
|
356
|
+
err: err != null ? traceErr(err) : null,
|
|
357
|
+
},
|
|
358
|
+
'undici:request',
|
|
359
|
+
)
|
|
360
|
+
}
|
|
298
361
|
}
|
|
299
362
|
}
|
|
300
363
|
|
|
301
364
|
// Finalization for a request whose inner dispatch threw synchronously:
|
|
302
365
|
// undici never took ownership of the handler, so no terminal callback
|
|
303
|
-
// (onError/onComplete) will ever arrive. Log the failure
|
|
304
|
-
// from the in-flight registry. Deliberately does
|
|
305
|
-
// the dispatch entry below rethrows and an outer
|
|
306
|
-
// delivers the error to the original handler chain,
|
|
307
|
-
// would double-deliver it.
|
|
366
|
+
// (onError/onComplete) will ever arrive. Log the failure, emit the trace
|
|
367
|
+
// end doc and deregister from the in-flight registry. Deliberately does
|
|
368
|
+
// NOT forward onError — the dispatch entry below rethrows and an outer
|
|
369
|
+
// interceptor (lookup) delivers the error to the original handler chain,
|
|
370
|
+
// so forwarding here would double-deliver it.
|
|
308
371
|
onDispatchError(err) {
|
|
309
372
|
if (this[kGlobalIndex] === -1) {
|
|
310
373
|
// A terminal callback already ran before the error escaped dispatch
|
|
@@ -315,18 +378,22 @@ class Handler extends DecoratorHandler {
|
|
|
315
378
|
|
|
316
379
|
this.#timing.end = performance.now() - this.#created
|
|
317
380
|
|
|
318
|
-
this.#logger
|
|
381
|
+
this.#logger?.error({ err, elapsedTime: this.#timing.end }, 'upstream request failed')
|
|
319
382
|
|
|
320
|
-
this.onDone()
|
|
383
|
+
this.onDone(err)
|
|
321
384
|
}
|
|
322
385
|
}
|
|
323
386
|
|
|
324
387
|
export default (logOpts) => (dispatch) => (opts, handler) => {
|
|
325
|
-
|
|
388
|
+
// Capture-once per request (see Handler#write). Resolution cost when both
|
|
389
|
+
// logging and tracing are off is one property read plus a typeof check.
|
|
390
|
+
const write = traceWrite(opts.trace)
|
|
391
|
+
|
|
392
|
+
if (!opts.logger && write === null) {
|
|
326
393
|
return dispatch(opts, handler)
|
|
327
394
|
}
|
|
328
395
|
|
|
329
|
-
const logHandler = new Handler(logOpts, opts, { handler })
|
|
396
|
+
const logHandler = new Handler(write, logOpts, opts, { handler })
|
|
330
397
|
|
|
331
398
|
try {
|
|
332
399
|
return dispatch(opts, logHandler)
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { DecoratorHandler } from '../utils.js'
|
|
2
|
+
import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
|
|
3
|
+
|
|
4
|
+
// Emit the single `undici:lookup` doc for an origin resolution: an async
|
|
5
|
+
// success carries the resolved origin, a failure carries the error tag. `url`
|
|
6
|
+
// is the requested origin+path; `resolved` is bounded like every other
|
|
7
|
+
// caller-influenced string.
|
|
8
|
+
function traceLookup(write, opts, start, resolved, err) {
|
|
9
|
+
traceSafe(
|
|
10
|
+
write,
|
|
11
|
+
{
|
|
12
|
+
id: opts.id ?? null,
|
|
13
|
+
method: opts.method ?? null,
|
|
14
|
+
url: traceUrl(opts),
|
|
15
|
+
resolved,
|
|
16
|
+
durationMs: Math.round(performance.now() - start),
|
|
17
|
+
err,
|
|
18
|
+
},
|
|
19
|
+
'undici:lookup',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
2
22
|
|
|
3
23
|
export default () => (dispatch) => async (opts, handler) => {
|
|
4
24
|
const lookup = opts.lookup
|
|
@@ -7,6 +27,16 @@ export default () => (dispatch) => async (opts, handler) => {
|
|
|
7
27
|
return dispatch(opts, handler)
|
|
8
28
|
}
|
|
9
29
|
|
|
30
|
+
// Per-request, resolved after the passthrough return; used at most once
|
|
31
|
+
// (one success OR failure doc per resolution).
|
|
32
|
+
const write = traceWrite(opts.trace)
|
|
33
|
+
const start = write !== null ? performance.now() : 0
|
|
34
|
+
// A success doc is only worth emitting when the callback fired
|
|
35
|
+
// asynchronously (service discovery): the default lookup calls back
|
|
36
|
+
// synchronously and would produce a ~0ms doc per request.
|
|
37
|
+
let resolvedAsync = false
|
|
38
|
+
let dispatched = false
|
|
39
|
+
|
|
10
40
|
// Wrap so the catch below can't deliver a second onError: if a downstream
|
|
11
41
|
// layer already reported a terminal callback and then let an error escape
|
|
12
42
|
// dispatch synchronously, DecoratorHandler's #errored/#completed guards
|
|
@@ -15,16 +45,28 @@ export default () => (dispatch) => async (opts, handler) => {
|
|
|
15
45
|
|
|
16
46
|
try {
|
|
17
47
|
const origin = await new Promise((resolve, reject) => {
|
|
48
|
+
let sync = true
|
|
18
49
|
const thenable = lookup(opts.origin, { signal: opts.signal ?? undefined }, (err, val) => {
|
|
50
|
+
if (!sync) {
|
|
51
|
+
resolvedAsync = true
|
|
52
|
+
}
|
|
19
53
|
if (err) {
|
|
20
54
|
reject(err)
|
|
21
55
|
} else {
|
|
22
56
|
resolve(val)
|
|
23
57
|
}
|
|
24
58
|
})
|
|
59
|
+
sync = false
|
|
25
60
|
|
|
26
61
|
if (thenable != null) {
|
|
27
|
-
|
|
62
|
+
// A promise-returning lookup is always asynchronous (`.then` callbacks
|
|
63
|
+
// never run on the current stack), so its success doc must be emitted
|
|
64
|
+
// like the async-callback shape's — only the sync callback path is
|
|
65
|
+
// suppressed as noise.
|
|
66
|
+
Promise.resolve(thenable).then((val) => {
|
|
67
|
+
resolvedAsync = true
|
|
68
|
+
resolve(val)
|
|
69
|
+
}, reject)
|
|
28
70
|
}
|
|
29
71
|
})
|
|
30
72
|
|
|
@@ -32,8 +74,18 @@ export default () => (dispatch) => async (opts, handler) => {
|
|
|
32
74
|
throw new Error('invalid origin: ' + origin)
|
|
33
75
|
}
|
|
34
76
|
|
|
77
|
+
if (write !== null && resolvedAsync) {
|
|
78
|
+
traceLookup(write, opts, start, String(origin).slice(0, 256), null)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
dispatched = true
|
|
35
82
|
return dispatch({ ...opts, origin }, wrapped)
|
|
36
83
|
} catch (err) {
|
|
84
|
+
// The try also covers the inner dispatch call — a sync throw escaping the
|
|
85
|
+
// inner chain is not a lookup failure and must not be attributed to one.
|
|
86
|
+
if (write !== null && !dispatched) {
|
|
87
|
+
traceLookup(write, opts, start, null, traceErr(err))
|
|
88
|
+
}
|
|
37
89
|
wrapped.onError(err)
|
|
38
90
|
}
|
|
39
91
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parsePriority, Scheduler } from '@nxtedition/scheduler'
|
|
2
2
|
import { DecoratorHandler } from '../utils.js'
|
|
3
|
+
import { traceSafe, traceUrl, traceWrite, validateTrace } from '../trace.js'
|
|
3
4
|
|
|
4
5
|
// Reconstruct a PSI-style pressure signal per origin from the request lifecycle
|
|
5
6
|
// this interceptor observes, then expose latched flags so producers can back
|
|
@@ -67,6 +68,13 @@ class PressureMonitor {
|
|
|
67
68
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
68
69
|
#timer = null
|
|
69
70
|
|
|
71
|
+
// Optional trace writer for saturation-episode docs (op 'undici:pressure'):
|
|
72
|
+
// undefined defers to the per-thread global writer at each emission site,
|
|
73
|
+
// null disables. Sampling ticks in a timer with no request opts in scope, so
|
|
74
|
+
// the writer comes from the factory opts (like the scheduler limiter's
|
|
75
|
+
// #trace). All emission sites are cold — hysteresis latch transitions.
|
|
76
|
+
#trace
|
|
77
|
+
|
|
70
78
|
#sampleInterval
|
|
71
79
|
#tau
|
|
72
80
|
#someHi
|
|
@@ -85,7 +93,9 @@ class PressureMonitor {
|
|
|
85
93
|
fullLo = 0.1,
|
|
86
94
|
errHi = 0.5,
|
|
87
95
|
errLo = 0.2,
|
|
96
|
+
trace,
|
|
88
97
|
} = {}) {
|
|
98
|
+
this.#trace = validateTrace(trace)
|
|
89
99
|
this.#sampleInterval = sampleInterval
|
|
90
100
|
this.#tau = tau
|
|
91
101
|
this.#someHi = someHi
|
|
@@ -120,6 +130,7 @@ class PressureMonitor {
|
|
|
120
130
|
shed: false, // latched: shed discretionary work
|
|
121
131
|
paused: false, // latched: pause the producer
|
|
122
132
|
degraded: false, // latched: error rate too high
|
|
133
|
+
episodes: null, // trace pairing: { some, full, error } of { write, start }, allocated on first engage
|
|
123
134
|
lastSample: performance.now(),
|
|
124
135
|
}
|
|
125
136
|
this.#origins.set(key, rec)
|
|
@@ -132,7 +143,7 @@ class PressureMonitor {
|
|
|
132
143
|
// loadavg-shaped EWMAs with a dt-aware gain (keeps the time-constant honest
|
|
133
144
|
// under a jittery loop). Counters (completed) carry the `full` decision so a
|
|
134
145
|
// burst that fills and drains between two samples can't alias it away.
|
|
135
|
-
#sample(rec, now) {
|
|
146
|
+
#sample(key, rec, now) {
|
|
136
147
|
const dt = now - rec.lastSample
|
|
137
148
|
if (dt <= 0) {
|
|
138
149
|
return
|
|
@@ -155,21 +166,80 @@ class PressureMonitor {
|
|
|
155
166
|
rec.full += a * ((fullNow ? 1 : 0) - rec.full)
|
|
156
167
|
rec.errorRate += a * (errNow - rec.errorRate)
|
|
157
168
|
|
|
158
|
-
// Hysteresis: engage high, release low.
|
|
169
|
+
// Hysteresis: engage high, release low. Each latch brackets a saturation
|
|
170
|
+
// episode, traced as an 'undici:pressure' start/end pair.
|
|
159
171
|
if (!rec.shed && rec.some > this.#someHi) {
|
|
160
172
|
rec.shed = true
|
|
173
|
+
this.#traceEngage(key, rec, 'some', rec.some, now)
|
|
161
174
|
} else if (rec.shed && rec.some < this.#someLo) {
|
|
162
175
|
rec.shed = false
|
|
176
|
+
this.#traceRelease(key, rec, 'some', rec.some, now)
|
|
163
177
|
}
|
|
164
178
|
if (!rec.paused && rec.full > this.#fullHi) {
|
|
165
179
|
rec.paused = true
|
|
180
|
+
this.#traceEngage(key, rec, 'full', rec.full, now)
|
|
166
181
|
} else if (rec.paused && rec.full < this.#fullLo) {
|
|
167
182
|
rec.paused = false
|
|
183
|
+
this.#traceRelease(key, rec, 'full', rec.full, now)
|
|
168
184
|
}
|
|
169
185
|
if (!rec.degraded && rec.errorRate > this.#errHi) {
|
|
170
186
|
rec.degraded = true
|
|
187
|
+
this.#traceEngage(key, rec, 'error', rec.errorRate, now)
|
|
171
188
|
} else if (rec.degraded && rec.errorRate < this.#errLo) {
|
|
172
189
|
rec.degraded = false
|
|
190
|
+
this.#traceRelease(key, rec, 'error', rec.errorRate, now)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Saturation-episode tracing (op 'undici:pressure'): one start/end doc pair
|
|
195
|
+
// per (origin, level) while a hysteresis latch is engaged — the scheduler
|
|
196
|
+
// limiter's limiter:backlog pattern, because an episode that can hang (an
|
|
197
|
+
// origin wedged under pressure) is exactly when in-progress visibility
|
|
198
|
+
// matters. `level` names the signal each latch trips on rather than the
|
|
199
|
+
// latch itself: shed -> 'some', paused -> 'full', degraded -> 'error' (the
|
|
200
|
+
// errorRate EWMA); `signal` is that EWMA at the transition. The write fn is
|
|
201
|
+
// captured once at engage and the SAME fn emits the release doc, so a writer
|
|
202
|
+
// toggling mid-episode can neither orphan nor split the pair — and a
|
|
203
|
+
// toggle-on mid-episode stays silent (nothing captured, no unpaired end).
|
|
204
|
+
#traceEngage(key, rec, level, signal, now) {
|
|
205
|
+
const write = traceWrite(this.#trace)
|
|
206
|
+
if (write !== null) {
|
|
207
|
+
rec.episodes ??= { some: null, full: null, error: null }
|
|
208
|
+
rec.episodes[level] = { write, start: now }
|
|
209
|
+
traceSafe(
|
|
210
|
+
write,
|
|
211
|
+
{
|
|
212
|
+
origin: traceUrl({ origin: key }),
|
|
213
|
+
level,
|
|
214
|
+
phase: 'start',
|
|
215
|
+
pending: rec.pending,
|
|
216
|
+
running: rec.running,
|
|
217
|
+
signal: Math.round(signal * 1000) / 1000,
|
|
218
|
+
},
|
|
219
|
+
'undici:pressure',
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#traceRelease(key, rec, level, signal, now) {
|
|
225
|
+
const episode = rec.episodes?.[level]
|
|
226
|
+
if (episode != null) {
|
|
227
|
+
// Clear the pairing state BEFORE invoking the writer (traceSafe only
|
|
228
|
+
// contains throws) so a fresh episode's capture can't be clobbered.
|
|
229
|
+
rec.episodes[level] = null
|
|
230
|
+
traceSafe(
|
|
231
|
+
episode.write,
|
|
232
|
+
{
|
|
233
|
+
origin: traceUrl({ origin: key }),
|
|
234
|
+
level,
|
|
235
|
+
phase: 'end',
|
|
236
|
+
pending: rec.pending,
|
|
237
|
+
running: rec.running,
|
|
238
|
+
signal: Math.round(signal * 1000) / 1000,
|
|
239
|
+
durationMs: Math.round(now - episode.start),
|
|
240
|
+
},
|
|
241
|
+
'undici:pressure',
|
|
242
|
+
)
|
|
173
243
|
}
|
|
174
244
|
}
|
|
175
245
|
|
|
@@ -179,7 +249,7 @@ class PressureMonitor {
|
|
|
179
249
|
#tick() {
|
|
180
250
|
const now = performance.now()
|
|
181
251
|
for (const [key, rec] of this.#origins) {
|
|
182
|
-
this.#sample(rec, now)
|
|
252
|
+
this.#sample(key, rec, now)
|
|
183
253
|
if (
|
|
184
254
|
rec.pending === 0 &&
|
|
185
255
|
rec.running === 0 &&
|
|
@@ -280,6 +350,19 @@ class PressureMonitor {
|
|
|
280
350
|
clearInterval(this.#timer)
|
|
281
351
|
this.#timer = null
|
|
282
352
|
}
|
|
353
|
+
// Close still-engaged episodes before dropping the records: a monitor
|
|
354
|
+
// teardown is not a hang, and a start doc with no end would read as one
|
|
355
|
+
// forever (the pairing is exactly what open-episode alerting keys on).
|
|
356
|
+
// Emitted through each episode's captured fn, like any release.
|
|
357
|
+
const now = performance.now()
|
|
358
|
+
for (const [key, rec] of this.#origins) {
|
|
359
|
+
if (rec.episodes != null) {
|
|
360
|
+
// #traceRelease no-ops per level unless an episode was captured.
|
|
361
|
+
this.#traceRelease(key, rec, 'some', rec.some, now)
|
|
362
|
+
this.#traceRelease(key, rec, 'full', rec.full, now)
|
|
363
|
+
this.#traceRelease(key, rec, 'error', rec.errorRate, now)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
283
366
|
this.#origins.clear()
|
|
284
367
|
}
|
|
285
368
|
|