Files
usenet-indexer/app/services/nntp_pool.ts
T
Daan MeijerandClaude Opus 5.5 cfe85abc3e
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped
Rebuild: chunks the server's compression cuts short are fetched plain
Eweka sends some ranges' compressed XOVER truncated, the same bytes
every time, while the plain reply is whole (a.b.models 3317215-3367214:
1,675,600 bytes that don't inflate, twice; 50,000 lines plain;
tools/false_end.ts). Not a false end of reply. The worker's corrupt
report removed the chunk and the range was fetched again, with the same
bytes: one range was reported 540 times. (Before this morning's change
the inflate check waited for more data until the 10-minute timeout,
like voyager 1's 'chunk never answered' ranges.)

A reported chunk's start goes into ranges.plain_chunks; the prefetcher
fetches those over one connection without XFEATURE COMPRESS GZIP
(NntpPool.usePlain, opened on demand) and deflates them here. A plain
chunk reported again sets the range aside (status failed, error). The
capture (edge:bench --capture) fetches such a chunk plain too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:51:25 +02:00

120 lines
3.5 KiB
TypeScript

import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import coordinatorConfig from '#config/coordinator'
import { XoverClient } from '#lib/nntp/index'
/**
* The coordinator's header connections (REBUILD.md, decision e: workers
* have no NNTP account). At most NNTP_CONNECTIONS, reused; a connection
* that failed is closed and replaced on the next use.
*/
class NntpPool {
private idle: XoverClient[] = []
private busy = new Set<XoverClient>()
private open = 0
private waiting: ((client: XoverClient) => void)[] = []
get size() {
return coordinatorConfig.nntpConnections
}
async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
const client = await this.acquire()
this.busy.add(client)
let broken = false
try {
return await work(client)
} catch (error) {
broken = true
throw error
} finally {
this.busy.delete(client)
this.release(client, broken)
}
}
private async acquire(): Promise<XoverClient> {
const ready = this.idle.pop()
if (ready) return ready
if (this.open < this.size) {
this.open++
try {
return await XoverClient.connect({
host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: true,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
} catch (error) {
this.open--
throw error
}
}
return new Promise((resolve) => this.waiting.push(resolve))
}
private release(client: XoverClient, broken: boolean) {
if (broken) {
this.open--
try {
client.close()
} catch (error: any) {
logger.debug(`Closing a failed NNTP connection: ${error.message}`)
}
// Someone waiting gets a fresh connection.
const next = this.waiting.shift()
if (next) this.acquire().then(next, () => this.waiting.unshift(next))
return
}
const next = this.waiting.shift()
if (next) next(client)
else this.idle.push(client)
}
/** Closes every connection, also those in use: their commands fail. */
private plain: Promise<XoverClient> | null = null
private plainQueue: Promise<unknown> = Promise.resolve()
/**
* One connection without XFEATURE COMPRESS GZIP, opened when first needed,
* one command at a time: for chunks whose compressed reply the server
* cuts short (Leases.corrupt). Its replies are deflated here.
*/
async usePlain<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
const run = this.plainQueue.then(async () => {
this.plain ??= XoverClient.connect({
host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: false,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
const client = await this.plain
try {
return await work(client)
} catch (error) {
this.plain = null
client.close()
throw error
}
})
this.plainQueue = run.catch(() => {})
return run
}
async close() {
void this.plain?.then((c) => c.close()).catch(() => {})
this.plain = null
for (const client of [...this.idle.splice(0), ...this.busy]) client.close()
this.busy.clear()
this.open = 0
}
}
export default new NntpPool()