import { BaseCommand, flags } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' import { spawn } from 'node:child_process' import { access, readdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import db from '@adonisjs/lucid/services/db' import coordinatorConfig from '#config/coordinator' import BatchStore from '#services/batch_store' import Leases from '#services/leases' import RuleSets from '#services/rule_sets' import { fromWire, type WireBatch } from '#lib/edge/index' /** * Only a database named like one of these is written: the replay leases and finishes ranges and fills the catalog. * Not the test database: the rows left behind failed 27 functional tests (LOG.md 2026-10-26 (111)). With * REPLAY_INTO_CATALOG=1 any database but the test one: voyager 3 deployed, history replayed from the corpus into * the catalog itself (deploy/replay-window.sh, LOG.md (116)). */ const SCRATCH_DB = /_(replay|scratch)$/ const TEST_DB = /_test$/ const NODE_NAME = 'replay' interface StoredRange { id: number start: number end: number } /** * The replay harness (REBUILD.md, "Voyager 3", 8): a group's stored corpus ranges in article order through one * window in the Rust edge (`edge-bench --group`, the window carried from range to range) and the batch store into a * scratch catalog, as the worker would store them: each range leased to a node named "replay", its batches stored * at its resume point, the range finished when its last batch is in. Then a report: releases finished, complete, * partial and provisional, and the catalog's bytes per header (the NVMe tables apart from those on CHUNK_TABLESPACE). * * node ace replay:corpus --group alt.binaries.ath [--dir CORPUS_DIR] [--from A] [--to B] [--limit N] [--batch-chunks 5] * * Refuses a database not named *_replay or *_scratch. The rules are the catalog's current rule set * (rule_sets, the groups' priorities): copy `groups` and `rule_sets` from the catalog for a faithful replay. */ export default class ReplayCorpus extends BaseCommand { static commandName = 'replay:corpus' static description = "Replays a group's corpus ranges through the Rust edge and the batch store into a scratch catalog" static options: CommandOptions = { startApp: true } @flags.string({ description: 'The group (its directory under the corpus)', required: true }) declare group: string @flags.string({ description: 'The corpus directory (default: CORPUS_DIR)' }) declare dir?: string @flags.number({ description: 'Replay this many ranges at most (default: all stored)' }) declare limit?: number @flags.number({ description: 'Skip ranges ending before this article (continue a replay)' }) declare from?: number @flags.number({ description: "Skip ranges starting after this article (one window of the group's history)", }) declare to?: number @flags.number({ description: 'Chunks of 100k articles per batch, as the worker posts them', default: 5, }) declare batchChunks: number @flags.string({ description: 'The edge-bench binary (default: EDGE_BENCH, edge/target/release/edge-bench, /usr/local/bin/edge-bench)', }) declare bench?: string @flags.boolean({ description: 'Leave the window open at the end (the last ranges provisional), to continue later', }) declare noFinish: boolean async run() { const database = db.connection().getReadClient().client.config.connection.database as string const intoCatalog = process.env.REPLAY_INTO_CATALOG === '1' && !TEST_DB.test(database) if (!SCRATCH_DB.test(database) && !intoCatalog) { this.logger.error( `The replay writes the catalog: it only runs against a database named *_replay or *_scratch (this is "${database}"; REPLAY_INTO_CATALOG=1 allows it)` ) this.exitCode = 1 return } const dir = this.dir ?? coordinatorConfig.corpus.dir if (!dir) { this.logger.error('No corpus directory: --dir or CORPUS_DIR') this.exitCode = 1 return } const bench = await this.benchBinary() if (!bench) { this.logger.error( 'No edge-bench binary found: build it (cargo build --release --manifest-path edge/Cargo.toml) or set EDGE_BENCH' ) this.exitCode = 1 return } const groupDir = join(dir, this.group) const stored = await this.storedRanges(groupDir) if (stored.length === 0) { this.logger.error(`${groupDir}: no complete range`) this.exitCode = 1 return } const nodeId = await this.nodeId() const groupId = await this.groupId() const ranges = await this.leaseRanges(groupId, nodeId, stored) const rules = await RuleSets.current() const rulesPath = join(tmpdir(), `replay-rules-${process.pid}.json`) await writeFile(rulesPath, JSON.stringify(rules)) this.logger.info( `${this.group}: ${ranges.length} ranges (${ranges[0].start}–${ranges[ranges.length - 1].end}) through ${bench}, rules v${rules.version}, into ${database}.` ) // edge-bench walks the group directory in article order itself; --from, --to and --limit cut its list as ours. const args = [ '--group', groupDir, '--rules', rulesPath, '--batch-chunks', String(this.batchChunks), '--wire', '-', ] if (this.from) args.push('--from', String(this.from)) if (this.to) args.push('--to', String(this.to)) if (this.limit) args.push('--limit', String(this.limit)) if (this.noFinish) args.push('--no-finish') const started = Date.now() const child = spawn(bench, args, { stdio: ['ignore', 'pipe', 'inherit'] }) const totals = { batches: 0, headers: 0, releases: 0, newReleases: 0, finalized: 0, candidates: 0, storeMs: 0, } let current: StoredRange | null = null const storeLine = async (line: string) => { const batch = fromWire(JSON.parse(line) as WireBatch) if (!current || batch.from < current.start || batch.from > current.end) { current = ranges.find((r) => batch.from >= r.start && batch.from <= r.end) ?? null if (!current) throw new Error(`Batch at ${batch.from} is in no leased range`) } const at = Date.now() const result = await BatchStore.store(current.id, nodeId, batch) totals.storeMs += Date.now() - at totals.batches++ totals.headers += batch.headers totals.releases += result.releases totals.newReleases += result.newReleases totals.finalized += result.finalized totals.candidates += result.candidates if (batch.next > current.end) { await Leases.finish(current.id, nodeId) const seconds = (Date.now() - started) / 1000 this.logger.info( ` range ${current.start}–${current.end} done: ${totals.headers.toLocaleString('en')} headers so far in ${seconds.toFixed(0)} s ` + `(${Math.round(totals.headers / seconds).toLocaleString('en')}/s), ${totals.releases} releases stored (${totals.finalized} finished later), store ${(totals.storeMs / 1000).toFixed(0)} s` ) } } // Lines pulled as the store keeps up: the child's stdout only moves when a chunk is read, so batches don't pile up. let rest = '' let failure: Error | null = null try { for await (const chunk of child.stdout as AsyncIterable) { rest += chunk.toString('utf8') let nl: number while ((nl = rest.indexOf('\n')) >= 0) { const line = rest.slice(0, nl) rest = rest.slice(nl + 1) if (line.length > 0) await storeLine(line) } } if (rest.trim().length > 0) await storeLine(rest) } catch (error: any) { failure = error child.kill('SIGTERM') } const code = await new Promise((done) => child.exitCode !== null ? done(child.exitCode) : child.on('exit', done) ) if (failure) { this.logger.error(`Replay failed: ${failure.message}`) this.exitCode = 1 return } if (code !== 0) { this.logger.error(`edge-bench exited with ${code}`) this.exitCode = 1 return } await this.report(groupId, ranges, Date.now() - started) } private async benchBinary(): Promise { const candidates = [ this.bench, process.env.EDGE_BENCH, resolve('edge/target/release/edge-bench'), '/usr/local/bin/edge-bench', ].filter((p): p is string => !!p) for (const path of candidates) { if ( await access(path).then( () => true, () => false ) ) return path } return null } /** The group directory's complete ranges (a range.json), in article order, cut by --from, --to and --limit. */ private async storedRanges(groupDir: string): Promise<{ start: number; end: number }[]> { const names = await readdir(groupDir).catch(() => [] as string[]) const ranges: { start: number; end: number }[] = [] for (const name of names) { const m = /^(\d+)-(\d+)$/.exec(name) if (!m) continue const json = await readFile(join(groupDir, name, 'range.json'), 'utf8').catch(() => null) if (!json) continue ranges.push({ start: Number(m[1]), end: Number(m[2]) }) } ranges.sort((a, b) => a.start - b.start) let chosen = this.from ? ranges.filter((r) => r.end >= this.from!) : ranges if (this.to) chosen = chosen.filter((r) => r.start <= this.to!) if (this.limit) chosen = chosen.slice(0, this.limit) return chosen } private async nodeId(): Promise { const row = await db.from('nodes').where('name', NODE_NAME).first() if (row) return Number(row.id) const [inserted] = await db.table('nodes').insert({ name: NODE_NAME }).returning('id') return Number(inserted.id) } /** The group's row (inserted after the last priority when the scratch catalog doesn't have it). */ private async groupId(): Promise { const row = await db.from('groups').where('name', this.group).first() if (row) return Number(row.id) const { rows } = await db.rawQuery(`SELECT coalesce(max(priority), 0) + 1 AS next FROM groups`) const [inserted] = await db .table('groups') .insert({ name: this.group, priority: Number(rows[0].next), active: true }) .returning('id') this.logger.info(`Group ${this.group} added with priority ${rows[0].next}.`) return Number(inserted.id) } /** The ranges as leased to the replay node, from their start (an earlier replay's rows are taken over). */ private async leaseRanges( groupId: number, nodeId: number, stored: { start: number; end: number }[] ): Promise { const out: StoredRange[] = [] const now = new Date() for (const r of stored) { const lease = { status: 'leased', node_id: nodeId, next_id: r.start, headers: 0, posted_low: null, posted_high: null, leased_at: now, heartbeat_at: now, done_at: null, error: null, capture: true, } const existing = await db .from('ranges') .where('group_id', groupId) .where('start_id', r.start) .first() if (existing) { await db .from('ranges') .where('id', existing.id) .update({ ...lease, end_id: r.end, attempts: Number(existing.attempts) + 1 }) out.push({ id: Number(existing.id), start: r.start, end: r.end }) continue } const [inserted] = await db .table('ranges') .insert({ ...lease, group_id: groupId, start_id: r.start, end_id: r.end, priority: 0, attempts: 1, }) .returning('id') out.push({ id: Number(inserted.id), start: r.start, end: r.end }) } return out } /** What the replay left in the catalog (this group's releases; the sizes are the whole catalog's), per article. */ private async report(groupId: number, ranges: StoredRange[], elapsedMs: number) { const ids = ranges.map((r) => r.id) const { rows: counts } = await db.rawQuery( `SELECT count(*)::bigint AS releases, count(settled_at)::bigint AS finished, count(*) FILTER (WHERE settled_at IS NULL)::bigint AS provisional, count(*) FILTER (WHERE edge)::bigint AS edges, count(*) FILTER (WHERE complete)::bigint AS complete, count(*) FILTER (WHERE complete = false)::bigint AS incomplete, count(*) FILTER (WHERE complete IS NULL)::bigint AS undecided, count(*) FILTER (WHERE readable)::bigint AS readable, count(*) FILTER (WHERE has_par2)::bigint AS with_par2, coalesce(sum(files), 0)::bigint AS files, coalesce(sum(segments), 0)::bigint AS segments, coalesce(sum(bytes), 0)::bigint AS bytes FROM releases WHERE group_id = ?`, [groupId] ) const { rows: sources } = await db.rawQuery( `SELECT source, count(*)::bigint AS n FROM releases WHERE group_id = ? GROUP BY source ORDER BY source`, [groupId] ) const { rows: pending } = await db.rawQuery( `SELECT count(*)::bigint AS n, coalesce(sum(segments), 0)::bigint AS segments, count(*) FILTER (WHERE jsonb_array_length(candidates) = 0)::bigint AS unprobable FROM pending_releases WHERE group_id = ?`, [groupId] ) const { rows: headerRows } = await db.rawQuery( `SELECT coalesce(sum(headers), 0)::bigint AS headers FROM ranges WHERE id = ANY(?::bigint[])`, [ids] ) const headers = Number(headerRows[0].headers) const { rows: sizes } = await db.rawQuery( `SELECT CASE WHEN c.relname LIKE 'release_chunks%' THEN 'release_chunks' ELSE c.relname END AS name, sum(pg_total_relation_size(c.oid))::bigint AS bytes FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r' AND (c.relname IN ('releases', 'release_texts', 'posters', 'group_sets', 'probe_candidates', 'rule_counts', 'rule_samples', 'blocks', 'pending_releases') OR c.relname LIKE 'release_chunks%') GROUP BY 1 ORDER BY 2 DESC` ) // On CHUNK_TABLESPACE (the HDD) in the catalog: the chunks (migration 3200), inspections and candidates (6000). const hdd = new Set([ 'release_chunks', 'probe_candidates', 'release_inspections', 'pending_releases', ]) let nvme = 0 let other = 0 const c = counts[0] const fmt = (n: unknown) => Number(n).toLocaleString('en') this.logger.info( `Replay of ${ranges.length} ranges: ${fmt(headers)} headers in ${(elapsedMs / 1000).toFixed(0)} s (${fmt(Math.round(headers / Math.max(1, elapsedMs / 1000)))}/s).` ) this.logger.info( `Releases ${fmt(c.releases)}: finished ${fmt(c.finished)}, provisional ${fmt(c.provisional)}, edges ${fmt(c.edges)}; ` + `complete ${fmt(c.complete)}, incomplete ${fmt(c.incomplete)}, undecided ${fmt(c.undecided)}; readable ${fmt(c.readable)}, with par2 ${fmt(c.with_par2)}; ` + `${fmt(c.files)} files, ${fmt(c.segments)} segments (${((Number(c.segments) / Math.max(1, headers)) * 100).toFixed(1)}% of the headers), ${(Number(c.bytes) / 1e12).toFixed(2)} TB.` ) this.logger.info( `By source: ${sources.map((s: any) => `${s.source}: ${fmt(s.n)}`).join(', ')}.` ) this.logger.info( `Pending (unnamed, outside the catalog): ${fmt(pending[0].n)} releases, ${fmt(pending[0].segments)} segments, ${fmt(pending[0].unprobable)} without a probe target.` ) for (const s of sizes) { const bytes = Number(s.bytes) if (hdd.has(s.name)) other += bytes else nvme += bytes this.logger.info( ` ${s.name}: ${(bytes / 1e6).toFixed(1)} MB, ${(bytes / Math.max(1, headers)).toFixed(2)} B/header` ) } this.logger.info( `NVMe tables: ${(nvme / 1e6).toFixed(1)} MB, ${(nvme / Math.max(1, headers)).toFixed(2)} B/header; HDD tables (CHUNK_TABLESPACE): ${(other / 1e6).toFixed(1)} MB, ${(other / Math.max(1, headers)).toFixed(2)} B/header (the whole catalog's sizes over this group's headers: only the first group's are its own).` ) } }