import { hash } from 'node:crypto' import type { Overview } from '../nntp/index.js' import { FileAggregator, type AggregatedFile, isArchiveStart, isLaterVolume, sevenZipVolume } from '../files/index.js' import { RuleSet, isRandomFile, uploadTool, type Rules } from '../rules/index.js' import { SegmentScanner, scanOverviewText, type EdgeSegment } from './scan.js' import { encodeChunk, type ChunkFile } from '../chunks/index.js' /** A release's files from one batch (REBUILD.md, chunk store): what the catalog adds up, and the encoded chunk. */ export interface ReleaseChunk { key: string /** Which upload of the key in this batch (0, 1, …; uploadsOf): the catalog keeps uploads apart. */ upload: number name: string source: string fileTotal: number | null poster: string groups: string[] postedAt: number | null /** The latest article's date: with postedAt, the upload's posting window. */ postedLast: number | null files: number segments: number bytes: number firstArticle: number lastArticle: number /** A file with a readable name; a par2 file. */ readable: boolean hasPar2: boolean /** * Voyager 3: the window closed on the release (default), or it is a tail release sent provisionally, finished by a * later chunk of the same release key (the totals then are the release's; the payload only the files not sent yet). */ finished?: boolean /** Still open at its block's end, or posted within the window of its start: the block merger joins it with the neighbour block's part. */ edge?: boolean /** A file of the release misses parts (the window closed on it): not complete, whatever the counter says. */ partial?: boolean /** * Voyager 3, an unnamed release (readable false): the upload tool from its first article's message-id, its * signature with the group; the payload is empty (no chunk until a probe names it: pending_releases). */ tool?: string payload: Buffer } export interface DroppedChunk extends ReleaseChunk { rule: string ruleKey: string } export type CandidateKind = 'archive' | 'par2' | 'lone' | '7z-tail' /** An article phase 2 should read (REBUILD.md, probe queue). */ export interface ProbeCandidate { releaseKey: string /** Its chunk's upload (ReleaseChunk.upload). */ upload: number kind: CandidateKind messageId: string file: string } /** What a rule dropped in a batch (REBUILD.md, rules and recovery). */ export interface RuleCount { rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'incomplete' key: string articles: number files: number } /** * A file cut off by the range's edges, for the neighbouring range: its * details once, and the segments this range had, as * [part, message-id, bytes, article number]. */ export interface Fragment { fileKey: string name: string poster: string subject: string totalParts: number groups: string[] release: { key: string; name: string; source: string; fileTotal?: number } | null postedAt: number | null segments: [number, string, number, number][] } export interface EdgeBatch { /** The range's resume point this batch starts at. */ from: number /** First article number not covered yet. */ next: number headers: number /** * The batch's posting times without its earliest and latest 1% (ms; null without a dated article): the range's * time span is their min and max (headers dated years off don't stretch it). The settler's final completeness * reads it (LOG.md 2026-10-16 (67)). */ postedLow: number | null postedHigh: number | null releases: ReleaseChunk[] samples: DroppedChunk[] candidates: ProbeCandidate[] counts: RuleCount[] /** The range path's cut-off files (the TypeScript indexer); the Rust edge's window has none, the store ignores them. */ fragments?: Fragment[] } export interface RangeIndexerOptions { group: string /** Inclusive article numbers. */ start: number end: number /** Where this worker starts: the range's resume point (a range taken over midway); its first batch starts here. */ resumeAt?: number rules: Rules /** Chunks without a new part before an open file is given up (as a fragment near an edge). */ idleChunks?: number /** * A file whose first part seen is above 1 and comes within this many * articles of the range's start is kept as a fragment (its earlier parts * are in the previous range). Default: the idle window (idleChunks of the * chunks seen so far), as uploads in busy groups spread over minutes. */ edgeArticles?: number } const MAX_CANDIDATES = 4 /** * One leased range on an edge worker (REBUILD.md, part 2): overview lines * in, chunk by chunk and in order; releases, probe candidates, rule counts * and edge fragments out, per batch (`take`). Holds only the files still * waiting for parts. */ export class RangeIndexer { private aggregator = new FileAggregator(UPLOAD_GAP_MS) private scanner = new SegmentScanner() private segments: EdgeSegment[] = [] /** Posting times of this batch's segments (postedSpan at take). */ private dates: number[] = [] private rules: RuleSet private batch: EdgeBatch private counts = new Map() private readonly idle: number private readonly edge: number /** Articles in the first chunk (for the default start window). */ private chunkArticles = 0 constructor(private options: RangeIndexerOptions) { this.rules = new RuleSet(options.rules) this.idle = options.idleChunks ?? 5 this.edge = options.edgeArticles ?? 0 this.batch = this.empty(options.resumeAt ?? options.start) } /** One chunk of overview lines (parsed); `next` is the first article after it. */ add(overviews: Overview[], next: number) { for (const [n, h] of overviews) this.line(n, h.subject ?? '', h.from ?? '', h.date ?? '', h['message-id'] ?? '', h[':bytes'] ?? '', h.xref ?? '') this.endChunk(next) } /** One chunk as the XOVER reply's text (the worker's path: no object per line). */ addText(text: string, next: number) { scanOverviewText(text, this.handleLine) this.endChunk(next) } private handleLine = (n: number, subject: string, from: string, date: string, messageId: string, bytes: string, xref: string) => this.line(n, subject, from, date, messageId, bytes, xref) private line(articleNumber: number, subject: string, from: string, date: string, messageId: string, bytes: string, xref: string) { if (!Number.isFinite(articleNumber) || !messageId) return const { group, rules } = this.options this.batch.headers++ const owner = xref ? this.scanner.owner(xref, group, rules.priorities) : group if (owner !== group) { this.count('crosspost', owner, 1, 0) return } const segment = this.scanner.segment(articleNumber, subject, from, date, messageId, bytes, xref, group) if (segment) { this.segments.push(segment) if (Number.isFinite(segment.postedAt)) this.dates.push(segment.postedAt) } else this.count('obfuscated', '', 1, 0) } private endChunk(next: number) { this.files(this.aggregator.add(this.segments)) this.segments = [] if (this.chunkArticles === 0) this.chunkArticles = next - this.batch.from this.cutOff(this.aggregator.evict(this.idle), false) this.batch.next = next } /** The end of the range: every open file is given up. */ finish() { this.cutOff(this.aggregator.evict(0), true) this.batch.next = this.options.end + 1 } /** What was found since the last take, and a fresh batch. */ take(): EdgeBatch { this.judgeReleases() this.scanner.clear() const out = this.batch out.counts = [...this.counts.values()] this.counts.clear() ;[out.postedLow, out.postedHigh] = postedSpan(this.dates) this.dates = [] this.batch = this.empty(out.next) return out } get openFiles() { return this.aggregator.openFiles } /** Files completed and fragments cut since the last take: how big the next batch is. */ get pending(): number { let files = this.batch.fragments!.length for (const list of this.completed.values()) files += list.length return files } /** Completed files, gathered per release until the batch is taken: one chunk per release per batch. */ private completed = new Map[]>() private files(files: AggregatedFile[]) { for (const file of files) { const key = releaseKeyOf(file.segments[0]) const list = this.completed.get(key) if (list) list.push(file) else this.completed.set(key, [file]) } } /** Judges and encodes the releases completed in this batch: each upload of a key on its own. */ private judgeReleases() { const byRelease = this.completed this.completed = new Map() for (const [key, all] of byRelease) { uploadsOf(all).forEach((list, upload) => { const verdict = this.rules.judge( key, list.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId, poster: f.segments[0].file.poster })) ) if (verdict.rule === null) { this.batch.releases.push(chunkOf(key, list, upload)) this.batch.candidates.push(...candidatesOf(key, list, upload)) return } this.count(verdict.rule, verdict.key, list.reduce((n, f) => n + f.segments.length, 0), list.length) if (verdict.sample) this.batch.samples.push({ ...chunkOf(key, list, upload), rule: verdict.rule, ruleKey: verdict.key }) }) } } /** * Given-up files. Kept as fragments only when the range's edge can have * cut them: parts missing before the first one seen, which comes within * the start window, or after the last one seen while the file was still * open at the range's end. Any other file missing parts is incomplete on * the server, and only counted (in the first 31 ranges, 390k of 406k * fragment files never met a neighbour; LOG.md 2026-10-09). */ private cutOff(segments: EdgeSegment[], atEnd: boolean) { const { start } = this.options const window = this.edge || this.idle * Math.max(1, this.chunkArticles) const byFile = new Map() for (const s of segments) { const list = byFile.get(s.fileKey) if (list) list.push(s) else byFile.set(s.fileKey, [s]) } for (const list of byFile.values()) { // One article of a "file" of many parts under a random name: a random subject per article, which // never completes from headers (99.8% of the boneless fragments; LOG.md 2026-10-09). if (list.length === 1 && isRandomFile(list[0].file.name)) { this.count('per-article', uploadTool(list[0].messageId), 1, 0) continue } let minPart = Number.POSITIVE_INFINITY let maxPart = 0 let firstArticle = Number.POSITIVE_INFINITY let lastArticle = 0 for (const s of list) { if (s.number < minPart) minPart = s.number if (s.number > maxPart) maxPart = s.number if (s.articleNumber < firstArticle) firstArticle = s.articleNumber if (s.articleNumber > lastArticle) lastArticle = s.articleNumber } // Cut at the end: still open when the range ends (it had parts within the idle window), later parts missing. const cutAtStart = minPart > 1 && firstArticle < start + window const cutAtEnd = atEnd && maxPart < list[0].totalParts if (!cutAtStart && !cutAtEnd) { this.count('incomplete', '', list.length, 1) continue } const fragment = fragmentOf(list[0]) for (const s of list) { fragment.segments.push([s.number, s.messageId, s.bytes, s.articleNumber]) if (Number.isFinite(s.postedAt) && (fragment.postedAt === null || s.postedAt < fragment.postedAt)) fragment.postedAt = s.postedAt } this.batch.fragments!.push(fragment) } } private count(rule: RuleCount['rule'], key: string, articles: number, files: number) { const id = `${rule}\n${key}` const c = this.counts.get(id) if (c) { c.articles += articles c.files += files } else { this.counts.set(id, { rule, key, articles, files }) } } private empty(from: number): EdgeBatch { return { from, next: from, headers: 0, postedLow: null, postedHigh: null, releases: [], samples: [], candidates: [], counts: [], fragments: [] } } } /** Posting times without the earliest and latest 1% (floor(n / 100) at each end); nulls without any. */ export function postedSpan(dates: number[]): [number | null, number | null] { if (dates.length === 0) return [null, null] const sorted = Float64Array.from(dates).sort() const k = Math.floor(sorted.length / 100) return [sorted[k], sorted[sorted.length - 1 - k]] } /** The release key; a file without a recognisable release becomes a release of its own. */ export function releaseKeyOf(s: EdgeSegment): string { return s.file.release?.key ?? hash('sha1', `file\n${s.fileKey}`) } /** * A pause of more than this in posting starts another upload of the same * release key (a re-upload): 91% of uploads post within 3 h, 73% of * re-uploaded copies come later (LOG.md 2026-10-09 (10)). */ export const UPLOAD_GAP_MS = 3 * 60 * 60 * 1000 /** * A key's files split into uploads where posting pauses for more than * UPLOAD_GAP_MS: files in order of their first article's date; a file * starts another upload when it begins more than UPLOAD_GAP_MS after the * latest article posted so far (so a file that takes hours to post doesn't * split its upload). Earliest upload first, each in the files' own order; * files without a date go with the first. */ export function uploadsOf>(files: F[]): F[][] { if (files.length < 2) return [files] const first = files.map((f) => postedOf(f.segments)) const last = files.map((f) => latestOf(f.segments)) const dated = files.map((_, i) => i).filter((i) => first[i] !== null).sort((a, b) => first[a]! - first[b]! || a - b) if (dated.length < 2) return [files] const upload = new Array(files.length).fill(0) let current = 0 let end = last[dated[0]]! for (let k = 1; k < dated.length; k++) { const i = dated[k] if (first[i]! - end > UPLOAD_GAP_MS) { current++ end = last[i]! } else if (last[i]! > end) { end = last[i]! } upload[i] = current } if (current === 0) return [files] return Array.from({ length: current + 1 }, (_, u) => files.filter((_, i) => upload[i] === u)) } /** The latest article date of a file's segments, or null. */ function latestOf(segments: EdgeSegment[]): number | null { let max: number | null = null for (const s of segments) if (Number.isFinite(s.postedAt) && (max === null || s.postedAt > max)) max = s.postedAt return max } function postedOf(segments: EdgeSegment[]): number | null { let min: number | null = null for (const s of segments) { const t = s.postedAt if (Number.isFinite(t) && (min === null || t < min)) min = t } return min } export function chunkOf(key: string, files: AggregatedFile[], upload = 0): ReleaseChunk { const first = files[0].segments[0].file const chunkFiles: ChunkFile[] = files.map((f) => { const m = f.segments[0].file return { name: m.name, poster: m.poster, subject: m.subject, postedAt: postedOf(f.segments), groups: m.groups, segments: f.segments.map((x) => [x.number, x.messageId, x.bytes]) } }) let segments = 0 let bytes = 0 let firstArticle = Number.POSITIVE_INFINITY let lastArticle = 0 let postedAt: number | null = null let postedLast: number | null = null for (const f of files) { for (const s of f.segments) { segments++ bytes += s.bytes const n = s.articleNumber if (n < firstArticle) firstArticle = n if (n > lastArticle) lastArticle = n if (Number.isFinite(s.postedAt) && (postedLast === null || s.postedAt > postedLast)) postedLast = s.postedAt } const t = postedOf(f.segments) if (t !== null && (postedAt === null || t < postedAt)) postedAt = t } return { key, upload, name: first.release?.name ?? first.name, source: first.release?.source ?? 'file', fileTotal: files.find((f) => f.segments[0].file.release?.fileTotal)?.segments[0].file.release?.fileTotal ?? null, poster: first.poster, groups: first.groups, postedAt, postedLast, files: files.length, segments, bytes, firstArticle, lastArticle, readable: chunkFiles.some((f) => !isRandomFile(f.name)), hasPar2: chunkFiles.some((f) => /\.par2$/i.test(f.name)), payload: encodeChunk(chunkFiles), } } /** * The articles that say what a release is: the first of its first archive * volume, of its par2 index, of a lone random-named file, and a 7z set's * last volume (its listing is in the tail). At most a few per release. */ export function candidatesOf(key: string, files: AggregatedFile[], upload = 0): ProbeCandidate[] { const out: ProbeCandidate[] = [] let tail: { volume: number; file: AggregatedFile } | null = null for (const f of files) { const name = f.segments[0].file.name const volume = sevenZipVolume(name) if (volume !== null && volume > 1) { if (!tail || volume > tail.volume) tail = { volume, file: f } continue } // An unsplit 7z of more than one segment: its listing is in its last one. if (volume === 0 && f.segments.length > 1 && !tail) tail = { volume, file: f } if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name }) else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name }) } if (tail) out.push({ releaseKey: key, upload, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name }) if (out.length === 0 && files.length === 1 && isRandomFile(files[0].segments[0].file.name)) { out.push({ releaseKey: key, upload, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name }) } return out.slice(0, MAX_CANDIDATES) } function fragmentOf(s: EdgeSegment): Fragment { const m = s.file return { fileKey: s.fileKey, name: m.name, poster: m.poster, subject: m.subject, totalParts: m.totalParts, groups: m.groups, release: m.release ? { key: m.release.key, name: m.release.name, source: m.release.source, ...(m.release.fileTotal ? { fileTotal: m.release.fileTotal } : {}) } : null, postedAt: null, segments: [], } }