Five ranges "never answered" in 10 minutes, yet their chunk came back in 0.3 s on a fresh connection: the news server drops idle connections without telling, and the pool's next request on one waited for the 10-minute inactivity timeout. Now: SEED_NNTP_TIMEOUT_MS 90 s (inactivity; slow ranges keep streaming), one retry on a fresh connection, and pooled connections idle over 60 s are replaced before use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
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 drops
|
|
* idle connections without telling, and a request on one then hangs until
|
|
* the inactivity timeout (LOG.md 2026-10-05 (11)).
|
|
*/
|
|
const IDLE_MS = 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
|
|
}
|
|
|
|
/** Retried once on a fresh connection: a failure is nearly always a dead connection, not the articles. */
|
|
async fetch(group: string, from: number, to: number): Promise<Buffer> {
|
|
try {
|
|
return await this.fetchOnce(group, from, to)
|
|
} catch {
|
|
return this.fetchOnce(group, from, to)
|
|
}
|
|
}
|
|
|
|
private async fetchOnce(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)
|
|
}
|
|
}
|
|
|
|
/** Closes the idle connections (tests, shutdown); busy ones close when released. */
|
|
closeIdle() {
|
|
for (const slot of this.idle.splice(0)) {
|
|
slot.client.close()
|
|
this.open--
|
|
}
|
|
}
|
|
|
|
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: indexerConfig.seed.nntpTimeoutMs,
|
|
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()
|