Half the live leases go to higher-priority ranges, the rest to the others, so a slow recent gap runs beside older history. With no article connections the article relay answers 503 instead of waiting forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
6.9 KiB
TypeScript
157 lines
6.9 KiB
TypeScript
import type { HttpContext } from '@adonisjs/core/http'
|
|
import SeedOrchestrator, { LeaseLost } from '#services/SeedOrchestrator'
|
|
import SeedFetchPool from '#services/SeedFetchPool'
|
|
import RemoteContentService, { ArticleRelayOff, JobLost } from '#services/RemoteContentService'
|
|
import type { SeedBatch } from '#lib/seed/wire'
|
|
|
|
/**
|
|
* The seed API (CONCEPT.md D38). Nodes make outgoing HTTPS requests only:
|
|
* lease a range, post batches of what they found, then finish or release
|
|
* the range. Bodies may be gzip-compressed (Content-Encoding: gzip).
|
|
*/
|
|
export default class SeedController {
|
|
/** POST /api/v1/seed/lease → 200 { range } or 204 when there's nothing to do. */
|
|
async lease({ seedNode, response }: HttpContext) {
|
|
const range = await SeedOrchestrator.lease(seedNode.id)
|
|
return range ? { range } : response.noContent()
|
|
}
|
|
|
|
/** POST /api/v1/seed/ranges/:id/batches with a SeedBatch. */
|
|
async batch({ seedNode, params, request, response }: HttpContext) {
|
|
const batch = request.body() as SeedBatch
|
|
if (!Number.isSafeInteger(batch?.next) || !Array.isArray(batch.files) || !Array.isArray(batch.fragments)) {
|
|
return response.badRequest({ error: 'Expected { next, headers, files[], fragments[] }' })
|
|
}
|
|
return this.leased(response, async () => {
|
|
const stored = await SeedOrchestrator.batch(seedNode.id, Number(params.id), {
|
|
...batch,
|
|
headers: Number(batch.headers) || 0,
|
|
obfuscated: Number(batch.obfuscated) || 0,
|
|
deferred: Number(batch.deferred) || 0,
|
|
})
|
|
return { stored }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/seed/ranges/:id/chunk?from=&to= → the overviews of those
|
|
* articles as a zlib/gzip stream (relay nodes fetch through the indexer).
|
|
*/
|
|
async chunk({ seedNode, params, request, response }: HttpContext) {
|
|
if (SeedFetchPool.size === 0) return response.serviceUnavailable({ error: 'Relay is off (SEED_NNTP_CONNECTIONS=0); fetch directly' })
|
|
const from = Number(request.input('from'))
|
|
const to = Number(request.input('to'))
|
|
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return response.badRequest({ error: 'Expected ?from=&to=' })
|
|
return this.leased(response, async () => {
|
|
try {
|
|
const raw = await SeedOrchestrator.chunk(seedNode.id, Number(params.id), from, to)
|
|
response.header('content-type', 'application/octet-stream')
|
|
return response.send(raw)
|
|
} catch (error) {
|
|
if (error instanceof RangeError) return response.badRequest({ error: error.message })
|
|
throw error
|
|
}
|
|
})
|
|
}
|
|
|
|
/** POST /api/v1/seed/ranges/:id/done */
|
|
async done({ seedNode, params, response }: HttpContext) {
|
|
return this.leased(response, async () => {
|
|
await SeedOrchestrator.finish(seedNode.id, Number(params.id))
|
|
return { ok: true }
|
|
})
|
|
}
|
|
|
|
/** POST /api/v1/seed/ranges/:id/release { error?, uncounted? } */
|
|
async release({ seedNode, params, request, response }: HttpContext) {
|
|
const error = request.input('error')
|
|
const uncounted = request.input('uncounted') === true
|
|
return this.leased(response, async () => {
|
|
await SeedOrchestrator.release(seedNode.id, Number(params.id), typeof error === 'string' ? error : undefined, uncounted)
|
|
return { ok: true }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/seed/inspect/lease { max } → 200 { jobs } or 204: content
|
|
* inspection and archive walks for the node (CONCEPT.md D41).
|
|
*/
|
|
async inspectLease({ seedNode, request, response }: HttpContext) {
|
|
const max = Math.min(10, Math.max(1, Number(request.input('max')) || 1))
|
|
const jobs = await RemoteContentService.lease(seedNode.id, max)
|
|
return jobs.length > 0 ? { jobs } : response.noContent()
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/seed/inspect/done { queue, jobId, token, result } where
|
|
* result is the probe as a JSON string (the body parser would turn the
|
|
* empty strings inside it into nulls).
|
|
*/
|
|
async inspectDone({ request, response }: HttpContext) {
|
|
const { queue, jobId, token, result } = request.only(['queue', 'jobId', 'token', 'result'])
|
|
if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string' || typeof result !== 'string') {
|
|
return response.badRequest({ error: 'Expected { queue, jobId, token, result: JSON string }' })
|
|
}
|
|
return this.job(response, () => RemoteContentService.finish(queue, jobId, token, JSON.parse(result)))
|
|
}
|
|
|
|
/** POST /api/v1/seed/inspect/failed { queue, jobId, token, error } */
|
|
async inspectFailed({ request, response }: HttpContext) {
|
|
const { queue, jobId, token, error } = request.only(['queue', 'jobId', 'token', 'error'])
|
|
if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string') {
|
|
return response.badRequest({ error: 'Expected { queue, jobId, token, error }' })
|
|
}
|
|
return this.job(response, () => RemoteContentService.fail(queue, jobId, token, String(error ?? 'unknown error')))
|
|
}
|
|
|
|
/** POST /api/v1/seed/inspect/release { queue, jobId, token }: a stopping node gives a job back. */
|
|
async inspectRelease({ request, response }: HttpContext) {
|
|
const { queue, jobId, token } = request.only(['queue', 'jobId', 'token'])
|
|
if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string') {
|
|
return response.badRequest({ error: 'Expected { queue, jobId, token }' })
|
|
}
|
|
return this.job(response, () => RemoteContentService.release(queue, jobId, token))
|
|
}
|
|
|
|
/** GET /api/v1/seed/articles?id=<message id> → the raw article body; 410 when the server doesn't have it. */
|
|
async article({ request, response }: HttpContext) {
|
|
const id = request.input('id')
|
|
if (typeof id !== 'string' || id.length === 0 || id.length > 250) return response.badRequest({ error: 'Expected ?id=<message id>' })
|
|
try {
|
|
const body = await RemoteContentService.article(id)
|
|
if (!body) return response.gone({ error: 'No such article' })
|
|
response.header('content-type', 'application/octet-stream')
|
|
return response.send(body)
|
|
} catch (error: any) {
|
|
if (error instanceof ArticleRelayOff) return response.serviceUnavailable({ error: error.message })
|
|
return response.badGateway({ error: `NNTP: ${error.message}` })
|
|
}
|
|
}
|
|
|
|
/** GET /api/v1/seed/status */
|
|
async status() {
|
|
return SeedOrchestrator.status()
|
|
}
|
|
|
|
/** 409 when the job's lock is gone (it was given to another node meanwhile). */
|
|
private async job(response: HttpContext['response'], action: () => Promise<unknown>) {
|
|
try {
|
|
await action()
|
|
return { ok: true }
|
|
} catch (error) {
|
|
if (error instanceof JobLost) return response.conflict({ error: error.message })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** 409 when the range was given to another node meanwhile; the node should drop it. */
|
|
private async leased(response: HttpContext['response'], action: () => Promise<unknown>) {
|
|
try {
|
|
return await action()
|
|
} catch (error) {
|
|
if (error instanceof LeaseLost) return response.conflict({ error: error.message })
|
|
throw error
|
|
}
|
|
}
|
|
}
|