Seeding node: gzip overviews, batched SFV lookups, fragments beside fetching
- 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:
1 parent
68eb9a44fd
commit
97bdf8e982
5 files changed
+329
-26
No files matched your search
+42
-18
@@ -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
|
||||
|
||||
Reference in new issue
Block a user