Rebuild: fresh AdonisJS 7 API app on Postgres

The rebuild branch starts over (REBUILD.md): the API starter kit, with
Postgres instead of SQLite (its own database, DB_SEARCH_PATH for a
shared one in development) and the #lib alias. Phase 1's code is
imported from the current app module by module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 00:05:24 +02:00
1 parent 699395c51f
commit 96f0ade10e
264 files changed
+777 -24132

No files matched your search

-70
View File
@@ -1,70 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
import ArchiveWalkService from '#services/ArchiveWalkService'
const BATCH = 5000
/**
* Complete archive listings for releases inspected before archive walks
* existed (CONCEPT.md D40): queues those whose listing leaves most of the
* release unaccounted for. worker:collection walks them, one at a time and
* behind inspection. With release ids, walks those right here instead.
*/
export default class ArchiveWalk extends BaseCommand {
public static commandName = 'content:walk-archives'
public static description = 'Queues releases with partial archive listings for a (low priority) header walk.'
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Release ids to walk now (instead of queueing)', required: false })
declare releaseIds?: string[]
@flags.number({ description: 'Queue at most this many releases' })
declare limit?: number
@flags.boolean({ description: 'Also releases that were walked before' })
declare again: boolean
public async run() {
try {
if (this.releaseIds?.length) {
for (const id of this.releaseIds) {
this.logger.info(JSON.stringify(await ArchiveWalkService.walk(Number(id))))
}
return
}
let lastId = 0
let queued = 0
while (!this.limit || queued < this.limit) {
const query = db
.from('releases as r')
.where('r.id', '>', lastId)
.whereNotNull('r.contents')
.orderBy('r.id')
.limit(BATCH)
.select('r.id', 'r.size', 'r.contents_walked_at', db.raw(`(
SELECT count(*) FROM jsonb_array_elements(r.contents)) AS n`), db.raw(`(
SELECT coalesce(sum((e->>'size')::bigint), 0) FROM jsonb_array_elements(r.contents) e) AS listed`))
const rows = await query
if (rows.length === 0) break
lastId = Number(rows.at(-1).id)
const jobs = rows
.filter((r) => this.again || !r.contents_walked_at)
.filter((r) => ArchiveWalkService.worthWalking(Number(r.n), Number(r.listed), Number(r.size)))
.slice(0, this.limit ? this.limit - queued : undefined)
.map((r) => ({ name: 'walk', data: { releaseId: Number(r.id) }, opts: { jobId: `walk-${r.id}-${Date.now()}` } }))
if (jobs.length > 0) await QueueService.archiveWalkQueue.addBulk(jobs)
queued += jobs.length
}
this.logger.info(`Queued ${queued} releases for an archive walk.`)
} finally {
await QueueService.closeAll()
await NntpService.shutdown()
}
}
}
-97
View File
@@ -1,97 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import indexerConfig from '#config/indexer'
import QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
import DeferredService, { isDeferred } from '#services/DeferredService'
const BATCH = 2000
/** Rebuilds the wait list (KEYS[1]) without the ids in the set KEYS[2], in one atomic pass. */
const FILTER_LIST = `
local ids = redis.call('LRANGE', KEYS[1], 0, -1)
local keep = {}
for _, id in ipairs(ids) do
if redis.call('SISMEMBER', KEYS[2], id) == 0 then keep[#keep + 1] = id end
end
redis.call('DEL', KEYS[1])
for i = 1, #keep, 5000 do
redis.call('RPUSH', KEYS[1], unpack(keep, i, math.min(i + 4999, #keep)))
end
return #ids - #keep`
/**
* Removes waiting body jobs whose message-id is on the skip or defer list,
* e.g. after adding a pattern (CONCEPT.md D24, D28). Deferred ones are
* recorded in deferred_files first, so they can still be processed later.
*/
export default class BodyQueuePrune extends BaseCommand {
public static commandName = 'body-queue:prune'
public static description = 'Drops waiting body jobs that are now skipped or deferred.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count' })
declare dryRun: boolean
@flags.boolean({ description: 'Drop deferred jobs without recording them (already recorded by an earlier run)' })
declare skipRecord: boolean
public async run() {
const redis = RedisService.client
const queue = QueueService.bodyQueue
const waitKey = queue.toKey('wait')
let skipped = 0
let deferred = 0
let scanned = 0
const dropped: string[] = []
try {
const ids: string[] = await redis.lrange(waitKey, 0, -1)
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH)
const data: [Error | null, string | null][] = await batch
.reduce((pipeline, id) => pipeline.hget(queue.toKey(id), 'data'), redis.pipeline())
.exec()
const drop: string[] = []
const toDefer = new Map<string, { articleNumber: number; header: Record<string, string> }[]>()
batch.forEach((id, j) => {
const job = data[j][1] ? JSON.parse(data[j][1]!) : null
if (!job?.header) return
const messageId = job.header['message-id'] ?? ''
if (isDeferred(job.header)) {
const list = toDefer.get(job.group) ?? []
list.push({ articleNumber: Number(job.articleNumber), header: job.header })
toDefer.set(job.group, list)
drop.push(id)
deferred++
} else if (indexerConfig.skipBodyMessageIds.some((re) => re.test(messageId))) {
drop.push(id)
skipped++
}
})
scanned += batch.length
if (this.dryRun || drop.length === 0) continue
if (!this.skipRecord) for (const [group, articles] of toDefer) await DeferredService.record(group, articles)
dropped.push(...drop)
}
if (dropped.length > 0) {
const dropKey = queue.toKey('prune-ids')
await redis.del(dropKey)
for (let i = 0; i < dropped.length; i += BATCH) await redis.sadd(dropKey, ...dropped.slice(i, i + BATCH))
await redis.eval(FILTER_LIST, 2, waitKey, dropKey)
await redis.del(dropKey)
for (let i = 0; i < dropped.length; i += BATCH) {
await redis.unlink(...dropped.slice(i, i + BATCH).map((id) => queue.toKey(id)))
}
}
this.logger.info(
`Scanned ${scanned} waiting body jobs: ${this.dryRun ? 'would drop' : 'dropped'} ${deferred} deferred and ${skipped} skipped.`
)
} finally {
await QueueService.closeAll()
await RedisService.close()
}
}
}
-91
View File
@@ -1,91 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import QueueService from '#services/QueueService'
import ContentService from '#services/ContentService'
import { isArchiveStart } from '#lib/content/index'
import InspectionQueue, { INSPECTION_PRIORITY } from '#services/InspectionQueue'
const BATCH = 1000
/**
* Queues completed files for content inspection (names and groupings from
* par2 and RAR headers, CONCEPT.md D21) that were completed before it
* existed. Costs one article download per file, done by worker:collection.
*/
export default class ContentBackfill extends BaseCommand {
public static commandName = 'content:backfill'
public static description = 'Queues existing files with random names for content inspection.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Queue at most this many files' })
declare limit?: number
@flags.boolean({ description: 'Also files that were inspected before (after release:rebuild --reset-content)' })
declare all: boolean
@flags.boolean({ description: 'Only archives whose listing contains NZBs, to import those (CONCEPT.md D28)' })
declare archiveNzbs: boolean
@flags.boolean({ description: 'Only unlisted 7z archives (first volumes: .7z, .7z.001)' })
declare sevenZip: boolean
@flags.boolean({ description: 'Archive starts never inspected (RAR first volumes, 7z, .bin), also files from NZBs/spots' })
declare archives: boolean
@flags.boolean({ description: 'SFV files not read yet (their listings group the files they name, D34)' })
declare sfv: boolean
@flags.boolean({ description: 'niconico captures not read yet (their releases get the video title, D34)' })
declare nicovideo: boolean
public async run() {
let lastId = 0
let queued = 0
try {
while (!this.limit || queued < this.limit) {
// Files never inspected: no fingerprint on their binary and no archive listing.
const query = db.from('files as f').leftJoin('file_details as d', 'd.file_id', 'f.id').where('f.id', '>', lastId)
// Files from NZBs (spots) have no binary.
if (this.sevenZip || this.archives || this.sfv || this.nicovideo) query.leftJoin('binaries as b', 'b.file_id', 'f.id')
else query.join('binaries as b', 'b.file_id', 'f.id')
if (this.nicovideo) {
query.whereNull('d.content_type').whereRaw(`f.filename ~* '^\\d{9,11}_nicovideo_jp_watch_sm\\d+\\.tar\\.zst$'`)
} else if (this.sfv) {
query.whereNull('d.content_type').whereRaw(`coalesce(f.real_name, f.filename) ~* '\\.sfv$'`)
} else if (this.archives) {
query.whereNull('d.content_type').whereNull('d.archive').whereRaw(`coalesce(f.real_name, f.filename) ~* '\\.(rar|7z|7z\\.0*1|bin)$'`)
} else if (this.sevenZip) {
query.whereNull('d.archive').whereRaw(`coalesce(f.real_name, f.filename) ~* '\\.7z(\\.0*1){0,1}$'`)
} else if (this.archiveNzbs) query.whereRaw(`d.archive::text ~* '\\.nzb"'`)
else if (!this.all) query.whereNull('b.md5_16k').whereNull('d.archive')
const files = await query
.orderBy('f.id')
.limit(BATCH)
.select('f.id', 'f.filename')
if (files.length === 0) break
lastId = Number(files.at(-1).id)
const jobs = files
.filter((f) => (this.archives ? isArchiveStart(f.filename) : this.sfv || this.nicovideo || ContentService.isCandidate(f.filename)))
.slice(0, this.limit ? this.limit - queued : undefined)
.map((f) => Number(f.id))
// Into the inspection backlog (InspectionQueue): targeted backfills go ahead of what's waiting;
// niconico captures are many (~190k) and only cosmetic, so behind everything else.
const priority = this.nicovideo
? INSPECTION_PRIORITY.low
: this.archiveNzbs || this.sevenZip || this.archives || this.sfv
? INSPECTION_PRIORITY.targeted
: INSPECTION_PRIORITY.normal
if (jobs.length > 0) await InspectionQueue.add(jobs, priority)
queued += jobs.length
}
this.logger.info(`Queued ${queued} files for content inspection.`)
} finally {
await QueueService.closeAll()
}
}
}
-22
View File
@@ -1,22 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import IndexMaintenance from '#services/IndexMaintenance'
/** Rebuilds bloated indexes online (gc:scheduler also does this weekly). */
export default class DbReindex extends BaseCommand {
public static commandName = 'db:reindex'
public static description = 'Rebuilds B-tree indexes that are mostly empty space (online, REINDEX CONCURRENTLY).'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only show which indexes would be rebuilt' })
declare dryRun: boolean
public async run() {
const rebuilt = await IndexMaintenance.rebuildBloated({ dryRun: this.dryRun })
const saved = rebuilt.reduce((sum, r) => sum + r.before - r.after, 0)
this.logger.info(`${rebuilt.length} indexes ${this.dryRun ? 'would be rebuilt' : `rebuilt, ${Math.round(saved / 1024 / 1024)} MB freed`}.`)
}
}
-32
View File
@@ -1,32 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import DeferredService from '#services/DeferredService'
/**
* Sends recorded deferred ranges (fully obfuscated ngPost articles, CONCEPT.md
* D28) to the body queue, oldest first. Every article in them is downloaded:
* their headers can't be grouped into files.
*/
export default class DeferredExpandRanges extends BaseCommand {
public static commandName = 'deferred:expand-ranges'
public static description = 'Queues the bodies of deferred article ranges (costly: every article is downloaded).'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Ranges to expand', default: 10 })
declare limit: number
public async run() {
try {
const { ranges, articles } = await DeferredService.expandRanges(this.limit)
this.logger.info(`Expanded ${ranges} ranges: ${articles} articles sent to the body queue.`)
} finally {
await NntpService.shutdown()
await QueueService.closeAll()
}
}
}
-35
View File
@@ -1,35 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import { unquotedFileName } from '#services/NzbImportService'
/**
* Files named after a whole NZB subject without quotes (spot NZBs), e.g.
* `Adventure.part0055.rar - [056/175] (1/1)`: renamed to the filename in it.
*/
export default class FilesCleanNames extends BaseCommand {
public static commandName = 'files:clean-names'
public static description = 'Strips subject leftovers (counters, "yEnc") from file names that came from NZBs.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only show what would change' })
declare dryRun: boolean
public async run() {
const files = await db
.from('files')
.whereRaw(`filename ~* '(\\(\\d+/\\d+\\)|yEnc|\\[\\d+/\\d+\\])\\s*$'`)
.select('id', 'filename')
const changes = files
.map((f) => ({ id: Number(f.id), from: f.filename as string, to: unquotedFileName(f.filename) }))
.filter((c) => c.to !== c.from)
for (const c of changes.slice(0, 5)) this.logger.info(`"${c.from}" → "${c.to}"`)
if (!this.dryRun) {
for (const c of changes) await db.from('files').where('id', c.id).update({ filename: c.to })
}
this.logger.info(`${changes.length} of ${files.length} file names ${this.dryRun ? 'would change' : 'cleaned'}.`)
}
}
-36
View File
@@ -1,36 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
/**
* One-off (LOG.md 2026-10-08 (14)): copies the content types and archive
* listings still on `files` into file_details, a partition at a time.
* Values written since the deploy (by the new code, into file_details) win:
* a file that has details already is skipped.
*/
export default class FilesMoveDetails extends BaseCommand {
public static commandName = 'files:move-details'
public static description = "Copies files' content types and archive listings into file_details."
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const { rows: partitions } = await db.rawQuery(
`SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'files'::regclass ORDER BY c.relname`
)
let total = 0
for (const { relname } of partitions) {
const started = Date.now()
const result = await db.rawQuery(
`INSERT INTO file_details (file_id, content_type, archive)
SELECT id, content_type, archive FROM ${relname} WHERE content_type IS NOT NULL OR archive IS NOT NULL
ON CONFLICT (file_id) DO NOTHING`
)
total += result.rowCount ?? 0
this.logger.info(`${relname}: ${result.rowCount} copied in ${Math.round((Date.now() - started) / 1000)} s.`)
}
this.logger.info(`${total} files' details copied.`)
}
}
-38
View File
@@ -1,38 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import GarbageFilter from '#services/GarbageFilter'
/**
* Learns the garbage upload signatures now (gc:scheduler does it every
* GARBAGE_LEARN_INTERVAL_MS) and lists them (CONCEPT.md D42). --seed first
* fills signature_probes from a sample of the files probed before it
* existed, so there is something to learn from right away.
*/
export default class GarbageLearn extends BaseCommand {
public static commandName = 'garbage:learn'
public static description = 'Learns which upload signatures are garbage and lists them.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'First add probed releases from TABLESAMPLE SYSTEM (percent) of files' })
declare seed?: number
public async run() {
if (this.seed) {
const started = Date.now()
const added = await GarbageFilter.seed(this.seed)
this.logger.info(`Seeded ${added} probed releases from ${this.seed}% of files in ${Math.round((Date.now() - started) / 1000)} s.`)
}
const { signatures, dropped, changed } = await GarbageFilter.learn()
for (const line of changed) this.logger.info(`Changed: ${line}`)
const rows = await db.from('upload_signatures').orderBy('probed', 'desc').limit(40)
for (const row of rows) {
const rate = row.probed ? ((100 * row.named) / row.probed).toFixed(1) : '-'
this.logger.info(`${row.dropped ? 'DROP' : 'keep'} ${row.tool.padEnd(10)} ${(row.group_name || '(no group)').padEnd(36)} ${String(row.named).padStart(6)}/${String(row.probed).padEnd(6)} named (${rate}%)`)
}
this.logger.info(`${dropped} of ${signatures} signatures dropped.`)
}
}
-424
View File
@@ -1,424 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import type { Queue } from 'bullmq'
import db from '@adonisjs/lucid/services/db'
import gcConfig from '#config/gc'
import QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
import IndexMaintenance from '#services/IndexMaintenance'
import ReleaseMergeService from '#services/ReleaseMergeService'
import StagingService from '#services/StagingService'
import FilePartitions from '#services/FilePartitions'
import ReleaseCompleteness from '#services/ReleaseCompleteness'
import FileKeys from '#services/FileKeys'
import GarbageFilter from '#services/GarbageFilter'
import indexerConfig from '#config/indexer'
import Shutdown from '#services/Shutdown'
const CLEAN_BATCH = 1000
const SCAN_COUNT = 1000
/** Binaries per statement when dropping copied parts (a few hundred parts each at most). */
const COPIED_PARTS_BATCH = 2000
export default class GcScheduler extends BaseCommand {
public static commandName = 'gc:scheduler'
public static description = 'Periodically removes old BullMQ jobs and stale binaries.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Run one sweep and exit' })
declare once: boolean
@flags.boolean({ description: 'Report what would be removed without changing anything' })
declare dryRun: boolean
/** When release completeness was last recomputed (it runs less often than the sweep). */
private completenessAt = 0
/** When the garbage signatures were last learned (D42). */
private learnedAt = 0
/** When split uploads were last merged. */
private mergedAt = 0
public async run() {
this.logger.info(
`GC started (interval ${gcConfig.intervalMs / 1000}s${this.dryRun ? ', dry run' : ''}).`
)
try {
do {
try {
await this.sweep()
} catch (err: any) {
this.logger.error(`GC sweep failed: ${err.message}`)
}
try {
await this.releaseCompleteness()
} catch (err: any) {
this.logger.error(`Release completeness failed: ${err.message}`)
}
try {
await this.fillFileKeys()
} catch (err: any) {
this.logger.error(`file_keys fill failed: ${err.message}`)
}
try {
await this.learnGarbage()
} catch (err: any) {
this.logger.error(`Garbage signatures failed: ${err.message}`)
}
if (!this.once) await Shutdown.sleep(gcConfig.intervalMs)
} while (!this.once && !Shutdown.requested)
} finally {
await QueueService.closeAll()
await RedisService.close()
}
}
private async sweep() {
const { completedAgeSeconds, failedAgeSeconds } = gcConfig.jobs
for (const queue of [...QueueService.all, ...QueueService.legacy]) {
const completed = await this.cleanJobs(queue, 'completed', completedAgeSeconds)
const failed = await this.cleanJobs(queue, 'failed', failedAgeSeconds)
if (completed || failed) {
this.logger.info(
`${queue.name}: ${this.verb('remove', 'removed')} ${completed} completed and ${failed} failed jobs.`
)
}
}
// Inserts fail without a partition for their id (D39). Four are kept ahead, so a failure here
// can wait for the next sweep; it used to end the sweep, deletes and all (LOG.md 2026-10-07 (4)).
if (!this.dryRun) {
try {
await FilePartitions.ensure()
} catch (err: any) {
this.logger.error(`files partitions: ${err.message}; trying again next sweep.`)
}
}
const trimmed = await this.trimBodyQueue()
if (trimmed) this.logger.info(`Body queue: ${this.verb('drop', 'dropped')} ${trimmed} oldest waiting jobs.`)
const binaries = await this.deleteStaleBinaries()
this.logger.info(`Binaries: ${this.verb('delete', 'deleted')} ${binaries} stale.`)
if (!this.dryRun) {
// Each kind gets its own time budget; empty releases last, as deleting binaries empties them.
const budget = () => Date.now() + gcConfig.deleteBudgetMs
const parts = await this.dropCopiedParts(budget())
const single = await this.deleteSingleArticleBinaries(budget())
const unfingerprinted = await this.deleteUnfingerprintedBinaries(budget())
const releases = await this.deleteEmptyReleases(budget())
const staging = await StagingService.sweep(gcConfig.stagingTtlSeconds, budget())
if (staging.adopted || staging.expired) {
this.logger.info(`Staging: ${staging.adopted} segments adopted by their file, ${staging.expired} expired.`)
}
if (parts || single || unfingerprinted || releases) {
this.logger.info(
`Space: ${parts} copied parts, ${single} single-article binaries, ${unfingerprinted} unfingerprinted completed binaries, ` +
`${releases} empty releases deleted.`
)
}
}
const deferred = await this.deleteOldDeferredFiles()
if (deferred) this.logger.info(`Deferred files: ${this.verb('delete', 'deleted')} ${deferred} past retention.`)
const { expired, scanned } = await this.expireLegacyPendingFiles()
if (scanned > 0) {
this.logger.info(
`Legacy pending files: scanned ${scanned}, ${this.verb('set a TTL on', 'set a TTL on')} ${expired} without expiry.`
)
}
// Uploads split per poster (CONCEPT.md D35); the releases this empties go in a later sweep. It reads
// every release and its files: hourly, not every sweep (LOG.md 2026-10-06 (12)).
if (this.once || Date.now() - this.mergedAt >= gcConfig.mergeIntervalMs) {
this.mergedAt = Date.now()
const merged = await ReleaseMergeService.mergeSplitPosts({ dryRun: this.dryRun })
if (merged.releases) this.logger.info(`Split uploads: ${this.verb('merge', 'merged')} ${merged.releases} releases into ${merged.names}.`)
}
await this.rebuildIndexes()
}
/** Rebuilds bloated indexes, at most every gcConfig.reindexIntervalMs (CONCEPT.md D33). */
/** The keys of files from before file_keys, a slice of time per sweep, until done. */
private async fillFileKeys() {
if (this.dryRun || (await FileKeys.complete())) return
const started = Date.now()
const { read, complete, nextId, untilId } = await FileKeys.fill(gcConfig.fileKeysFillMs)
this.logger.info(
`file_keys: ${read.toLocaleString('en')} keys copied in ${Math.round((Date.now() - started) / 1000)} s, ` +
(complete ? 'complete: file inserts use the filter now.' : `at file ${nextId.toLocaleString('en')} of ${untilId.toLocaleString('en')}.`)
)
}
private async releaseCompleteness() {
if (this.dryRun || (!this.once && Date.now() - this.completenessAt < gcConfig.releaseCompleteIntervalMs)) return
this.completenessAt = Date.now()
const started = Date.now()
const checked = await ReleaseCompleteness.sweep()
if (checked > 0) this.logger.info(`Release completeness: ${checked} changed releases checked in ${Math.round((Date.now() - started) / 1000)} s.`)
}
/** Learns which upload signatures are garbage from the probed releases (CONCEPT.md D42). */
private async learnGarbage() {
if (this.dryRun || (!this.once && Date.now() - this.learnedAt < indexerConfig.garbage.learnIntervalMs)) return
this.learnedAt = Date.now()
const { signatures, dropped, changed } = await GarbageFilter.learn()
this.logger.info(`Garbage signatures: ${dropped} of ${signatures} dropped.`)
for (const line of changed) this.logger.info(`Garbage signature ${line}`)
}
private async rebuildIndexes() {
const key = 'gc:reindex:last'
const last = Number((await RedisService.client.get(key)) ?? 0)
if (Date.now() - last < gcConfig.reindexIntervalMs) return
const rebuilt = await IndexMaintenance.rebuildBloated({ dryRun: this.dryRun })
if (!this.dryRun) await RedisService.client.set(key, String(Date.now()))
const saved = rebuilt.reduce((sum, r) => sum + r.before - r.after, 0)
this.logger.info(`Indexes: ${this.verb('rebuild', 'rebuilt')} ${rebuilt.length}, ${Math.round(saved / 1024 / 1024)} MB freed.`)
}
/**
* Drops the oldest waiting body jobs above gcConfig.bodyQueueMax. BullMQ
* workers take jobs from the right end of the wait list, so the oldest are
* there.
*/
private async trimBodyQueue(): Promise<number> {
const redis = RedisService.client
const key = QueueService.bodyQueue.toKey('wait')
const excess = (await redis.llen(key)) - gcConfig.bodyQueueMax
if (excess <= 0 || this.dryRun) return Math.max(excess, 0)
let dropped = 0
while (dropped < excess) {
const ids: string[] = (await redis.rpop(key, Math.min(5000, excess - dropped))) ?? []
if (ids.length === 0) break
await redis.unlink(...ids.map((id) => QueueService.bodyQueue.toKey(id)))
dropped += ids.length
}
return dropped
}
/**
* Deletes binaries (and, by cascade, their parts) that received no segment
* for `staleAfterSeconds`: incomplete ones are given up on; completed ones
* already live on in `files`; conflicted ones will never become a file.
* Completed ones still waiting for worker:file are kept.
*/
/**
* Binaries without new segments for staleAfterSeconds: incomplete ones are given up on,
* finished ones have their file. A finished one whose file waits for content inspection
* stays until it's inspected (its fingerprint names it from par2 sets, D16), at most
* awaitInspectionSeconds (LOG.md 2026-10-06 (11)). Three deletes, one per partial index,
* so none reads past millions of waiting rows.
*/
private async deleteStaleBinaries(): Promise<number> {
const stale = new Date(Date.now() - gcConfig.binaries.staleAfterSeconds * 1000)
const waited = new Date(Date.now() - gcConfig.binaries.awaitInspectionSeconds * 1000)
const deletes = [
db.from('binaries').where('last_seen_at', '<', stale).whereNotNull('file_id').whereRaw('awaiting_inspection IS NOT TRUE'),
db.from('binaries').where('last_seen_at', '<', stale).where((q) => q.whereNull('completed_at').orWhereNotNull('conflicted_at')),
db.from('binaries').where('last_seen_at', '<', waited).whereRaw('awaiting_inspection IS TRUE'),
]
let total = 0
for (const query of deletes) {
if (this.dryRun) total += Number((await query.count('* as count'))[0].count)
else total += Number(await query.delete())
}
return total
}
private async deleteOldDeferredFiles(): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.deferredRetentionDays * 24 * 60 * 60 * 1000)
let total = 0
for (const [table, column] of [['deferred_files', 'first_seen_at'], ['deferred_ranges', 'created_at']]) {
const query = db.from(table).where(column, '<', cutoff)
if (this.dryRun) {
const [{ count }] = await query.count('* as count')
total += Number(count)
} else {
total += Number(await query.delete())
}
}
return total
}
// Keyset cursors over the tables, kept between sweeps so one pass over
// millions of rows is spread over several sweeps.
private singleCursor = 0
private releaseCursor = 0
/**
* Header parts of binaries that are a file already. worker:file drops them
* when it creates the file, and ingest no longer adds them afterwards; this
* catches what slipped through. A cursor over binaries (as for single
* articles) visits each one once per pass, instead of searching all of
* them for parts on every batch (LOG.md 2026-10-04).
*/
private copiedCursor = 0
private async dropCopiedParts(budget: number): Promise<number> {
let total = 0
while (Date.now() < budget) {
const { rows } = await db.rawQuery(
`WITH batch AS (
SELECT id FROM binaries WHERE id > ? AND file_id IS NOT NULL ORDER BY id LIMIT ?
), gone AS (
DELETE FROM parts p USING batch WHERE p.binary_id = batch.id AND p.pcrc32 IS NULL
RETURNING 1)
SELECT (SELECT max(id) FROM batch) AS last, (SELECT count(*) FROM gone) AS deleted`,
[this.copiedCursor, COPIED_PARTS_BATCH]
)
total += Number(rows[0].deleted)
if (rows[0].last === null) {
this.copiedCursor = 0
break
}
this.copiedCursor = Number(rows[0].last)
}
return total
}
/** Header-derived binaries that never got a second article (gcConfig.binaries.singleArticleAfterSeconds). */
private async deleteSingleArticleBinaries(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.binaries.singleArticleAfterSeconds * 1000)
let total = 0
while (Date.now() < budget) {
const { rows } = await db.rawQuery(
`WITH batch AS (
SELECT id FROM binaries WHERE id > ? ORDER BY id LIMIT ?
), gone AS (
DELETE FROM binaries b USING batch
WHERE b.id = batch.id AND b.completed_at IS NULL AND b.size IS NULL AND b.total_parts > 1
AND b.last_seen_at < ? AND (SELECT count(*) FROM parts p WHERE p.binary_id = b.id) <= 1
RETURNING 1)
SELECT (SELECT max(id) FROM batch) AS last, (SELECT count(*) FROM gone) AS deleted`,
[this.singleCursor, gcConfig.deleteBatch, cutoff]
)
total += Number(rows[0].deleted)
if (rows[0].last === null) {
this.singleCursor = 0
break
}
this.singleCursor = Number(rows[0].last)
}
return total
}
/**
* Completed header-derived binaries without a fingerprint, some hours after
* completion: their file exists, their parts are gone, and par2 naming needs
* a size and MD5 they don't have. Oldest first, via the last_seen_at index.
*/
private async deleteUnfingerprintedBinaries(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.binaries.unfingerprintedAfterSeconds * 1000)
let total = 0
while (Date.now() < budget) {
const result = await db.rawQuery(
`DELETE FROM binaries WHERE id IN (
SELECT id FROM binaries
WHERE last_seen_at < ? AND file_id IS NOT NULL AND completed_at IS NOT NULL
AND size IS NULL AND md5_16k IS NULL AND conflicted_at IS NULL
AND awaiting_inspection IS NOT TRUE
ORDER BY last_seen_at LIMIT ?)`,
[cutoff, gcConfig.deleteBatch]
)
total += result.rowCount
if (result.rowCount < gcConfig.deleteBatch) break
}
return total
}
/** Releases nothing refers to any more: no files, binaries, par2 listing or spot. */
private async deleteEmptyReleases(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.emptyReleaseAfterSeconds * 1000)
let total = 0
while (Date.now() < budget) {
const { rows } = await db.rawQuery(
`WITH batch AS (
SELECT id FROM releases WHERE id > ? ORDER BY id LIMIT ?
), gone AS (
DELETE FROM releases r USING batch
WHERE r.id = batch.id AND r.file_count = 0 AND r.first_seen_at < ?
AND NOT EXISTS (SELECT 1 FROM binaries b WHERE b.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id)
RETURNING 1)
SELECT (SELECT max(id) FROM batch) AS last, (SELECT count(*) FROM gone) AS deleted`,
[this.releaseCursor, gcConfig.deleteBatch, cutoff]
)
total += Number(rows[0].deleted)
if (rows[0].last === null) {
this.releaseCursor = 0
break
}
this.releaseCursor = Number(rows[0].last)
}
return total
}
/** Removes jobs in `state` that finished more than `ageSeconds` ago. */
private async cleanJobs(queue: Queue, state: 'completed' | 'failed', ageSeconds: number) {
const graceMs = ageSeconds * 1000
if (this.dryRun) {
const cutoff = Date.now() - graceMs
// Finished-job sets are sorted by finishedOn, so a score range counts them cheaply.
return RedisService.client.zcount(queue.toKey(state), '-inf', cutoff)
}
let total = 0
let removed: string[]
do {
removed = await queue.clean(graceMs, CLEAN_BATCH, state)
total += removed.length
} while (removed.length === CLEAN_BATCH)
return total
}
/**
* Pending-file hashes of the old header worker (and the pre-AdonisJS app)
* never expire. Give them a TTL rather than deleting them outright.
*/
private async expireLegacyPendingFiles() {
const redis = RedisService.client
const { prefix, ttlSeconds } = gcConfig.legacyPendingFiles
let cursor = '0'
let scanned = 0
let expired = 0
do {
const [next, keys]: [string, string[]] = await redis.scan(
cursor,
'MATCH',
`${prefix}*`,
'COUNT',
SCAN_COUNT
)
cursor = next
if (keys.length === 0) continue
scanned += keys.length
const ttls: [Error | null, number][] = await keys
.reduce((pipeline, key) => pipeline.ttl(key), redis.pipeline())
.exec()
const orphaned = keys.filter((_, i) => ttls[i][1] === -1)
expired += orphaned.length
if (!this.dryRun && orphaned.length > 0) {
await orphaned
.reduce((pipeline, key) => pipeline.expire(key, ttlSeconds, 'NX'), redis.pipeline())
.exec()
}
} while (cursor !== '0')
return { scanned, expired }
}
private verb(present: string, past: string) {
return this.dryRun ? `would ${present}` : past
}
}
-79
View File
@@ -1,79 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import Group from '#models/group'
import NntpService from '#services/NntpService'
import { parseGroupResponse } from '#lib/nntp/group'
/**
* Adds newsgroups to index (or reactivates them). The scheduler picks them
* up on its next tick, starting INDEX_INITIAL_BACKFILL articles back.
*/
export default class GroupAdd extends BaseCommand {
public static commandName = 'group:add'
public static description = 'Adds newsgroups to index.'
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Group names, e.g. alt.binaries.hdtv' })
declare names: string[]
@flags.boolean({ description: 'Deactivate the groups instead' })
declare disable: boolean
@flags.boolean({
description: 'Also download bodies to identify obfuscated posts (one body per article; bandwidth-heavy)',
})
declare bodies: boolean
@flags.number({ description: 'Start this many articles back instead of INDEX_INITIAL_BACKFILL (new groups only)' })
declare backfill?: number
@flags.boolean({ description: 'With --backfill: also for groups indexed before, skipping what was missed since' })
declare reset: boolean
@flags.boolean({ description: "Start at the server's oldest article: the group's full history (also for groups indexed before)" })
declare fromStart: boolean
public async run() {
for (const name of this.names) {
if (!/^[a-z0-9][a-z0-9.+_-]*$/i.test(name)) {
this.logger.error(`Not a valid group name: ${name}`)
continue
}
const group = await Group.firstOrCreate({ name }, { name, active: !this.disable, fetchBodies: this.bodies })
group.active = !this.disable
if (this.bodies) group.fetchBodies = true
if (this.fromStart) {
// The scheduler continues after last_queued_id; 0 would mean "new group" to it.
const { first } = await this.groupInfo(name)
group.lastQueuedId = first > 1n ? first - 1n : 1n
group.lastIndexedId = group.lastQueuedId
} else if (this.backfill !== undefined && (this.reset || BigInt(group.lastQueuedId ?? 0) === 0n)) {
const { last } = await this.groupInfo(name)
group.lastQueuedId = (last > BigInt(this.backfill) ? last - BigInt(this.backfill) : 0n)
group.lastIndexedId = group.lastQueuedId
}
await group.save()
this.logger.info(
`${name}: ${group.active ? 'active' : 'inactive'}, bodies ${group.fetchBodies ? 'on' : 'off'}` +
(this.backfill !== undefined || this.fromStart ? `, starting after article ${group.lastQueuedId}` : '')
)
}
if (this.backfill !== undefined || this.fromStart) await NntpService.shutdown()
}
private async groupInfo(name: string) {
const conn = await NntpService.acquire()
let failed = false
try {
return parseGroupResponse((await conn.group(name)).resp)
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
}
}
-52
View File
@@ -1,52 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import NntpService from '#services/NntpService'
import BackfillService from '#services/BackfillService'
import { isSpotGroup } from '#services/SpotService'
/**
* Indexes a group's older headers (CONCEPT.md D32): from where live
* indexing began back to N days ago, header-only and behind live indexing.
* index:scheduler queues the ranges; this only sets where to go.
*/
export default class GroupBackfill extends BaseCommand {
public static commandName = 'group:backfill'
public static description = "Indexes groups' older headers (header-only, behind live indexing)."
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Groups (default: every active binary group)', required: false })
declare names?: string[]
@flags.number({ description: 'How many days back', default: 21 })
declare days: number
@flags.boolean({ description: 'Stop backfilling these groups (ranges already queued still run)' })
declare stop: boolean
public async run() {
const names = this.names?.length
? this.names
: (await db.from('groups').where('active', true).orderBy('name').select('name')).map((g) => g.name as string).filter((n) => !isSpotGroup(n))
try {
for (const name of names) {
if (this.stop) {
await BackfillService.stop(name)
this.logger.info(`${name}: backfill stopped.`)
continue
}
const range = await BackfillService.start(name, this.days)
this.logger.info(
range
? `${name}: backfilling articles ${range.from}–${range.to} (${(range.to - range.from).toLocaleString('en')} articles, ${this.days} days).`
: `${name}: nothing to backfill.`
)
}
} finally {
await NntpService.shutdown()
}
}
}
-176
View File
@@ -1,176 +0,0 @@
import { writeFile } from 'node:fs/promises'
import { setTimeout as sleep } from 'node:timers/promises'
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import indexerConfig from '#config/indexer'
import { parseGroupResponse } from '#lib/nntp/group'
import { parseSubject } from '#lib/subject/index'
interface GroupRate {
name: string
perMinute: number
sampled?: number
normal?: number
obfuscated?: number
skipped?: number
avgArticleBytes?: number
avgHeaderBytes?: number
}
/**
* Lists the busiest newsgroups (by articles posted per minute, from two
* LIST ACTIVE snapshots) and optionally samples their newest headers to
* estimate what indexing them would cost. See LOG.md (2026-09-28 (14)).
*
* node ace groups:top --minutes 5 --limit 1000 --sample 300 --out /data/top-groups.txt
*/
export default class GroupsTop extends BaseCommand {
public static commandName = 'groups:top'
public static description = 'Lists the busiest binary groups and estimates the bandwidth to index them.'
public static options: CommandOptions = {
startApp: true,
}
@flags.string({ description: 'Group pattern for LIST ACTIVE', default: 'alt.binaries.*' })
declare pattern: string
@flags.number({ description: 'Minutes between the two snapshots', default: 5 })
declare minutes: number
@flags.number({ description: 'Number of groups to list', default: 1000 })
declare limit: number
@flags.number({ description: 'Headers to sample per listed group (0 = no sampling)', default: 0 })
declare sample: number
@flags.string({ description: 'Write the list to this file (default: print only)' })
declare out?: string
public async run() {
const conn = await NntpService.acquire()
let failed = false
try {
this.logger.info(`Snapshot 1 of ${this.pattern}…`)
const first = await this.snapshot(conn)
this.logger.info(`${first.size} groups. Waiting ${this.minutes} min for snapshot 2…`)
const started = Date.now()
await sleep(this.minutes * 60_000)
const second = await this.snapshot(conn)
const minutes = (Date.now() - started) / 60_000
const groups: GroupRate[] = []
for (const [name, high] of second) {
const before = first.get(name)
if (before === undefined || high <= before) continue
groups.push({ name, perMinute: Number(high - before) / minutes })
}
groups.sort((a, b) => b.perMinute - a.perMinute)
const top = groups.slice(0, this.limit)
this.logger.info(`${groups.length} active groups; listing ${top.length}.`)
if (this.sample > 0) {
for (const [i, group] of top.entries()) {
try {
Object.assign(group, await this.sampleGroup(conn, group.name))
} catch (error: any) {
this.logger.warning(`${group.name}: ${error.message}`)
}
if ((i + 1) % 50 === 0) this.logger.info(`Sampled ${i + 1}/${top.length} groups…`)
}
}
const text = this.format(top, minutes)
if (this.out) {
await writeFile(this.out, text)
this.logger.info(`Written to ${this.out}`)
} else {
console.log(text)
}
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
await NntpService.shutdown()
}
}
/** Group name → highest article number, from LIST ACTIVE. */
private async snapshot(conn: any): Promise<Map<string, bigint>> {
const { data } = await conn.list(this.pattern)
const result = new Map<string, bigint>()
for (const line of Buffer.from(data).toString('latin1').split('\r\n')) {
const [name, high] = line.split(' ')
if (name && /^\d+$/.test(high ?? '')) result.set(name, BigInt(high))
}
return result
}
/** Classifies the newest headers the way worker:fetch does. */
private async sampleGroup(conn: any, name: string) {
const { last } = parseGroupResponse((await conn.group(name)).resp)
const start = last - BigInt(this.sample) + 1n
const { overviews } = await conn.xover((start > 0n ? start : 1n).toString(), last.toString())
let normal = 0
let skipped = 0
let obfuscated = 0
let obfuscatedBytes = 0
let headerBytes = 0
for (const [, header] of overviews as [number, Record<string, string>][]) {
if (!header) continue
headerBytes += Object.values(header).reduce((sum, v) => sum + String(v).length + 1, 0)
if (header.subject && parseSubject(header.subject)) normal++
else if (indexerConfig.skipBodyMessageIds.some((re) => re.test(header['message-id'] ?? ''))) skipped++
else {
obfuscated++
obfuscatedBytes += Number(header[':bytes']) || 0
}
}
const sampled = normal + skipped + obfuscated
return {
sampled,
normal,
skipped,
obfuscated,
avgArticleBytes: obfuscated ? obfuscatedBytes / obfuscated : 0,
avgHeaderBytes: sampled ? headerBytes / sampled : 0,
}
}
private format(groups: GroupRate[], minutes: number): string {
const sampled = groups.some((g) => g.sampled)
const lines = [
`# Busiest ${this.pattern} groups by articles/min, measured over ${minutes.toFixed(1)} min on ${new Date().toISOString()}`,
sampled
? '# rank\tgroup\tarticles/min\tobfuscated%\tskipped%\tbody Mbit/s\theader Mbit/s'
: '# rank\tgroup\tarticles/min',
]
let bodyTotal = 0
let headerTotal = 0
let articlesTotal = 0
for (const [i, g] of groups.entries()) {
articlesTotal += g.perMinute
if (!g.sampled) {
lines.push(`${i + 1}\t${g.name}\t${Math.round(g.perMinute)}`)
continue
}
const obfuscatedShare = g.obfuscated! / g.sampled
// Bodies: every obfuscated article is downloaded once. Headers: every article.
const bodyMbit = (g.perMinute * obfuscatedShare * g.avgArticleBytes! * 8) / 60 / 1e6
const headerMbit = (g.perMinute * g.avgHeaderBytes! * 8) / 60 / 1e6
bodyTotal += bodyMbit
headerTotal += headerMbit
lines.push(
[i + 1, g.name, Math.round(g.perMinute), (100 * obfuscatedShare).toFixed(1), ((100 * g.skipped!) / g.sampled).toFixed(1), bodyMbit.toFixed(1), headerMbit.toFixed(2)].join('\t')
)
}
lines.push(`# total: ${Math.round(articlesTotal)} articles/min`)
if (sampled) {
lines.push(`# bandwidth to index like alt.binaries.test (headers + obfuscated bodies): ${(bodyTotal + headerTotal).toFixed(0)} Mbit/s`)
lines.push(`# of which bodies ${bodyTotal.toFixed(0)} Mbit/s, headers ${headerTotal.toFixed(1)} Mbit/s (skip list applied)`)
}
return lines.join('\n') + '\n'
}
}
-144
View File
@@ -1,144 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import Group from '#models/group'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import indexerConfig from '#config/indexer'
import Shutdown from '#services/Shutdown'
import InspectionQueue from '#services/InspectionQueue'
import FetchRangeService from '#services/FetchRangeService'
import SplitService from '#services/SplitService'
import BackfillService from '#services/BackfillService'
export default class IndexScheduler extends BaseCommand {
public static commandName = 'index:scheduler'
public static description = 'Periodically checks for new articles and schedules them for fetching.'
public static options: CommandOptions = {
startApp: true,
}
private pool = NntpService
public async run() {
this.logger.info('Scheduler started. Awaiting tasks...')
const schedule = async () => {
this.logger.info('Checking for new headers...')
const groups = await Group.query().where('active', true)
if (groups.length === 0) {
this.logger.info('No active groups to index. Add some via `node ace db:seed` or manually.')
return
}
let conn
let failed = false
try {
conn = await this.pool.acquire()
for (const group of groups) {
try {
const { first: firstArticle, last: lastArticle } = await this.pool.selectGroup(
conn,
group.name
)
await FetchRangeService.requeue(group)
// Postgres returns bigint columns as strings.
const lastQueued = BigInt(group.lastQueuedId ?? 0)
const { batchSize, maxBatchesPerTick, initialBackfill } = indexerConfig
let startId =
lastQueued === 0n ? lastArticle - initialBackfill + 1n : lastQueued + 1n
if (startId < firstArticle) startId = firstArticle
if (startId > lastArticle) {
await FetchRangeService.advanceWatermark(group)
this.logger.info(`No new headers for group ${group.name}.`)
continue
}
let batches = 0
let endId = startId - 1n
while (endId < lastArticle && batches < maxBatchesPerTick) {
const batchStart = endId + 1n
endId = batchStart + batchSize - 1n < lastArticle ? batchStart + batchSize - 1n : lastArticle
await FetchRangeService.queue(group, batchStart, endId)
batches++
}
this.logger.info(
`Queued ${batches} fetch job(s) for ${group.name}: articles ${startId} to ${endId}` +
(endId < lastArticle ? ` (${lastArticle - endId} left for next tick)` : '')
)
group.lastQueuedId = endId
await group.save()
const watermark = await FetchRangeService.advanceWatermark(group)
if (endId - watermark > batchSize * BigInt(maxBatchesPerTick) * 3n) {
this.logger.warning(
`${group.name}: indexed up to ${watermark}, ${endId - watermark} articles behind the queue.`
)
}
} catch (err: any) {
failed = true
this.logger.error(`Error processing group ${group.name}: ${err.message}`)
}
}
} catch (err: any) {
failed = true
this.logger.error(`Error in scheduler main loop: ${err.message}`)
} finally {
if (conn) {
this.pool.release(conn, failed)
}
}
}
const split = async () => {
try {
const result = await SplitService.splitPending()
if (result.parents > 0) {
this.logger.info(
`Split ${result.parents} conflicted binaries: ${result.volumes} verified volumes, ${result.completed} completed.`
)
}
} catch (err: any) {
this.logger.error(`Error splitting conflicted binaries: ${err.message}`)
}
}
// The inspection backlog lives in Postgres; BullMQ gets a working set (LOG.md 2026-10-07 (8)).
const feedInspection = async () => {
const moved = await InspectionQueue.feed()
if (moved > 0) this.logger.info(`Inspection: ${moved} files moved from the backlog to the queue.`)
}
const backfill = async () => {
try {
const queued = await BackfillService.tick()
if (queued > 0) this.logger.info(`Queued ${queued} backfill range(s).`)
} catch (err: any) {
this.logger.error(`Error queuing backfill: ${err.message}`)
}
}
// Run immediately, then wait a full interval after each run so runs never overlap. A failed
// round (e.g. Postgres restarting) is logged and the next one tries again: an error that escaped
// here once ended the loop while the process stayed up, and live indexing stopped for ~3 hours
// (LOG.md 2026-10-07 (3)).
while (!Shutdown.requested) {
for (const step of [schedule, backfill, split, feedInspection]) {
try {
await step()
} catch (err: any) {
this.logger.error(`Scheduler round failed, retrying next round: ${err.message}`)
}
}
await Shutdown.sleep(indexerConfig.intervalMs)
}
await this.pool.shutdown()
await QueueService.closeAll()
}
}
-56
View File
@@ -1,56 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import QueueService from '#services/QueueService'
import InspectionQueue, { INSPECTION_PRIORITY } from '#services/InspectionQueue'
/**
* One-off (LOG.md 2026-10-07 (8)): moves the inspection jobs waiting in
* BullMQ into the inspection_queue table, then empties BullMQ's waiting and
* prioritized jobs so Redis gives the memory back. The file id is read from
* the job id (file-<id>, content-<id>-<time>), so no job is loaded.
* index-scheduler then feeds a working set back in.
*/
export default class InspectionFromRedis extends BaseCommand {
public static commandName = 'inspection:from-redis'
public static description = 'Moves waiting inspection jobs from Redis into the inspection_queue table.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count, change nothing' })
declare dryRun: boolean
public async run() {
const queue = QueueService.collectionQueue
const client = await queue.client
const prefix = `${queue.opts.prefix ?? 'bull'}:${queue.name}`
try {
const idOf = (jobId: string) => {
const m = /^(?:file|content)-(\d+)/.exec(jobId)
return m ? Number(m[1]) : null
}
let moved = 0
let skipped = 0
const take = async (ids: string[], priority: number) => {
const fileIds = ids.map(idOf).filter((id): id is number => id !== null)
skipped += ids.length - fileIds.length
if (!this.dryRun) await InspectionQueue.add(fileIds, priority)
moved += fileIds.length
}
const waiting = await client.llen(`${prefix}:wait`)
for (let i = 0; i < waiting; i += 50_000) await take(await client.lrange(`${prefix}:wait`, i, i + 49_999), INSPECTION_PRIORITY.normal)
// Prioritized jobs were the low-priority niconico captures.
const prioritized = await client.zcard(`${prefix}:prioritized`)
for (let i = 0; i < prioritized; i += 50_000) await take(await client.zrange(`${prefix}:prioritized`, i, i + 49_999), INSPECTION_PRIORITY.low)
this.logger.info(`${this.dryRun ? 'Would move' : 'Moved'} ${moved} jobs into inspection_queue (${skipped} with other ids skipped).`)
if (this.dryRun) return
// Waiting and prioritized jobs only; active ones finish as they are.
await queue.drain()
const counts = await queue.getJobCounts('wait', 'prioritized', 'active')
this.logger.info(`BullMQ now: ${counts.wait} waiting, ${counts.prioritized} prioritized, ${counts.active} active; table: ${await InspectionQueue.size()}.`)
} finally {
await QueueService.closeAll()
}
}
}
-21
View File
@@ -1,21 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import ReleaseMergeService from '#services/ReleaseMergeService'
/** Merges uploads split per poster (gc:scheduler also does this every sweep). */
export default class ReleaseMergeSplit extends BaseCommand {
public static commandName = 'release:merge-split'
public static description = 'Merges releases of one upload that was posted under a different poster per file.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count them' })
declare dryRun: boolean
public async run() {
const { names, releases } = await ReleaseMergeService.mergeSplitPosts({ dryRun: this.dryRun })
this.logger.info(`${names} split uploads; ${releases} releases ${this.dryRun ? 'would be' : ''} merged into their first.`)
}
}
-165
View File
@@ -1,165 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import IngestService from '#services/IngestService'
import NameService from '#services/NameService'
import QueueService from '#services/QueueService'
import { rarReleaseIdentity, releaseIdentity } from '#lib/release/index'
const BATCH = 5000
/**
* Recomputes the release of every binary (and its file) with the current
* rules in lib/release. Used after changing those rules.
*/
export default class ReleaseRebuild extends BaseCommand {
public static commandName = 'release:rebuild'
public static description = 'Reassigns all binaries and files to releases using the current grouping rules.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({
description:
'Also drop everything derived from file contents (par2 lists, par2/RAR groupings, real names), ' +
'to rebuild it with content:backfill --all',
})
declare resetContent: boolean
public async run() {
if (this.resetContent) await this.dropContentDerived()
let lastId = 0
let assigned = 0
let unassigned = 0
try {
while (true) {
const binaries = await db
.from('binaries')
.where('id', '>', lastId)
.orderBy('id')
.limit(BATCH)
.select('id', 'poster', 'subject', 'name', 'groups', 'posted_at')
if (binaries.length === 0) break
lastId = Number(binaries.at(-1).id)
const segments = binaries.map((binary) => {
// Body-derived binaries store a synthetic subject, see segmentFromYenc.
const weak = /^"[^"]*" yEnc$/.test(binary.subject)
return {
id: Number(binary.id),
poster: binary.poster,
groups: binary.groups,
postedAt: binary.posted_at ? new Date(binary.posted_at) : undefined,
weakIdentity: weak,
release: releaseIdentity({ poster: binary.poster, subject: binary.subject, name: binary.name, weak }),
}
})
await db.transaction(async (trx) => {
const releaseIds = await IngestService.upsertReleases(trx.knexClient, segments)
const ids = segments.map((s) => s.id)
const releaseIdsPerBinary = segments.map((s) =>
s.release ? releaseIds.get(s.release.key)! : null
)
await trx.rawQuery(
`UPDATE binaries b SET release_id = v.release_id
FROM unnest(?::bigint[], ?::bigint[]) AS v(id, release_id)
WHERE b.id = v.id AND b.release_id IS DISTINCT FROM v.release_id
AND NOT (v.release_id IS NULL
AND b.release_id IN (SELECT id FROM releases WHERE source IN ('par2', 'rar')))
-- A posted NZB decided this grouping (NzbImportService); name rules don't override it.
AND NOT EXISTS (SELECT 1 FROM files n JOIN file_details nd ON nd.file_id = n.id WHERE n.release_id = b.release_id AND nd.content_type = 'nzb')`,
[ids, releaseIdsPerBinary] as any
)
})
assigned += segments.filter((s) => s.release).length
unassigned += segments.filter((s) => !s.release).length
}
// Files grouped by their RAR headers: regroup from the stored listing (no downloads).
let rarGrouped = 0
let rarCursor = 0
while (true) {
const rows = await db
.from('files as f')
.join('binaries as b', 'b.file_id', 'f.id')
.join('file_details as d', 'd.file_id', 'f.id')
.whereNotNull('d.archive')
.whereNull('b.release_id')
.where('f.id', '>', rarCursor)
.orderBy('f.id')
.limit(BATCH)
.select('f.id as file_id', 'd.archive', 'b.id as binary_id', 'b.poster', 'b.groups', 'b.posted_at')
if (rows.length === 0) break
rarCursor = Number(rows.at(-1).file_id)
const segments = rows.map((r) => ({
fileId: Number(r.file_id),
binaryId: Number(r.binary_id),
poster: r.poster,
groups: r.groups,
postedAt: r.posted_at ? new Date(r.posted_at) : undefined,
weakIdentity: true,
release: rarReleaseIdentity(r.archive?.entries ?? []),
}))
const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), segments)
for (const s of segments.filter((x) => x.release)) {
const releaseId = ids.get(s.release!.key)
await db.from('binaries').where('id', s.binaryId).update({ release_id: releaseId })
await db.from('files').where('id', s.fileId).update({ release_id: releaseId })
rarGrouped++
}
}
// Real names from par2 for binaries with a known size (no downloads).
let named = 0
let nameCursor = 0
while (true) {
const rows = await db.from('binaries').whereNotNull('size').where('id', '>', nameCursor).orderBy('id').limit(BATCH).select('id')
if (rows.length === 0) break
nameCursor = Number(rows.at(-1).id)
named += await NameService.resolve(rows.map((r) => Number(r.id)))
}
const files = await db.rawQuery(
`UPDATE files f SET release_id = b.release_id FROM binaries b
WHERE b.file_id = f.id AND f.release_id IS DISTINCT FROM b.release_id`
)
const orphans = await db.rawQuery(
`DELETE FROM releases r WHERE NOT EXISTS (SELECT 1 FROM binaries b WHERE b.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.release_id = r.id)`
)
if (this.resetContent) {
this.logger.info('Content-derived data dropped; run `node ace content:backfill --all` to rebuild it.')
}
this.logger.info(
`Binaries: ${assigned} in a release, ${unassigned} without, ${rarGrouped} regrouped from RAR headers, ` +
`${named} named from par2. ` +
`Files updated: ${files.rowCount}. Empty releases removed: ${orphans.rowCount}.`
)
} finally {
await QueueService.closeAll()
}
}
/** par2 lists, par2/RAR release assignments and recovered names. */
private async dropContentDerived() {
await db.transaction(async (trx) => {
await trx.rawQuery('DELETE FROM release_files')
await trx.rawQuery(`UPDATE binaries SET real_name = NULL WHERE real_name IS NOT NULL`)
await trx.rawQuery(`UPDATE files SET real_name = NULL WHERE real_name IS NOT NULL`)
await trx.rawQuery(`UPDATE file_details SET archive = NULL WHERE archive IS NOT NULL`)
await trx.rawQuery(`UPDATE releases SET contents = NULL, encrypted = NULL WHERE contents IS NOT NULL OR encrypted IS NOT NULL`)
// Binaries whose name rules find no release were grouped from contents (or wrongly by size).
await trx.rawQuery(`
UPDATE binaries SET release_id = NULL
WHERE subject ~ '^"[^"]*" yEnc$' AND release_id IS NOT NULL
AND (name !~ '\\.[A-Za-z0-9]{1,5}$' OR release_id IN (SELECT id FROM releases WHERE source IN ('par2', 'rar')))`)
await trx.rawQuery(`
UPDATE files f SET release_id = b.release_id FROM binaries b
WHERE b.file_id = f.id AND f.release_id IS DISTINCT FROM b.release_id`)
})
this.logger.info('Dropped par2 lists, content-derived groupings and recovered names.')
}
}
-27
View File
@@ -1,27 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { renameFromStoredContents, renameHiddenNamed } from '#services/ArchiveService'
import { regroupAndRenameFromPar2 } from '#services/NameService'
/** Renames random-named releases from their stored archive listings (no downloads). */
export default class ReleaseRename extends BaseCommand {
public static commandName = 'release:rename'
public static description = 'Renames random-named releases from their par2 listings and archive contents.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only releases named after a hidden archive entry (e.g. a decoy ".x.mkv")' })
declare hidden: boolean
public async run() {
if (this.hidden) {
this.logger.info(`Renamed ${await renameHiddenNamed()} releases named after a hidden archive entry.`)
return
}
const par2 = await regroupAndRenameFromPar2()
this.logger.info(`par2: ${par2.regrouped} files moved into the set that names them, ${par2.renamed} releases renamed.`)
this.logger.info(`Renamed ${await renameFromStoredContents()} releases from their archive contents.`)
}
}
-57
View File
@@ -1,57 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
/** Prints how well files are being grouped into releases. */
export default class ReleaseStats extends BaseCommand {
public static commandName = 'release:stats'
public static description = 'Shows release recognition statistics.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const { rows } = await db.rawQuery(`
WITH r AS (
SELECT r.id, r.file_total, r.poster IS NULL AS weak, r.source,
count(b.id) AS binaries,
count(b.id) FILTER (WHERE b.completed_at IS NOT NULL) AS completed
FROM releases r LEFT JOIN binaries b ON b.release_id = r.id
GROUP BY r.id
)
SELECT
count(*) AS releases,
count(*) FILTER (WHERE completed > 0) AS with_files,
count(*) FILTER (WHERE completed >= 2) AS multi_file,
count(*) FILTER (WHERE file_total IS NOT NULL AND completed >= file_total) AS complete_by_counter,
count(*) FILTER (WHERE weak) AS from_obfuscated,
count(*) FILTER (WHERE weak AND completed > 0) AS from_obfuscated_with_files
FROM r`)
const { rows: coverage } = await db.rawQuery(`
SELECT count(*) AS completed_files,
count(*) FILTER (WHERE release_id IS NOT NULL) AS in_release
FROM binaries WHERE completed_at IS NOT NULL`)
const { rows: bySource } = await db.rawQuery(`
SELECT coalesce(r.source, 'unknown') AS source, count(DISTINCT r.id) AS releases,
count(b.id) FILTER (WHERE b.completed_at IS NOT NULL) AS completed_files
FROM releases r JOIN binaries b ON b.release_id = r.id GROUP BY 1 ORDER BY 2 DESC`)
// Readable names: descriptive as posted, or recovered from contents.
const { rows: naming } = await db.rawQuery(`
WITH f AS (
SELECT real_name IS NOT NULL AS recovered,
is_readable_name(coalesce(real_name, filename)) AS readable_name,
regexp_replace(coalesce(real_name, filename),
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z(\\.\\d{3})?|zip|\\d{3}|nfo|sfv|nzb|tar|zst|mkv|mp4|avi|flac|mp3|jpg))+$', '', 'i') AS stem,
coalesce(real_name, filename) AS name
FROM files
)
SELECT count(*) AS files,
count(*) FILTER (WHERE recovered) AS names_recovered,
count(*) FILTER (WHERE name ~ '\\.[A-Za-z0-9]{1,5}$' AND readable_name) AS readable
FROM f`)
this.logger.info(JSON.stringify({ ...rows[0], ...coverage[0] }))
this.logger.info(`naming: ${JSON.stringify(naming[0])}`)
this.logger.info(`by source: ${JSON.stringify(bySource)}`)
}
}
-461
View File
@@ -1,461 +0,0 @@
import { gzipSync, unzipSync } from 'node:zlib'
import { spawn, type ChildProcess } from 'node:child_process'
import { availableParallelism } from 'node:os'
import { setTimeout as sleep } from 'node:timers/promises'
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import nntpConfig from '#config/nntp'
import indexerConfig from '#config/indexer'
import { XoverClient, parseOverviews, type Overview } from '#lib/nntp/xover_client'
import { runRange, type OverviewSource } from '#lib/seed/runner'
import type { SeedBatch } from '#lib/seed/wire'
import type { OverviewHeader } from '#lib/ingest/segment'
import { probeInspection, probeWalk, type ContentTask } from '#lib/content/probe'
class LeaseLost extends Error {}
class Stopping extends Error {}
interface InspectJob {
queue: string
jobId: string
token: string
task: ContentTask
}
interface Lease {
id: number
group: string
start: number
end: number
}
/**
* A seeding node (CONCEPT.md D38): leases article ranges from the seed API
* over HTTPS, fetches and aggregates their headers over its own NNTP
* connection(s) (D37), and posts the results back in gzip-compressed
* batches. Outgoing requests only: no ports, database or Redis needed.
*
* SEED_URL=https://seed.example SEED_TOKEN=sn_… node ace seed:node --ranges 4 --inspectors 2
*/
export default class SeedNode extends BaseCommand {
public static commandName = 'seed:node'
public static description =
'Runs a seeding node: leases ranges from the seed API and submits what it aggregated.'
public static options: CommandOptions = {
startApp: false,
staysAlive: true,
}
@flags.string({ description: 'Seed API base URL (default: SEED_URL)' })
declare url?: string
@flags.string({ description: 'Node token (default: SEED_TOKEN)' })
declare token?: string
@flags.number({
description:
'Ranges seeded at once (with SEED_FETCH=direct, one NNTP connection each); 0 for an inspection-only node (default: SEED_RANGES or 1)',
default: process.env.SEED_RANGES ? Number(process.env.SEED_RANGES) : 1,
})
declare ranges: number
@flags.number({
description:
'Content inspections and archive walks worked on at once, articles through the seed API (default: SEED_INSPECTORS or 0)',
default: Number(process.env.SEED_INSPECTORS) || 0,
})
declare inspectors: number
@flags.number({ description: 'Articles per XOVER', default: 20_000 })
declare chunk: number
@flags.number({ description: 'XOVER chunks per batch posted to the API', default: 5 })
declare submitEvery: number
@flags.string({
description:
'relay: headers come through the seed API (no NNTP account needed); direct: own NNTP connections (default: SEED_FETCH or relay)',
default: process.env.SEED_FETCH || 'relay',
})
declare fetch: string
@flags.number({
description:
'Processes the ranges and inspections are spread over; one process uses one core (default: SEED_PROCESSES or the number of cores)',
default: process.env.SEED_PROCESSES ? Number(process.env.SEED_PROCESSES) : availableParallelism(),
})
declare processes: number
@flags.boolean({ description: 'Stop when there is nothing to lease instead of waiting' })
declare once: boolean
private stopping = false
/** In a worker process: its number, in front of its log lines. */
private readonly child = process.env.SEED_PROCESS ?? ''
public async run() {
const base = (this.url ?? process.env.SEED_URL ?? '').replace(/\/+$/, '')
const token = this.token ?? process.env.SEED_TOKEN ?? ''
if (!base || !token) {
this.logger.error('Set --url and --token (or SEED_URL and SEED_TOKEN).')
this.exitCode = 1
return
}
// JavaScript runs on one core per process: several slots are spread over worker processes.
const processes = Math.max(1, Math.min(this.processes || 1, this.ranges + this.inspectors))
if (!this.child && processes > 1) return this.supervise(base, token, processes)
const stop = () => {
if (!this.stopping && !this.child) this.logger.info('Stopping: giving back the ranges in progress…')
this.stopping = true
}
process.on('SIGTERM', stop)
process.on('SIGINT', stop)
const api = new SeedApi(base, token, (message) => this.logger.warning(message))
if (!this.child) this.logger.info(
`Seeding node for ${base}: ${this.ranges} range(s) at once, ${this.fetch === 'direct' ? 'own NNTP connections' : 'headers through the seed API'}` +
(this.inspectors > 0 ? `; ${this.inspectors} inspection(s) at once, articles through the seed API.` : '.')
)
await Promise.all([
...Array.from({ length: this.ranges }, (_, i) => this.work(api, i + 1)),
...Array.from({ length: this.inspectors }, (_, i) => this.inspect(api, i + 1)),
])
if (!this.child) this.logger.info('Seeding node stopped.')
}
/**
* Runs the slots in `processes` worker processes (this command again, with
* SEED_PROCESS set), each with its share of the ranges and inspections. A
* worker that exits while the node runs is restarted; SIGTERM/SIGINT are
* passed on, and the node stops when all workers have given their work back.
*/
private async supervise(base: string, token: string, processes: number) {
// Slots dealt out in turn (ranges first, then inspections), so every process has work.
const share = (from: number, to: number, i: number) =>
Array.from({ length: to - from }, (_, k) => from + k).filter((k) => k % processes === i).length
this.logger.info(
`Seeding node for ${base}: ${this.ranges} range(s) and ${this.inspectors} inspection(s) at once, over ${processes} processes, ` +
`${this.fetch === 'direct' ? 'own NNTP connections' : 'articles and headers through the seed API'}.`
)
const children = new Set<ChildProcess>()
const start = (n: number): Promise<void> =>
new Promise((resolve) => {
const args = [
...process.execArgv,
process.argv[1],
'seed:node',
`--ranges=${share(0, this.ranges, n - 1)}`,
`--inspectors=${share(this.ranges, this.ranges + this.inspectors, n - 1)}`,
`--fetch=${this.fetch}`,
`--chunk=${this.chunk}`,
`--submit-every=${this.submitEvery}`,
...(this.once ? ['--once'] : []),
]
const child = spawn(process.execPath, args, {
env: { ...process.env, SEED_URL: base, SEED_TOKEN: token, SEED_PROCESS: String(n) },
stdio: 'inherit',
})
children.add(child)
child.on('exit', (code, signal) => {
children.delete(child)
if (this.stopping || this.once || code === 0) return resolve()
this.logger.warning(`Process ${n} exited (${signal ?? code}); restarting in 10 s`)
setTimeout(() => start(n).then(resolve), 10_000)
})
})
const stop = (signal: NodeJS.Signals) => {
if (!this.stopping) this.logger.info('Stopping: giving back the ranges and inspections in progress…')
this.stopping = true
for (const child of children) child.kill(signal)
}
process.on('SIGTERM', () => stop('SIGTERM'))
process.on('SIGINT', () => stop('SIGINT'))
await Promise.all(Array.from({ length: processes }, (_, i) => start(i + 1)))
this.logger.info('Seeding node stopped.')
}
private async work(api: SeedApi, worker: number) {
const log = (message: string) => this.logger.info(`[${this.child && `${this.child}/`}${worker}] ${message}`)
// The NNTP connection is opened for a lease and kept while leases keep coming; with
// nothing to lease it's closed, so an idle node holds no connections.
let client: XoverClient | undefined
try {
while (!this.stopping) {
const lease = await this.lease(api, log)
if (!lease) {
client?.close()
client = undefined
if (this.once) return
await this.pause(60_000)
continue
}
if (this.fetch !== 'direct') {
await this.workRange(api, new RelaySource(api, lease.id), lease, log)
continue
}
if (!client) {
try {
client = await this.connect()
} catch (error: any) {
// Never started: the lease doesn't count as one of the range's attempts.
const ipLimit = /simultaneous IP/i.test(error.message)
log(`NNTP login failed: ${error.message}; trying again in ${ipLimit ? 5 : 1} min`)
await api
.post(`/api/v1/seed/ranges/${lease.id}/release`, {
error: `NNTP login: ${error.message}`,
uncounted: true,
})
.catch(() => {})
await this.pause(ipLimit ? 300_000 : 60_000)
continue
}
}
if (!(await this.workRange(api, client, lease, log))) {
client.close()
client = undefined
}
}
} finally {
client?.close()
}
}
/**
* Content inspection and archive walks (CONCEPT.md D41): lease a few jobs,
* probe each with articles through the seed API, post the results. The
* indexer applies them; a job left unanswered goes back after 10 minutes.
*/
private async inspect(api: SeedApi, slot: number) {
const log = (message: string) => this.logger.info(`[inspect ${this.child && `${this.child}/`}${slot}] ${message}`)
const fetchArticle = (id: string) => api.get(`/api/v1/seed/articles?id=${encodeURIComponent(id)}`, 4)
while (!this.stopping) {
let jobs: InspectJob[] = []
try {
jobs = ((await api.post('/api/v1/seed/inspect/lease', { max: 2 })) as { jobs: InspectJob[] } | null)?.jobs ?? []
} catch (error: any) {
if (error instanceof Unauthorized) throw error
log(`Lease failed: ${error.message}`)
}
if (jobs.length === 0) {
if (this.once) return
await this.pause(30_000)
continue
}
for (const job of jobs) {
const ref = { queue: job.queue, jobId: job.jobId, token: job.token }
if (this.stopping) {
await api.post('/api/v1/seed/inspect/release', ref).catch(() => {})
continue
}
try {
const result =
job.task.kind === 'inspect'
? await probeInspection(job.task, fetchArticle, (message) => log(message))
: await probeWalk(job.task, fetchArticle)
await api.post('/api/v1/seed/inspect/done', { ...ref, result: JSON.stringify(result) })
} catch (error: any) {
if (error instanceof Unauthorized) throw error
// The job's lock expired and it went to another node: nothing to report.
if (error instanceof LeaseLost) continue
log(`${job.task.kind === 'inspect' ? `"${job.task.name}"` : `walk of release ${job.task.releaseId}`} failed: ${error.message}`)
await api.post('/api/v1/seed/inspect/failed', { ...ref, error: error.message }).catch(() => {})
}
}
}
}
private connect() {
return XoverClient.connect({
host: nntpConfig.host,
port: nntpConfig.port,
secure: nntpConfig.secure,
servername: nntpConfig.tlsServername,
user: nntpConfig.user,
password: nntpConfig.password?.release(),
timeoutMs: nntpConfig.commandTimeoutMs,
compress: true,
})
}
/** The next range, or null when there is none (or the API can't be reached; logged). */
private async lease(api: SeedApi, log: (message: string) => void): Promise<Lease | null> {
try {
return ((await api.post('/api/v1/seed/lease')) as { range: Lease } | null)?.range ?? null
} catch (error: any) {
if (error instanceof Unauthorized) throw error
log(`Lease failed: ${error.message}`)
return null
}
}
/** Works one leased range; false when the NNTP connection should be replaced. */
private async workRange(
api: SeedApi,
client: OverviewSource,
lease: Lease,
log: (message: string) => void
): Promise<boolean> {
log(`${lease.group} ${lease.start}–${lease.end}`)
const started = Date.now()
let headers = 0
let chain: Promise<void> = Promise.resolve()
// Where the range resumes after what was stored: a batch split in two reports it with its first half.
let reported = lease.start
const posting = new Set<Promise<void>>()
// Throws once a post has failed, so the range stops fetching.
const failedSoFar = () => Promise.race([chain, Promise.resolve()])
try {
await runRange({
client,
group: lease.group,
start: lease.start,
end: lease.end,
chunk: this.chunk,
submitEvery: this.submitEvery,
isDeferred: (header: OverviewHeader) =>
indexerConfig.deferBodyMessageIds.some((re) => re.test(header['message-id'] ?? '')),
// Batches are posted in order, one after the other, while fetching goes on; with two
// waiting, fetching waits too. A failed post stops the chain, so no batch is stored
// past a missing one (the range would resume beyond it).
sink: async (batch: SeedBatch) => {
if (this.stopping) throw new Stopping()
while (posting.size >= 2) await Promise.race(posting)
const post = chain.then(async () => {
await this.postBatch(api, lease.id, batch, reported)
reported = batch.next
headers += batch.headers
})
chain = post
posting.add(post)
post.catch(() => {}).finally(() => posting.delete(post))
await failedSoFar()
},
})
await chain
await api.post(`/api/v1/seed/ranges/${lease.id}/done`)
const seconds = (Date.now() - started) / 1000
log(
`done: ${headers.toLocaleString('en')} headers in ${seconds.toFixed(0)} s (${Math.round(headers / seconds).toLocaleString('en')}/s)`
)
return true
} catch (error: any) {
if (error instanceof Unauthorized) throw error
// An XOVER may still be in flight: the connection is replaced either way.
if (error instanceof LeaseLost) {
log(`Range ${lease.id} passed to another node; dropping it.`)
return false
}
const reason = error instanceof Stopping ? 'node stopping' : error.message
if (!(error instanceof Stopping)) log(`Range ${lease.id} failed: ${reason}`)
// Let posts in flight finish (or fail) first, so the range resumes after what was stored.
await chain.catch(() => {})
await api.post(`/api/v1/seed/ranges/${lease.id}/release`, { error: reason }).catch(() => {})
if (!this.stopping) await this.pause(30_000)
return false
}
}
/**
* Posts a batch; one the seed API finds too large (413: dense recent ranges) is
* posted in two halves, at most three splits deep. Only the second half moves
* the range's resume point, so a failure in between loses nothing.
*/
private async postBatch(api: SeedApi, rangeId: number, batch: SeedBatch, reported: number, depth = 0): Promise<void> {
try {
await api.post(`/api/v1/seed/ranges/${rangeId}/batches`, batch)
} catch (error: any) {
const parts = batch.files.length + batch.fragments.length
if (!/: 413 /.test(String(error.message)) || depth >= 3 || parts < 2) throw error
const files = Math.ceil(batch.files.length / 2)
const fragments = Math.ceil(batch.fragments.length / 2)
const first: SeedBatch = { ...batch, next: reported, headers: 0, obfuscated: 0, deferred: 0, files: batch.files.slice(0, files), fragments: batch.fragments.slice(0, fragments) }
const second: SeedBatch = { ...batch, files: batch.files.slice(files), fragments: batch.fragments.slice(fragments) }
await this.postBatch(api, rangeId, first, reported, depth + 1)
await this.postBatch(api, rangeId, second, reported, depth + 1)
}
}
/** Sleeps, but wakes up early when the node is stopping. */
private async pause(ms: number) {
for (let waited = 0; waited < ms && !this.stopping; waited += 1000) await sleep(1000)
}
}
class Unauthorized extends Error {}
/** The seed API client: JSON over HTTPS, gzip request bodies, retries for network errors and 5xx. */
class SeedApi {
constructor(
private base: string,
private token: string,
private warn: (message: string) => void
) {}
async post(path: string, body?: unknown): Promise<unknown> {
const payload = body === undefined ? undefined : gzipSync(JSON.stringify(body))
const result = await this.request('POST', path, payload)
if (result === null) return null
const text = result.toString('utf8')
return text ? JSON.parse(text) : null
}
/** A binary GET (a relay chunk, an article). */
async get(path: string, attempts = 14): Promise<Buffer> {
return (await this.request('GET', path, undefined, attempts)) ?? Buffer.alloc(0)
}
private async request(method: string, path: string, payload?: Buffer, attempts = 14): Promise<Buffer | null> {
for (let attempt = 1; ; attempt++) {
let status = 0
let data = Buffer.alloc(0)
try {
const response = await fetch(this.base + path, {
method,
headers: {
authorization: `Bearer ${this.token}`,
...(payload ? { 'content-type': 'application/json', 'content-encoding': 'gzip' } : {}),
},
body: payload,
signal: AbortSignal.timeout(10 * 60 * 1000),
})
status = response.status
data = Buffer.from(await response.arrayBuffer())
} catch (error: any) {
data = Buffer.from(error.message)
}
const text = () => data.toString('utf8').slice(0, 300)
if (status === 204) return null
if (status >= 200 && status < 300) return data
if (status === 401) throw new Unauthorized('The seed API rejected this node token')
if (status === 409) throw new LeaseLost(text())
// 404 comes from the proxy while the seed API restarts (a deploy): this API has no 404s of its own.
if (status >= 400 && status < 500 && status !== 404)
throw new Error(`Seed API ${path}: ${status} ${text()}`)
// Network errors, 404 and 5xx: storing a batch twice is harmless, so try again.
// ~20 minutes in all, longer than a lease lasts: a restart or maintenance of the API is waited out.
if (attempt >= attempts) throw new Error(`Seed API ${path}: ${status || 'no response'} ${text()}`)
const wait = Math.min(120_000, 2000 * 2 ** (attempt - 1))
this.warn(`Seed API ${path}: ${status || text()}; retrying in ${wait / 1000} s`)
await sleep(wait)
}
}
}
/** A range's overviews through the seed API's relay (CONCEPT.md D38): no news server account on the node. */
class RelaySource implements OverviewSource {
constructor(
private api: SeedApi,
private rangeId: number
) {}
async group() {}
async xover(from: number, to: number): Promise<Overview[]> {
const raw = await this.api.get(
`/api/v1/seed/ranges/${this.rangeId}/chunk?from=${from}&to=${to}`
)
return raw.length > 0 ? parseOverviews(unzipSync(raw).toString('utf8')) : []
}
}
-47
View File
@@ -1,47 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import SeedOrchestrator from '#services/SeedOrchestrator'
/**
* Plans seed ranges (CONCEPT.md D38) for groups: every article from below
* what live indexing and the backfill cover down to `--days` back (or the
* server's oldest article) that no earlier range covers. Running it again
* plans only what's missing. A group's backfill stops where its plan
* starts. Ranges are interleaved across groups, newest first.
*
* node ace seed:plan alt.binaries.ath alt.binaries.mom --days 365
*/
export default class SeedPlan extends BaseCommand {
public static commandName = 'seed:plan'
public static description = 'Plans article ranges of groups for seeding nodes to lease.'
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Groups' })
declare groups: string[]
@flags.number({ description: 'How many days back (default: everything the server has)' })
declare days?: number
@flags.number({ description: 'Articles per range', default: 2_000_000 })
declare size: number
@flags.number({ description: 'Leased before ranges of a lower priority (default 0)', default: 0 })
declare priority: number
public async run() {
try {
const summary = await SeedOrchestrator.plan(this.groups, { days: this.days, size: this.size, priority: this.priority })
for (const s of summary) {
this.logger.info(
`${s.group}: ${s.ranges.toLocaleString('en')} new ranges in ${s.gaps} gap(s) not yet planned, articles ${s.from.toLocaleString('en')}–${s.to.toLocaleString('en')}.`
)
}
} finally {
await NntpService.shutdown()
}
}
}
-126
View File
@@ -1,126 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import nntpConfig from '#config/nntp'
import SeedService from '#services/SeedService'
import QueueService from '#services/QueueService'
import { isDeferred } from '#services/DeferredService'
import { XoverClient } from '#lib/nntp/xover_client'
import { runRange } from '#lib/seed/runner'
import type { SeedBatch } from '#lib/seed/wire'
/**
* Edge aggregation (CONCEPT.md D37) on this machine: indexes a range of a
* group's history over one NNTP connection (gzip-compressed overviews when
* the server has them), grouping headers into files in memory and storing
* one row per file directly. seed:node does the same for a remote node,
* through the seed API. Header-only, like the backfill.
*
* node ace seed:range alt.binaries.ath --start 46000000000 --count 2000000
* node ace seed:range alt.binaries.ath --start 46000000000 --count 2000000 --dry-run
*/
export default class SeedRange extends BaseCommand {
public static commandName = 'seed:range'
public static description = "Indexes a range of a group's headers by aggregating them into files on this node."
public static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Group' })
declare group: string
@flags.string({ description: 'First article number' })
declare start: string
@flags.number({ description: 'Articles to index', default: 1_000_000 })
declare count: number
@flags.number({ description: 'Articles per XOVER', default: 20_000 })
declare chunk: number
@flags.number({ description: 'Batches without a new part before an open file is given up as fragments', default: 5 })
declare idle: number
@flags.boolean({ description: 'Fetch and aggregate only; store nothing' })
declare dryRun: boolean
@flags.boolean({ description: 'Ask the server for gzip-compressed overviews', default: true })
declare compress: boolean
public async run() {
const first = Number(this.start)
if (!Number.isSafeInteger(first) || first < 1) throw new Error(`--start must be an article number, got ${this.start}`)
const last = first + this.count - 1
const stats = { headers: 0, obfuscated: 0, deferred: 0, complete: 0, files: 0, existing: 0, dropped: 0, fragments: 0, inspections: 0 }
let storeMs = 0
const cpuStart = process.cpuUsage()
const started = Date.now()
const client = await XoverClient.connect({
host: nntpConfig.host,
port: nntpConfig.port,
secure: nntpConfig.secure,
servername: nntpConfig.tlsServername,
user: nntpConfig.user,
password: nntpConfig.password?.release(),
timeoutMs: nntpConfig.commandTimeoutMs,
compress: this.compress,
})
this.logger.info(`Connected${client.compressed ? ', overviews gzip-compressed' : ''}.`)
try {
await runRange({
client,
group: this.group,
start: first,
end: last,
chunk: this.chunk,
idle: this.idle,
submitEvery: 1,
isDeferred,
sink: async (batch: SeedBatch) => {
stats.headers += batch.headers
stats.obfuscated += batch.obfuscated
stats.deferred += batch.deferred
stats.complete += batch.files.length
stats.fragments += batch.fragments.length
if (!this.dryRun) {
const t = Date.now()
const stored = await SeedService.store(batch.files)
await SeedService.storeFragments(batch.fragments, { start: first, end: last })
storeMs += Date.now() - t
stats.files += stored.files
stats.existing += stored.existing
stats.dropped += stored.dropped
stats.inspections += stored.inspections
}
this.progress(stats, started)
},
})
} finally {
client.close()
await QueueService.closeAll()
}
const seconds = (Date.now() - started) / 1000
const cpu = process.cpuUsage(cpuStart)
const n = (x: number) => x.toLocaleString('en')
this.logger.info(
`${this.group} ${first}-${last}: ${n(stats.headers)} headers in ${seconds.toFixed(0)} s (${n(Math.round(stats.headers / seconds))}/s). ` +
`${n(stats.obfuscated)} obfuscated, ${n(stats.deferred)} deferred. ` +
`${n(stats.complete)} complete files (${n(stats.files)} new, ${n(stats.existing)} known, ${n(stats.dropped)} garbage), ` +
`${n(stats.fragments)} fragment segments, ${n(stats.inspections)} queued for inspection.`
)
this.logger.info(`Store ${(storeMs / 1000).toFixed(0)} s; node CPU ${((cpu.user + cpu.system) / 1e6).toFixed(0)} s.`)
}
private lastReport = 0
private progress(stats: Record<string, number>, started: number) {
if (Date.now() - this.lastReport < 30_000) return
this.lastReport = Date.now()
const seconds = (Date.now() - started) / 1000
this.logger.info(
`${stats.headers.toLocaleString('en')} headers (${Math.round(stats.headers / seconds).toLocaleString('en')}/s), ` +
`${stats.complete.toLocaleString('en')} files, ${stats.fragments.toLocaleString('en')} fragment segments.`
)
}
}
-26
View File
@@ -1,26 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import SeedOrchestrator from '#services/SeedOrchestrator'
/** Seeding progress per range status and per node (CONCEPT.md D38). */
export default class SeedStatus extends BaseCommand {
public static commandName = 'seed:status'
public static description = 'Shows seeding progress per range status and per node.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const status = await SeedOrchestrator.status()
const n = (x: unknown) => Number(x ?? 0).toLocaleString('en')
for (const r of status.ranges) {
this.logger.info(`${r.status}: ${n(r.ranges)} ranges, ${n(r.reached)} of ${n(r.articles)} articles reached, ${n(r.headers)} headers, ${n(r.files)} files`)
}
for (const node of status.nodes) {
this.logger.info(`node ${node.name}${node.disabled ? ' (disabled)' : ''}: ${node.leases} leases, ${n(node.headers)} headers, last seen ${node.last_seen_at ?? 'never'}`)
}
this.logger.info(`Headers in ranges finished in the last hour: ${n(status.headersDoneLastHour)}`)
if (status.paused) this.logger.warning(`No new leases: ${status.paused} (BACKFILL_MAX_NVME_GB / BACKFILL_MAX_HDD_GB).`)
}
}
-37
View File
@@ -1,37 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import SeedOrchestrator from '#services/SeedOrchestrator'
/**
* Creates a seeding node, or gives it a new token (the old one stops
* working), and prints the token once (CONCEPT.md D38).
*
* node ace seed:token hetzner-1
* node ace seed:token hetzner-1 --disable
*/
export default class SeedToken extends BaseCommand {
public static commandName = 'seed:token'
public static description = 'Creates a seeding node or replaces its token; prints the token once.'
public static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Node name' })
declare name: string
@flags.boolean({ description: 'Disable the node instead (its leases pass to others after they expire)' })
declare disable: boolean
public async run() {
if (this.disable) {
const n = await db.from('seed_nodes').where('name', this.name).update({ disabled: true })
this.logger.info(n ? `${this.name}: disabled.` : `${this.name}: no such node.`)
return
}
const token = await SeedOrchestrator.issueToken(this.name)
this.logger.info(`${this.name}: token (shown once, keep it on the node only):`)
console.log(token)
}
}
-25
View File
@@ -1,25 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import SpotService from '#services/SpotService'
/** Passwords from the titles and descriptions of spots read so far, onto their releases. */
export default class SpotPasswords extends BaseCommand {
public static commandName = 'spot:passwords'
public static description = "Takes archive passwords from spots' titles and descriptions onto their releases."
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const spots = await db
.from('spots')
.whereNotNull('release_id')
.where((q) => q.whereNotNull('description').orWhere('title', 'like', '%{{%}}%'))
.select('message_id')
let found = 0
for (const spot of spots) if (await SpotService.applyPassword(spot.message_id)) found++
this.logger.info(`${found} of ${spots.length} spots name a password in their title or description.`)
}
}
-55
View File
@@ -1,55 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
const BATCH = 500
/**
* Reads the article and NZB of listed spots that were listed from their
* overview only (LOG.md 2026-10-08 (10)): description, the NZB's password or
* one in the spot's text, placeholder NZBs. Newest first; can be stopped and
* started again (worker:spots does the same when idle, a batch at a time).
* Connections: NNTP_POOL_SIZE of this process, --concurrency at a time.
*/
export default class SpotPostprocess extends BaseCommand {
public static commandName = 'spot:postprocess'
public static description = "Reads listed spots' articles and NZBs for their passwords and details."
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Spots read at a time (default: NNTP_POOL_SIZE)' })
declare concurrency?: number
@flags.number({ description: 'Stop after this many spots' })
declare limit?: number
public async run() {
const concurrency = this.concurrency ?? env.get('NNTP_POOL_SIZE', 4)
const [{ count }] = await db.from('spots').where('status', 'listed').whereNull('nzb_read_at').count('* as count')
this.logger.info(`${count} listed spots to read, ${concurrency} at a time.`)
const totals: Record<string, number> = {}
const started = Date.now()
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
const counts = await SpotService.readListings(Math.min(BATCH, this.limit ? this.limit - done : BATCH), concurrency)
const read = Object.values(counts).reduce((sum, n) => sum + n, 0)
if (read === 0) break
for (const [k, v] of Object.entries(counts)) totals[k] = (totals[k] ?? 0) + v
done += read
const rate = done / ((Date.now() - started) / 1000)
this.logger.info(`${done} read (${rate.toFixed(1)}/s): ${Object.entries(totals).map(([k, v]) => `${v} ${k}`).join(', ')}.`)
// Usenet unreachable: everything was given back; don't spin on it.
if (counts.retry === read) await Shutdown.sleep(30_000)
}
} finally {
await NntpService.shutdown()
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
/**
* Imported spots whose files sit in a release that isn't the spot's own
* (another source, or another name): their NZB is read again, which moves
* the files into the spot's release under its title (CONCEPT.md D29).
*/
export default class SpotReimport extends BaseCommand {
public static commandName = 'spot:reimport'
public static description = "Moves imported spots' files into their own release, named after the spot."
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count them' })
declare dryRun: boolean
public async run() {
const spots = await db
.from('spots as s')
.join('releases as r', 'r.id', 's.release_id')
.where('s.status', 'imported')
.where((q) => q.whereNot('r.source', 'spot').orWhereRaw('r.name <> s.title'))
.orderBy('s.posted_at', 'desc')
.select('s.*', 'r.name as release_name')
this.logger.info(`${spots.length} imported spots are in a release that isn't their own.`)
if (this.dryRun) return
let moved = 0
let failed = 0
try {
for (const spot of spots) {
try {
const releaseId = await SpotService.reimport(spot)
if (releaseId) moved++
else failed++
} catch (error: any) {
failed++
this.logger.warning(`${spot.title}: ${error.message}`)
}
}
} finally {
await NntpService.shutdown()
}
this.logger.info(`${moved} spots re-imported, ${failed} without a usable NZB (dummy, gone) or failed.`)
}
}
-97
View File
@@ -1,97 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import fs from 'node:fs/promises'
import path from 'node:path'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import env from '#start/env'
import NntpService from '#services/NntpService'
import IngestService, { segmentFromYenc } from '#services/IngestService'
import { parseYencMeta, YencError } from '#lib/yenc/index'
import { firstChunkMd5 } from '#services/ContentService'
export default class BodyWorker extends BaseCommand {
public static commandName = 'worker:body'
public static description = 'Starts a worker to process article bodies for yEnc metadata.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting body worker...')
const pool = NntpService
const worker = new Worker('body-queue', async (job) => {
const { header, group, articleNumber } = job.data
const messageId = header['message-id']
this.logger.debug(`Processing header with unparsable subject: ${header.subject}`)
let conn
let failed = false
try {
conn = await pool.acquire()
const bodyBuffer = await pool.fetchBody(conn, messageId)
let meta
try {
meta = parseYencMeta(bodyBuffer)
} catch (parseError: any) {
if (parseError instanceof YencError && bodyBuffer.indexOf('=ybegin ') === -1) {
this.logger.debug(`Not a yEnc post, skipping: ${messageId}`)
return
}
this.logger.error(`Failed to parse yEnc data for message ID ${messageId}. Dumping buffer.`)
const debugDir = path.join(this.app.appRoot.pathname, 'debug')
await fs.mkdir(debugDir, { recursive: true })
const timestamp = new Date().toISOString().replace(/:/g, '-')
const dumpFile = path.join(debugDir, `body-error-${timestamp}-${messageId.replace(/[<>]/g, '')}.bin`)
await fs.writeFile(dumpFile, bodyBuffer)
this.logger.error(`Problematic body buffer saved to: ${dumpFile}`)
throw parseError
}
pool.release(conn)
conn = undefined
const segment = segmentFromYenc(header, group, meta, articleNumber)
if (segment?.number === 1) segment.md5_16k = firstChunkMd5(bodyBuffer, meta.begin.size)
if (!segment) {
this.logger.warning(`Inconsistent yEnc part numbering in ${messageId}, skipping.`)
return
}
const completed = await IngestService.ingest([segment])
this.logger.debug(
`Stored part ${segment.number}/${segment.totalParts} of "${segment.name}"` +
(completed.length > 0 ? ' (file complete)' : '')
)
} catch (error: any) {
failed = true
this.logger.error(`Error in body worker for message ID ${messageId}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(conn, failed)
}
}
}, {
connection: queueConfig.connection,
// Each job holds one NNTP connection while downloading, so keep this <= the pool size.
concurrency: env.get('BODY_CONCURRENCY', 1),
})
worker.on('failed', (job, err) => {
this.logger.error(`Body job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Body worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
-75
View File
@@ -1,75 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
import ContentService, { describeContent } from '#services/ContentService'
import ArchiveWalkService from '#services/ArchiveWalkService'
import File from '#models/file'
export default class CollectionWorker extends BaseCommand {
public static commandName = 'worker:collection'
public static description = 'Starts a worker to process file collections (e.g., RAR archives).'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting collection worker...')
const worker = new Worker('collection-queue', async (job) => {
const { fileId } = job.data
this.logger.debug(`Processing file ID ${fileId} for collection.`)
const file = await File.find(fileId)
if (!file) {
this.logger.error(`File with ID ${fileId} not found in the database.`)
return
}
try {
const content = await ContentService.inspect(fileId)
for (const line of describeContent(file.filename, content)) this.logger.info(line)
} catch (error: any) {
this.logger.warning(`Content inspection of "${file.filename}" failed: ${error.message}`)
}
}, {
connection: queueConfig.connection,
// Each job holds one NNTP connection while downloading; keep <= the pool size.
concurrency: env.get('COLLECTION_CONCURRENCY', 1),
})
worker.on('failed', (job, err) => {
this.logger.error(`Collection job ${job?.id} failed: ${err.message}`)
})
// Complete archive listings (D40): background work beside inspection, on the same NNTP pool.
const walkConcurrency = env.get('ARCHIVE_WALK_CONCURRENCY', 1)
const walker = walkConcurrency > 0
? new Worker('archive-walk-queue', async (job) => {
const o = await ArchiveWalkService.walk(Number(job.data.releaseId))
if (o.skipped) return o
this.logger.info(
`Archive walk of release ${o.releaseId}: ${o.before} → ${o.entries} entries from ${o.segments} segments` +
(o.complete ? '' : ` (stopped: ${o.stopped})`) +
(o.renamed ? `, renamed to "${o.renamed}"` : '')
)
return o
}, { connection: queueConfig.connection, concurrency: walkConcurrency })
: null
walker?.on('failed', (job, err) => {
this.logger.error(`Archive walk job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Collection worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await Promise.all([worker.close(), walker?.close()])
await NntpService.shutdown()
await QueueService.closeAll()
}
}
-63
View File
@@ -1,63 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import DeferredService from '#services/DeferredService'
const IDLE_MS = 60_000
/**
* Works through `deferred_files` (CONCEPT.md D28): one body per file, and
* small files completely. Not started by default (compose profile
* `deferred`); run it when there is bandwidth to spare.
*/
export default class DeferredWorker extends BaseCommand {
public static commandName = 'worker:deferred'
public static description = 'Samples deferred files (e.g. ngPost uploads): one article body per file.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Stop after this many files (default: keep running)' })
declare limit?: number
public async run() {
const concurrency = env.get('DEFERRED_CONCURRENCY', 4)
this.logger.info(`Deferred worker started (concurrency ${concurrency}).`)
const counts: Record<string, number> = {}
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
try {
const batch = await DeferredService.claim(Math.min(concurrency * 4, this.limit ? this.limit - done : Infinity))
if (batch.length === 0) {
if (this.limit) break
await Shutdown.sleep(IDLE_MS)
continue
}
let next = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (next < batch.length) {
const status = await DeferredService.process(batch[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})
)
done += batch.length
this.logger.info(`${done} deferred files processed: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}.`)
} catch (err: any) {
// E.g. Postgres restarting: wait and claim again, instead of ending the loop (LOG.md 2026-10-07 (3)).
this.logger.error(`Round failed, retrying in 30 s: ${err.message}`)
await Shutdown.sleep(30_000)
}
}
} finally {
await NntpService.shutdown()
await QueueService.closeAll()
}
}
}
-118
View File
@@ -1,118 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import env from '#start/env'
import db from '@adonisjs/lucid/services/db'
import indexerConfig from '#config/indexer'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
import FetchRangeService from '#services/FetchRangeService'
import IngestService, { segmentFromHeader, type IngestSegment } from '#services/IngestService'
import DeferredService, { bodyJob, isDeferred, type DeferredArticle } from '#services/DeferredService'
import SpotService, { isSpotGroup } from '#services/SpotService'
import StagingService from '#services/StagingService'
export default class FetchWorker extends BaseCommand {
public static commandName = 'worker:fetch'
public static description = 'Starts a worker that fetches headers (XOVER) and stores them as binaries/parts.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting fetch worker...')
const pool = NntpService
const bodyQueue = QueueService.bodyQueue
const worker = new Worker('nntp-fetch-queue', async (job) => {
const { groupName, startId, endId } = job.data
this.logger.info(`Processing fetch job for ${groupName}, articles ${startId}-${endId}`)
let overviews: [number, Record<string, string>][]
let conn
let failed = false
try {
conn = await pool.acquire()
await pool.timed(conn.group(groupName), `GROUP ${groupName}`)
const reply = await pool.timed<any>(conn.xover(startId, endId), `XOVER ${groupName} ${startId}-${endId}`)
// An empty reply parses as one line without fields ([null, {}]).
overviews = reply.overviews.filter(([number, header]: [number | null, Record<string, string>]) => number !== null && header?.['message-id'])
} catch (error: any) {
failed = true
this.logger.error(`Error fetching headers for ${groupName}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(conn, failed)
}
}
if (isSpotGroup(groupName)) {
const spots = await SpotService.recordHeaders(groupName, overviews)
if (job.data.rangeId) await FetchRangeService.markDone(job.data.rangeId)
this.logger.info(`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${spots} spots queued.`)
return
}
const group = await db.from('groups').where('name', groupName).first()
// Backfill is header-only: old bodies would be far more than the link carries (CONCEPT.md D32).
const fetchBodies = Boolean(group?.fetch_bodies) && !job.data.backfill
const segments: IngestSegment[] = []
const obfuscated: { articleNumber: number; header: Record<string, string> }[] = []
const deferred: DeferredArticle[] = []
let skipped = 0
for (const [articleNumber, header] of overviews) {
if (!header) continue
const segment = segmentFromHeader(header, groupName, articleNumber)
if (segment) segments.push(segment)
// Recorded per file (or as article ranges) in every group, bodies or not; processed later if at all.
else if (isDeferred(header)) deferred.push({ articleNumber, header })
else if (!fetchBodies || indexerConfig.skipBodyMessageIds.some((re) => re.test(header['message-id'] ?? ''))) skipped++
else obfuscated.push({ articleNumber, header })
}
// Lone segments of new files wait in staging until their file proves real (D35).
const { ingest, staged } = StagingService.enabled(groupName)
? await StagingService.filter(segments, { counters: StagingService.countersEnabled(groupName) })
: { ingest: segments, staged: 0 }
const completed = await IngestService.ingest(ingest)
const deferredFiles = await DeferredService.record(groupName, deferred)
if (obfuscated.length > 0) {
await bodyQueue.addBulk(obfuscated.map(({ articleNumber, header }) => bodyJob(header, groupName, articleNumber)))
}
if (job.data.rangeId) await FetchRangeService.markDone(job.data.rangeId)
this.logger.info(
`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${ingest.length} stored${staged ? `, ${staged} staged` : ''}, ` +
`${obfuscated.length} sent to body-queue${skipped ? ` (${skipped} skipped)` : ''}` +
`${deferred.length ? `, ${deferred.length} deferred (${deferredFiles} files)` : ''}, ${completed.length} files completed.`
)
}, {
connection: queueConfig.connection,
// XOVER is quick; most of a job is storing. Each concurrent job uses one DB connection (DB_POOL_MAX).
concurrency: env.get('FETCH_CONCURRENCY', 5),
})
worker.on('failed', async (job, err) => {
this.logger.error(`Fetch job ${job?.id} failed for group ${job?.data.groupName}: ${err.message}`)
// Only after the last attempt; the scheduler re-queues the range later.
if (job?.data.rangeId && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await FetchRangeService.markFailed(job.data.rangeId, err.message).catch(() => {})
}
})
this.logger.info('Fetch worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
-54
View File
@@ -1,54 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import env from '#start/env'
import BinaryFileService from '#services/BinaryFileService'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
export default class FileWorker extends BaseCommand {
public static commandName = 'worker:file'
public static description = 'Starts a worker that turns completed binaries into files.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting file worker...')
const worker = new Worker('file-queue', async (job) => {
// A batch of completed binaries ('process-files'), or one from before batching ('process-file').
const ids: number[] = (job.data.binaryIds ?? [job.data.binaryId]).map(Number)
const result = await BinaryFileService.store(ids)
if (result.saved || result.existing) {
this.logger.info(
`Files: ${result.saved} saved, ${result.existing} existed already` +
(result.skipped ? `, ${result.skipped} skipped` : '') +
(result.inspections ? `, ${result.inspections} queued for inspection` : '') +
` (${ids.length} binaries).`
)
}
// The rest is stored; the job's retry picks these up (the others are linked now and skipped).
if (result.incomplete.length > 0) {
throw new Error(`${result.incomplete.length} of ${ids.length} binaries don't have all their parts yet.`)
}
}, {
connection: queueConfig.connection,
// One job at a time could not keep up with the backfill (130k waiting); jobs are per binary and
// the file insert ignores duplicates, so they can run side by side.
concurrency: env.get('FILE_CONCURRENCY', 8),
})
worker.on('failed', (job, err) => {
this.logger.error(`File job ${job?.id} failed: ${err.message}`)
})
this.logger.info('File worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await QueueService.closeAll()
}
}
-76
View File
@@ -1,76 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
const IDLE_MS = 30_000
/** Spots claimed at a time; most of a history backfill is listed in bulk. */
const BATCH = 500
/** Imported spots whose details are read per idle round. */
const DETAILS_BATCH = 50
/** Listed spots whose article and NZB are read per idle round (passwords, dummies). */
const LISTINGS_BATCH = 50
/**
* Works through queued Spotnet spots (CONCEPT.md D29): verifies each one,
* downloads its compressed NZB (a few KB to a few MB) and imports it. When
* idle, reads what listed spots were listed without: details, passwords.
*/
export default class SpotsWorker extends BaseCommand {
public static commandName = 'worker:spots'
public static description = 'Reads Spotnet spots: verifies them and imports their NZBs.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Stop after this many spots (default: keep running)' })
declare limit?: number
public async run() {
const concurrency = env.get('SPOT_CONCURRENCY', 4)
this.logger.info(`Spot worker started (concurrency ${concurrency}).`)
const counts: Record<string, number> = {}
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
try {
const batch = await SpotService.claim(Math.min(BATCH, this.limit ? this.limit - done : Infinity))
if (batch.length === 0) {
if (this.limit) break
// Idle: read the details of imported spots that don't have them yet (newest first), then
// the article and NZB of listed spots (their passwords; LOG.md 2026-10-08 (10)).
if ((await SpotService.fillDetails(DETAILS_BATCH)) > 0) continue
const listings = await SpotService.readListings(LISTINGS_BATCH, concurrency)
const read = Object.values(listings).reduce((sum, n) => sum + n, 0)
if (read === 0 || listings.retry === read) await Shutdown.sleep(IDLE_MS)
continue
}
// Old, verified spots are listed in bulk; the rest need their header (and maybe NZB) read.
const listable = batch.filter((spot) => SpotService.isListable(spot))
const single = batch.filter((spot) => !SpotService.isListable(spot))
counts.listed = (counts.listed ?? 0) + (await SpotService.list(listable))
let next = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (next < single.length) {
const status = await SpotService.process(single[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})
)
done += batch.length
this.logger.info(`${done} spots: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}.`)
} catch (err: any) {
// E.g. Postgres restarting: wait and claim again, instead of ending the loop (LOG.md 2026-10-07 (3)).
this.logger.error(`Round failed, retrying in 30 s: ${err.message}`)
await Shutdown.sleep(30_000)
}
}
} finally {
await NntpService.shutdown()
}
}
}