Judge the releases whose facts changed every 2 minutes, all daily
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 1m5s

A full round took 35-38 min of each hour and grew with every inspection
the prober added. Triggers note changed releases in judge_dirty; the
judge judges those (and releases a file hash of theirs tipped to
repeated) by every rule, and every release in a daily full round for
what time changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-19 20:10:49 +02:00
1 parent 036e2a8397
commit 76e52f69ce
6 files changed
+365 -62

No files matched your search

+5 -3
View File
@@ -260,9 +260,11 @@ Live indexing (the tail) runs since 2026-10-09.
**Junk** (voyager 1's second filter, D42). After the header pass's rules **Junk** (voyager 1's second filter, D42). After the header pass's rules
(garbage signatures, executables, per-article and obfuscated subjects), the (garbage signatures, executables, per-article and obfuscated subjects), the
judge (`releases:judge`, service `judge`) judges releases hourly by what the judge (`releases:judge`, service `judge`) judges releases by what the
prober, the merges and the spots found, in this order (the first rule prober, the merges and the spots found: every 2 minutes the releases whose
wins): *dead* (every probed first article gone), *fake* (a film, episode facts changed (noted by triggers in `judge_dirty`), daily every release (for
what time changes: a day after settling, ranges read beside it). In this
order (the first rule wins): *dead* (every probed first article gone), *fake* (a film, episode
or music name whose content is a program: archive entries all or music name whose content is a program: archive entries all
executables, or the file itself one by name or by its first bytes, sniffed executables, or the file itself one by name or by its first bytes, sniffed
`exe`), *repeated* (the same first 16 KiB and size in 20+ releases), `exe`), *repeated* (the same first 16 KiB and size in 20+ releases),
+163 -52
View File
@@ -1,10 +1,18 @@
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
/** Raised when a rule changes: verdicts of an older version are judged again. */ /** Raised when a rule changes: verdicts of an older version are judged again (a full round, right away). */
export const JUDGE_VERSION = 1 export const JUDGE_VERSION = 1
/** Between rounds: each rule is a scan of its table. */ /** Between rounds of the changed releases (judge_dirty, noted by triggers; migration 4900). */
const ROUND_MS = 60 * 60 * 1000 const ROUND_MS = 2 * 60 * 1000
/**
* A full round (every release, each rule a scan of its table: 35–38 min) this often: for what no trigger notes. Time
* (unusable a day after settling, a release final once the ranges beside it are read) and fewer releases with a file
* after a merge (repeated).
*/
const FULL_MS = 24 * 60 * 60 * 1000
/** Changed releases judged at a time. */
const CHUNK = 50_000
/** A file's first 16 KiB and size in this many releases is one file posted again and again (test files, spam). */ /** A file's first 16 KiB and size in this many releases is one file posted again and again (test files, spam). */
const REPEATED_RELEASES = 20 const REPEATED_RELEASES = 20
@@ -24,10 +32,18 @@ export interface Rule {
name: string name: string
/** Why, in a few words (the log, the web interface). */ /** Why, in a few words (the log, the web interface). */
says: string says: string
/** SELECT of the release ids the rule judges. */ /** SELECT of the release ids the rule judges; scoped, only those in judge_scope. */
sql: string sql: (scoped: boolean) => string
} }
/** A join to the releases in scope (an incremental round), on the given release id column. */
const only = (scoped: boolean, column: string) => (scoped ? `JOIN judge_scope s ON s.release_id = ${column}` : '')
/** The file hashes (first 16 KiB and size) of the releases in scope that are in REPEATED_RELEASES+ releases. */
const TIPPED = `hashes AS (SELECT DISTINCT h.hash16k, h.size FROM file_hashes h JOIN judge_scope s ON s.release_id = h.release_id),
tipped AS (SELECT x.hash16k, x.size FROM hashes x
WHERE (SELECT count(DISTINCT f.release_id) FROM file_hashes f WHERE f.hash16k = x.hash16k AND f.size = x.size) >= ${REPEATED_RELEASES})`
/** /**
* The junk judge's rules, in order: a release takes the first that matches (REBUILD.md, junk). Each runs on what * The junk judge's rules, in order: a release takes the first that matches (REBUILD.md, junk). Each runs on what
* the header pass, the prober, the merges and the spots know by now. * the header pass, the prober, the merges and the spots know by now.
@@ -36,26 +52,33 @@ export const RULES: Rule[] = [
{ {
name: 'dead', name: 'dead',
says: 'every probed first article is gone from the server (taken down or expired)', says: 'every probed first article is gone from the server (taken down or expired)',
sql: `SELECT release_id FROM probe_candidates WHERE status IN ('done', 'missing', 'error') GROUP BY 1 HAVING bool_and(status = 'missing')`, sql: (scoped) =>
`SELECT c.release_id FROM probe_candidates c ${only(scoped, 'c.release_id')}
WHERE c.status IN ('done', 'missing', 'error') GROUP BY 1 HAVING bool_and(c.status = 'missing')`,
}, },
{ {
name: 'fake', name: 'fake',
says: 'named as a film, episode or music, but the content is a program', says: 'named as a film, episode or music, but the content is a program',
// An archive whose entries are all executables, under a media name (its own or its entries'); or a file that is // An archive whose entries are all executables, under a media name (its own or its entries'); or a file that is
// an executable by name or by its first bytes, under a media name. // an executable by name or by its first bytes, under a media name.
sql: `SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id sql: (scoped) => `SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id ${only(scoped, 'i.release_id')}
WHERE i.kind = 'archive' AND jsonb_array_length(i.result->'names') > 0 WHERE i.kind = 'archive' AND jsonb_array_length(i.result->'names') > 0
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(i.result->'names') n WHERE n !~* '${EXECUTABLE}') AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(i.result->'names') n WHERE n !~* '${EXECUTABLE}')
AND (r.name ~* '${MEDIA}' OR (i.result->>'names') ~* '${MEDIA}') AND (r.name ~* '${MEDIA}' OR (i.result->>'names') ~* '${MEDIA}')
UNION UNION
SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id ${only(scoped, 'i.release_id')}
WHERE i.kind = 'lone' AND ((i.result->>'yencName') ~* '${EXECUTABLE}' OR i.result->>'type' = 'exe') WHERE i.kind = 'lone' AND ((i.result->>'yencName') ~* '${EXECUTABLE}' OR i.result->>'type' = 'exe')
AND (r.name ~* '${MEDIA}' OR (i.result->>'yencName') ~* '${MEDIA}')`, AND (r.name ~* '${MEDIA}' OR (i.result->>'yencName') ~* '${MEDIA}')`,
}, },
{ {
name: 'repeated', name: 'repeated',
says: `the same file content in ${REPEATED_RELEASES}+ releases (test files, spam)`, says: `the same file content in ${REPEATED_RELEASES}+ releases (test files, spam)`,
sql: `SELECT DISTINCT h.release_id FROM file_hashes h // Scoped: the hashes of the releases in scope, counted each (a round first adds the other releases a hash of theirs
// tipped over: ReleaseJudge.changed).
sql: (scoped) =>
scoped
? `WITH ${TIPPED} SELECT DISTINCT h.release_id FROM file_hashes h JOIN judge_scope s ON s.release_id = h.release_id JOIN tipped USING (hash16k, size)`
: `SELECT DISTINCT h.release_id FROM file_hashes h
JOIN (SELECT hash16k, size FROM file_hashes GROUP BY 1, 2 HAVING count(DISTINCT release_id) >= ${REPEATED_RELEASES}) x USING (hash16k, size)`, JOIN (SELECT hash16k, size FROM file_hashes GROUP BY 1, 2 HAVING count(DISTINCT release_id) >= ${REPEATED_RELEASES}) x USING (hash16k, size)`,
}, },
{ {
@@ -66,20 +89,24 @@ export const RULES: Rule[] = [
// titles (LOG.md 2026-10-16 (64)). Not Celeborn, CelebTA or other posters with "celeb" in their name. // titles (LOG.md 2026-10-16 (64)). Not Celeborn, CelebTA or other posters with "celeb" in their name.
// By the posters table's trigram index, each poster's releases then by releases_poster_newest_idx: 7 s, where the // By the posters table's trigram index, each poster's releases then by releases_poster_newest_idx: 7 s, where the
// regex on all of releases was 45 s, every round (LOG.md 2026-10-19 (80)). // regex on all of releases was 45 s, every round (LOG.md 2026-10-19 (80)).
sql: `SELECT r.id FROM posters p JOIN releases r ON left(r.poster, 200) = p.poster sql: (scoped) =>
scoped
? `SELECT r.id FROM releases r ${only(scoped, 'r.id')}
WHERE r.poster ~* '${SCRAPED_POSTERS}' AND r.source = 'subject' AND r.file_total IS NULL`
: `SELECT r.id FROM posters p JOIN releases r ON left(r.poster, 200) = p.poster
WHERE p.poster ~* '${SCRAPED_POSTERS}' AND r.poster ~* '${SCRAPED_POSTERS}' AND r.source = 'subject' AND r.file_total IS NULL`, WHERE p.poster ~* '${SCRAPED_POSTERS}' AND r.poster ~* '${SCRAPED_POSTERS}' AND r.source = 'subject' AND r.file_total IS NULL`,
}, },
{ {
name: 'locked', name: 'locked',
says: 'passworded, and no spot, NZB or title gives the password', says: 'passworded, and no spot, NZB or title gives the password',
// releases_passworded_idx: a scan of all of releases was 30 s a round. // releases_passworded_idx: a scan of all of releases was 30 s a round.
sql: `SELECT r.id FROM releases r WHERE r.passworded AND NOT EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id)`, sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')} WHERE r.passworded AND NOT EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id)`,
}, },
{ {
name: 'unusable', name: 'unusable',
says: 'random names, no name inside, no par2 or inner-name merge, no spot, a day after settling, and final', says: 'random names, no name inside, no par2 or inner-name merge, no spot, a day after settling, and final',
// Final (release_final): next to an unread range its par2 or the rest of its files may still come (LOG.md 2026-10-16 (67)). // Final (release_final): next to an unread range its par2 or the rest of its files may still come (LOG.md 2026-10-16 (67)).
sql: `SELECT r.id FROM releases r sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')}
WHERE r.settled_at < now() - interval '1 day' AND NOT r.readable AND r.inner_name IS NULL WHERE r.settled_at < now() - interval '1 day' AND NOT r.readable AND r.inner_name IS NULL
AND release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete) AND release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete)
AND NOT EXISTS (SELECT 1 FROM release_merges m WHERE m.into_id = r.id) AND NOT EXISTS (SELECT 1 FROM release_merges m WHERE m.into_id = r.id)
@@ -89,15 +116,18 @@ export const RULES: Rule[] = [
] ]
export interface JudgeRound { export interface JudgeRound {
/** Per rule: releases it judges now, verdicts added, verdicts taken back. */ /** Releases judged: every one (a full round), or the changed ones and those a hash of theirs tipped over. */
releases?: number
/** Per rule: releases it judges now (in scope), verdicts it gained, verdicts it lost. */
rules: Record<string, { matched: number; added: number; removed: number }> rules: Record<string, { matched: number; added: number; removed: number }>
} }
/** /**
* The junk judge (REBUILD.md, junk; voyager 1's second filter, D42): after the header pass's rules (garbage * The junk judge (REBUILD.md, junk; voyager 1's second filter, D42): after the header pass's rules (garbage
* signatures, executables, …) a release is judged again once it's probed, by what its articles showed. Each * signatures, executables, …) a release is judged again once it's probed, by what its articles showed. Every few
* round recomputes every rule: a release a rule matches gets a verdict (the first rule in order, over a later one's), one it no longer * minutes the releases whose facts changed (judge_dirty) are judged by every rule; a full round judges every release
* matches loses its verdict. Verdicts only hide (search leaves them out); nothing is dropped yet. * daily. A release gets the verdict of the first rule in order that matches it; one that no longer matches loses its
* verdict. Verdicts only hide (search leaves them out); nothing is dropped yet.
*/ */
export class ReleaseJudge { export class ReleaseJudge {
private stopped = false private stopped = false
@@ -110,9 +140,13 @@ export class ReleaseJudge {
while (!this.stopped) { while (!this.stopped) {
const started = Date.now() const started = Date.now()
try { try {
const round = await this.round() if (await this.fullDue()) {
const parts = Object.entries(round.rules).map(([n, r]) => `${n} ${r.matched} (+${r.added} −${r.removed})`) const round = await this.full()
logger.info(`Judged: ${parts.join(', ')}.`) if (!this.stopped) logger.info(`Judged all releases: ${describe(round)}.`)
} else {
const round = await this.changed()
if (round.releases! > 0) logger.info(`Judged ${round.releases!.toLocaleString('en')} changed releases: ${describe(round)}.`)
}
} catch (error: any) { } catch (error: any) {
logger.error(`Judge round failed: ${error.message}`) logger.error(`Judge round failed: ${error.message}`)
} }
@@ -120,47 +154,124 @@ export class ReleaseJudge {
} }
} }
/** One round over every rule; with dry, only counts. */ /** A full round is due: a day after the last, or the last was by another rule set. */
async round(dry = false): Promise<JudgeRound> { async fullDue(): Promise<boolean> {
const state = await db.from('judge_state').first()
return !state || state.version !== JUDGE_VERSION || Date.now() - new Date(state.full_at).getTime() >= FULL_MS
}
/**
* Every release by every rule, the matches gathered in judge_full (each rule its own statement: no transaction of
* half an hour), then applied at once. With dry, only counts. Stopped halfway, nothing is applied.
*/
async full(dry = false): Promise<JudgeRound> {
const started = new Date()
const round: JudgeRound = { rules: {} } const round: JudgeRound = { rules: {} }
// Verdicts of an older rule set are judged again from scratch. // Verdicts of an older rule set are judged again from scratch.
if (!dry) await db.from('release_verdicts').where('rule_version', '<>', JUDGE_VERSION).delete() if (!dry) await db.from('release_verdicts').where('rule_version', '<>', JUDGE_VERSION).delete()
const judged = new Set<string>() await db.rawQuery(`TRUNCATE judge_full`)
for (const rule of RULES) { for (const rule of RULES) {
if (this.stopped) break if (this.stopped) return round
const { rows } = await db.rawQuery(rule.sql) const { rowCount } = await db.rawQuery(
const ids = rows.map((r: Record<string, unknown>) => String(Object.values(r)[0])).filter((id: string) => !judged.has(id)) `INSERT INTO judge_full (release_id, rule) SELECT id, ? FROM (${rule.sql(false)}) AS x(id) ON CONFLICT DO NOTHING`,
for (const id of ids) judged.add(id)
const stats = { matched: ids.length, added: 0, removed: 0 }
if (!dry) {
await db.transaction(async (trx) => {
// Dropped first: inside an outer transaction (the tests) ON COMMIT DROP comes only at its end.
await trx.rawQuery(`DROP TABLE IF EXISTS judged`)
await trx.rawQuery(`CREATE TEMP TABLE judged (release_id bigint PRIMARY KEY) ON COMMIT DROP`)
for (let i = 0; i < ids.length; i += 50_000) {
await trx.rawQuery(`INSERT INTO judged SELECT unnest(?::bigint[]) ON CONFLICT DO NOTHING`, [ids.slice(i, i + 50_000)])
}
const removed = await trx.rawQuery(
`DELETE FROM release_verdicts v WHERE v.rule = ? AND NOT EXISTS (SELECT 1 FROM judged j WHERE j.release_id = v.release_id)`,
[rule.name] [rule.name]
) )
// Only the releases whose verdict changes: each row through the insert's conflict check took 60–150 s a rule round.rules[rule.name] = { matched: rowCount ?? 0, added: 0, removed: 0 }
// for a few thousand changes in a million matches (LOG.md 2026-10-19 (80)).
await trx.rawQuery(`ANALYZE judged`)
const added = await trx.rawQuery(
`INSERT INTO release_verdicts (release_id, rule, rule_version)
SELECT j.release_id, ?, ? FROM judged j LEFT JOIN release_verdicts v ON v.release_id = j.release_id
WHERE v.rule IS DISTINCT FROM ? AND EXISTS (SELECT 1 FROM releases r WHERE r.id = j.release_id)
ON CONFLICT (release_id) DO UPDATE SET rule = excluded.rule, rule_version = excluded.rule_version
WHERE release_verdicts.rule <> excluded.rule`,
[rule.name, JUDGE_VERSION, rule.name]
)
stats.removed = removed.rowCount ?? 0
stats.added = added.rowCount ?? 0
})
} }
round.rules[rule.name] = stats if (dry || this.stopped) return round
await db.rawQuery(`ANALYZE judge_full`)
await db.transaction((trx) => this.apply(trx, 'judge_full', false, round))
await db.rawQuery(
`INSERT INTO judge_state (id, full_at, version) VALUES (1, ?, ?) ON CONFLICT (id) DO UPDATE SET full_at = excluded.full_at, version = excluded.version`,
[started, JUDGE_VERSION]
)
return round
}
/**
* The releases noted in judge_dirty since, CHUNK at a time, each chunk in a transaction (a failed one stays noted).
* A changed release can bring a file to REPEATED_RELEASES releases: the others with it are judged too (all but those
* already repeated, or dead or fake, which come first).
*/
async changed(): Promise<JudgeRound> {
const round: JudgeRound = { releases: 0, rules: {} }
for (const rule of RULES) round.rules[rule.name] = { matched: 0, added: 0, removed: 0 }
let more = true
while (more && !this.stopped) {
await db.transaction(async (trx) => {
// Dropped first: inside an outer transaction (the tests) ON COMMIT DROP comes only at its end.
await trx.rawQuery(`DROP TABLE IF EXISTS judge_scope`)
await trx.rawQuery(`DROP TABLE IF EXISTS judged`)
await trx.rawQuery(`CREATE TEMP TABLE judge_scope (release_id bigint PRIMARY KEY) ON COMMIT DROP`)
const { rows } = await trx.rawQuery(
`WITH taken AS (DELETE FROM judge_dirty WHERE ctid = ANY(ARRAY(SELECT ctid FROM judge_dirty LIMIT ${CHUNK})) RETURNING release_id),
scoped AS (INSERT INTO judge_scope SELECT DISTINCT release_id FROM taken ON CONFLICT DO NOTHING RETURNING 1)
SELECT (SELECT count(*) FROM taken) AS taken`
)
const taken = Number(rows[0].taken)
more = taken === CHUNK
if (taken === 0) return
await trx.rawQuery(
`WITH ${TIPPED}
INSERT INTO judge_scope SELECT DISTINCT f.release_id FROM file_hashes f JOIN tipped USING (hash16k, size)
WHERE NOT EXISTS (SELECT 1 FROM release_verdicts v WHERE v.release_id = f.release_id AND v.rule IN ('dead', 'fake', 'repeated'))
ON CONFLICT DO NOTHING`
)
await trx.rawQuery(`ANALYZE judge_scope`)
const { rows: scope } = await trx.rawQuery(`SELECT count(*) AS n FROM judge_scope`)
round.releases! += Number(scope[0].n)
await trx.rawQuery(`CREATE TEMP TABLE judged (release_id bigint PRIMARY KEY, rule varchar(16) NOT NULL) ON COMMIT DROP`)
for (const rule of RULES) {
const { rowCount } = await trx.rawQuery(
`INSERT INTO judged (release_id, rule) SELECT id, ? FROM (${rule.sql(true)}) AS x(id) ON CONFLICT DO NOTHING`,
[rule.name]
)
round.rules[rule.name].matched += rowCount ?? 0
}
await trx.rawQuery(`ANALYZE judged`)
await this.apply(trx, 'judged', true, round)
})
} }
return round return round
} }
/**
* The matches (judged: release_id, rule; the first rule's each) into release_verdicts: verdicts no rule gives any
* more removed (scoped: of the releases in judge_scope only), changed ones written. Only the changes go through the
* insert: each match through its conflict check took 60–150 s a rule (LOG.md 2026-10-19 (80)).
*/
private async apply(trx: any, judged: string, scoped: boolean, round: JudgeRound) {
const tally = (rows: { rule: string; n: string }[], field: 'added' | 'removed') => {
for (const { rule, n } of rows) {
round.rules[rule] ??= { matched: 0, added: 0, removed: 0 }
round.rules[rule][field] += Number(n)
}
}
const { rows: removed } = await trx.rawQuery(
`WITH gone AS (DELETE FROM release_verdicts v
WHERE ${scoped ? 'v.release_id IN (SELECT release_id FROM judge_scope) AND' : ''}
NOT EXISTS (SELECT 1 FROM ${judged} j WHERE j.release_id = v.release_id)
RETURNING v.rule)
SELECT rule, count(*) AS n FROM gone GROUP BY 1`
)
tally(removed, 'removed')
const { rows: added } = await trx.rawQuery(
`WITH written AS (INSERT INTO release_verdicts (release_id, rule, rule_version)
SELECT j.release_id, j.rule, ? FROM ${judged} j LEFT JOIN release_verdicts v ON v.release_id = j.release_id
WHERE v.rule IS DISTINCT FROM j.rule AND EXISTS (SELECT 1 FROM releases r WHERE r.id = j.release_id)
ON CONFLICT (release_id) DO UPDATE SET rule = excluded.rule, rule_version = excluded.rule_version
WHERE release_verdicts.rule <> excluded.rule
RETURNING rule)
SELECT rule, count(*) AS n FROM written GROUP BY 1`,
[JUDGE_VERSION]
)
tally(added, 'added')
}
}
/** "dead 1,157,018 (+1,136 −712), …" for the log. */
function describe(round: JudgeRound): string {
return Object.entries(round.rules)
.map(([name, r]) => `${name} ${r.matched.toLocaleString('en')} (+${r.added.toLocaleString('en')} −${r.removed.toLocaleString('en')})`)
.join(', ')
} }
+3 -3
View File
@@ -5,10 +5,10 @@ import { ReleaseJudge, RULES } from '#services/release_judge'
/** The junk judge (REBUILD.md, junk): releases judged after their probes; verdicts hide them from search. */ /** The junk judge (REBUILD.md, junk): releases judged after their probes; verdicts hide them from search. */
export default class ReleasesJudge extends BaseCommand { export default class ReleasesJudge extends BaseCommand {
static commandName = 'releases:judge' static commandName = 'releases:judge'
static description = 'Judges releases by what their probes showed (dead, fake, repeated, locked, unusable), hourly' static description = 'Judges releases by what their probes showed (dead, fake, repeated, locked, unusable): the changed ones every few minutes, all daily'
static options: CommandOptions = { startApp: true, staysAlive: true } static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.boolean({ description: 'One round, then exit' }) @flags.boolean({ description: 'One full round, then exit' })
declare once: boolean declare once: boolean
@flags.boolean({ description: 'With --once: count only, write no verdicts' }) @flags.boolean({ description: 'With --once: count only, write no verdicts' })
@@ -17,7 +17,7 @@ export default class ReleasesJudge extends BaseCommand {
async run() { async run() {
const judge = new ReleaseJudge() const judge = new ReleaseJudge()
if (this.once) { if (this.once) {
const round = await judge.round(this.dry) const round = await judge.full(this.dry)
for (const rule of RULES) { for (const rule of RULES) {
const r = round.rules[rule.name] const r = round.rules[rule.name]
if (r) this.logger.info(`${rule.name}: ${r.matched} (+${r.added} −${r.removed}) — ${rule.says}`) if (r) this.logger.info(`${rule.name}: ${r.matched} (+${r.added} −${r.removed}) — ${rule.says}`)
@@ -0,0 +1,119 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The junk judge by what changed (LOG.md 2026-10-19 (83)): a full round over every release took 35–38 min of each
* hour, longer with every inspection the prober added. Triggers note each release whose facts a rule reads changed
* (judge_dirty: its probes' statuses, inspections, file hashes, passwords, spots, par2 sets, merges, its own judged
* columns); the judge judges those every few minutes, and every release in a daily full round (judge_state: when the
* last one was, by which rule set), its matches gathered in judge_full. judge_dirty is unlogged: what a crash loses
* the next full round judges.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`CREATE UNLOGGED TABLE IF NOT EXISTS judge_dirty (release_id bigint NOT NULL)`)
this.schema.raw(`CREATE UNLOGGED TABLE IF NOT EXISTS judge_full (release_id bigint PRIMARY KEY, rule varchar(16) NOT NULL)`)
this.schema.raw(
`CREATE TABLE IF NOT EXISTS judge_state (id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), full_at timestamptz NOT NULL, version integer NOT NULL)`
)
// The verdicts are an hourly full round's: the next full one a day from now.
this.schema.raw(`INSERT INTO judge_state (full_at, version) VALUES (now(), 1) ON CONFLICT DO NOTHING`)
// Rows of a table with release_id: the releases of the rows inserted, deleted, or updated (both, when moved).
this.schema.raw(`
CREATE OR REPLACE FUNCTION judge_dirty_by_release_id() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
INSERT INTO judge_dirty (release_id) SELECT DISTINCT release_id FROM new_rows WHERE release_id IS NOT NULL;
END IF;
IF TG_OP IN ('UPDATE', 'DELETE') THEN
INSERT INTO judge_dirty (release_id) SELECT DISTINCT release_id FROM old_rows WHERE release_id IS NOT NULL;
END IF;
RETURN NULL;
END $$`)
// probe_candidates: only a status that counts for the dead rule, or a candidate moved by a merge (not the claims'
// 'taken', the settler's ready).
this.schema.raw(`
CREATE OR REPLACE FUNCTION judge_dirty_probes() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO judge_dirty (release_id)
SELECT DISTINCT unnest(ARRAY[n.release_id, o.release_id]) FROM new_rows n JOIN old_rows o ON o.id = n.id
WHERE n.release_id IS DISTINCT FROM o.release_id
OR (n.status IS DISTINCT FROM o.status AND (n.status IN ('done', 'missing', 'error') OR o.status IN ('done', 'missing', 'error')));
RETURN NULL;
END $$`)
this.schema.raw(`
CREATE OR REPLACE FUNCTION judge_dirty_merges() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO judge_dirty (release_id) SELECT DISTINCT into_id FROM new_rows;
RETURN NULL;
END $$`)
this.schema.raw(`
CREATE OR REPLACE FUNCTION judge_dirty_releases() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO judge_dirty (release_id) SELECT id FROM new_rows;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO judge_dirty (release_id) SELECT id FROM old_rows;
ELSE
INSERT INTO judge_dirty (release_id) VALUES (NEW.id);
END IF;
RETURN NULL;
END $$`)
for (const table of ['release_inspections', 'file_hashes', 'release_passwords', 'release_spots', 'par2_sets']) {
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_insert ON ${table}`)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_update ON ${table}`)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_delete ON ${table}`)
this.schema.raw(
`CREATE TRIGGER judge_dirty_insert AFTER INSERT ON ${table} REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_by_release_id()`
)
this.schema.raw(
`CREATE TRIGGER judge_dirty_update AFTER UPDATE ON ${table} REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_by_release_id()`
)
this.schema.raw(
`CREATE TRIGGER judge_dirty_delete AFTER DELETE ON ${table} REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_by_release_id()`
)
}
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_update ON probe_candidates`)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_delete ON probe_candidates`)
this.schema.raw(
`CREATE TRIGGER judge_dirty_update AFTER UPDATE ON probe_candidates REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_probes()`
)
this.schema.raw(
`CREATE TRIGGER judge_dirty_delete AFTER DELETE ON probe_candidates REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_by_release_id()`
)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_insert ON release_merges`)
this.schema.raw(
`CREATE TRIGGER judge_dirty_insert AFTER INSERT ON release_merges REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_merges()`
)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_insert ON releases`)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_update ON releases`)
this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_delete ON releases`)
this.schema.raw(
`CREATE TRIGGER judge_dirty_insert AFTER INSERT ON releases REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_releases()`
)
this.schema.raw(
`CREATE TRIGGER judge_dirty_delete AFTER DELETE ON releases REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_releases()`
)
// Per row, and only when a column a rule reads changed: the tail and the settler update releases all the time.
this.schema.raw(
`CREATE TRIGGER judge_dirty_update AFTER UPDATE ON releases FOR EACH ROW
WHEN (OLD.name IS DISTINCT FROM NEW.name OR OLD.poster IS DISTINCT FROM NEW.poster OR OLD.source IS DISTINCT FROM NEW.source
OR OLD.file_total IS DISTINCT FROM NEW.file_total OR OLD.passworded IS DISTINCT FROM NEW.passworded
OR OLD.settled_at IS DISTINCT FROM NEW.settled_at OR OLD.readable IS DISTINCT FROM NEW.readable
OR OLD.inner_name IS DISTINCT FROM NEW.inner_name OR OLD.group_id IS DISTINCT FROM NEW.group_id
OR OLD.first_article IS DISTINCT FROM NEW.first_article OR OLD.last_article IS DISTINCT FROM NEW.last_article
OR OLD.posted_at IS DISTINCT FROM NEW.posted_at OR OLD.last_posted_at IS DISTINCT FROM NEW.last_posted_at
OR OLD.complete IS DISTINCT FROM NEW.complete)
EXECUTE FUNCTION judge_dirty_releases()`
)
}
async down() {
for (const table of ['release_inspections', 'file_hashes', 'release_passwords', 'release_spots', 'par2_sets', 'probe_candidates', 'release_merges', 'releases']) {
for (const event of ['insert', 'update', 'delete']) this.schema.raw(`DROP TRIGGER IF EXISTS judge_dirty_${event} ON ${table}`)
}
this.schema.raw(`DROP FUNCTION IF EXISTS judge_dirty_by_release_id(), judge_dirty_probes(), judge_dirty_merges(), judge_dirty_releases()`)
this.schema.raw(`DROP TABLE IF EXISTS judge_dirty, judge_full, judge_state`)
}
}
+2 -2
View File
@@ -140,8 +140,8 @@ services:
command: ['node', 'ace', 'releases:merge'] command: ['node', 'ace', 'releases:merge']
volumes: [] volumes: []
# The junk judge: releases judged hourly by what their probes showed (dead, fake, repeated, locked, unusable); # The junk judge: releases judged by what their probes showed (dead, fake, repeated, locked, unusable), the changed
# verdicts hide them from search. No NNTP. # ones every 2 minutes, all daily; verdicts hide them from search. No NNTP.
judge: judge:
<<: *app <<: *app
command: ['node', 'ace', 'releases:judge'] command: ['node', 'ace', 'releases:judge']
+73 -2
View File
@@ -80,7 +80,7 @@ test.group('ReleaseJudge', (group) => {
const spotted = await release('yy12CD34ef56GH78ij90', { readable: false, ...placed }) const spotted = await release('yy12CD34ef56GH78ij90', { readable: false, ...placed })
await db.table('release_spots').insert({ release_id: spotted, spot_id: 's@x', files: 1 }) await db.table('release_spots').insert({ release_id: spotted, spot_id: 's@x', files: 1 })
const round = await new ReleaseJudge().round() const round = await new ReleaseJudge().full()
const verdicts = new Map((await db.from('release_verdicts').select('release_id', 'rule')).map((v) => [Number(v.release_id), v.rule])) const verdicts = new Map((await db.from('release_verdicts').select('release_id', 'rule')).map((v) => [Number(v.release_id), v.rule]))
assert.equal(verdicts.get(dead), 'dead') assert.equal(verdicts.get(dead), 'dead')
assert.isUndefined(verdicts.get(halfDead)) assert.isUndefined(verdicts.get(halfDead))
@@ -107,11 +107,82 @@ test.group('ReleaseJudge', (group) => {
// A password found: the next round takes the verdict back. // A password found: the next round takes the verdict back.
await db.table('release_passwords').insert({ release_id: locked, password: 'found', source: 'nzb' }) await db.table('release_passwords').insert({ release_id: locked, password: 'found', source: 'nzb' })
const again = await new ReleaseJudge().round() const again = await new ReleaseJudge().full()
assert.equal(again.rules.locked.removed, 1) assert.equal(again.rules.locked.removed, 1)
// Verdicts that stay are not written again. // Verdicts that stay are not written again.
assert.equal(again.rules.repeated.added, 0) assert.equal(again.rules.repeated.added, 0)
assert.equal(again.rules.fake.added, 0) assert.equal(again.rules.fake.added, 0)
assert.isNull(await db.from('release_verdicts').where('release_id', locked).first()) assert.isNull(await db.from('release_verdicts').where('release_id', locked).first())
}) })
test('judges the releases whose facts changed, and those a hash of theirs tipped over', async ({ assert }) => {
let n = 0
const settled = new Date(Date.now() - 3 * 86400_000)
const release = async (name: string, extra: Record<string, unknown> = {}) => {
n++
const key = `jc${String(n).padStart(6, '0')}`.padEnd(40, '0')
const [row] = await db
.table('releases')
.insert({ release_key: key, identity_key: key, name, source: 'stem', groups: '[]', files: 3, posted_at: settled, readable: true, settled_at: settled, ...extra })
.returning('id')
return Number(row.id)
}
const judge = new ReleaseJudge()
const verdict = async (id: number) => (await db.from('release_verdicts').where('release_id', id).first())?.rule
const dirty = async () => Number((await db.from('judge_dirty').count('* as n'))[0].n)
// New releases are noted; a passworded one is locked, until a password turns up.
const locked = await release('Locked.Thing', { passworded: true })
assert.isAbove(await dirty(), 0)
let round = await judge.changed()
assert.equal(await verdict(locked), 'locked')
assert.equal(round.rules.locked.added, 1)
assert.equal(await dirty(), 0)
await db.table('release_passwords').insert({ release_id: locked, password: 'pw', source: 'nzb' })
round = await judge.changed()
assert.isUndefined(await verdict(locked))
assert.equal(round.rules.locked.removed, 1)
// Probes: a release whose probed articles are all missing is dead once the prober's statuses come in.
const dead = await release('Gone.Movie.2020.1080p')
const ids = await db.table('probe_candidates').multiInsert([
{ release_id: dead, kind: 'lone', message_id: '<jc1@x>', file: 'a', status: 'waiting' },
{ release_id: dead, kind: 'lone', message_id: '<jc2@x>', file: 'b', status: 'waiting' },
]).returning('id')
await judge.changed()
assert.isUndefined(await verdict(dead))
await db.from('probe_candidates').whereIn('id', ids.map((r: { id: string }) => r.id)).update({ status: 'missing' })
await judge.changed()
assert.equal(await verdict(dead), 'dead')
// One file in 19 releases, one of them locked: the 20th makes them all repeated, the locked one too (repeated
// comes first), though only the 20th changed.
const hash = 'b'.repeat(32)
const holders: number[] = []
for (let i = 0; i < 19; i++) holders.push(await release(`holder${i}`, i === 0 ? { passworded: true } : {}))
for (const [i, id] of holders.entries()) await db.table('file_hashes').insert({ candidate_id: 30000 + i, release_id: id, hash16k: hash, size: 5000 })
await judge.changed()
assert.equal(await verdict(holders[0]), 'locked')
assert.isUndefined(await verdict(holders[1]))
const twentieth = await release('holder19')
await db.table('file_hashes').insert({ candidate_id: 30019, release_id: twentieth, hash16k: hash, size: 5000 })
round = await judge.changed()
assert.equal(round.releases, 20)
const rules = new Set()
for (const id of [...holders, twentieth]) rules.add(await verdict(id))
assert.deepEqual([...rules], ['repeated'])
// A verdict nothing noted stays until the full round; it's due a day after the last one.
const stale = await release('Fine.Release')
await judge.changed()
await db.table('release_verdicts').insert({ release_id: stale, rule: 'fake', rule_version: 1 })
await judge.changed()
assert.equal(await verdict(stale), 'fake')
await db.from('judge_state').update({ full_at: new Date(Date.now() - 2 * 86400_000) })
assert.isTrue(await judge.fullDue())
round = await judge.full()
assert.isUndefined(await verdict(stale))
assert.equal(await verdict(dead), 'dead')
assert.isFalse(await judge.fullDue())
})
}) })