Seeding node: gzip overviews, batched SFV lookups, fragments beside fetching
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 1m43s

- lib/nntp/xover_client: a minimal one-connection client for seed:range
  with XFEATURE COMPRESS GZIP (a zlib stream after "224 … [COMPRESS=GZIP]",
  then a "." line; 420 for an empty range). About 6x less header traffic.
  Overviews come out with the field names nntp-js uses.
- SfvService.claimAll: one sfv_entries lookup per stored batch.
- Fragments go through the regular ingest path beside fetching (at most
  two batches in flight); a failure fails the run at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-05 11:11:41 +02:00
1 parent 68eb9a44fd
commit 97bdf8e982
5 files changed
+329 -26

No files matched your search

+42 -18
View File
@@ -1,6 +1,7 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import nntpConfig from '#config/nntp'
import { XoverClient, type Overview } from '#lib/nntp/xover_client'
import IngestService, { segmentFromHeader, type IngestSegment } from '#services/IngestService'
import StagingService from '#services/StagingService'
import SeedService from '#services/SeedService'
@@ -10,7 +11,8 @@ import { FileAggregator } from '#lib/aggregate/index'
/**
* Edge aggregation (CONCEPT.md D37): indexes a range of a group's history
* over one NNTP connection, grouping headers into files in memory and
* over one NNTP connection (gzip-compressed overviews when the server has
* them), grouping headers into files in memory and
* storing one row per file. Files cut by the range edges, or that never
* complete, go through the regular ingest path as fragments. Header-only,
* like the backfill: obfuscated subjects are counted, not downloaded.
@@ -44,6 +46,13 @@ export default class SeedRange extends BaseCommand {
@flags.boolean({ description: 'Fetch and aggregate only; store nothing' })
declare dryRun: boolean
@flags.boolean({ description: 'Ask the server for gzip-compressed overviews', default: true })
declare compress: boolean
/** Fragment batches being stored the regular way, off the node's critical path. */
private fragmentWork = new Set<Promise<void>>()
private fragmentError?: unknown
public async run() {
const first = Number(this.start)
const last = first + this.count - 1
@@ -54,16 +63,24 @@ export default class SeedRange extends BaseCommand {
const cpuStart = process.cpuUsage()
const started = Date.now()
const conn = await NntpService.acquire()
let failed = false
const client = await XoverClient.connect({
host: nntpConfig.host,
port: nntpConfig.port,
secure: nntpConfig.secure,
user: nntpConfig.user,
password: nntpConfig.password?.release(),
timeoutMs: nntpConfig.commandTimeoutMs,
compress: this.compress,
})
this.logger.info(`Connected${client.compressed ? ', overviews gzip-compressed' : ''}.`)
try {
await NntpService.timed(conn.group(this.group), `GROUP ${this.group}`)
await client.group(this.group)
const fetch = async (from: number) => {
const to = Math.min(from + this.chunk - 1, last)
const t = Date.now()
const reply = await NntpService.timed<any>(conn.xover(from, to), `XOVER ${this.group} ${from}-${to}`)
const overviews = await client.xover(from, to)
time.fetch += Date.now() - t
return reply.overviews.filter(([n, h]: [number | null, Record<string, string>]) => n !== null && h?.['message-id'])
return overviews.filter(([n, h]) => Number.isFinite(n) && h['message-id'])
}
// One XOVER in flight while the previous batch is parsed and stored.
@@ -74,7 +91,7 @@ export default class SeedRange extends BaseCommand {
promise.catch(() => {})
return promise
}
let next: Promise<[number, Record<string, string>][]> | null = prefetch(from)
let next: Promise<Overview[]> | null = prefetch(from)
while (next) {
const overviews = await next
from += this.chunk
@@ -105,12 +122,10 @@ export default class SeedRange extends BaseCommand {
const rest = aggregator.evict(0)
stats.fragments += rest.length
if (!this.dryRun) await this.store([], rest, stats)
} catch (error) {
failed = true
throw error
await Promise.all(this.fragmentWork)
if (this.fragmentError) throw this.fragmentError
} finally {
NntpService.release(conn, failed)
await NntpService.shutdown()
client.close()
await QueueService.closeAll()
}
@@ -135,11 +150,20 @@ export default class SeedRange extends BaseCommand {
stats.existing += stored.existing
stats.inspections += stored.inspections
if (fragments.length === 0) return
// The regular path: staging (D35) for lone segments, binaries and parts for the rest.
const { ingest } = StagingService.enabled(this.group)
? await StagingService.filter(fragments, { counters: StagingService.countersEnabled(this.group) })
: { ingest: fragments }
await IngestService.ingest(ingest)
// The regular path: staging (D35) for lone segments, binaries and parts for the rest. It runs
// beside fetching; with two batches in flight already, the node waits for one to finish.
while (this.fragmentWork.size >= 2) await Promise.race([...this.fragmentWork].map((w) => w.catch(() => {})))
if (this.fragmentError) throw this.fragmentError
const work = (async () => {
const { ingest } = StagingService.enabled(this.group)
? await StagingService.filter(fragments, { counters: StagingService.countersEnabled(this.group) })
: { ingest: fragments }
await IngestService.ingest(ingest)
})()
this.fragmentWork.add(work)
work
.catch((error) => (this.fragmentError ??= error))
.finally(() => this.fragmentWork.delete(work))
}
private lastReport = 0