End to end on the workstation (coordinator:run, the API, edge:node, real NNTP): a.b.e-book.french and a.b.cd.image.playstation2.dvdiso ranges. - Prefetcher: every connection takes the next missing chunk (leased ranges first, in order); one connection per range (~26k lines/s) kept the worker waiting - executable rule: releases of bare executables (malware floods named after films and software) are dropped; they were 92% of the catalog rows of the test ranges, 21% of the segments - fragments per file (details once) and gzipped batch bodies; the JSON limit (1 MB by default) raised to 256 MB, empty strings kept (a rule count's key is '') - release_chunks.batch_from is a bigint (boneless article numbers) - tests get their own database; edge:bench --store times BatchStore: 1.3 s for a boneless range (1.24 ms per release chunk) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.5 KiB
TypeScript
115 lines
4.5 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 resume point. */
|
|
total: number
|
|
}
|
|
|
|
/** How often the targets are read again from the database. */
|
|
const REFRESH_MS = 2000
|
|
|
|
/**
|
|
* 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
|
|
|
|
/** 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)
|
|
})
|
|
await Spool.write(target.id, span, data)
|
|
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[]> {
|
|
const rows = (q: any) =>
|
|
q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').whereNull('r.spooled_at').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id')
|
|
// Leased ranges whose chunks aren't all there: a worker is waiting for them.
|
|
const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at')
|
|
const [{ leases }] = await db.from('ranges').where('status', 'leased').count('* as leases')
|
|
const ahead = Math.max(coordinatorConfig.spool.minAhead, coordinatorConfig.spool.aheadPerLease * Number(leases))
|
|
const room = (await Spool.size()) < coordinatorConfig.spool.maxBytes
|
|
const planned = room ? await rows(db.query()).where('r.status', 'planned').orderBy([{ column: 'r.priority' }, { column: 'r.id' }]).limit(ahead) : []
|
|
const targets: Target[] = []
|
|
for (const row of [...leased, ...planned]) {
|
|
const spans = Spool.chunksOf({ start: Number(row.start_id), end: Number(row.end_id) }, Number(row.next_id))
|
|
const missing: ChunkSpan[] = []
|
|
for (const span of spans) if (!this.claimed.has(`${row.id}:${span.from}`) && !(await Spool.has(Number(row.id), span))) missing.push(span)
|
|
targets.push({ id: Number(row.id), group: row.group, missing, total: spans.length })
|
|
}
|
|
return targets
|
|
}
|
|
}
|