Rebuild: prefetch report samples what the connections do
Test and deploy / test (push) Successful in 44s
Test and deploy / deploy (push) Skipped

Once a second: pool connections with a command in flight, loops
fetching/writing/idle; per request GROUP time, XOVER time to the status
line and the transfer, transfers with a silence over 5 s. The old
"connections busy" (fetch time of the chunks finished in the minute)
said 59-75% while all 40 connections were in flight all the time:
dropped.

Homelab: 39-40 of 40 in flight, no idle loops; the status line comes at
once, the whole 8-35 s is the transfer, with data flowing (few gaps
over 5 s): ~3 MB per chunk at 0.1-0.4 MB/s per connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 22:40:50 +02:00
1 parent 8475969145
commit 4bf526ab6e
3 files changed
+109 -16

No files matched your search

+5
View File
@@ -32,6 +32,11 @@ class NntpPool {
return this.acquire().then((client) => this.run(client, work))
}
/** Connections with a command in flight (the prefetcher's, the planner's). */
get busyCount() {
return this.busy.size
}
/** How long ago the connection was opened, in ms. */
ageOf(client: XoverClient): number {
return Date.now() - (this.openedAt.get(client) ?? Date.now())
+77 -13
View File
@@ -51,6 +51,10 @@ export class Prefetcher {
private sizedAt = 0
/** Fetched since the last report: chunks, articles, ms spent fetching. */
private stats = this.freshStats()
/** Loops in a fetch or a spool write right now. */
private fetchingNow = 0
private writingNow = 0
private sampler?: NodeJS.Timeout
private freshStats() {
return {
@@ -61,6 +65,21 @@ export class Prefetcher {
hedgeWon: 0,
/** First requests per connection-age bucket, and how many of them were slow. */
byAge: AGE_BUCKETS.map(() => ({ requests: 0, slow: 0 })),
/** Spool writes and the range's update, ms. */
writeMs: 0,
/** Per answered request, ms: GROUP, XOVER until its status line, the transfer; transfers with a silence over 5 s. */
requests: 0,
groupMs: 0,
replyMs: 0,
bodyMs: 0,
silent: 0,
/** Once a second: loops fetching, writing, not running; pool connections with a command in flight. */
samples: 0,
fetching: 0,
writing: 0,
idle: 0,
inFlight: 0,
inFlightMin: Infinity,
since: Date.now(),
}
}
@@ -72,6 +91,7 @@ export class Prefetcher {
/** One round: refreshes the targets now and then, and puts idle connections to work. */
async tick() {
this.sampler ??= setInterval(() => this.sample(), 1000).unref()
if (Date.now() - this.refreshedAt >= REFRESH_MS) {
this.refreshedAt = Date.now()
this.targets = await this.readTargets()
@@ -87,6 +107,7 @@ export class Prefetcher {
stop() {
this.stopped = true
clearInterval(this.sampler)
}
get active() {
@@ -101,23 +122,36 @@ export class Prefetcher {
const { target, span } = next
try {
const fetchedAt = Date.now()
this.fetchingNow++
const fetch = async (client: Parameters<Parameters<typeof NntpPool.use>[0]>[0]) => {
await client.group(target.group)
return client.xoverRaw(span.from, span.to)
}
const data = target.plain.has(span.from) ? await NntpPool.usePlain(fetch) : await this.fetchHedged(target.group, span)
let data: Buffer
try {
data = target.plain.has(span.from) ? await NntpPool.usePlain(fetch) : await this.fetchHedged(target.group, span)
} finally {
this.fetchingNow--
}
this.stats.chunks++
this.stats.articles += span.to - span.from + 1
this.stats.fetchMs += Date.now() - fetchedAt
const existed = await Spool.has(target.id, span)
await Spool.write(target.id, span, data)
if (existed) continue
this.spoolBytes += data.length
const { rows } = await db.rawQuery(
`UPDATE ranges SET spooled_chunks = spooled_chunks + 1 WHERE id = ? RETURNING spooled_chunks`,
[target.id]
)
if (Number(rows[0]?.spooled_chunks) >= target.total) await db.from('ranges').where('id', target.id).update({ spooled_at: new Date() })
const writtenAt = Date.now()
this.writingNow++
try {
const existed = await Spool.has(target.id, span)
await Spool.write(target.id, span, data)
if (existed) continue
this.spoolBytes += data.length
const { rows } = await db.rawQuery(
`UPDATE ranges SET spooled_chunks = spooled_chunks + 1 WHERE id = ? RETURNING spooled_chunks`,
[target.id]
)
if (Number(rows[0]?.spooled_chunks) >= target.total) await db.from('ranges').where('id', target.id).update({ spooled_at: new Date() })
} finally {
this.writingNow--
this.stats.writeMs += Date.now() - writtenAt
}
} finally {
this.claimed.delete(`${target.id}:${span.from}`)
}
@@ -142,8 +176,19 @@ export class Prefetcher {
if (cancelled) return Buffer.alloc(0)
client = c
if (first) age = NntpPool.ageOf(c)
const groupAt = performance.now()
await c.group(group)
return c.xoverRaw(span.from, span.to)
const groupMs = performance.now() - groupAt
const data = await c.xoverRaw(span.from, span.to)
if (!cancelled) {
const t = c.lastTiming
this.stats.requests++
this.stats.groupMs += groupMs
this.stats.replyMs += t.replyMs
this.stats.bodyMs += t.bodyMs
if (t.longestGapMs > 5000) this.stats.silent++
}
return data
})
if (!done) return null
done.catch(() => {})
@@ -171,20 +216,39 @@ export class Prefetcher {
return result.value
}
private sample() {
const s = this.stats
s.samples++
s.fetching += this.fetchingNow
s.writing += this.writingNow
s.idle += Math.max(0, this.loops - this.fetchingNow - this.writingNow)
s.inFlight += NntpPool.busyCount
s.inFlightMin = Math.min(s.inFlightMin, NntpPool.busyCount)
}
/** A line a minute: what the connections fetched, and how long a chunk took. */
private report() {
const seconds = (Date.now() - this.stats.since) / 1000
if (seconds < 60) return
const { chunks, articles, fetchMs, hedged, hedgeWon, byAge } = this.stats
const { chunks, articles, fetchMs, hedged, hedgeWon, byAge, samples, requests } = this.stats
if (chunks > 0) {
const ages = byAge
.map((b, i) => `${i === 0 ? '<' : `${AGE_BUCKETS[i - 1]}–`}${AGE_BUCKETS[i] === Infinity ? '' : AGE_BUCKETS[i]} min ${b.slow}/${b.requests}`.replace('– min', '+ min'))
.join(', ')
logger.info(
`Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` +
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk, loops busy ${Math.round((fetchMs / (seconds * 1000 * this.loops)) * 100)}%; ` +
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk; ` +
`hedged ${hedged} (second won ${hedgeWon}); slow by connection age: ${ages}.`
)
const st = this.stats
const avg = (n: number) => (samples > 0 ? n / samples : 0)
const per = (ms: number) => (requests > 0 ? (ms / requests / 1000).toFixed(1) : '-')
logger.info(
`Connections: ${avg(st.inFlight).toFixed(1)} of ${NntpPool.size} with a command in flight (min ${st.inFlightMin}); ` +
`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; ` +
`${st.silent} of ${requests} transfers silent >5 s; writing ${(st.writeMs / Math.max(1, chunks)).toFixed(0)} ms a chunk.`
)
}
this.stats = this.freshStats()
}
+27 -3
View File
@@ -40,12 +40,26 @@ export class XoverClient {
private wake?: () => void
private failure?: Error
compressed = false
/**
* The last xoverRaw's timing, ms: until the status line came (the server
* thinking), from there to the end (the transfer), and the longest silence
* during the transfer.
*/
lastTiming = { replyMs: 0, bodyMs: 0, longestGapMs: 0 }
private measuring = false
private lastDataAt = 0
private longestGap = 0
private constructor(
private sock: net.Socket,
private timeoutMs: number
) {
sock.on('data', (chunk: Buffer) => {
if (this.measuring) {
const now = performance.now()
this.longestGap = Math.max(this.longestGap, now - this.lastDataAt)
this.lastDataAt = now
}
this.chunks.push(chunk)
this.length += chunk.length
this.wake?.()
@@ -119,12 +133,22 @@ export class XoverClient {
async xoverRaw(from: number, to: number): Promise<Buffer> {
return this.timed(async () => {
const sentAt = performance.now()
const reply = await this.command(`XOVER ${from}-${to}`)
const repliedAt = performance.now()
if (reply.startsWith('420') || reply.startsWith('423')) return deflateSync(Buffer.alloc(0))
await this.expect(reply, ['224'])
if (reply.includes('COMPRESS=GZIP')) return (await this.compressedRaw(false)).raw
// Plain replies (the relay's default): compressed off the event loop, at the fastest level.
return deflateAsync(Buffer.from(await this.plainBody()), { level: 1 })
this.measuring = true
this.lastDataAt = repliedAt
this.longestGap = 0
try {
if (reply.includes('COMPRESS=GZIP')) return (await this.compressedRaw(false)).raw
// Plain replies (the relay's default): compressed off the event loop, at the fastest level.
return deflateAsync(Buffer.from(await this.plainBody()), { level: 1 })
} finally {
this.measuring = false
this.lastTiming = { replyMs: repliedAt - sentAt, bodyMs: performance.now() - repliedAt, longestGapMs: this.longestGap }
}
})
}