End to end (workstation, recent a.b.boneless ranges from the spool): a 2M range in 10–12 s, 166k–202k headers/s per worker process (edge:bench on one core: 215k). - Prefetcher: spooled_at was set against a total counted from the range's resume point, which shrinks as the worker advances: the last chunks were never fetched and the worker waited. Now counted against the whole range, only new chunks counted, and leased ranges are always checked against the files. Planned ranges ahead are bounded (it had spooled ever more); the spool size is measured every 30 s (a stat per file). - The stream sends an empty frame every 20 s while waiting for a chunk (Node's fetch gave up on a quiet body after 5 minutes). - A range taken over midway: the first batch starts at the resume point (it started at the range's start and was refused). - edge:node inflates the next chunk on the thread pool while indexing the current one, and doesn't wait for a post before indexing on (posts stay in order; a failed post stops the range). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
5.4 KiB
TypeScript
133 lines
5.4 KiB
TypeScript
import db from '@adonisjs/lucid/services/db'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
import coordinatorConfig from '#config/coordinator'
|
|
import NntpPool from '#services/nntp_pool'
|
|
import Spool, { type ChunkSpan } from '#services/spool'
|
|
|
|
interface Target {
|
|
id: number
|
|
group: string
|
|
/** Chunks not spooled yet, in order. */
|
|
missing: ChunkSpan[]
|
|
/** All of the range's chunks, from its start. */
|
|
total: number
|
|
}
|
|
|
|
/** How often the targets are read again from the database. */
|
|
const REFRESH_MS = 2000
|
|
/** How often the spool's size is measured (a stat per file). */
|
|
const SIZE_MS = 30_000
|
|
|
|
/**
|
|
* Keeps the news server busy ahead of the workers (REBUILD.md, prefetch).
|
|
* Every connection takes the next missing chunk: the leased ranges' first
|
|
* (a worker waits for them; all connections work on the same range, in
|
|
* order), then the next planned ranges, about SPOOL_AHEAD_PER_LEASE per
|
|
* active lease (at least SPOOL_MIN_AHEAD), within SPOOL_MAX_GB. One
|
|
* connection fills ~26 k overview lines/s, so a range on one connection
|
|
* kept its worker waiting (LOG.md 2026-10-09).
|
|
*/
|
|
export class Prefetcher {
|
|
private targets: Target[] = []
|
|
private claimed = new Set<string>()
|
|
private running = 0
|
|
private refreshedAt = 0
|
|
private stopped = false
|
|
private spoolBytes = 0
|
|
private sizedAt = 0
|
|
|
|
/** One round: refreshes the targets now and then, and puts idle connections to work. */
|
|
async tick() {
|
|
if (Date.now() - this.refreshedAt >= REFRESH_MS) {
|
|
this.refreshedAt = Date.now()
|
|
this.targets = await this.readTargets()
|
|
}
|
|
while (!this.stopped && this.running < NntpPool.size && this.peek()) {
|
|
this.running++
|
|
this.work()
|
|
.catch((error) => logger.warn(`Prefetching: ${error.message}`))
|
|
.finally(() => this.running--)
|
|
}
|
|
}
|
|
|
|
stop() {
|
|
this.stopped = true
|
|
}
|
|
|
|
get active() {
|
|
return this.running
|
|
}
|
|
|
|
/** One connection's loop: chunks until there is nothing left to fetch. */
|
|
private async work() {
|
|
while (!this.stopped) {
|
|
const next = this.take()
|
|
if (!next) return
|
|
const { target, span } = next
|
|
try {
|
|
const data = await NntpPool.use(async (client) => {
|
|
await client.group(target.group)
|
|
return client.xoverRaw(span.from, span.to)
|
|
})
|
|
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.claimed.delete(`${target.id}:${span.from}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
private peek(): boolean {
|
|
return this.targets.some((t) => t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`)))
|
|
}
|
|
|
|
/** The first unclaimed missing chunk, in target order. */
|
|
private take(): { target: Target; span: ChunkSpan } | null {
|
|
for (const target of this.targets) {
|
|
const index = target.missing.findIndex((s) => !this.claimed.has(`${target.id}:${s.from}`))
|
|
if (index === -1) continue
|
|
const [span] = target.missing.splice(index, 1)
|
|
this.claimed.add(`${target.id}:${span.from}`)
|
|
return { target, span }
|
|
}
|
|
return null
|
|
}
|
|
|
|
private async readTargets(): Promise<Target[]> {
|
|
if (Date.now() - this.sizedAt >= SIZE_MS) {
|
|
this.sizedAt = Date.now()
|
|
this.spoolBytes = await Spool.size()
|
|
}
|
|
const rows = (q: any) => q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id')
|
|
// Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them.
|
|
const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at')
|
|
// Planned ranges ahead: those started already, then new ones up to SPOOL_AHEAD_PER_LEASE per lease.
|
|
const ahead = Math.max(coordinatorConfig.spool.minAhead, coordinatorConfig.spool.aheadPerLease * leased.length)
|
|
const started = await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', '>', 0).whereNull('r.spooled_at').orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
|
|
const [{ ready }] = await db.from('ranges').where('status', 'planned').where('spooled_chunks', '>', 0).count('* as ready')
|
|
const room = this.spoolBytes < coordinatorConfig.spool.maxBytes
|
|
const fresh = room && Number(ready) < ahead
|
|
? await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', 0).orderBy([{ column: 'r.priority' }, { column: 'r.id' }]).limit(ahead - Number(ready))
|
|
: []
|
|
const targets: Target[] = []
|
|
for (const row of [...leased, ...(room ? started : []), ...fresh]) {
|
|
const start = Number(row.start_id)
|
|
const end = Number(row.end_id)
|
|
const total = Spool.chunksOf({ start, end }).length
|
|
const missing: ChunkSpan[] = []
|
|
for (const span of Spool.chunksOf({ start, end }, Number(row.next_id))) {
|
|
if (!this.claimed.has(`${row.id}:${span.from}`) && !(await Spool.has(Number(row.id), span))) missing.push(span)
|
|
}
|
|
if (missing.length > 0) targets.push({ id: Number(row.id), group: row.group, missing, total })
|
|
}
|
|
return targets
|
|
}
|
|
}
|