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' import Corpus, { type CorpusFilter } from '#services/corpus' import RuleSets from '#services/rule_sets' import CatalogSpace from '#services/catalog_space' import { hedge, type Request, type XoverClient } from '#lib/nntp/index' interface Target { id: number group: string /** Chunk starts to fetch without compression (ranges.plain_chunks). */ plain: Set /** Chunks not spooled yet, in order. */ missing: ChunkSpan[] /** All of the range's chunks, from its start. */ total: number start: number end: number /** Its chunks are kept in the corpus (ranges.capture), and read from there when stored. */ capture: boolean /** Fetched into the corpus only, not the spool: a capture range no worker leases soon (corpus.ahead). */ corpusOnly?: boolean } /** 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. */ const AGE_BUCKETS = [2, 10, Infinity] /** 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), then the next planned ranges, with at most * PREFETCH_PER_RANGE connections on a range and PREFETCH_PER_GROUP on a * group (Eweka answers faster spread out), 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). A chunk that takes long is * asked again on a spare connection (fetchHedged). */ export class Prefetcher { /** * This process's share of the ranges, `id % count = index` (`coordinator:run --shard`): one * process's event loop handles ~60 MB/s of compressed XOVER (16.5% of a core at ~10 MB/s; * LOG.md 2026-10-09), less than 50 connections bring. */ constructor(private shard = { index: 0, count: 1 }) {} private targets: Target[] = [] private claimed = new Set() /** Chunks being fetched per range and per group (spool.perRange, spool.perGroup). */ private perRange = new Map() private perGroup = new Map() private running = 0 private refreshedAt = 0 private stopped = false private spoolBytes = 0 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 /** 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 }>() /** Capture ranges found complete in the corpus (not looked at again). */ private stored = new Set() private freshStats() { return { chunks: 0, /** Chunks read from the corpus instead of fetched. */ stored: 0, /** Chunks fetched into the corpus only (corpus.ahead). */ captured: 0, articles: 0, fetchMs: 0, hedged: 0, 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, /** Requests given up as stalled (nntpStallBytesPerMinute). */ deadlines: 0, ranges: 0, groups: 0, since: Date.now(), } } /** The prefetcher's loops: the pool less the connections kept for hedging. */ private get loops() { return Math.max(1, NntpPool.size - coordinatorConfig.nntpHedgeConnections) } /** 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() } this.report() while (!this.stopped && this.running < this.loops && this.peek()) { this.running++ this.work() .catch((error) => logger.warn(`Prefetching: ${error.message}`)) .finally(() => this.running--) } // Work left with every loop running: the pool's share could be bigger. if (!this.stopped && this.running >= this.loops && this.peek()) NntpPool.starved() } stop() { this.stopped = true clearInterval(this.sampler) } get active() { return this.running } /** One connection's loop: chunks until there is nothing left to fetch. */ private async work() { while (!this.stopped) { // The pool's share got smaller: fewer loops (waiting on the pool, they'd count as work short of a connection). // Two leaving at once is one too many for a tick (TICK_MS), whose loop comes back. if (this.running > this.loops) return const next = this.take() if (!next) return const { target, span } = next try { const fetchedAt = Date.now() this.fetchingNow++ const fetch = async (client: Parameters[0]>[0]) => { await client.group(target.group) return client.xoverRaw(span.from, span.to) } const corpusRange = { group: target.group, start: target.start, end: target.end } // A range in the corpus is read from disk (a reset re-indexes it without the news server). let data = target.capture && !target.corpusOnly ? await Corpus.read(corpusRange, span) : null const stored = data !== null try { if (!data) data = target.plain.has(span.from) ? await NntpPool.usePlain(fetch) : await this.fetchHedged(target.id, target.group, span) } finally { this.fetchingNow-- } if (stored) { this.stats.stored++ } else { this.stats.chunks++ this.stats.articles += span.to - span.from + 1 this.stats.fetchMs += Date.now() - fetchedAt if (target.capture) await Corpus.write(corpusRange, span, data, target.total, await this.corpusFilter()) } if (target.corpusOnly) { this.stats.captured++ continue } 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}`) this.count(target, -1) } } } /** * A chunk over a pooled connection. Not in after nntpHedgeAfterMs, it's * asked again on a free connection (if there is one): Eweka stalls single * requests, and the same chunk on another connection mostly comes in * seconds (LOG.md 2026-10-09 (15)). The first answer wins; the other * request can't be cancelled, so its connection is closed (the pool opens * a fresh one). */ private async fetchHedged(rangeId: number, group: string, span: ChunkSpan): Promise { let age: number | undefined const request = ( start: (work: (client: XoverClient) => Promise) => Promise | null, first: boolean ): Request | null => { let client: XoverClient | undefined let cancelled = false const done = start(async (c) => { // Given up before it got a connection: the connection goes back unused. if (cancelled) return Buffer.alloc(0) client = c if (first) age = NntpPool.ageOf(c) const entry = { rangeId, from: span.from, to: span.to, since: Date.now(), client: c } this.inFlight.add(entry) // Bytes in, sampled every STALL_SAMPLE_MS: less than nntpStallBytesPerMinute over the last minute is a stall. let late = false const samples: number[] = [] const watch = setInterval(() => { samples.push(c.buffered) if (samples.length <= STALL_SAMPLES) return const minuteAgo = samples.shift()! if (c.buffered - minuteAgo < coordinatorConfig.nntpStallBytesPerMinute) { late = true this.stats.deadlines++ c.close() } }, STALL_SAMPLE_MS) let data: Buffer let groupMs = 0 try { const groupAt = performance.now() await c.group(group) groupMs = performance.now() - groupAt data = await c.xoverRaw(span.from, span.to) } catch (error) { if (!late) throw error 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 { clearInterval(watch) this.inFlight.delete(entry) } 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(() => {}) return { done, // The request can't be called off on the wire: its connection is closed, and the pool opens a fresh one. cancel: () => { cancelled = true client?.close() }, } } const result = await hedge( () => request((work) => NntpPool.use(work), true)!, () => request((work) => NntpPool.tryUse(work), false), coordinatorConfig.nntpHedgeAfterMs ) if (age !== undefined) { const bucket = this.stats.byAge[AGE_BUCKETS.findIndex((minutes) => age! < minutes * 60_000)] bucket.requests++ if (result.late) bucket.slow++ } if (result.hedged) this.stats.hedged++ if (result.secondWon) this.stats.hedgeWon++ return result.value } /** The longest-running request: range, chunk, how long, how much came in. */ private oldest() { let oldest: { rangeId: number; from: number; to: number; since: number; client: XoverClient } | undefined for (const r of this.inFlight) if (!oldest || r.since < oldest.since) oldest = r if (!oldest) return 'none in flight' return `oldest request ${Math.round((Date.now() - oldest.since) / 1000)} s (range ${oldest.rangeId}, ${oldest.from}–${oldest.to}, ${Math.round(oldest.client.buffered / 1000)} kB in)` } 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) s.ranges += this.perRange.size s.groups += this.perGroup.size } /** 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, 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; ` + `hedged ${hedged} (second won ${hedgeWon}); slow by connection age: ${ages}` + (this.stats.stored > 0 ? `; ${this.stats.stored} chunks from the corpus` : '') + (this.stats.captured > 0 ? `; ${this.stats.captured} fetched into the corpus only` : '') + '.' ) 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}), ` + `on ${avg(st.ranges).toFixed(1)} ranges of ${avg(st.groups).toFixed(1)} groups; ` + `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; ` + `${st.deadlines} stalled (under ${coordinatorConfig.nntpStallBytesPerMinute / 1000} kB a minute); ${this.oldest()}.` ) } this.stats = this.freshStats() } private peek(): boolean { return this.targets.some( (t) => this.open(t) && t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`)) ) } /** Whether the target is under its range's and its group's limit of connections. */ private open(target: Target) { const { perRange, perGroup } = coordinatorConfig.spool return ( (perRange <= 0 || (this.perRange.get(target.id) ?? 0) < perRange) && (perGroup <= 0 || (this.perGroup.get(target.group) ?? 0) < perGroup) ) } private count(target: Target, by: number) { const range = (this.perRange.get(target.id) ?? 0) + by const group = (this.perGroup.get(target.group) ?? 0) + by if (range > 0) this.perRange.set(target.id, range) else this.perRange.delete(target.id) if (group > 0) this.perGroup.set(target.group, group) else this.perGroup.delete(target.group) } /** The first unclaimed missing chunk, in target order, of a target under its limits. */ private take(): { target: Target; span: ChunkSpan } | null { for (const target of this.targets) { if (!this.open(target)) continue 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}`) this.count(target, 1) return { target, span } } return null } private async readTargets(): Promise { if (Date.now() - this.sizedAt >= SIZE_MS) { this.sizedAt = Date.now() this.spoolBytes = await Spool.size() } const mine = (q: any) => this.shard.count > 1 ? q.whereRaw('r.id % ? = ?', [this.shard.count, this.shard.index]) : q const rows = (q: any) => mine( 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', 'r.plain_chunks', 'r.capture' ) ) // 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') // The groups workers hold (group_leases; REBUILD.md design 1), by lane: the prefetcher runs ahead of their // cursors (a group's history and tail lanes are two cursors). const held: { group: number; lane: string }[] = ( await db .from('group_leases') .whereNotNull('node_id') .where( 'heartbeat_at', '>=', new Date(Date.now() - coordinatorConfig.leaseTtlSeconds * 1000) ) .select('group_id', 'lane') ).map((l) => ({ group: Number(l.group_id), lane: l.lane })) // 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 * Math.max(leased.length, held.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 mine( db.from('ranges as r').where('r.status', 'planned').where('r.spooled_chunks', '>', 0) ).count('* as ready') const room = this.spoolBytes < coordinatorConfig.spool.maxBytes // With at most perRange connections a range, enough ranges still being fetched for all loops: fully // spooled ones count as ready but need no connection (30 of 40 busy on small ranges; LOG.md 2026-10-10 (2)). // 8 more than that: ranges with a chunk or two left take fewer than perRange (with +2, 3 of 40 loops idle). const { perRange } = coordinatorConfig.spool const short = perRange > 0 ? Math.ceil(this.loops / perRange) + 8 - (leased.length + started.length) : 0 const want = Math.max(ahead - Number(ready), short) // The catalog at its cap on the NVMe: no history fetched ahead (CatalogSpace). const full = await CatalogSpace.full() // Ahead of each held cursor: the lane's next ranges in article order (the history lane's within what the group // leases hand out: LEASE_MIN/MAX_PRIORITY, LEASE_CAPTURE_ONLY, and none at the catalog's cap; the tail lane's // at the tail's priority), a range of every held cursor before any cursor's second; without held groups (no // window worker running) nothing is fetched ahead for workers. const { leasePriorityMin, leasePriorityMax, leaseCaptureOnly } = coordinatorConfig const tailPriority = coordinatorConfig.tail.priority const heldHistory = full ? [] : held.filter((h) => h.lane === 'history').map((h) => h.group) const heldTail = held.filter((h) => h.lane === 'tail').map((h) => h.group) const fresh = room && want > 0 && heldHistory.length + heldTail.length > 0 ? await rows(db.query()) .where('r.status', 'planned') .where('r.spooled_chunks', 0) .whereRaw('r.next_id <= r.end_id') .where((q: any) => q .where((h: any) => h .whereIn('r.group_id', heldHistory) .where('r.priority', '>', tailPriority) .whereBetween('r.priority', [leasePriorityMin, leasePriorityMax]) .where((c: any) => (leaseCaptureOnly ? c.where('r.capture', true) : c)) ) .orWhere((t: any) => t.whereIn('r.group_id', heldTail).where('r.priority', '<=', tailPriority) ) ) .orderByRaw( 'row_number() OVER (PARTITION BY r.group_id, r.priority <= ? ORDER BY r.start_id), r.priority, r.start_id', [tailPriority] ) .limit(want) : [] 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[] = [] // A chunk already in the corpus isn't spooled: the stream serves it from there (REBUILD.md design 1). const corpusRange = row.capture === true ? { group: row.group as string, start, end } : null 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))) continue if (corpusRange && (await Corpus.has(corpusRange, span))) continue missing.push(span) } if (missing.length > 0) { targets.push({ id: Number(row.id), group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total, start, end, capture: row.capture === true, }) } } // Last, for loops with nothing else to do: capture ranges not stored yet, into the corpus only. for (const target of await this.corpusTargets(rows, new Set(targets.map((t) => t.id)))) targets.push(target) return targets } private filterRules: { at: number; filter: CorpusFilter } | null = null /** The rules corpus chunks are filtered with (CORPUS_FILTER), read again once a minute; undefined when off. */ private async corpusFilter(): Promise { if (!coordinatorConfig.corpus.filter) return undefined if (!this.filterRules || Date.now() - this.filterRules.at > 60_000) { const rules = await RuleSets.current() this.filterRules = { at: Date.now(), filter: { ruleVersion: rules.version, priorities: rules.priorities }, } } return this.filterRules.filter } /** * Up to corpus.ahead capture ranges whose chunks aren't all in the corpus, by priority: planned ones not started * (not leased soon), and done ones (a window of every group, ranges:capture, takes those indexed already too); * as many of a group as its connections allow (perGroup / perRange): in id order the 16 were one group's, which * took 4 of the loops and left the rest idle (30k articles/s; LOG.md 2026-10-23 (100)). The candidates are taken * a range of every group at a time (the n-th unstored range of each group before any group's n+1-th): a group's * ranges have consecutive ids, and once the small groups of a window were stored the 512 lowest ids were all one * group's, two targets on two connections (10 ranges an hour; LOG.md 2026-10-25 (110)). A range found complete * gets stored_at and isn't read again. */ private async corpusTargets(rows: (q: any) => any, taken: Set): Promise { const { dir, ahead } = coordinatorConfig.corpus if (!dir || ahead <= 0) return [] const { perRange, perGroup } = coordinatorConfig.spool const ofGroup = perGroup > 0 && perRange > 0 ? Math.max(1, Math.floor(perGroup / perRange)) : Infinity const groups = new Map() const candidates = await rows(db.query()) .where('r.capture', true) // A group taken off the list (groups.active) isn't worth the connections. .where('g.active', true) .whereNull('r.stored_at') .where((q: any) => q .where((p: any) => p.where('r.status', 'planned').where('r.spooled_chunks', 0)) .orWhere('r.status', 'done') ) .orderByRaw('r.priority, row_number() OVER (PARTITION BY r.group_id ORDER BY r.id), r.id') .limit(ahead * 16) const targets: Target[] = [] for (const row of candidates) { if (targets.length >= ahead) break const id = Number(row.id) if (taken.has(id) || this.stored.has(id)) continue if ((groups.get(row.group) ?? 0) >= ofGroup) continue const start = Number(row.start_id) const end = Number(row.end_id) const range = { group: row.group, start, end } if (await Corpus.complete(range)) { this.stored.add(id) await db.from('ranges').where('id', id).update({ stored_at: new Date() }) continue } const chunks = Spool.chunksOf({ start, end }) const missing: ChunkSpan[] = [] for (const span of chunks) if (!this.claimed.has(`${id}:${span.from}`) && !(await Corpus.has(range, span))) missing.push(span) if (missing.length > 0) { targets.push({ id, group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total: chunks.length, start, end, capture: true, corpusOnly: true, }) groups.set(row.group, (groups.get(row.group) ?? 0) + 1) } } return targets } }