Rebuild: phase 1 modules from the current app, ordered by pipeline stage
lib/nntp (XOVER client, Xref, GROUP), lib/headers (subjects, file keys, segments from overview lines; the yEnc path stays behind for phase 2), lib/release (identity, random names, now in lib), lib/files (aggregator, volume names), lib/rules (posting tools, owner by priority, release verdicts), lib/chunks (release chunk codec). Their unit tests came along; new ones for codec, rules and volumes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96f0ade10e
commit
d773c91ad0
26 files changed
+1486
No files matched your search
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Edge aggregation (CONCEPT.md D37): groups header segments into files in
|
||||
* memory, so a seeding node writes one row per file instead of one per
|
||||
* article. A file is done once every part number from 1 to its total has
|
||||
* an article; what never completes within the node's window (an upload cut
|
||||
* by the range edges, a random subject per article) leaves as fragments
|
||||
* for the regular ingest path, where it can meet its other parts.
|
||||
*/
|
||||
|
||||
/** What the aggregator needs of a segment (IngestSegment has all of it). */
|
||||
export interface AggregatableSegment {
|
||||
fileKey: string
|
||||
number: number
|
||||
totalParts: number
|
||||
messageId: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface AggregatedFile<S extends AggregatableSegment> {
|
||||
fileKey: string
|
||||
/** One segment per part number, in part order; the first article seen wins (a later one is a repost). */
|
||||
segments: S[]
|
||||
}
|
||||
|
||||
interface OpenFile<S> {
|
||||
parts: Map<number, S>
|
||||
totalParts: number
|
||||
/** Batch in which a segment last arrived. */
|
||||
touched: number
|
||||
}
|
||||
|
||||
export class FileAggregator<S extends AggregatableSegment> {
|
||||
private open = new Map<string, OpenFile<S>>()
|
||||
private batch = 0
|
||||
/** Segments with a part number outside 1..total: never part of a complete file. */
|
||||
private stray: S[] = []
|
||||
|
||||
/** Adds one batch of segments; returns the files they completed. */
|
||||
add(segments: S[]): AggregatedFile<S>[] {
|
||||
this.batch++
|
||||
const touched = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (segment.number < 1 || segment.number > segment.totalParts) {
|
||||
this.stray.push(segment)
|
||||
continue
|
||||
}
|
||||
let file = this.open.get(segment.fileKey)
|
||||
if (!file) {
|
||||
file = { parts: new Map(), totalParts: segment.totalParts, touched: this.batch }
|
||||
this.open.set(segment.fileKey, file)
|
||||
}
|
||||
if (!file.parts.has(segment.number)) file.parts.set(segment.number, segment)
|
||||
file.touched = this.batch
|
||||
touched.add(segment.fileKey)
|
||||
}
|
||||
|
||||
const complete: AggregatedFile<S>[] = []
|
||||
for (const key of touched) {
|
||||
const file = this.open.get(key)!
|
||||
if (file.parts.size < file.totalParts) continue
|
||||
this.open.delete(key)
|
||||
complete.push({ fileKey: key, segments: [...file.parts.values()].sort((a, b) => a.number - b.number) })
|
||||
}
|
||||
return complete
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives up on files that got no segment in the last `idleBatches` batches
|
||||
* (all open files when 0) and returns their segments, plus stray ones.
|
||||
*/
|
||||
evict(idleBatches = 0): S[] {
|
||||
const out = this.stray
|
||||
this.stray = []
|
||||
for (const [key, file] of this.open) {
|
||||
if (idleBatches > 0 && this.batch - file.touched < idleBatches) continue
|
||||
this.open.delete(key)
|
||||
out.push(...file.parts.values())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Files still waiting for parts. */
|
||||
get openFiles(): number {
|
||||
return this.open.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FileAggregator, type AggregatableSegment, type AggregatedFile } from './aggregator.js'
|
||||
export { isArchiveStart, isLaterVolume, rarVolume, sevenZipVolume } from './volumes.js'
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Files whose first segment says what an archive holds: named first RAR
|
||||
* volumes (.part01.rar, .rar), 7z (.7z, .7z.001) and `.bin`, an extension
|
||||
* uploaders use for archive volumes too (sniffed by content).
|
||||
*/
|
||||
export function isArchiveStart(name: string): boolean {
|
||||
if (/\.bin$/i.test(name)) return true
|
||||
const volume = sevenZipVolume(name)
|
||||
if (volume === 0 || volume === 1) return true
|
||||
return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* A later RAR volume (.part02.rar, .r00) or a par2 recovery volume
|
||||
* (.vol03+04.par2): the volume number is readable even under a random stem.
|
||||
* The set is listed from its first volume and named from its index .par2,
|
||||
* so the first segment of these says nothing new (LOG.md 2026-10-04).
|
||||
*/
|
||||
export function isLaterVolume(name: string): boolean {
|
||||
const part = /\.part(\d+)\.rar$/i.exec(name)
|
||||
if (part) return Number(part[1]) > 1
|
||||
return /\.r\d{2,3}$/i.test(name) || /\.vol\d+[+-]\d+\.par2$/i.test(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The set and 1-based number of a RAR volume: `x.part03.rar` is volume 3 of
|
||||
* `x`, and in the old naming `x.rar` is 1, `x.r00` 2, `x.r01` 3. Null for
|
||||
* other names.
|
||||
*/
|
||||
export function rarVolume(name: string): { base: string; number: number } | null {
|
||||
const part = /^(.*)\.part(\d+)\.rar$/i.exec(name)
|
||||
if (part) return { base: part[1].toLowerCase(), number: Number(part[2]) }
|
||||
const old = /^(.*)\.r(\d{2,3})$/i.exec(name)
|
||||
if (old) return { base: old[1].toLowerCase(), number: Number(old[2]) + 2 }
|
||||
const first = /^(.*)\.rar$/i.exec(name)
|
||||
if (first) return { base: first[1].toLowerCase(), number: 1 }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Volume number of a split 7z name (`x.7z.001` → 1), 0 for an unsplit `.7z`, null otherwise. */
|
||||
export function sevenZipVolume(name: string): number | null {
|
||||
const split = /\.7z\.(\d{1,4})$/i.exec(name)
|
||||
if (split) return Number(split[1])
|
||||
return /\.7z$/i.test(name) ? 0 : null
|
||||
}
|
||||
Reference in new issue
Block a user