The par2 merge: releases whose files one par2 set describes become one
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m51s
Test and deploy / deploy (push) Successful in 1m27s

The prober stores, per probed article that starts a file, the MD5 of its
first 16 KiB (file_hashes), and per par2 body the set's whole file list
(par2_files, par2_sets): FileDesc holds the same hash per file, whatever
the file was renamed to. releases:merge (the merger service, no NNTP)
merges every minute the releases of one group, posted within 48 hours of
a release holding the set's par2, whose files the set describes, into the
one with the most files: chunks, probe candidates, inspections, hashes and
counts move; the par2's shared name when none was readable; release_merges
keeps what went where. Obfuscated uploads whose files were each a release
of their own ("[9/20] - "14f5999a…"", hep: 13,232) become one.

Also: corpus_sweep.py counts the marked ranges without psql's command tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-12 09:37:35 +02:00
1 parent 9ea2669b2a
commit 3d0dc6ca1f
13 files changed
+502 -9

No files matched your search

+11
View File
@@ -228,6 +228,17 @@ reads the probe queue's first articles (`lone`, `par2`, `archive`) and
stores per release whether it's passworded and its inner name, which search, stores per release whether it's passworded and its inner name, which search,
the web interface and Newznab use. the web interface and Newznab use.
Built: the par2 merge (2026-10-12; `releases:merge`, the `merger` service,
LOG.md 2026-10-12 (13)). The prober stores per probed file start the MD5 of
its first 16 KiB (`file_hashes`) and per par2 body its set's file list
(`par2_files`, `par2_sets`), which holds the same hash per file whatever the
file was renamed to. Every minute the merger merges the releases of one
group, posted within 48 hours of a release holding the set's par2, whose
files the set describes, into the one with the most files (chunks,
candidates, inspections, counts; the par2's shared name when none was
readable); `release_merges` keeps what went where. Obfuscated uploads whose
files were each a release of their own (`lone` candidates) become one.
**Backlog: voyager 1's features not in voyager 2** (CONCEPT.md decisions; **Backlog: voyager 1's features not in voyager 2** (CONCEPT.md decisions;
voyager 1's data exported to `/mnt/bulk/usenet-rebuild/v1-export/` on voyager 1's data exported to `/mnt/bulk/usenet-rebuild/v1-export/` on
homelab: `voyager1-tables.dump` with 2.76 M spots, upload signatures, homelab: `voyager1-tables.dump` with 2.76 M spots, upload signatures,
+154
View File
@@ -0,0 +1,154 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { par2ReleaseName } from '#lib/release/index'
import { looksRandom } from '#lib/release/random'
/** Releases of a set further apart than this (posted) aren't one upload: a repost is merged separately (decision d). */
const MAX_SPREAD_MS = 48 * 3600 * 1000
/** Between rounds. */
const ROUND_MS = 60_000
/** A round looks back this far past the previous one's start (rows committed while it ran). */
const OVERLAP_MS = 60_000
export interface MergeRound {
sets: number
merges: number
releases: number
}
interface Member {
id: number
release_key: string
group_id: number
files: number
posted_at: Date | null
readable: boolean
}
/**
* The par2 merge (REBUILD.md, phase 2): releases whose files a par2 set
* describes are one upload. Obfuscated uploads post each file under a name of
* its own ("[9/20] - "14f5999a7bd4680b0720e524cac0b3b5"", hep: 13,232
* one-file releases), so the header pass can't group them; the prober reads
* each such file's first article (a `lone` candidate) and stores the MD5 of
* its first 16 KiB, and a par2 body's file list holds the same hash per file,
* whatever the file was renamed to. Releases in one group, posted within
* MAX_SPREAD_MS of a release that holds the set's par2, become the one with
* the most files: its chunks, probe candidates, inspections and hashes, the
* sums of its counts, and when none of them has a readable name the name the
* par2's file names share. Each merge is kept in release_merges.
*/
export class Par2Merger {
private stopped = false
private since = new Date(0)
stop() {
this.stopped = true
}
async run() {
while (!this.stopped) {
const started = Date.now()
try {
const round = await this.round()
if (round.merges > 0) logger.info(`Merged ${round.releases} releases into ${round.merges} by par2 set (${round.sets} sets checked).`)
} catch (error: any) {
logger.error(`Par2 merge round failed: ${error.message}`)
}
for (let waited = Date.now() - started; waited < ROUND_MS && !this.stopped; waited += 1000) await new Promise((r) => setTimeout(r, 1000))
}
}
/** The sets with something new since the last round: a par2 found, or a hash of one of their files. */
async round(): Promise<MergeRound> {
const { rows: now } = await db.rawQuery(`SELECT now() AS at`)
const since = new Date(this.since.getTime() - OVERLAP_MS)
const { rows } = await db.rawQuery(
`SELECT set_id FROM par2_sets WHERE created_at >= ?
UNION
SELECT f.set_id FROM file_hashes h JOIN par2_files f ON f.hash16k = h.hash16k AND f.length = h.size WHERE h.created_at >= ?`,
[since, since]
)
const round: MergeRound = { sets: rows.length, merges: 0, releases: 0 }
for (const { set_id: setId } of rows) {
if (this.stopped) return round
const merged = await this.mergeSet(setId.trim())
if (merged > 0) {
round.merges++
round.releases += merged + 1
}
}
this.since = now[0].at
return round
}
/** Merges the releases of one set; how many went into another. */
async mergeSet(setId: string): Promise<number> {
return db.transaction(async (trx) => {
const { rows } = await trx.rawQuery(
`WITH m AS (
SELECT release_id, true AS holds FROM par2_sets WHERE set_id = ?
UNION ALL
SELECT h.release_id, false FROM par2_files f JOIN file_hashes h ON h.hash16k = f.hash16k AND h.size = f.length WHERE f.set_id = ?
), d AS (SELECT release_id, bool_or(holds) AS holds FROM m GROUP BY 1)
SELECT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable, d.holds
FROM releases r JOIN d ON d.release_id = r.id ORDER BY r.id FOR UPDATE OF r`,
[setId, setId]
)
const anchor = rows.find((r: Member & { holds: boolean }) => r.holds)
if (!anchor) return 0
const at = (r: Member) => (r.posted_at ? new Date(r.posted_at).getTime() : null)
const near = (r: Member) => at(r) === null || at(anchor) === null || Math.abs(at(r)! - at(anchor)!) <= MAX_SPREAD_MS
const members: Member[] = rows.filter((r: Member) => r.group_id === anchor.group_id && near(r))
if (members.length < 2) return 0
const target = members.reduce((a, b) => (Number(b.files) > Number(a.files) ? b : a))
const others = members.filter((m) => m.id !== target.id).map((m) => Number(m.id))
const name = members.every((m) => !m.readable) ? await this.setName(trx, setId) : undefined
await trx.rawQuery(
`INSERT INTO release_merges (from_id, from_key, into_id, set_id, files)
SELECT id, release_key, ?, ?, files FROM releases WHERE id = ANY(?)`,
[target.id, setId, others]
)
for (const table of ['release_chunks', 'probe_candidates', 'release_inspections', 'file_hashes']) {
await trx.rawQuery(`UPDATE ${table} SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others])
}
// A set held by the target already isn't held twice.
await trx.rawQuery(`DELETE FROM par2_sets WHERE release_id = ANY(?) AND set_id IN (SELECT set_id FROM par2_sets WHERE release_id = ?)`, [others, target.id])
await trx.rawQuery(`UPDATE par2_sets SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others])
await trx.rawQuery(
`UPDATE releases t SET
files = t.files + s.files, segments = t.segments + s.segments, bytes = t.bytes + s.bytes, chunks = t.chunks + s.chunks,
first_article = least(t.first_article, s.first_article), last_article = greatest(t.last_article, s.last_article),
posted_at = least(t.posted_at, s.posted_at), last_posted_at = greatest(t.last_posted_at, s.last_posted_at),
first_seen_at = least(t.first_seen_at, s.first_seen_at), last_seen_at = greatest(t.last_seen_at, s.last_seen_at),
readable = t.readable OR s.readable, has_par2 = t.has_par2 OR s.has_par2, passworded = t.passworded OR s.passworded,
file_total = greatest(t.file_total, s.file_total), inner_name = coalesce(t.inner_name, s.inner_name),
name = coalesce(nullif(?, ''), t.name)
FROM (SELECT sum(files) AS files, sum(segments) AS segments, sum(bytes) AS bytes, sum(chunks) AS chunks,
min(first_article) AS first_article, max(last_article) AS last_article, min(posted_at) AS posted_at,
max(last_posted_at) AS last_posted_at, min(first_seen_at) AS first_seen_at, max(last_seen_at) AS last_seen_at,
bool_or(readable) AS readable, bool_or(has_par2) AS has_par2, bool_or(passworded) AS passworded,
max(file_total) AS file_total, max(inner_name) AS inner_name
FROM releases WHERE id = ANY(?)) s
WHERE t.id = ?`,
[name ?? '', others, target.id]
)
// Completeness as the settler decides it, once settled.
await trx.rawQuery(
`UPDATE releases SET complete = CASE WHEN file_total IS NULL THEN NULL ELSE files >= file_total END WHERE id = ? AND settled_at IS NOT NULL`,
[target.id]
)
await trx.rawQuery(`DELETE FROM releases WHERE id = ANY(?)`, [others])
return others.length
})
}
/** The name a set's files share (par2ReleaseName), for an upload none of whose names is readable. */
private async setName(trx: any, setId: string): Promise<string | undefined> {
const { rows } = await trx.rawQuery(`SELECT name FROM par2_files WHERE set_id = ?`, [setId])
const name = par2ReleaseName(rows.map((r: { name: string }) => r.name))
// A par2 made after the renaming names the random names again.
return name && !looksRandom(name) ? name.slice(0, 255) : undefined
}
}
+27 -1
View File
@@ -159,8 +159,10 @@ export class Prober {
`INSERT INTO release_inspections (release_id, candidate_id, kind, result) `INSERT INTO release_inspections (release_id, candidate_id, kind, result)
SELECT v.release_id, v.candidate_id, v.kind, v.result FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, candidate_id bigint, kind text, result jsonb) SELECT v.release_id, v.candidate_id, v.kind, v.result FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, candidate_id bigint, kind text, result jsonb)
ON CONFLICT (release_id, candidate_id) DO UPDATE SET result = excluded.result, created_at = now()`, ON CONFLICT (release_id, candidate_id) DO UPDATE SET result = excluded.result, created_at = now()`,
[JSON.stringify(done.map((o) => ({ release_id: o.candidate.releaseId, candidate_id: o.candidate.id, kind: o.candidate.kind, result: o.inspection })))] // A par2 set's file list goes to par2_files, not into every inspection.
[JSON.stringify(done.map((o) => ({ release_id: o.candidate.releaseId, candidate_id: o.candidate.id, kind: o.candidate.kind, result: { ...o.inspection, par2: undefined } })))]
) )
await this.storeForMerge(trx, done)
// Each touched release from all its inspections (earlier rounds may have stored others). // Each touched release from all its inspections (earlier rounds may have stored others).
const ids = [...new Set(done.map((o) => o.candidate.releaseId))] const ids = [...new Set(done.map((o) => o.candidate.releaseId))]
const { rows } = await trx.rawQuery( const { rows } = await trx.rawQuery(
@@ -187,6 +189,30 @@ export class Prober {
}) })
} }
/** What the par2 merge matches (app/services/par2_merger.ts): files' first-16-KiB hashes, par2 sets' file lists. */
private async storeForMerge(trx: any, done: Outcome[]) {
const hashed = done.filter((o) => o.inspection!.hash16k)
if (hashed.length > 0) {
await trx.rawQuery(
`INSERT INTO file_hashes (candidate_id, release_id, hash16k, size)
SELECT v.candidate_id, v.release_id, v.hash16k, v.size FROM jsonb_to_recordset(?::jsonb) AS v(candidate_id bigint, release_id bigint, hash16k text, size bigint)
ON CONFLICT (candidate_id) DO NOTHING`,
[JSON.stringify(hashed.map((o) => ({ candidate_id: o.candidate.id, release_id: o.candidate.releaseId, hash16k: o.inspection!.hash16k, size: o.inspection!.size })))]
)
}
for (const o of done) {
const set = o.inspection!.par2
if (!set) continue
await trx.rawQuery(
`INSERT INTO par2_files (set_id, file_id, hash16k, length, name)
SELECT ?, v.file_id, v.hash16k, v.length, v.name FROM jsonb_to_recordset(?::jsonb) AS v(file_id text, hash16k text, length bigint, name text)
ON CONFLICT DO NOTHING`,
[set.setId, JSON.stringify(set.files.map((f) => ({ file_id: f.fileId, hash16k: f.hash16k, length: f.length, name: f.name })))]
)
await trx.rawQuery(`INSERT INTO par2_sets (set_id, release_id, files) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`, [set.setId, o.candidate.releaseId, set.files.length])
}
}
private report() { private report() {
const seconds = (Date.now() - this.stats.since) / 1000 const seconds = (Date.now() - this.stats.since) / 1000
if (seconds < REPORT_MS / 1000) return if (seconds < REPORT_MS / 1000) return
+33
View File
@@ -0,0 +1,33 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { Par2Merger } from '#services/par2_merger'
/** The par2 merge (REBUILD.md, phase 2): releases whose files one par2 set describes become one. */
export default class ReleasesMerge extends BaseCommand {
static commandName = 'releases:merge'
static description = 'Merges the releases whose files one par2 set describes (what the prober read), every minute'
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.boolean({ description: 'One round over every set, then exit' })
declare once: boolean
async run() {
const merger = new Par2Merger()
if (this.once) {
const round = await merger.round()
this.logger.info(`${round.sets} sets checked: ${round.releases} releases merged into ${round.merges}.`)
await this.terminate()
return
}
let done: () => void
const finished = new Promise<void>((resolve) => (done = resolve))
this.app.terminating(async () => {
merger.stop()
await finished
})
await merger.run()
this.logger.info('Merger stopped.')
done!()
await this.terminate()
}
}
@@ -0,0 +1,60 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The par2 merge (REBUILD.md, phase 2; app/services/par2_merger.ts): what the
* prober read that ties files to a par2 set, and the merges made from it.
* - file_hashes: per probed article that starts a file, the MD5 of its first 16 KiB and the file's size.
* - par2_files: per par2 recovery set, every file it describes (FileDesc: the same 16 KiB MD5, length, name).
* - par2_sets: which releases hold a par2 file of a set.
* - release_merges: releases merged into another, by set.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE file_hashes (
candidate_id bigint PRIMARY KEY,
release_id bigint NOT NULL,
hash16k char(32) NOT NULL,
size bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`)
this.schema.raw(`CREATE INDEX file_hashes_hash_idx ON file_hashes (hash16k)`)
this.schema.raw(`CREATE INDEX file_hashes_release_idx ON file_hashes (release_id)`)
this.schema.raw(`CREATE INDEX file_hashes_created_idx ON file_hashes (created_at)`)
this.schema.raw(`
CREATE TABLE par2_files (
set_id char(32) NOT NULL,
file_id char(32) NOT NULL,
hash16k char(32) NOT NULL,
length bigint NOT NULL,
name text NOT NULL,
PRIMARY KEY (set_id, file_id)
)`)
this.schema.raw(`CREATE INDEX par2_files_hash_idx ON par2_files (hash16k)`)
this.schema.raw(`
CREATE TABLE par2_sets (
set_id char(32) NOT NULL,
release_id bigint NOT NULL,
files integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (set_id, release_id)
)`)
this.schema.raw(`CREATE INDEX par2_sets_release_idx ON par2_sets (release_id)`)
this.schema.raw(`CREATE INDEX par2_sets_created_idx ON par2_sets (created_at)`)
this.schema.raw(`
CREATE TABLE release_merges (
id bigserial PRIMARY KEY,
from_id bigint NOT NULL,
from_key varchar(48) NOT NULL,
into_id bigint NOT NULL,
set_id char(32) NOT NULL,
files integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`)
this.schema.raw(`CREATE INDEX release_merges_into_idx ON release_merges (into_id)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS file_hashes, par2_files, par2_sets, release_merges`)
}
}
+100 -3
View File
@@ -32,6 +32,21 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
export class FileHashSchema extends BaseModel {
static $columns = ['candidateId', 'createdAt', 'hash16K', 'releaseId', 'size'] as const
$columns = FileHashSchema.$columns
@column({ isPrimary: true })
declare candidateId: bigint | number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare hash16K: string
@column()
declare releaseId: bigint | number
@column()
declare size: bigint | number
}
export class FragmentSchema extends BaseModel { export class FragmentSchema extends BaseModel {
static $columns = ['articleNumber', 'fileKey', 'fragment', 'id', 'rangeId', 'segments'] as const static $columns = ['articleNumber', 'fileKey', 'fragment', 'id', 'rangeId', 'segments'] as const
$columns = FragmentSchema.$columns $columns = FragmentSchema.$columns
@@ -50,7 +65,7 @@ export class FragmentSchema extends BaseModel {
} }
export class GroupSchema extends BaseModel { export class GroupSchema extends BaseModel {
static $columns = ['active', 'createdAt', 'high', 'id', 'low', 'name', 'plannedAt', 'priority'] as const static $columns = ['active', 'createdAt', 'high', 'id', 'low', 'name', 'plannedAt', 'priority', 'tail'] as const
$columns = GroupSchema.$columns $columns = GroupSchema.$columns
@column() @column()
declare active: boolean declare active: boolean
@@ -68,6 +83,8 @@ export class GroupSchema extends BaseModel {
declare plannedAt: DateTime | null declare plannedAt: DateTime | null
@column() @column()
declare priority: number declare priority: number
@column()
declare tail: boolean
} }
export class NodeAccessTokenSchema extends BaseModel { export class NodeAccessTokenSchema extends BaseModel {
@@ -108,6 +125,34 @@ export class NodeSchema extends BaseModel {
declare name: string declare name: string
} }
export class Par2FileSchema extends BaseModel {
static $columns = ['fileId', 'hash16K', 'length', 'name', 'setId'] as const
$columns = Par2FileSchema.$columns
@column()
declare fileId: string
@column()
declare hash16K: string
@column()
declare length: bigint | number
@column()
declare name: string
@column({ isPrimary: true })
declare setId: string
}
export class Par2SetSchema extends BaseModel {
static $columns = ['createdAt', 'files', 'releaseId', 'setId'] as const
$columns = Par2SetSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare files: number
@column()
declare releaseId: bigint | number
@column({ isPrimary: true })
declare setId: string
}
export class ProbeCandidateSchema extends BaseModel { export class ProbeCandidateSchema extends BaseModel {
static $columns = ['createdAt', 'file', 'id', 'kind', 'messageId', 'releaseId', 'status'] as const static $columns = ['createdAt', 'file', 'id', 'kind', 'messageId', 'releaseId', 'status'] as const
$columns = ProbeCandidateSchema.$columns $columns = ProbeCandidateSchema.$columns
@@ -128,10 +173,12 @@ export class ProbeCandidateSchema extends BaseModel {
} }
export class RangeSchema extends BaseModel { export class RangeSchema extends BaseModel {
static $columns = ['attempts', 'doneAt', 'endId', 'error', 'groupId', 'headers', 'heartbeatAt', 'id', 'leasedAt', 'nextId', 'nodeId', 'priority', 'ruleVersion', 'spooledAt', 'spooledChunks', 'startId', 'status'] as const static $columns = ['attempts', 'capture', 'doneAt', 'endId', 'error', 'groupId', 'headers', 'heartbeatAt', 'id', 'leasedAt', 'nextId', 'nodeId', 'plainChunks', 'priority', 'ruleVersion', 'spooledAt', 'spooledChunks', 'startId', 'status'] as const
$columns = RangeSchema.$columns $columns = RangeSchema.$columns
@column() @column()
declare attempts: number declare attempts: number
@column()
declare capture: boolean
@column.dateTime() @column.dateTime()
declare doneAt: DateTime | null declare doneAt: DateTime | null
@column() @column()
@@ -153,6 +200,8 @@ export class RangeSchema extends BaseModel {
@column() @column()
declare nodeId: number | null declare nodeId: number | null
@column() @column()
declare plainChunks: any
@column()
declare priority: number declare priority: number
@column() @column()
declare ruleVersion: number | null declare ruleVersion: number | null
@@ -187,12 +236,48 @@ export class ReleaseChunkSchema extends BaseModel {
declare segments: number declare segments: number
} }
export class ReleaseInspectionSchema extends BaseModel {
static $columns = ['candidateId', 'createdAt', 'kind', 'releaseId', 'result'] as const
$columns = ReleaseInspectionSchema.$columns
@column()
declare candidateId: bigint | number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare kind: string
@column({ isPrimary: true })
declare releaseId: bigint | number
@column()
declare result: any
}
export class ReleaseMergeSchema extends BaseModel {
static $columns = ['createdAt', 'files', 'fromId', 'fromKey', 'id', 'intoId', 'setId'] as const
$columns = ReleaseMergeSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare files: number
@column()
declare fromId: bigint | number
@column()
declare fromKey: string
@column({ isPrimary: true })
declare id: bigint | number
@column()
declare intoId: bigint | number
@column()
declare setId: string
}
export class ReleaseSchema extends BaseModel { export class ReleaseSchema extends BaseModel {
static $columns = ['bytes', 'chunks', 'complete', 'fileTotal', 'files', 'firstArticle', 'firstSeenAt', 'groups', 'hasPar2', 'id', 'lastArticle', 'lastSeenAt', 'name', 'postedAt', 'poster', 'readable', 'releaseKey', 'segments', 'settledAt', 'source'] as const static $columns = ['bytes', 'category', 'chunks', 'complete', 'fileTotal', 'files', 'firstArticle', 'firstSeenAt', 'groupId', 'groups', 'hasPar2', 'id', 'identityKey', 'innerName', 'inspectedAt', 'lastArticle', 'lastPostedAt', 'lastSeenAt', 'name', 'passworded', 'postedAt', 'poster', 'readable', 'releaseKey', 'segments', 'settledAt', 'source'] as const
$columns = ReleaseSchema.$columns $columns = ReleaseSchema.$columns
@column() @column()
declare bytes: bigint | number declare bytes: bigint | number
@column() @column()
declare category: number | null
@column()
declare chunks: number declare chunks: number
@column() @column()
declare complete: boolean | null declare complete: boolean | null
@@ -205,17 +290,29 @@ export class ReleaseSchema extends BaseModel {
@column.dateTime() @column.dateTime()
declare firstSeenAt: DateTime declare firstSeenAt: DateTime
@column() @column()
declare groupId: number | null
@column()
declare groups: any declare groups: any
@column() @column()
declare hasPar2: boolean declare hasPar2: boolean
@column({ isPrimary: true }) @column({ isPrimary: true })
declare id: bigint | number declare id: bigint | number
@column() @column()
declare identityKey: string
@column()
declare innerName: string | null
@column.dateTime()
declare inspectedAt: DateTime | null
@column()
declare lastArticle: bigint | number | null declare lastArticle: bigint | number | null
@column.dateTime() @column.dateTime()
declare lastPostedAt: DateTime | null
@column.dateTime()
declare lastSeenAt: DateTime declare lastSeenAt: DateTime
@column() @column()
declare name: string declare name: string
@column()
declare passworded: boolean | null
@column.dateTime() @column.dateTime()
declare postedAt: DateTime | null declare postedAt: DateTime | null
@column() @column()
+1
View File
@@ -19,6 +19,7 @@ homelab with the deploy key, whose forced command
| `node-api` | `usenet-rebuild` | the edge workers' API (leases, streams, batches); port 3335 on the compose network only (`HTTP_ROUTES=node`) | | `node-api` | `usenet-rebuild` | the edge workers' API (leases, streams, batches); port 3335 on the compose network only (`HTTP_ROUTES=node`) |
| `coordinator` | `usenet-rebuild` | prefetching (all NNTP connections), planning, the tail, merging, settling | | `coordinator` | `usenet-rebuild` | prefetching (all NNTP connections), planning, the tail, merging, settling |
| `prober` | `usenet-rebuild` | phase 2: reads the probe queue's articles over `PROBE_CONNECTIONS` (6) connections, stores what they show | | `prober` | `usenet-rebuild` | phase 2: reads the probe queue's articles over `PROBE_CONNECTIONS` (6) connections, stores what they show |
| `merger` | `usenet-rebuild` | the par2 merge: releases whose files one par2 set describes become one (no NNTP) |
| `edge` | `usenet-rebuild-edge` (target `edge`) | the Rust worker, `--threads $EDGE_THREADS` (8) | | `edge` | `usenet-rebuild-edge` (target `edge`) | the Rust worker, `--threads $EDGE_THREADS` (8) |
| `edge-ts` | `usenet-rebuild` | the TypeScript worker, profile `edge-ts`, not started | | `edge-ts` | `usenet-rebuild` | the TypeScript worker, profile `edge-ts`, not started |
| `ace` | `usenet-rebuild` | one-off ace commands, profile `tools` | | `ace` | `usenet-rebuild` | one-off ace commands, profile `tools` |
+6
View File
@@ -108,6 +108,12 @@ services:
<<: *app-env <<: *app-env
PROBE_CONNECTIONS: ${PROBE_CONNECTIONS:-6} PROBE_CONNECTIONS: ${PROBE_CONNECTIONS:-6}
# The par2 merge: releases whose files one par2 set describes (what the prober read) become one. No NNTP.
merger:
<<: *app
command: ['node', 'ace', 'releases:merge']
volumes: []
# The Rust worker; it has no NNTP account and talks to node-api only. # The Rust worker; it has no NNTP account and talks to node-api only.
# Stopped before node-api (a worker holding a stream keeps the server from # Stopped before node-api (a worker holding a stream keeps the server from
# exiting). # exiting).
+2 -1
View File
@@ -42,7 +42,8 @@ def psql(sql):
['docker', 'compose', 'exec', '-T', 'postgres', 'psql', '-U', env('DB_USER'), '-d', env('DB_DATABASE'), '-At', '-F', '\t', '-v', 'ON_ERROR_STOP=1'], ['docker', 'compose', 'exec', '-T', 'postgres', 'psql', '-U', env('DB_USER'), '-d', env('DB_DATABASE'), '-At', '-F', '\t', '-v', 'ON_ERROR_STOP=1'],
input=sql, capture_output=True, text=True, check=True, input=sql, capture_output=True, text=True, check=True,
).stdout ).stdout
return [line.split('\t') for line in out.splitlines() if line] # Without the command tag a RETURNING prints after its rows ("UPDATE 100").
return [line.split('\t') for line in out.splitlines() if line and not line.startswith(('UPDATE ', 'INSERT ', 'DELETE '))]
def corpus_bytes(): def corpus_bytes():
+2 -2
View File
@@ -45,10 +45,10 @@ docker compose run --rm --no-deps migrate
# stream keeps the node API from exiting. # stream keeps the node API from exiting.
echo "== recreating app services" echo "== recreating app services"
docker compose stop edge docker compose stop edge
docker compose up -d --no-deps node-api web coordinator prober docker compose up -d --no-deps node-api web coordinator prober merger
docker compose up -d --no-deps edge docker compose up -d --no-deps edge
echo "$SHA $(date -Is)" >> "$ROOT/deployed.log" echo "$SHA $(date -Is)" >> "$ROOT/deployed.log"
# The images this replaced (321 MB each; the root filesystem is the NVMe, with a 10 GB floor). # The images this replaced (321 MB each; the root filesystem is the NVMe, with a 10 GB floor).
docker image prune -f --filter label=com.docker.compose.project=usenet-rebuild >/dev/null docker image prune -f --filter label=com.docker.compose.project=usenet-rebuild >/dev/null
docker compose ps --format '{{.Service}} {{.Status}}' node-api web coordinator prober edge docker compose ps --format '{{.Service}} {{.Status}}' node-api web coordinator prober merger edge
echo "== deployed $SHA" echo "== deployed $SHA"
+18
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import { decodeSegment, YencCrcError, YencError, type DecodedSegment } from '#lib/yenc/index' import { decodeSegment, YencCrcError, YencError, type DecodedSegment } from '#lib/yenc/index'
import { isPar2, parsePar2 } from '#lib/par2/index' import { isPar2, parsePar2 } from '#lib/par2/index'
import { parseRarHeaders } from '#lib/rar/index' import { parseRarHeaders } from '#lib/rar/index'
@@ -16,8 +17,17 @@ export interface Inspection {
entries?: number entries?: number
/** An archive with encrypted entries or headers. */ /** An archive with encrypted entries or headers. */
passworded?: boolean passworded?: boolean
/**
* MD5 of the file's first 16 KiB (hex), when this article starts the file: what a par2 set's FileDesc
* holds per file, so a renamed file is recognised by it (the par2 merge; app/services/par2_merger.ts).
*/
hash16k?: string
/** A par2 body's recovery set: every file it describes (not kept in release_inspections; par2_files). */
par2?: { setId: string; files: { fileId: string; hash16k: string; length: number; name: string }[] }
} }
const HASH_BYTES = 16 * 1024
/** Names kept per inspection: the catalog wants the content's name, not every volume's. */ /** Names kept per inspection: the catalog wants the content's name, not every volume's. */
export const MAX_NAMES = 8 export const MAX_NAMES = 8
const MAX_NAME_LENGTH = 200 const MAX_NAME_LENGTH = 200
@@ -46,12 +56,20 @@ function largestFirst(items: { name: string; size: number }[]): string[] {
export function inspect(body: Buffer): Inspection { export function inspect(body: Buffer): Inspection {
const segment = decodeBody(body) const segment = decodeBody(body)
const result: Inspection = { yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH), size: segment.meta.begin.size, type: sniffContentType(segment.data) } const result: Inspection = { yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH), size: segment.meta.begin.size, type: sniffContentType(segment.data) }
// The file's first 16 KiB, or all of a smaller file.
if (segment.offset === 0 && (segment.data.length >= HASH_BYTES || segment.data.length === result.size)) {
result.hash16k = createHash('md5').update(segment.data.subarray(0, HASH_BYTES)).digest('hex')
}
if (isPar2(segment.data)) { if (isPar2(segment.data)) {
// An index file fits one article; what a larger one cut off is skipped by the parser. // An index file fits one article; what a larger one cut off is skipped by the parser.
const set = parsePar2(segment.data) const set = parsePar2(segment.data)
if (set) { if (set) {
result.entries = set.files.length result.entries = set.files.length
result.names = largestFirst(set.files.map((f) => ({ name: f.name, size: f.length }))) result.names = largestFirst(set.files.map((f) => ({ name: f.name, size: f.length })))
result.par2 = {
setId: set.recoverySetId,
files: set.files.map((f) => ({ fileId: f.fileId, hash16k: f.md5_16k, length: f.length, name: f.name.slice(0, MAX_NAME_LENGTH) })),
}
} }
return result return result
} }
+65
View File
@@ -0,0 +1,65 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { Par2Merger } from '#services/par2_merger'
const hash = (c: string) => c.repeat(32)
test.group('Par2Merger', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('merges the one-file releases of an obfuscated upload by its par2 set, named by the par2', async ({ assert }) => {
const at = new Date('2022-07-08T03:46:00Z')
const release = async (key: string, posted: Date, groupId: number, files = 1) => {
const [row] = await db
.table('releases')
.insert({
release_key: key.padEnd(40, '0'), identity_key: key.padEnd(40, '0'), name: key, source: 'stem', groups: '[]', group_id: groupId,
files, segments: files * 10, bytes: files * 1000, first_article: 100, last_article: 200, posted_at: posted, readable: false, file_total: 20,
settled_at: posted,
})
.returning('id')
return Number(row.id)
}
// The par2 index, two data files of the upload, the same file reposted a week later, and one in another group.
const par2 = await release('14f5999a', at, 1)
const a = await release('2b7c00e1', new Date(at.getTime() + 60_000), 1)
const b = await release('9d01aa3f', new Date(at.getTime() + 120_000), 1, 2)
const later = await release('5e5e5e5e', new Date(at.getTime() + 7 * 86400_000), 1)
const elsewhere = await release('6f6f6f6f', at, 2)
let candidate = 1000
const hashed = async (releaseId: number, h: string, size: number) => {
candidate++
await db.table('probe_candidates').insert({ id: candidate, release_id: releaseId, kind: 'lone', message_id: `<${candidate}@x>`, file: 'x', status: 'done' })
await db.table('file_hashes').insert({ candidate_id: candidate, release_id: releaseId, hash16k: h, size })
}
await hashed(a, hash('a'), 104857600)
await hashed(b, hash('b'), 104857600)
await hashed(later, hash('a'), 104857600)
await hashed(elsewhere, hash('b'), 104857600)
// A different size: not the same file.
await hashed(await release('7a7a7a7a', at, 1), hash('a'), 5)
const setId = 'c'.repeat(32)
await db.table('par2_files').multiInsert([
{ set_id: setId, file_id: 'd'.repeat(32), hash16k: hash('a'), length: 104857600, name: 'Some.Movie.2022.1080p.part01.rar' },
{ set_id: setId, file_id: 'e'.repeat(32), hash16k: hash('b'), length: 104857600, name: 'Some.Movie.2022.1080p.part02.rar' },
])
await db.table('par2_sets').insert({ set_id: setId, release_id: par2, files: 2 })
const round = await new Par2Merger().round()
assert.deepEqual(round, { sets: 1, merges: 1, releases: 3 })
const target = await db.from('releases').where('id', b).firstOrFail()
assert.equal(target.files, 4)
assert.equal(target.segments, 40)
assert.equal(target.name, 'Some.Movie.2022.1080p')
assert.isTrue(target.complete === false)
assert.deepEqual((await db.from('releases').whereIn('id', [par2, a]).select('id')).length, 0)
assert.equal((await db.from('releases').whereIn('id', [later, elsewhere]).select('id')).length, 2)
assert.deepEqual((await db.from('probe_candidates').where('release_id', b).orderBy('id').select('id')).map((r) => Number(r.id)), [1001, 1002])
assert.equal((await db.from('par2_sets').where('set_id', setId).firstOrFail()).release_id, String(b))
assert.equal((await db.from('release_merges').where('into_id', b).count('* as n'))[0].n, '2')
// Again: nothing new.
assert.deepEqual(await new Par2Merger().round().then((r) => r.merges), 0)
})
})
+23 -2
View File
@@ -80,7 +80,28 @@ test.group('probe / inspect', () => {
const setId = Buffer.alloc(16, 3) const setId = Buffer.alloc(16, 3)
const par2 = Buffer.concat([fileDesc(setId, 'Movie.2020.part02.rar', 100), fileDesc(setId, 'Movie.2020.part01.rar', 200)]) const par2 = Buffer.concat([fileDesc(setId, 'Movie.2020.part02.rar', 100), fileDesc(setId, 'Movie.2020.part01.rar', 200)])
const result = inspect(article(par2, 'a8f3k2.par2')) const result = inspect(article(par2, 'a8f3k2.par2'))
assert.deepEqual(result, { yencName: 'a8f3k2.par2', size: par2.length, type: 'par2', entries: 2, names: ['Movie.2020.part01.rar', 'Movie.2020.part02.rar'] }) const { par2: set, hash16k, ...rest } = result
assert.deepEqual(rest, { yencName: 'a8f3k2.par2', size: par2.length, type: 'par2', entries: 2, names: ['Movie.2020.part01.rar', 'Movie.2020.part02.rar'] })
// The whole set for the par2 merge: per file the hash of its first 16 KiB, its length and name.
assert.equal(set!.setId, setId.toString('hex'))
assert.deepEqual(
set!.files.map((f) => [f.name, f.length, f.hash16k]),
[
['Movie.2020.part02.rar', 100, md5(Buffer.from('Movie.2020.part02.rar-16k')).toString('hex')],
['Movie.2020.part01.rar', 200, md5(Buffer.from('Movie.2020.part01.rar-16k')).toString('hex')],
]
)
assert.equal(hash16k, md5(par2).toString('hex'))
})
test("the hash of a file's first 16 KiB, when the article starts the file", ({ assert }) => {
const data = Buffer.alloc(40_000, 7)
assert.equal(inspect(article(data, 'x')).hash16k, md5(data.subarray(0, 16384)).toString('hex'))
const part = Buffer.from(
[`=ybegin part=2 total=3 line=128 size=120000 name=x`, `=ypart begin=40001 end=80000`, yencode.encode(data).toString('latin1'), `=yend size=40000 part=2 pcrc32=${crc32Hex(data)}`].join('\r\n'),
'latin1'
)
assert.isUndefined(inspect(part).hash16k)
}) })
test('a RAR volume: its entries, and whether they are encrypted', ({ assert }) => { test('a RAR volume: its entries, and whether they are encrypted', ({ assert }) => {
@@ -96,7 +117,7 @@ test.group('probe / inspect', () => {
test('other data: the yEnc name and what the bytes are', ({ assert }) => { test('other data: the yEnc name and what the bytes are', ({ assert }) => {
const mkv = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)]) const mkv = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)])
assert.deepEqual(inspect(article(mkv, 'Real.Name.mkv')), { yencName: 'Real.Name.mkv', size: 104, type: 'matroska' }) assert.deepEqual(inspect(article(mkv, 'Real.Name.mkv')), { yencName: 'Real.Name.mkv', size: 104, type: 'matroska', hash16k: md5(mkv).toString('hex') })
}) })
test('not yEnc: throws', ({ assert }) => { test('not yEnc: throws', ({ assert }) => {