Inspection is split into prepare (database), probe (lib/content/probe: downloads, decoding and parsing, no database) and apply (database). worker:collection runs all three; seed:node with SEED_INSPECTORS leases jobs from the same queues through the seed API (BullMQ manual leases), fetches articles through the seed API and posts the probes back. A node now needs only SEED_URL and SEED_TOKEN: the settings the app requires but a node never uses get placeholders for seed:node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import { gzipSync, unzipSync } from 'node:zlib'
|
||
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_… NNTP_HOST=… node ace seed:node --connections 4
|
||
*/
|
||
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 worked on at once, one NNTP connection each; 0 for an inspection-only node (default: SEED_CONNECTIONS or 1)',
|
||
default: process.env.SEED_CONNECTIONS ? Number(process.env.SEED_CONNECTIONS) : 1,
|
||
})
|
||
declare connections: 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.boolean({ description: 'Stop when there is nothing to lease instead of waiting' })
|
||
declare once: boolean
|
||
|
||
private stopping = false
|
||
|
||
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
|
||
}
|
||
const stop = () => {
|
||
if (!this.stopping) 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))
|
||
this.logger.info(
|
||
`Seeding node for ${base}: ${this.connections} 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.connections }, (_, i) => this.work(api, i + 1)),
|
||
...Array.from({ length: this.inspectors }, (_, i) => this.inspect(api, i + 1)),
|
||
])
|
||
this.logger.info('Seeding node stopped.')
|
||
}
|
||
|
||
private async work(api: SeedApi, worker: number) {
|
||
const log = (message: string) => this.logger.info(`[${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 ${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()
|
||
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 api.post(`/api/v1/seed/ranges/${lease.id}/batches`, batch)
|
||
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
|
||
}
|
||
}
|
||
|
||
/** 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')) : []
|
||
}
|
||
}
|