Rebuild: give up stalled requests, not slow ones
a.b.0day.stuffz sends steadily but slowly (~400 kB a minute, 5-7 minutes per 100k chunk): the fixed deadline (90 s, doubling up to 360 s) gave its chunks up again and again, and two of its ranges' worker streams timed out. A stall is a request that got less than NNTP_STALL_KB_PER_MINUTE (100) in the last minute (the stuck replies got ~25 kB a minute); then its connection is closed and the chunk fetched again. Replaces NNTP_CHUNK_DEADLINE_SECONDS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbccec932c
commit
887df12ead
3 files changed
+26
-21
No files matched your search
+17
-12
@@ -16,6 +16,10 @@ interface Target {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A request's bytes in are sampled this often; STALL_SAMPLES of them make the minute a stall is judged over. */
|
||||||
|
const STALL_SAMPLE_MS = 15_000
|
||||||
|
const STALL_SAMPLES = 60_000 / STALL_SAMPLE_MS
|
||||||
|
|
||||||
/** Connection ages the report counts slow first requests by, in minutes. */
|
/** Connection ages the report counts slow first requests by, in minutes. */
|
||||||
const AGE_BUCKETS = [2, 10, Infinity]
|
const AGE_BUCKETS = [2, 10, Infinity]
|
||||||
|
|
||||||
@@ -59,8 +63,6 @@ export class Prefetcher {
|
|||||||
private fetchingNow = 0
|
private fetchingNow = 0
|
||||||
private writingNow = 0
|
private writingNow = 0
|
||||||
private sampler?: NodeJS.Timeout
|
private sampler?: NodeJS.Timeout
|
||||||
/** Deadlines a chunk hit, by range and chunk start: each doubles its next deadline, so a slow chunk still comes. */
|
|
||||||
private lateChunks = new Map<string, number>()
|
|
||||||
/** Requests in flight: what, since when, on which connection (the report names the oldest). */
|
/** Requests in flight: what, since when, on which connection (the report names the oldest). */
|
||||||
private inFlight = new Set<{ rangeId: number; from: number; to: number; since: number; client: XoverClient }>()
|
private inFlight = new Set<{ rangeId: number; from: number; to: number; since: number; client: XoverClient }>()
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export class Prefetcher {
|
|||||||
idle: 0,
|
idle: 0,
|
||||||
inFlight: 0,
|
inFlight: 0,
|
||||||
inFlightMin: Infinity,
|
inFlightMin: Infinity,
|
||||||
/** Requests given up at nntpChunkDeadlineMs. */
|
/** Requests given up as stalled (nntpStallBytesPerMinute). */
|
||||||
deadlines: 0,
|
deadlines: 0,
|
||||||
ranges: 0,
|
ranges: 0,
|
||||||
groups: 0,
|
groups: 0,
|
||||||
@@ -191,15 +193,19 @@ export class Prefetcher {
|
|||||||
if (first) age = NntpPool.ageOf(c)
|
if (first) age = NntpPool.ageOf(c)
|
||||||
const entry = { rangeId, from: span.from, to: span.to, since: Date.now(), client: c }
|
const entry = { rangeId, from: span.from, to: span.to, since: Date.now(), client: c }
|
||||||
this.inFlight.add(entry)
|
this.inFlight.add(entry)
|
||||||
|
// Bytes in, sampled every STALL_SAMPLE_MS: less than nntpStallBytesPerMinute over the last minute is a stall.
|
||||||
let late = false
|
let late = false
|
||||||
const key = `${rangeId}:${span.from}`
|
const samples: number[] = []
|
||||||
const deadlineMs = coordinatorConfig.nntpChunkDeadlineMs * 2 ** Math.min(this.lateChunks.get(key) ?? 0, 4)
|
const watch = setInterval(() => {
|
||||||
const deadline = setTimeout(() => {
|
samples.push(c.buffered)
|
||||||
|
if (samples.length <= STALL_SAMPLES) return
|
||||||
|
const minuteAgo = samples.shift()!
|
||||||
|
if (c.buffered - minuteAgo < coordinatorConfig.nntpStallBytesPerMinute) {
|
||||||
late = true
|
late = true
|
||||||
this.stats.deadlines++
|
this.stats.deadlines++
|
||||||
this.lateChunks.set(key, (this.lateChunks.get(key) ?? 0) + 1)
|
|
||||||
c.close()
|
c.close()
|
||||||
}, deadlineMs)
|
}
|
||||||
|
}, STALL_SAMPLE_MS)
|
||||||
let data: Buffer
|
let data: Buffer
|
||||||
let groupMs = 0
|
let groupMs = 0
|
||||||
try {
|
try {
|
||||||
@@ -209,12 +215,11 @@ export class Prefetcher {
|
|||||||
data = await c.xoverRaw(span.from, span.to)
|
data = await c.xoverRaw(span.from, span.to)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!late) throw error
|
if (!late) throw error
|
||||||
throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} not in after ${deadlineMs / 1000} s; fetching it again`)
|
throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} stalled (under ${coordinatorConfig.nntpStallBytesPerMinute / 1000} kB in a minute, ${Math.round(c.buffered / 1000)} kB in); fetching it again`)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(deadline)
|
clearInterval(watch)
|
||||||
this.inFlight.delete(entry)
|
this.inFlight.delete(entry)
|
||||||
}
|
}
|
||||||
this.lateChunks.delete(key)
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const t = c.lastTiming
|
const t = c.lastTiming
|
||||||
this.stats.requests++
|
this.stats.requests++
|
||||||
@@ -294,7 +299,7 @@ export class Prefetcher {
|
|||||||
`loops ${avg(st.fetching).toFixed(1)} fetching, ${avg(st.writing).toFixed(1)} writing, ${avg(st.idle).toFixed(1)} idle; ` +
|
`loops ${avg(st.fetching).toFixed(1)} fetching, ${avg(st.writing).toFixed(1)} writing, ${avg(st.idle).toFixed(1)} idle; ` +
|
||||||
`a request: GROUP ${per(st.groupMs)} s, XOVER reply ${per(st.replyMs)} s, transfer ${per(st.bodyMs)} s; ` +
|
`a request: GROUP ${per(st.groupMs)} s, XOVER reply ${per(st.replyMs)} s, transfer ${per(st.bodyMs)} s; ` +
|
||||||
`${st.silent} of ${requests} transfers silent >5 s; writing ${(st.writeMs / Math.max(1, chunks)).toFixed(0)} ms a chunk; ` +
|
`${st.silent} of ${requests} transfers silent >5 s; writing ${(st.writeMs / Math.max(1, chunks)).toFixed(0)} ms a chunk; ` +
|
||||||
`${st.deadlines} given up after ${coordinatorConfig.nntpChunkDeadlineMs / 1000} s; ${this.oldest()}.`
|
`${st.deadlines} stalled (under ${coordinatorConfig.nntpStallBytesPerMinute / 1000} kB a minute); ${this.oldest()}.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.stats = this.freshStats()
|
this.stats = this.freshStats()
|
||||||
|
|||||||
@@ -38,15 +38,15 @@ export default {
|
|||||||
*/
|
*/
|
||||||
nntpTimeoutMs: env.get('NNTP_TIMEOUT_MS', 2 * 60 * 1000),
|
nntpTimeoutMs: env.get('NNTP_TIMEOUT_MS', 2 * 60 * 1000),
|
||||||
/**
|
/**
|
||||||
* A chunk still coming in after this long is given up (its connection
|
* A request that got less than this in the last minute (kB) is given up,
|
||||||
* closed) and fetched again: a reply that trickles never goes quiet long
|
* its connection closed, and the chunk fetched again: a reply that trickles
|
||||||
* enough for nntpTimeoutMs, and kept a worker waiting on one chunk for an
|
* (6,204 → 6,229 kB in a minute) never goes quiet long enough for
|
||||||
* hour (LOG.md 2026-10-10 (2)). Chunks take 3–35 s; at 300 s, 258 were
|
* nntpTimeoutMs, and kept a worker waiting on one chunk for an hour. Not a
|
||||||
* given up in 3 hours (~1.4 a minute, each holding a connection 5 minutes);
|
* deadline: some groups send steadily but slowly (a.b.0day.stuffz ~400 kB a
|
||||||
* at 180 s 60 in 15 minutes, ~30% of 40 connections' time, while finished
|
* minute, 5–7 minutes a chunk), and a fixed 90–360 s gave those up again
|
||||||
* chunks took 24 s on average.
|
* and again (LOG.md 2026-10-10 (3)).
|
||||||
*/
|
*/
|
||||||
nntpChunkDeadlineMs: env.get('NNTP_CHUNK_DEADLINE_SECONDS', 90) * 1000,
|
nntpStallBytesPerMinute: env.get('NNTP_STALL_KB_PER_MINUTE', 100) * 1000,
|
||||||
/**
|
/**
|
||||||
* A chunk not fetched after this long is asked again on another
|
* A chunk not fetched after this long is asked again on another
|
||||||
* connection; the first answer wins and the other connection is closed.
|
* connection; the first answer wins and the other connection is closed.
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||||||
NNTP_HEDGE_AFTER_SECONDS: Env.schema.number.optional(),
|
NNTP_HEDGE_AFTER_SECONDS: Env.schema.number.optional(),
|
||||||
NNTP_HEDGE_CONNECTIONS: Env.schema.number.optional(),
|
NNTP_HEDGE_CONNECTIONS: Env.schema.number.optional(),
|
||||||
NNTP_CONNECTION_MAX_MINUTES: Env.schema.number.optional(),
|
NNTP_CONNECTION_MAX_MINUTES: Env.schema.number.optional(),
|
||||||
NNTP_CHUNK_DEADLINE_SECONDS: Env.schema.number.optional(),
|
NNTP_STALL_KB_PER_MINUTE: Env.schema.number.optional(),
|
||||||
|
|
||||||
// Newznab API key (empty: no key needed)
|
// Newznab API key (empty: no key needed)
|
||||||
NEWZNAB_API_KEY: Env.schema.secret.optional(),
|
NEWZNAB_API_KEY: Env.schema.secret.optional(),
|
||||||
|
|||||||
Reference in new issue
Block a user