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
@@ -0,0 +1,91 @@
|
||||
import nntpConfig from '#config/nntp'
|
||||
import indexerConfig from '#config/indexer'
|
||||
import { XoverClient } from '#lib/nntp/xover_client'
|
||||
|
||||
interface Slot {
|
||||
client: XoverClient
|
||||
group?: string
|
||||
usedAt: number
|
||||
}
|
||||
|
||||
/** A pooled connection unused this long is replaced: the news server may have dropped it. */
|
||||
const IDLE_MS = 2 * 60 * 1000
|
||||
|
||||
/**
|
||||
* NNTP connections in the seed API for relay nodes (CONCEPT.md D38): the
|
||||
* indexer fetches overview chunks and passes the gzip stream on unopened, so
|
||||
* nodes need no news server account and every connection comes from this
|
||||
* machine's IP. SEED_NNTP_CONNECTIONS (0: relay off).
|
||||
*/
|
||||
class SeedFetchPool {
|
||||
private idle: Slot[] = []
|
||||
private open = 0
|
||||
private waiters: (() => void)[] = []
|
||||
|
||||
get size() {
|
||||
return indexerConfig.seed.nntpConnections
|
||||
}
|
||||
|
||||
async fetch(group: string, from: number, to: number): Promise<Buffer> {
|
||||
const slot = await this.acquire()
|
||||
let ok = false
|
||||
try {
|
||||
if (slot.group !== group) {
|
||||
await slot.client.group(group)
|
||||
slot.group = group
|
||||
}
|
||||
const raw = await slot.client.xoverRaw(from, to)
|
||||
ok = true
|
||||
return raw
|
||||
} finally {
|
||||
this.release(slot, ok)
|
||||
}
|
||||
}
|
||||
|
||||
private async acquire(): Promise<Slot> {
|
||||
while (true) {
|
||||
const slot = this.idle.pop()
|
||||
if (slot) {
|
||||
if (Date.now() - slot.usedAt < IDLE_MS) return slot
|
||||
slot.client.close()
|
||||
this.open--
|
||||
continue
|
||||
}
|
||||
if (this.open < this.size) {
|
||||
this.open++
|
||||
try {
|
||||
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: true,
|
||||
})
|
||||
return { client, usedAt: Date.now() }
|
||||
} catch (error) {
|
||||
this.open--
|
||||
this.waiters.shift()?.()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolve) => this.waiters.push(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
private release(slot: Slot, ok: boolean) {
|
||||
if (ok) {
|
||||
slot.usedAt = Date.now()
|
||||
this.idle.push(slot)
|
||||
} else {
|
||||
// A failed command can leave the connection mid-reply: start afresh.
|
||||
slot.client.close()
|
||||
this.open--
|
||||
}
|
||||
this.waiters.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
export default new SeedFetchPool()
|
||||
Reference in new issue
Block a user