Inspection lists only the entries whose headers are in the first segment of a first volume. A walk hops from header to header, fetching one segment per entry and volume, in its own queue (one job per collection worker) on NNTP connections inspection isn't waiting for. content:walk-archives queues releases inspected earlier (CONCEPT D40). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import nntpConfig from '#config/nntp'
|
|
import { NNTP } from "#lib/nntp/nntp_js";
|
|
import { parseGroupResponse, type GroupInfo } from '#lib/nntp/group'
|
|
|
|
class NntpService {
|
|
private readonly poolSize: number;
|
|
private allConnections: Set<any>;
|
|
private idleConnections: any[];
|
|
private waiters: ((conn: any) => void)[];
|
|
/** Background work (archive walks): served only when no one else waits. */
|
|
private lowWaiters: ((conn: any) => void)[] = [];
|
|
private createdCount: number;
|
|
|
|
constructor(poolSize = 10) {
|
|
this.poolSize = poolSize;
|
|
this.allConnections = new Set();
|
|
this.idleConnections = [];
|
|
this.waiters = [];
|
|
this.createdCount = 0;
|
|
}
|
|
|
|
private async _createConnection() {
|
|
const conn = new NNTP(nntpConfig.host, nntpConfig.port);
|
|
try {
|
|
await conn.connect();
|
|
if (nntpConfig.user) {
|
|
await conn.login(nntpConfig.user, nntpConfig.password?.release());
|
|
}
|
|
} catch (error) {
|
|
// Free the slot, otherwise a failed connect shrinks the pool for good.
|
|
this.createdCount--;
|
|
this._destroy(conn);
|
|
throw error;
|
|
}
|
|
this.allConnections.add(conn);
|
|
return conn;
|
|
}
|
|
|
|
/** A connection from the pool; with `low`, after every waiting normal caller. */
|
|
public async acquire(low = false) {
|
|
if (this.idleConnections.length > 0) {
|
|
return this.idleConnections.pop();
|
|
}
|
|
|
|
if (this.createdCount < this.poolSize) {
|
|
this.createdCount++;
|
|
console.log(`Creating new connection (${this.createdCount}/${this.poolSize}).`);
|
|
return this._createConnection();
|
|
}
|
|
|
|
return new Promise(resolve => (low ? this.lowWaiters : this.waiters).push(resolve));
|
|
}
|
|
|
|
/**
|
|
* Returns a connection to the pool. Pass `failed` when a command on it
|
|
* threw: the connection may be dead (e.g. closed by the server while idle),
|
|
* so it is discarded instead of being handed to the next job.
|
|
*/
|
|
public release(conn: any, failed = false) {
|
|
if (failed) {
|
|
this.discard(conn);
|
|
return;
|
|
}
|
|
const resolve = this.nextWaiter();
|
|
if (resolve) {
|
|
resolve(conn);
|
|
} else {
|
|
this.idleConnections.push(conn);
|
|
}
|
|
}
|
|
|
|
/** Closes a broken connection and frees its slot for a new one. */
|
|
public discard(conn: any) {
|
|
this.allConnections.delete(conn);
|
|
this.createdCount--;
|
|
this._destroy(conn);
|
|
|
|
// Someone is waiting for a slot: open a fresh connection for them.
|
|
const resolve = this.nextWaiter();
|
|
if (resolve) {
|
|
this.createdCount++;
|
|
this._createConnection().then(resolve, () => this.waiters.unshift(resolve));
|
|
}
|
|
}
|
|
|
|
private nextWaiter() {
|
|
return this.waiters.shift() ?? this.lowWaiters.shift();
|
|
}
|
|
|
|
private _destroy(conn: any) {
|
|
try {
|
|
conn.sock?.destroy();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
}
|
|
|
|
/** Selects a group and returns its correctly parsed article range. */
|
|
public async selectGroup(conn: any, name: string): Promise<GroupInfo> {
|
|
const { resp } = await conn.group(name)
|
|
return parseGroupResponse(resp)
|
|
}
|
|
|
|
/**
|
|
* Rejects when `promise` hasn't settled within `ms`. nntp-js has no
|
|
* timeouts: a connection the server stops answering (still ESTABLISHED,
|
|
* nothing in flight) leaves its command waiting forever. The caller must
|
|
* then release the connection as failed, so it's destroyed.
|
|
*/
|
|
public timed<T>(promise: Promise<T>, what: string, ms = nntpConfig.commandTimeoutMs): Promise<T> {
|
|
let timer: NodeJS.Timeout
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(`NNTP ${what}: no answer within ${ms / 1000} s`)), ms)
|
|
})
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
}
|
|
|
|
/** Fetches a raw (still dot-stuffed) article body as a Buffer. */
|
|
public async fetchBody(conn: any, messageId: string): Promise<Buffer> {
|
|
const id = messageId.startsWith('<') ? messageId : `<${messageId}>`
|
|
const { data } = await conn.body(id)
|
|
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
|
|
}
|
|
|
|
public async shutdown() {
|
|
console.log('Shutting down all connections in the pool.');
|
|
// A connection the server already closed may never answer QUIT.
|
|
const quit = (conn: any) =>
|
|
Promise.race([conn.quit(), new Promise((resolve) => setTimeout(resolve, 2000).unref())])
|
|
.catch(() => {})
|
|
.finally(() => this._destroy(conn));
|
|
await Promise.all([...this.allConnections].map(quit));
|
|
this.allConnections.clear();
|
|
this.idleConnections.length = 0;
|
|
this.waiters.length = 0;
|
|
this.lowWaiters.length = 0;
|
|
this.createdCount = 0;
|
|
}
|
|
}
|
|
|
|
export default new NntpService(nntpConfig.poolSize);
|