implemented a lot of features

This commit is contained in:
Daan Meijer committed 2026-09-29 00:32:01 +02:00
1 parent 62f1767df3
commit bcb9be5da9
141 files changed
+10705 -385

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
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()
}
}
}
+68
View File
@@ -0,0 +1,68 @@
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'
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
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')
.join('binaries as b', 'b.file_id', 'f.id')
.where('f.id', '>', lastId)
if (this.archiveNzbs) query.whereRaw(`f.archive::text ~* '\\.nzb"'`)
else if (!this.all) query.whereNull('b.md5_16k').whereNull('f.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) => ContentService.isCandidate(f.filename))
.slice(0, this.limit ? this.limit - queued : undefined)
.map((f) => ({
name: 'process-collection',
data: { fileId: Number(f.id) },
// A new id per run: BullMQ ignores an add() for an id it still knows.
// Targeted backfills go ahead of whatever is waiting.
opts: { jobId: `content-${f.id}-${Date.now()}`, lifo: this.archiveNzbs },
}))
if (jobs.length > 0) await QueueService.collectionQueue.addBulk(jobs)
queued += jobs.length
}
this.logger.info(`Queued ${queued} files for content inspection.`)
} finally {
await QueueService.closeAll()
}
}
}
+32
View File
@@ -0,0 +1,32 @@
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()
}
}
}
+282
View File
@@ -0,0 +1,282 @@
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 Shutdown from '#services/Shutdown'
const CLEAN_BATCH = 1000
const SCAN_COUNT = 1000
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
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}`)
}
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.`
)
}
}
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) {
const budget = Date.now() + gcConfig.deleteBudgetMs
const parts = await this.dropCopiedParts(budget)
const single = await this.deleteSingleArticleBinaries(budget)
const releases = await this.deleteEmptyReleases(budget)
if (parts || single || releases) {
this.logger.info(
`Space: ${parts} copied parts, ${single} single-article 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.`
)
}
}
/**
* 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.
*/
private async deleteStaleBinaries(): Promise<number> {
const query = db
.from('binaries')
.where('last_seen_at', '<', new Date(Date.now() - gcConfig.binaries.staleAfterSeconds * 1000))
.where((q) =>
q.whereNull('completed_at').orWhereNotNull('file_id').orWhereNotNull('conflicted_at')
)
if (this.dryRun) {
const [{ count }] = await query.count('* as count')
return Number(count)
}
return Number(await query.delete())
}
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
/** Parts of header-derived binaries that are a file already (worker:file now drops them itself). */
private async dropCopiedParts(budget: number): Promise<number> {
let total = 0
while (Date.now() < budget) {
const result = await db.rawQuery(
`DELETE FROM parts WHERE binary_id IN (
SELECT b.id FROM binaries b
WHERE b.file_id IS NOT NULL AND b.size IS NULL
AND EXISTS (SELECT 1 FROM parts p WHERE p.binary_id = b.id)
LIMIT ?)`,
[500]
)
total += result.rowCount ?? 0
if (!result.rowCount) break
}
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
}
/** 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
}
}
+72
View File
@@ -0,0 +1,72 @@
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
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.backfill !== undefined && (this.reset || BigInt(group.lastQueuedId ?? 0) === 0n)) {
// The scheduler continues after last_queued_id.
const last = await this.lastArticle(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 ? `, starting after article ${group.lastQueuedId}` : '')
)
}
if (this.backfill !== undefined) await NntpService.shutdown()
}
private async lastArticle(name: string): Promise<bigint> {
const conn = await NntpService.acquire()
let failed = false
try {
return parseGroupResponse((await conn.group(name)).resp).last
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
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'
}
}
+59 -25
View File
@@ -3,6 +3,10 @@ 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 FetchRangeService from '#services/FetchRangeService'
import SplitService from '#services/SplitService'
export default class IndexScheduler extends BaseCommand {
public static commandName = 'index:scheduler'
@@ -13,7 +17,6 @@ export default class IndexScheduler extends BaseCommand {
}
private pool = NntpService
private fetchQueue = QueueService.nntpFetchQueue
public async run() {
this.logger.info('Scheduler started. Awaiting tasks...')
@@ -28,58 +31,89 @@ export default class IndexScheduler extends BaseCommand {
}
let conn
let failed = false
try {
conn = await this.pool.acquire()
for (const group of groups) {
try {
const groupInfo: any = await conn.group(group.name)
// nntp-js returns article numbers as strings. We must parse them to BigInts.
const firstArticle = BigInt(groupInfo.first)
const lastArticle = BigInt(groupInfo.last)
const { first: firstArticle, last: lastArticle } = await this.pool.selectGroup(
conn,
group.name
)
// lastIndexedId from the database should also be treated as a BigInt.
const lastIndexed = group.lastIndexedId ? BigInt(group.lastIndexedId) : null
await FetchRangeService.requeue(group)
const startId = lastIndexed ? lastIndexed + 1n : firstArticle
// 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
}
const BATCH_SIZE = 100000n
const proposedEndId = startId + BATCH_SIZE - 1n
const endId = proposedEndId < lastArticle ? proposedEndId : lastArticle
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(`Queueing fetch job for ${group.name}: articles ${startId} to ${endId}`)
await this.fetchQueue.add('fetch-headers', {
groupName: group.name,
startId: startId.toString(),
endId: endId.toString(),
})
this.logger.info(
`Queued ${batches} fetch job(s) for ${group.name}: articles ${startId} to ${endId}` +
(endId < lastArticle ? ` (${lastArticle - endId} left for next tick)` : '')
)
group.lastIndexedId = endId
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)
this.pool.release(conn, failed)
}
}
}
// Run once immediately and then on an interval.
schedule()
setInterval(schedule, 60000) // 1 minute
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}`)
}
}
// Keep the command running
await new Promise(() => {})
// Run immediately, then wait a full interval after each run so runs never overlap.
while (!Shutdown.requested) {
await schedule()
await split()
await Shutdown.sleep(indexerConfig.intervalMs)
}
await this.pool.shutdown()
await QueueService.closeAll()
}
}
+163
View File
@@ -0,0 +1,163 @@
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 WHERE n.release_id = b.release_id AND n.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')
.whereNotNull('f.archive')
.whereNull('b.release_id')
.where('f.id', '>', rarCursor)
.orderBy('f.id')
.limit(BATCH)
.select('f.id as file_id', 'f.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, archive = NULL WHERE real_name IS NOT NULL OR 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.')
}
}
+20
View File
@@ -0,0 +1,20 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { renameFromStoredContents } 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,
}
public async run() {
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
@@ -0,0 +1,57 @@
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)}`)
}
}
+41 -18
View File
@@ -4,9 +4,13 @@ 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 { parseYencMeta } from '#services/YencService'
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'
@@ -20,31 +24,26 @@ export default class BodyWorker extends BaseCommand {
this.logger.info('Starting body worker...')
const pool = NntpService
const headerQueue = QueueService.headerQueue
const worker = new Worker('body-queue', async (job) => {
const { header, group } = job.data
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: Buffer = (await conn.body(messageId)).data
const bodyBuffer = await pool.fetchBody(conn, messageId)
let meta
try {
const meta = parseYencMeta(bodyBuffer)
if (meta.header.name) {
const { name, part, total } = meta.header
const newSubject = `"${name}" yEnc (${part}/${total})`
header.subject = newSubject
this.logger.info(`Found yEnc metadata in body. New subject: ${newSubject}`)
await headerQueue.add('process-header', { header, group })
} else {
this.logger.warning(`Could not find yEnc metadata in body for header: ${header.subject}`)
}
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 })
@@ -54,21 +53,45 @@ export default class BodyWorker extends BaseCommand {
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)
pool.release(conn, failed)
}
}
}, { connection: queueConfig.connection })
}, {
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 new Promise(() => {})
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
+37 -41
View File
@@ -2,10 +2,12 @@ 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 { YencFile } from '#services/YencFile'
import ContentService from '#services/ContentService'
import File from '#models/file'
import { createExtractorFromData } from 'node-unrar-js'
export default class CollectionWorker extends BaseCommand {
public static commandName = 'worker:collection'
@@ -18,8 +20,6 @@ export default class CollectionWorker extends BaseCommand {
public async run() {
this.logger.info('Starting collection worker...')
const pool = NntpService
const worker = new Worker('collection-queue', async (job) => {
const { fileId } = job.data
this.logger.debug(`Processing file ID ${fileId} for collection.`)
@@ -30,51 +30,47 @@ export default class CollectionWorker extends BaseCommand {
return
}
const RAR_REGEX = /\.part0*1\.rar$/
if (RAR_REGEX.test(file.filename)) {
this.logger.info(`File "${file.filename}" is the first part of a RAR set.`)
const firstPart = file.messageIds['1']
if (!firstPart || !firstPart.id) {
this.logger.error(`Could not find message ID for the first part of file "${file.filename}".`)
return
try {
const content = await ContentService.inspect(fileId)
if (content?.par2) {
const { par2 } = content
this.logger.info(`Par2 "${file.filename}": release "${par2.releaseName}", ${par2.listed} files listed.`)
} else if (content?.archive) {
const { info, releaseRenamed, grouped } = content.archive
const flags = [info.headersEncrypted && 'encrypted headers', info.entries.some((e) => e.encrypted) && 'encrypted files']
.filter(Boolean)
.join(', ')
this.logger.info(
`Archive "${file.filename}": ${info.format}, ${info.entries.length} entries${flags ? ` (${flags})` : ''}` +
(grouped ? ', grouped into a release' : '') +
(releaseRenamed ? `, release renamed to "${releaseRenamed}"` : '')
)
}
let conn
try {
conn = await pool.acquire()
const bodyBuffer = (await conn.body(`<${firstPart.id}>`)).data
const yencFile = new YencFile()
yencFile.processPart(bodyBuffer)
const decodedBuffer = yencFile.getBuffer()
const extractor = await createExtractorFromData({ data: new Uint8Array(decodedBuffer).buffer })
const fileList = extractor.getFileList()
// In a real implementation, we would save this file list.
this.logger.info(`Files in "${file.filename}": ${JSON.stringify(fileList)}`)
} catch (error: any) {
if (error.code === 430) {
this.logger.error(`Article not found for first part of RAR set (Message ID: ${firstPart.id})`)
} else {
this.logger.error(`Error processing RAR file: ${error.message}`)
}
} finally {
if (conn) {
pool.release(conn)
}
if (content?.named) this.logger.info(`"${file.filename}" got its real name from par2.`)
if (content?.contentType === 'nzbe') this.logger.info(`"${file.filename}" is an encrypted NZB container.`)
if (content?.contentType === 'rclone') this.logger.info(`"${file.filename}" is rclone-encrypted.`)
if (content?.nzb) {
const n = content.nzb
this.logger.info(`NZB "${file.filename}": ${n.listed} files (${n.known} known, ${n.added} added) → "${n.releaseName}".`)
}
} else {
this.logger.debug(`File "${file.filename}" is not the first part of a RAR set.`)
} catch (error: any) {
this.logger.warning(`Content inspection of "${file.filename}" failed: ${error.message}`)
}
}, { connection: queueConfig.connection })
}, {
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}`)
})
this.logger.info('Collection worker started and listening for jobs.')
await new Promise(() => {})
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
+57
View File
@@ -0,0 +1,57 @@
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)) {
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(', ')}.`)
}
} finally {
await NntpService.shutdown()
await QueueService.closeAll()
}
}
}
+67 -26
View File
@@ -2,12 +2,20 @@ 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'
export default class FetchWorker extends BaseCommand {
public static commandName = 'worker:fetch'
public static description = 'Starts a worker to fetch headers from the NNTP server.'
public static description = 'Starts a worker that fetches headers (XOVER) and stores them as binaries/parts.'
public static options: CommandOptions = {
startApp: true,
@@ -17,53 +25,86 @@ export default class FetchWorker extends BaseCommand {
this.logger.info('Starting fetch worker...')
const pool = NntpService
const headerQueue = QueueService.headerQueue
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 conn.group(groupName)
const overview: any = await conn.xover(startId, endId)
this.logger.info(`Fetched ${overview.overviews.length} headers from ${groupName}.`)
if (overview.overviews.length > 0) {
const jobs = overview.overviews.map(([id, header]: [number, any]) => {
if (!header) {
this.logger.warning(`Header is undefined for job ${job.id}`)
return null
}
return {
name: 'process-header',
data: { header, group: groupName },
opts: { jobId: `${groupName}-${id}` },
}
}).filter(Boolean)
await headerQueue.addBulk(jobs)
this.logger.info(`Added ${jobs.length} header jobs to the queue for ${groupName}.`)
}
overviews = (await conn.xover(startId, endId)).overviews
} catch (error: any) {
failed = true
this.logger.error(`Error fetching headers for ${groupName}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(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()
const fetchBodies = Boolean(group?.fetch_bodies)
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 })
}
const completed = await IngestService.ingest(segments)
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, ${segments.length} stored, ` +
`${obfuscated.length} sent to body-queue${skipped ? ` (${skipped} skipped)` : ''}` +
`${deferred.length ? `, ${deferred.length} deferred (${deferredFiles} files)` : ''}, ${completed.length} files completed.`
)
}, {
connection: queueConfig.connection,
concurrency: 5,
// 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', (job, err) => {
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 new Promise(() => {}) // Keep command running
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
+84 -37
View File
@@ -1,13 +1,14 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import db from '@adonisjs/lucid/services/db'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import File from '#models/file'
export default class FileWorker extends BaseCommand {
public static commandName = 'worker:file'
public static description = 'Starts a worker to process completed files.'
public static description = 'Starts a worker that turns completed binaries into files.'
public static options: CommandOptions = {
startApp: true,
@@ -19,45 +20,78 @@ export default class FileWorker extends BaseCommand {
const collectionQueue = QueueService.collectionQueue
const worker = new Worker('file-queue', async (job) => {
const { filename, parts, groups } = job.data
const partCount = Object.keys(parts).length
this.logger.debug(`Processing complete file: "${filename}" with ${partCount} parts.`)
const { binaryId } = job.data
const firstPart = JSON.parse(Object.values(parts)[0] as string)
const poster = firstPart.from
const date = new Date(firstPart.date).getTime()
const messageIds = Object.entries(parts).reduce((acc, [partNumber, partData]) => {
const part = JSON.parse(partData as string)
const messageId = part['message-id']
if (messageId) {
acc[partNumber] = {
id: messageId.replace(/[<>]/g, ''),
size: part[':bytes'],
}
} else {
this.logger.warning(`Message ID not found for part ${partNumber} of file "${filename}"`)
}
return acc
}, {} as Record<string, any>)
if (Object.keys(messageIds).length !== partCount) {
throw new Error(`Could not process all parts for file "${filename}" due to missing message IDs.`)
const binary = await db.from('binaries').where('id', binaryId).first()
if (!binary) {
this.logger.warning(`Binary ${binaryId} no longer exists (garbage collected?).`)
return
}
const file = await File.create({
filename,
poster,
date,
parts: partCount,
messageIds,
groups,
})
if (binary.conflicted_at) {
this.logger.warning(`Binary ${binaryId} ("${binary.name}") has conflicting parts, skipping.`)
return
}
this.logger.debug(`Saved file "${filename}" to database with ID: ${file.id}`)
const parts = await db
.from('parts')
.where('binary_id', binaryId)
.orderBy('number')
.select('number', 'message_id', 'bytes')
if (parts.length < binary.total_parts) {
throw new Error(`Binary ${binaryId} has ${parts.length}/${binary.total_parts} parts.`)
}
await collectionQueue.add('process-collection', { fileId: file.id })
this.logger.debug(`Added file ID ${file.id} to collection queue.`)
const messageIds = Object.fromEntries(
parts.map((part) => [part.number, { id: part.message_id, size: part.bytes }])
)
// Already known as a file, e.g. added from a posted NZB before its own articles completed.
const existing = await db
.from('files')
.whereRaw(`(message_ids->'1'->>'id') = ?`, [messageIds['1']?.id ?? ''])
.first()
if (existing) {
await db.from('binaries').where('id', binaryId).update({ file_id: existing.id })
await dropCopiedParts(binary)
this.logger.debug(`File "${binary.name}" already exists as ID ${existing.id} (same first segment).`)
return
}
// file_key is unique: a second completion of the same upload inserts nothing.
const knex = db.connection().getWriteClient()
const now = new Date()
const [inserted] = await knex('files')
.insert({
file_key: binary.file_key,
filename: binary.name,
poster: binary.poster,
date: binary.posted_at ? new Date(binary.posted_at).getTime() : now.getTime(),
parts: parts.length,
message_ids: JSON.stringify(messageIds),
groups: JSON.stringify(binary.groups),
release_id: binary.release_id,
real_name: binary.real_name,
size: parts.reduce((sum, part) => sum + Number(part.bytes), 0),
created_at: now,
updated_at: now,
})
.onConflict('file_key')
.ignore()
.returning('id')
const fileId =
inserted?.id ?? (await db.from('files').where('file_key', binary.file_key).first())?.id
await db.from('binaries').where('id', binaryId).update({ file_id: fileId })
await dropCopiedParts(binary)
if (!inserted) {
this.logger.debug(`File "${binary.name}" (${binary.file_key}) already exists as ID ${fileId}.`)
return
}
this.logger.info(`Saved file "${binary.name}" (${parts.length} parts) as ID ${fileId}.`)
await collectionQueue.add('process-collection', { fileId }, { jobId: `file-${fileId}` })
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
@@ -65,6 +99,19 @@ export default class FileWorker extends BaseCommand {
})
this.logger.info('File worker started and listening for jobs.')
await new Promise(() => {})
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await QueueService.closeAll()
}
}
/**
* The file holds every message id now. Binaries identified by their subject
* (no size: header-derived) don't need their parts any more; body-derived
* ones keep them, so articles of an equal-sized next volume are recognised
* as a conflict and split off (CONCEPT.md D20). LOG.md 2026-09-28 (17).
*/
async function dropCopiedParts(binary: { id: number; size: number | null }) {
if (binary.size === null) await db.from('parts').where('binary_id', binary.id).delete()
}
-65
View File
@@ -1,65 +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 QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
export default class HeaderWorker extends BaseCommand {
public static commandName = 'worker:header'
public static description = 'Starts a worker to process headers.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting header worker...')
const redis = RedisService.client
const fileQueue = QueueService.fileQueue
const bodyQueue = QueueService.bodyQueue
const worker = new Worker('header-queue', async (job) => {
const { header, group } = job.data
if (!header || !header.subject) {
this.logger.warning(`Received job with invalid header data. JobID: ${job.id}`)
return
}
const subject = header.subject
const SUBJECT_REGEX = /"(.+)"(?: yEnc)? \((\d+)\/(\d+)\)/
const match = subject.match(SUBJECT_REGEX)
if (match) {
const filename = match[1]
const part = parseInt(match[2], 10)
const total = parseInt(match[3], 10)
const fileKey = `file:${filename}`
await redis.hset(fileKey, part, JSON.stringify(header))
const partCount = await redis.hlen(fileKey)
if (partCount === total) {
const fileParts = await redis.hgetall(fileKey)
await fileQueue.add('process-file', { filename, parts: fileParts, groups: [group] })
await redis.del(fileKey)
this.logger.info(`File "${filename}" is complete and moved to file-queue.`)
} else {
this.logger.info(`Stored part ${part}/${total} for file "${filename}"`)
}
} else {
this.logger.warning(`Could not parse subject: "${subject}". Moving to body-queue.`)
await bodyQueue.add('process-body', { header, group })
}
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
this.logger.error(`Header job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Header worker started and listening for jobs.')
await new Promise(() => {})
}
}
+54
View File
@@ -0,0 +1,54 @@
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
/**
* 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.
*/
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)) {
const batch = await SpotService.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 SpotService.process(batch[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})
)
done += batch.length
this.logger.info(`${done} spots: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}.`)
}
} finally {
await NntpService.shutdown()
}
}
}