import db from '@adonisjs/lucid/services/db' import coordinatorConfig from '#config/coordinator' /** A share whose process hasn't synced for this long is gone: its connections closed with it. */ const STALE_SECONDS = 60 /** Syncs one at a time (pg_advisory_xact_lock). */ const LOCK = 2_026_101_875 export interface Share { name: string priority: number /** Kept whatever the others want (as far as the total goes, by priority). */ min: number /** What it could use now. */ want: number /** Connections it has open (or may have: a lane without one yet counts). */ open: number } /** What each share should have: its minimum first, then by priority what it wants, up to the total. */ export function targets(total: number, shares: Share[]): Map { const ordered = [...shares].sort((a, b) => b.priority - a.priority || a.name.localeCompare(b.name)) const target = new Map() let left = total for (const s of ordered) { const t = Math.min(s.min, left) target.set(s.name, t) left -= t } for (const s of ordered) { const more = Math.min(Math.max(s.want - target.get(s.name)!, 0), left) target.set(s.name, target.get(s.name)! + more) left -= more } return target } /** * The connections `self` may have: its target, but no more than the others leave over. Another share holds what it * has open or was granted, whichever is more (it may open up to its grant until its next sync tells it otherwise), * so the account never goes over the total: a share grows only into what the others closed or gave up. */ export function grant(total: number, self: Share, others: (Share & { granted: number })[]): number { const target = targets(total, [self, ...others]).get(self.name)! const held = others.reduce((sum, o) => sum + Math.max(o.open, o.granted), 0) return Math.max(0, Math.min(target, total - held)) } /** * A process's share of the NNTP account's connections (LOG.md 2026-10-18 (75)): the coordinator and the prober had a * fixed split, and with history paused the coordinator's 38 stood idle while the prober's 8 were its limit. Each * syncs every nntpShares.everyMs (what it wants and has open) and gets what it may open; one wanting less closes * connections as they come free, and the other grows as those show up closed. */ export class NntpShare { granted = 0 constructor( readonly name: string, private priority: number, readonly min: number, readonly max: number ) {} async sync(want: number, open: number): Promise { const self: Share = { name: this.name, priority: this.priority, min: this.min, want: Math.min(Math.max(want, this.min), this.max), open } const total = coordinatorConfig.nntpShares.total this.granted = await db.transaction(async (trx) => { await trx.rawQuery(`SELECT pg_advisory_xact_lock(?)`, [LOCK]) const { rows } = await trx.rawQuery( `SELECT name, priority, min, want, open, granted FROM nntp_shares WHERE name <> ? AND heartbeat_at > now() - make_interval(secs => ?)`, [this.name, STALE_SECONDS] ) const granted = grant(total, self, rows) await trx.rawQuery( `INSERT INTO nntp_shares (name, priority, min, want, open, granted, heartbeat_at) VALUES (?, ?, ?, ?, ?, ?, now()) ON CONFLICT (name) DO UPDATE SET priority = excluded.priority, min = excluded.min, want = excluded.want, open = excluded.open, granted = excluded.granted, heartbeat_at = now()`, [this.name, this.priority, this.min, self.want, open, granted] ) return granted }) return this.granted } /** Stopping: its connections are closed, the others needn't wait for the row to go stale. */ async leave() { await db.from('nntp_shares').where('name', this.name).delete() } }