Seed relay: nodes get their headers through the seed API
The seed API keeps a pool of NNTP connections (SEED_NNTP_CONNECTIONS) and serves GET /api/v1/seed/ranges/:id/chunk?from=&to= for leased ranges: the gzip stream the news server sent, passed on unopened. seed:node fetches that way by default (SEED_FETCH=relay), so a node needs only a token and SEED_CONNECTIONS; SEED_FETCH=direct keeps its own NNTP connections. All fetching then comes from the indexer's IP (the account allows two). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d5cfb2527
commit
41859636d3
13 files changed
+289
-33
No files matched your search
+57
-16
@@ -1,11 +1,11 @@
|
||||
import { gzipSync } from 'node:zlib'
|
||||
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 } from '#lib/nntp/xover_client'
|
||||
import { runRange } from '#lib/seed/runner'
|
||||
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'
|
||||
|
||||
@@ -56,6 +56,13 @@ export default class SeedNode extends BaseCommand {
|
||||
@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
|
||||
|
||||
@@ -77,7 +84,9 @@ export default class SeedNode extends BaseCommand {
|
||||
process.on('SIGINT', stop)
|
||||
|
||||
const api = new SeedApi(base, token, (message) => this.logger.warning(message))
|
||||
this.logger.info(`Seeding node for ${base} with ${this.connections} connection(s).`)
|
||||
this.logger.info(
|
||||
`Seeding node for ${base}: ${this.connections} range(s) at once, ${this.fetch === 'direct' ? 'own NNTP connections' : 'headers through the seed API'}.`
|
||||
)
|
||||
await Promise.all(Array.from({ length: this.connections }, (_, i) => this.work(api, i + 1)))
|
||||
this.logger.info('Seeding node stopped.')
|
||||
}
|
||||
@@ -97,6 +106,10 @@ export default class SeedNode extends BaseCommand {
|
||||
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()
|
||||
@@ -151,7 +164,7 @@ export default class SeedNode extends BaseCommand {
|
||||
/** Works one leased range; false when the NNTP connection should be replaced. */
|
||||
private async workRange(
|
||||
api: SeedApi,
|
||||
client: XoverClient,
|
||||
client: OverviewSource,
|
||||
lease: Lease,
|
||||
log: (message: string) => void
|
||||
): Promise<boolean> {
|
||||
@@ -230,38 +243,66 @@ class SeedApi {
|
||||
|
||||
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). */
|
||||
async get(path: string): Promise<Buffer> {
|
||||
return (await this.request('GET', path)) ?? Buffer.alloc(0)
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, payload?: Buffer): Promise<Buffer | null> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
let status = 0
|
||||
let text = ''
|
||||
let data = Buffer.alloc(0)
|
||||
try {
|
||||
const response = await fetch(this.base + path, {
|
||||
method: 'POST',
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.token}`,
|
||||
accept: 'application/json',
|
||||
...(payload ? { 'content-type': 'application/json', 'content-encoding': 'gzip' } : {}),
|
||||
},
|
||||
body: payload,
|
||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
||||
})
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
data = Buffer.from(await response.arrayBuffer())
|
||||
} catch (error: any) {
|
||||
text = error.message
|
||||
data = Buffer.from(error.message)
|
||||
}
|
||||
const text = () => data.toString('utf8').slice(0, 300)
|
||||
if (status === 204) return null
|
||||
if (status >= 200 && status < 300) return text ? JSON.parse(text) : 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)
|
||||
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.slice(0, 300)}`)
|
||||
throw new Error(`Seed API ${path}: ${status} ${text()}`)
|
||||
// Network errors, 404 and 5xx: storing a batch twice is harmless, so try again.
|
||||
if (attempt >= 8)
|
||||
throw new Error(`Seed API ${path}: ${status || 'no response'} ${text.slice(0, 300)}`)
|
||||
if (attempt >= 8) 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`)
|
||||
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')) : []
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user