NNTP shares: the account's connections shared by priority
The coordinator (38) and the prober (8) had a fixed split; with history paused the coordinator's stood idle while the prober's were its limit. nntp_shares: each process syncs every 15 s (priority, minimum, what it could use, what it has open) and gets what it may open: minimums first, then by priority what each wants, growing only into connections the others closed or gave up. The pool's size and the prober's lanes follow the grant; the coordinator doubles its want while work waits for a connection, else asks for its peak in use and a margin; the prober wants its maximum unless the queue is dry. Claims scale with the lanes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7eba53043a
commit
39480c486f
10 files changed
+373
-75
No files matched your search
+55
-10
@@ -2,12 +2,17 @@ import env from '#start/env'
|
|||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import coordinatorConfig from '#config/coordinator'
|
import coordinatorConfig from '#config/coordinator'
|
||||||
import { XoverClient } from '#lib/nntp/index'
|
import { XoverClient } from '#lib/nntp/index'
|
||||||
|
import type { NntpShare } from '#services/nntp_shares'
|
||||||
|
|
||||||
|
/** Connections wanted beyond the most in use at once since the last sync (the planner's, a hedge's). */
|
||||||
|
const SHARE_MARGIN = 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The coordinator's header connections (REBUILD.md, decision e: workers
|
* The coordinator's header connections (REBUILD.md, decision e: workers
|
||||||
* have no NNTP account). At most NNTP_CONNECTIONS, reused; a connection
|
* have no NNTP account). At most NNTP_CONNECTIONS, reused; a connection
|
||||||
* that failed, or is older than NNTP_CONNECTION_MAX_MINUTES, is closed and
|
* that failed, or is older than NNTP_CONNECTION_MAX_MINUTES, is closed and
|
||||||
* replaced on the next use.
|
* replaced on the next use. Sharing the account (share()), at most its
|
||||||
|
* share: over it, connections close as they come free.
|
||||||
*/
|
*/
|
||||||
class NntpPool {
|
class NntpPool {
|
||||||
private idle: XoverClient[] = []
|
private idle: XoverClient[] = []
|
||||||
@@ -17,9 +22,40 @@ class NntpPool {
|
|||||||
private openedAt = new Map<XoverClient, number>()
|
private openedAt = new Map<XoverClient, number>()
|
||||||
/** When a connection gets closed once free: its max age ±25%, so they don't all reconnect at once. */
|
/** When a connection gets closed once free: its max age ±25%, so they don't all reconnect at once. */
|
||||||
private retireAt = new Map<XoverClient, number>()
|
private retireAt = new Map<XoverClient, number>()
|
||||||
|
private limit = coordinatorConfig.nntpConnections
|
||||||
|
/** Since the last sync: the most connections in use at once, and whether work waited for one (starved()). */
|
||||||
|
private peak = 0
|
||||||
|
private short = false
|
||||||
|
|
||||||
get size() {
|
get size() {
|
||||||
return coordinatorConfig.nntpConnections
|
return this.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Work that could use another connection didn't get one (the prefetcher with all its loops running). */
|
||||||
|
starved() {
|
||||||
|
this.short = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the share what the pool could use and has open, and takes the share's grant as its size. After work waited:
|
||||||
|
* twice its size (history going on gets its maximum in a few syncs; a tail round, done in seconds, doesn't take
|
||||||
|
* the prober's connections for one); else the most in use at once since the last sync and a margin.
|
||||||
|
*/
|
||||||
|
async share(share: NntpShare) {
|
||||||
|
const want = this.short || this.waiting.length > 0 ? Math.max(this.limit * 2, this.limit + SHARE_MARGIN) : this.peak + SHARE_MARGIN
|
||||||
|
this.short = false
|
||||||
|
this.peak = this.busy.size
|
||||||
|
const granted = await share.sync(want, this.open)
|
||||||
|
const was = this.limit
|
||||||
|
this.limit = granted
|
||||||
|
// Over the share: the idle ones close now, the busy ones once free (release()).
|
||||||
|
while (this.open > this.limit && this.idle.length > 0) this.retire(this.idle.pop()!)
|
||||||
|
// Under it with work waiting: connections for it.
|
||||||
|
while (this.waiting.length > 0 && this.open < this.limit) {
|
||||||
|
const next = this.waiting.shift()!
|
||||||
|
this.acquire().then(next, () => this.waiting.unshift(next))
|
||||||
|
}
|
||||||
|
return { was, granted }
|
||||||
}
|
}
|
||||||
|
|
||||||
async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
|
async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
|
||||||
@@ -44,6 +80,7 @@ class NntpPool {
|
|||||||
|
|
||||||
private async run<T>(client: XoverClient, work: (client: XoverClient) => Promise<T>): Promise<T> {
|
private async run<T>(client: XoverClient, work: (client: XoverClient) => Promise<T>): Promise<T> {
|
||||||
this.busy.add(client)
|
this.busy.add(client)
|
||||||
|
this.peak = Math.max(this.peak, this.busy.size)
|
||||||
let broken = false
|
let broken = false
|
||||||
try {
|
try {
|
||||||
return await work(client)
|
return await work(client)
|
||||||
@@ -84,15 +121,12 @@ class NntpPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private release(client: XoverClient, broken: boolean) {
|
private release(client: XoverClient, broken: boolean) {
|
||||||
|
if (this.open > this.limit) {
|
||||||
|
this.retire(client)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (broken || Date.now() >= (this.retireAt.get(client) ?? Infinity)) {
|
if (broken || Date.now() >= (this.retireAt.get(client) ?? Infinity)) {
|
||||||
this.open--
|
this.retire(client)
|
||||||
this.openedAt.delete(client)
|
|
||||||
this.retireAt.delete(client)
|
|
||||||
try {
|
|
||||||
client.close()
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.debug(`Closing a failed NNTP connection: ${error.message}`)
|
|
||||||
}
|
|
||||||
// Someone waiting gets a fresh connection.
|
// Someone waiting gets a fresh connection.
|
||||||
const next = this.waiting.shift()
|
const next = this.waiting.shift()
|
||||||
if (next) this.acquire().then(next, () => this.waiting.unshift(next))
|
if (next) this.acquire().then(next, () => this.waiting.unshift(next))
|
||||||
@@ -103,6 +137,17 @@ class NntpPool {
|
|||||||
else this.idle.push(client)
|
else this.idle.push(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private retire(client: XoverClient) {
|
||||||
|
this.open--
|
||||||
|
this.openedAt.delete(client)
|
||||||
|
this.retireAt.delete(client)
|
||||||
|
try {
|
||||||
|
client.close()
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.debug(`Closing an NNTP connection: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Closes every connection, also those in use: their commands fail. */
|
/** Closes every connection, also those in use: their commands fail. */
|
||||||
private plain: Promise<XoverClient> | null = null
|
private plain: Promise<XoverClient> | null = null
|
||||||
private plainQueue: Promise<unknown> = Promise.resolve()
|
private plainQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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<string, number> {
|
||||||
|
const ordered = [...shares].sort((a, b) => b.priority - a.priority || a.name.localeCompare(b.name))
|
||||||
|
const target = new Map<string, number>()
|
||||||
|
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<number> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,8 @@ export class Prefetcher {
|
|||||||
.catch((error) => logger.warn(`Prefetching: ${error.message}`))
|
.catch((error) => logger.warn(`Prefetching: ${error.message}`))
|
||||||
.finally(() => this.running--)
|
.finally(() => this.running--)
|
||||||
}
|
}
|
||||||
|
// Work left with every loop running: the pool's share could be bigger.
|
||||||
|
if (!this.stopped && this.running >= this.loops && this.peek()) NntpPool.starved()
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -145,6 +147,9 @@ export class Prefetcher {
|
|||||||
/** One connection's loop: chunks until there is nothing left to fetch. */
|
/** One connection's loop: chunks until there is nothing left to fetch. */
|
||||||
private async work() {
|
private async work() {
|
||||||
while (!this.stopped) {
|
while (!this.stopped) {
|
||||||
|
// The pool's share got smaller: fewer loops (waiting on the pool, they'd count as work short of a connection).
|
||||||
|
// Two leaving at once is one too many for a tick (TICK_MS), whose loop comes back.
|
||||||
|
if (this.running > this.loops) return
|
||||||
const next = this.take()
|
const next = this.take()
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const { target, span } = next
|
const { target, span } = next
|
||||||
|
|||||||
+96
-57
@@ -6,6 +6,7 @@ import coordinatorConfig from '#config/coordinator'
|
|||||||
import { XoverClient } from '#lib/nntp/index'
|
import { XoverClient } from '#lib/nntp/index'
|
||||||
import { YencError } from '#lib/yenc/index'
|
import { YencError } from '#lib/yenc/index'
|
||||||
import { inspect, innerName, PROBE_VERSION, type Inspection } from '#lib/probe/index'
|
import { inspect, innerName, PROBE_VERSION, type Inspection } from '#lib/probe/index'
|
||||||
|
import { NntpShare } from '#services/nntp_shares'
|
||||||
|
|
||||||
interface Candidate {
|
interface Candidate {
|
||||||
id: number
|
id: number
|
||||||
@@ -46,6 +47,12 @@ export class Prober {
|
|||||||
private refilling: Promise<void> | null = null
|
private refilling: Promise<void> | null = null
|
||||||
private stopped = false
|
private stopped = false
|
||||||
private stats = Prober.freshStats()
|
private stats = Prober.freshStats()
|
||||||
|
/** Lanes running (each one connection, once it has work); at most the share's grant. Counted apart from the set: a lane leaving takes itself off at once. */
|
||||||
|
private lanes = new Set<Promise<void>>()
|
||||||
|
private running = 0
|
||||||
|
/** The last lane to look found nothing to claim: the prober wants its minimum only. */
|
||||||
|
private dry = false
|
||||||
|
private share = new NntpShare('prober', coordinatorConfig.nntpShares.proberPriority, coordinatorConfig.probe.minConnections, coordinatorConfig.probe.connections)
|
||||||
|
|
||||||
/** Per minute: what was read, and where the lanes' time went (ms summed over lanes; flushes ms on their own). */
|
/** Per minute: what was read, and where the lanes' time went (ms summed over lanes; flushes ms on their own). */
|
||||||
private static freshStats() {
|
private static freshStats() {
|
||||||
@@ -56,8 +63,7 @@ export class Prober {
|
|||||||
// A previous run's claims: one prober at a time.
|
// A previous run's claims: one prober at a time.
|
||||||
const { rowCount } = await db.rawQuery(`UPDATE probe_candidates SET status = 'waiting' WHERE status = 'taken'`)
|
const { rowCount } = await db.rawQuery(`UPDATE probe_candidates SET status = 'waiting' WHERE status = 'taken'`)
|
||||||
if (rowCount) logger.info(`Probing: ${rowCount} candidates from a previous run back in the queue.`)
|
if (rowCount) logger.info(`Probing: ${rowCount} candidates from a previous run back in the queue.`)
|
||||||
const connections = coordinatorConfig.probe.connections
|
logger.info(`Probing with ${this.share.min}–${this.share.max} connections (its share of the account's).`)
|
||||||
logger.info(`Probing with ${connections} connections.`)
|
|
||||||
const flusher = (async () => {
|
const flusher = (async () => {
|
||||||
while (!this.stopped || this.outcomes.length > 0) {
|
while (!this.stopped || this.outcomes.length > 0) {
|
||||||
await sleep(FLUSH_MS)
|
await sleep(FLUSH_MS)
|
||||||
@@ -67,7 +73,26 @@ export class Prober {
|
|||||||
this.report()
|
this.report()
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
await Promise.all(Array.from({ length: connections }, () => this.lane()))
|
// Lanes as the share allows: the coordinator's history comes first (app/services/nntp_shares.ts).
|
||||||
|
while (!this.stopped) {
|
||||||
|
try {
|
||||||
|
const was = this.share.granted
|
||||||
|
const granted = await this.share.sync(this.dry ? 0 : this.share.max, this.running)
|
||||||
|
if (granted !== was) logger.info(`Probing: ${granted} connections (was ${was}).`)
|
||||||
|
while (this.running < granted) {
|
||||||
|
this.running++
|
||||||
|
const lane: Promise<void> = this.lane()
|
||||||
|
.catch((error) => logger.error(`Probing: a lane failed: ${error.message}`))
|
||||||
|
.finally(() => this.lanes.delete(lane))
|
||||||
|
this.lanes.add(lane)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Probing: NNTP share failed: ${error.message}`)
|
||||||
|
}
|
||||||
|
for (let waited = 0; waited < coordinatorConfig.nntpShares.everyMs && !this.stopped; waited += 1000) await sleep(1000)
|
||||||
|
}
|
||||||
|
await Promise.all(this.lanes)
|
||||||
|
await this.share.leave().catch(() => {})
|
||||||
// What was claimed and not probed goes back.
|
// What was claimed and not probed goes back.
|
||||||
for (const candidate of this.queue.splice(0)) this.outcomes.push({ candidate, status: 'waiting' })
|
for (const candidate of this.queue.splice(0)) this.outcomes.push({ candidate, status: 'waiting' })
|
||||||
this.stopped = true
|
this.stopped = true
|
||||||
@@ -78,68 +103,82 @@ export class Prober {
|
|||||||
this.stopped = true
|
this.stopped = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One connection's loop: PROBE_PIPELINE articles at a time (pipelined), reconnecting after a failure. */
|
/**
|
||||||
|
* One connection's loop: PROBE_PIPELINE articles at a time (pipelined), reconnecting after a failure; done when
|
||||||
|
* there are more lanes than the share (its connection closed, the coordinator takes it on its next sync).
|
||||||
|
*/
|
||||||
private async lane() {
|
private async lane() {
|
||||||
let client: XoverClient | null = null
|
let client: XoverClient | null = null
|
||||||
while (!this.stopped) {
|
try {
|
||||||
const batch: Candidate[] = []
|
while (!this.stopped) {
|
||||||
const waited = Date.now()
|
if (this.running > this.share.granted) break
|
||||||
while (batch.length < coordinatorConfig.probe.pipeline) {
|
const batch: Candidate[] = []
|
||||||
const candidate = await this.next()
|
const waited = Date.now()
|
||||||
if (!candidate) break
|
while (batch.length < coordinatorConfig.probe.pipeline) {
|
||||||
batch.push(candidate)
|
const candidate = await this.next()
|
||||||
}
|
if (!candidate) break
|
||||||
this.stats.waitMs += Date.now() - waited
|
batch.push(candidate)
|
||||||
if (batch.length === 0) {
|
}
|
||||||
this.stats.idleMs += 30_000
|
this.stats.waitMs += Date.now() - waited
|
||||||
await sleep(30_000)
|
this.dry = batch.length === 0
|
||||||
continue
|
if (batch.length === 0) {
|
||||||
}
|
this.stats.idleMs += 30_000
|
||||||
try {
|
await sleep(30_000)
|
||||||
client ??= await XoverClient.connect({
|
continue
|
||||||
host: env.get('NNTP_HOST')!,
|
}
|
||||||
port: env.get('NNTP_PORT', 563),
|
try {
|
||||||
user: env.get('NNTP_USER'),
|
client ??= await XoverClient.connect({
|
||||||
password: env.get('NNTP_PASSWORD'),
|
host: env.get('NNTP_HOST')!,
|
||||||
secure: env.get('NNTP_SECURE', true),
|
port: env.get('NNTP_PORT', 563),
|
||||||
compress: false,
|
user: env.get('NNTP_USER'),
|
||||||
timeoutMs: coordinatorConfig.nntpTimeoutMs,
|
password: env.get('NNTP_PASSWORD'),
|
||||||
})
|
secure: env.get('NNTP_SECURE', true),
|
||||||
const fetched = Date.now()
|
compress: false,
|
||||||
const bodies = await client.bodies(batch.map((c) => c.messageId))
|
timeoutMs: coordinatorConfig.nntpTimeoutMs,
|
||||||
this.stats.fetchMs += Date.now() - fetched
|
})
|
||||||
const inspected = Date.now()
|
const fetched = Date.now()
|
||||||
for (const [i, candidate] of batch.entries()) {
|
const bodies = await client.bodies(batch.map((c) => c.messageId))
|
||||||
const body = bodies[i]
|
this.stats.fetchMs += Date.now() - fetched
|
||||||
this.stats.articles++
|
const inspected = Date.now()
|
||||||
if (body === null) {
|
for (const [i, candidate] of batch.entries()) {
|
||||||
// Taken down or expired: final.
|
const body = bodies[i]
|
||||||
this.outcomes.push({ candidate, status: 'missing' })
|
this.stats.articles++
|
||||||
continue
|
if (body === null) {
|
||||||
|
// Taken down or expired: final.
|
||||||
|
this.outcomes.push({ candidate, status: 'missing' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.stats.bytes += body.length
|
||||||
|
try {
|
||||||
|
this.outcomes.push({ candidate, status: 'done', inspection: inspect(body) })
|
||||||
|
} catch (error: any) {
|
||||||
|
// Not yEnc, a broken one, or data a parser chokes on: a final answer, not a connection problem.
|
||||||
|
if (!(error instanceof YencError)) logger.warn(`Probing ${candidate.messageId}: ${error.message}`)
|
||||||
|
this.outcomes.push({ candidate, status: 'error' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.stats.bytes += body.length
|
this.stats.inspectMs += Date.now() - inspected
|
||||||
try {
|
} catch (error: any) {
|
||||||
this.outcomes.push({ candidate, status: 'done', inspection: inspect(body) })
|
logger.warn(`Probing ${batch[0].messageId} and ${batch.length - 1} more: ${error.message}`)
|
||||||
} catch (error: any) {
|
for (const candidate of batch) this.outcomes.push({ candidate, status: 'waiting' })
|
||||||
// Not yEnc, a broken one, or data a parser chokes on: a final answer, not a connection problem.
|
client?.close()
|
||||||
if (!(error instanceof YencError)) logger.warn(`Probing ${candidate.messageId}: ${error.message}`)
|
client = null
|
||||||
this.outcomes.push({ candidate, status: 'error' })
|
await sleep(5000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.stats.inspectMs += Date.now() - inspected
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.warn(`Probing ${batch[0].messageId} and ${batch.length - 1} more: ${error.message}`)
|
|
||||||
for (const candidate of batch) this.outcomes.push({ candidate, status: 'waiting' })
|
|
||||||
client?.close()
|
|
||||||
client = null
|
|
||||||
await sleep(5000)
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
client?.close()
|
||||||
|
this.running--
|
||||||
}
|
}
|
||||||
client?.close()
|
}
|
||||||
|
|
||||||
|
/** Candidates claimed at a time: a few rounds of every lane's pipeline (a claim takes seconds; 400 lasted ~2 s at 37 lanes). */
|
||||||
|
private get batch() {
|
||||||
|
return Math.max(coordinatorConfig.probe.batch, this.running * coordinatorConfig.probe.pipeline * 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async next(): Promise<Candidate | undefined> {
|
private async next(): Promise<Candidate | undefined> {
|
||||||
if (this.queue.length < coordinatorConfig.probe.batch / 2) {
|
if (this.queue.length < this.batch / 2) {
|
||||||
this.refilling ??= this.claim().finally(() => (this.refilling = null))
|
this.refilling ??= this.claim().finally(() => (this.refilling = null))
|
||||||
if (this.queue.length === 0) await this.refilling
|
if (this.queue.length === 0) await this.refilling
|
||||||
}
|
}
|
||||||
@@ -163,7 +202,7 @@ export class Prober {
|
|||||||
|
|
||||||
private async claimTiers() {
|
private async claimTiers() {
|
||||||
for (const { kind, unreadable } of TIERS) {
|
for (const { kind, unreadable } of TIERS) {
|
||||||
const want = coordinatorConfig.probe.batch - this.queue.length
|
const want = this.batch - this.queue.length
|
||||||
if (want <= 0) return
|
if (want <= 0) return
|
||||||
// The starting points chosen here: in the query Postgres drew random() once for all slices.
|
// The starting points chosen here: in the query Postgres drew random() once for all slices.
|
||||||
// Over the claimable ones of this tier only: the rest of the queue isn't settled yet.
|
// Over the claimable ones of this tier only: the rest of the queue isn't settled yet.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Settler from '#services/settler'
|
|||||||
import RangeBlocks from '#services/range_blocks'
|
import RangeBlocks from '#services/range_blocks'
|
||||||
import Leases from '#services/leases'
|
import Leases from '#services/leases'
|
||||||
import NntpPool from '#services/nntp_pool'
|
import NntpPool from '#services/nntp_pool'
|
||||||
|
import { NntpShare } from '#services/nntp_shares'
|
||||||
import coordinatorConfig from '#config/coordinator'
|
import coordinatorConfig from '#config/coordinator'
|
||||||
|
|
||||||
const TICK_MS = 2000
|
const TICK_MS = 2000
|
||||||
@@ -43,6 +44,7 @@ export default class CoordinatorRun extends BaseCommand {
|
|||||||
const [index, count] = (this.shard ?? '1/1').split('/').map(Number)
|
const [index, count] = (this.shard ?? '1/1').split('/').map(Number)
|
||||||
if (!(count >= 1 && index >= 1 && index <= count)) throw new Error(`--shard: "i/n" with 1 <= i <= n, not "${this.shard}"`)
|
if (!(count >= 1 && index >= 1 && index <= count)) throw new Error(`--shard: "i/n" with 1 <= i <= n, not "${this.shard}"`)
|
||||||
const prefetcher = new Prefetcher({ index: index - 1, count })
|
const prefetcher = new Prefetcher({ index: index - 1, count })
|
||||||
|
const share = new NntpShare(count > 1 ? `coordinator ${index}/${count}` : 'coordinator', coordinatorConfig.nntpShares.coordinatorPriority, coordinatorConfig.nntpMinConnections, coordinatorConfig.nntpConnections)
|
||||||
const main = index === 1
|
const main = index === 1
|
||||||
let stopping = false
|
let stopping = false
|
||||||
let stopped: () => void
|
let stopped: () => void
|
||||||
@@ -55,6 +57,21 @@ export default class CoordinatorRun extends BaseCommand {
|
|||||||
await done
|
await done
|
||||||
})
|
})
|
||||||
this.logger.info(count > 1 ? `Coordinator started (shard ${index}/${count}${main ? '' : ', prefetching only'}).` : 'Coordinator started.')
|
this.logger.info(count > 1 ? `Coordinator started (shard ${index}/${count}${main ? '' : ', prefetching only'}).` : 'Coordinator started.')
|
||||||
|
// The pool's size: its share of the account's connections, the prober the rest (app/services/nntp_shares.ts).
|
||||||
|
// Before the first connection: until then it's NNTP_CONNECTIONS.
|
||||||
|
const first = await NntpPool.share(share)
|
||||||
|
this.logger.info(`NNTP share: ${first.granted} connections.`)
|
||||||
|
const sharing = (async () => {
|
||||||
|
while (!stopping) {
|
||||||
|
try {
|
||||||
|
const { was, granted } = await NntpPool.share(share)
|
||||||
|
if (granted !== was) this.logger.info(`NNTP share: ${granted} connections (was ${was}).`)
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`NNTP share failed: ${error.message}`)
|
||||||
|
}
|
||||||
|
for (let waited = 0; waited < coordinatorConfig.nntpShares.everyMs && !stopping; waited += TICK_MS) await sleep(TICK_MS)
|
||||||
|
}
|
||||||
|
})()
|
||||||
// Two loops: the prefetcher's ticks don't wait for merging or planning (a merge round
|
// Two loops: the prefetcher's ticks don't wait for merging or planning (a merge round
|
||||||
// of up to MERGE_BUDGET_MS left the connections idle once their targets were fetched).
|
// of up to MERGE_BUDGET_MS left the connections idle once their targets were fetched).
|
||||||
const prefetching = (async () => {
|
const prefetching = (async () => {
|
||||||
@@ -126,12 +143,13 @@ export default class CoordinatorRun extends BaseCommand {
|
|||||||
}
|
}
|
||||||
await sleep(TICK_MS)
|
await sleep(TICK_MS)
|
||||||
}
|
}
|
||||||
await Promise.all([prefetching, blocks])
|
await Promise.all([prefetching, blocks, sharing])
|
||||||
// The chunks in flight, for at most STOP_WAIT_MS (a connection the server holds back can take minutes); then closed.
|
// The chunks in flight, for at most STOP_WAIT_MS (a connection the server holds back can take minutes); then closed.
|
||||||
const waitUntil = Date.now() + STOP_WAIT_MS
|
const waitUntil = Date.now() + STOP_WAIT_MS
|
||||||
while (prefetcher.active > 0 && Date.now() < waitUntil) await sleep(200)
|
while (prefetcher.active > 0 && Date.now() < waitUntil) await sleep(200)
|
||||||
if (prefetcher.active > 0) this.logger.info(`Closing ${prefetcher.active} connections still fetching.`)
|
if (prefetcher.active > 0) this.logger.info(`Closing ${prefetcher.active} connections still fetching.`)
|
||||||
await NntpPool.close()
|
await NntpPool.close()
|
||||||
|
await share.leave().catch(() => {})
|
||||||
this.logger.info('Coordinator stopped.')
|
this.logger.info('Coordinator stopped.')
|
||||||
stopped!()
|
stopped!()
|
||||||
await this.terminate()
|
await this.terminate()
|
||||||
|
|||||||
+17
-2
@@ -31,8 +31,21 @@ export default {
|
|||||||
},
|
},
|
||||||
/** A leased range without a heartbeat for this long goes back to the queue. */
|
/** A leased range without a heartbeat for this long goes back to the queue. */
|
||||||
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
|
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
|
||||||
/** NNTP connections the coordinator fetches headers with. */
|
/** NNTP connections the coordinator fetches headers with: at most (its share, nntpShares), and at least while sharing. */
|
||||||
nntpConnections: env.get('NNTP_CONNECTIONS', 4),
|
nntpConnections: env.get('NNTP_CONNECTIONS', 4),
|
||||||
|
nntpMinConnections: env.get('NNTP_MIN_CONNECTIONS', 8),
|
||||||
|
/**
|
||||||
|
* The account's connections, shared by the coordinator and the prober by priority (app/services/nntp_shares.ts):
|
||||||
|
* all of them less the reserved ones (the spotter's and the spot inspector's 1 each, the coordinator's plain one,
|
||||||
|
* and 2 for a hedged request's or a reconnect's new connection while the old one closes).
|
||||||
|
*/
|
||||||
|
nntpShares: {
|
||||||
|
total: env.get('NNTP_ACCOUNT_CONNECTIONS', 50) - env.get('NNTP_RESERVED_CONNECTIONS', 5),
|
||||||
|
everyMs: 15_000,
|
||||||
|
/** Headers before probes: history takes what it can use, the prober the rest. */
|
||||||
|
coordinatorPriority: 2,
|
||||||
|
proberPriority: 1,
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* A reply with no data for this long fails, and its connection is replaced.
|
* A reply with no data for this long fails, and its connection is replaced.
|
||||||
* Eweka sometimes stops answering on long-lived connections (all 40 quiet,
|
* Eweka sometimes stops answering on long-lived connections (all 40 quiet,
|
||||||
@@ -70,11 +83,13 @@ export default {
|
|||||||
nntpConnectionMaxMs: env.get('NNTP_CONNECTION_MAX_MINUTES', 0) * 60 * 1000,
|
nntpConnectionMaxMs: env.get('NNTP_CONNECTION_MAX_MINUTES', 0) * 60 * 1000,
|
||||||
probe: {
|
probe: {
|
||||||
/**
|
/**
|
||||||
* NNTP connections the prober reads articles with, besides the
|
* At most (its share, nntpShares): NNTP connections the prober reads articles with, besides the
|
||||||
* coordinator's: the account has 50, the coordinator 40 + 1 plain, and a
|
* coordinator's: the account has 50, the coordinator 40 + 1 plain, and a
|
||||||
* couple stay free for downloads. ~7 MB/s each (~10 articles/s).
|
* couple stay free for downloads. ~7 MB/s each (~10 articles/s).
|
||||||
*/
|
*/
|
||||||
connections: env.get('PROBE_CONNECTIONS', 6),
|
connections: env.get('PROBE_CONNECTIONS', 6),
|
||||||
|
/** Of them, always (its share's minimum; nntpShares). */
|
||||||
|
minConnections: env.get('PROBE_MIN_CONNECTIONS', 2),
|
||||||
/** Candidates claimed at a time. */
|
/** Candidates claimed at a time. */
|
||||||
batch: 400,
|
batch: 400,
|
||||||
/** BODY commands in flight per connection (pipelined; XoverClient.bodies). */
|
/** BODY commands in flight per connection (pipelined; XoverClient.bodies). */
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nntp_shares: the account's connections shared by the processes that read articles (app/services/nntp_shares.ts).
|
||||||
|
* Each process's row says what it could use and has open, and what it may open; a row without a heartbeat for a
|
||||||
|
* minute is a process gone (its connections closed with it). The split was fixed per process: with history paused
|
||||||
|
* the coordinator's 38 stood idle while the prober's 8 were the limit (LOG.md 2026-10-18 (75)).
|
||||||
|
*/
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
async up() {
|
||||||
|
this.schema.raw(`
|
||||||
|
CREATE TABLE IF NOT EXISTS nntp_shares (
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
priority integer NOT NULL,
|
||||||
|
min integer NOT NULL,
|
||||||
|
want integer NOT NULL,
|
||||||
|
open integer NOT NULL,
|
||||||
|
granted integer NOT NULL,
|
||||||
|
heartbeat_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.raw(`DROP TABLE IF EXISTS nntp_shares`)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-5
@@ -115,11 +115,14 @@ services:
|
|||||||
<<: *app-env
|
<<: *app-env
|
||||||
NNTP_CONNECTIONS: ${NNTP_CONNECTIONS:-38}
|
NNTP_CONNECTIONS: ${NNTP_CONNECTIONS:-38}
|
||||||
|
|
||||||
# Phase 2's prober: reads the probe queue's articles over PROBE_CONNECTIONS
|
# Phase 2's prober: reads the probe queue's articles over connections of its
|
||||||
# connections of its own. The account has 50: the coordinator's 38 (+ its
|
# own. The account has 50 (NNTP_ACCOUNT_CONNECTIONS): 5 reserved (the
|
||||||
# plain one), the prober's 8, the spotter's and the spot inspector's 1 each,
|
# coordinator's plain one, the spotter's and the spot inspector's 1 each, 2
|
||||||
# 2 to spare (a hedged request closes its connection and opens another)
|
# to spare: a hedged request closes its connection and opens another; with
|
||||||
# (with 10 the spot inspector got 502 Too many connections).
|
# 10 the spot inspector got 502 Too many connections), the other 45 shared
|
||||||
|
# by the coordinator (NNTP_MIN_CONNECTIONS–NNTP_CONNECTIONS) and the prober
|
||||||
|
# (PROBE_MIN_CONNECTIONS–PROBE_CONNECTIONS), headers first
|
||||||
|
# (app/services/nntp_shares.ts).
|
||||||
prober:
|
prober:
|
||||||
<<: *app
|
<<: *app
|
||||||
command: ['node', 'ace', 'probe:run']
|
command: ['node', 'ace', 'probe:run']
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||||||
NNTP_PASSWORD: Env.schema.string.optional(),
|
NNTP_PASSWORD: Env.schema.string.optional(),
|
||||||
NNTP_SECURE: Env.schema.boolean.optional(),
|
NNTP_SECURE: Env.schema.boolean.optional(),
|
||||||
NNTP_CONNECTIONS: Env.schema.number.optional(),
|
NNTP_CONNECTIONS: Env.schema.number.optional(),
|
||||||
|
NNTP_MIN_CONNECTIONS: Env.schema.number.optional(),
|
||||||
|
NNTP_ACCOUNT_CONNECTIONS: Env.schema.number.optional(),
|
||||||
|
NNTP_RESERVED_CONNECTIONS: Env.schema.number.optional(),
|
||||||
NNTP_TIMEOUT_MS: Env.schema.number.optional(),
|
NNTP_TIMEOUT_MS: Env.schema.number.optional(),
|
||||||
NNTP_HEDGE_AFTER_SECONDS: Env.schema.number.optional(),
|
NNTP_HEDGE_AFTER_SECONDS: Env.schema.number.optional(),
|
||||||
NNTP_HEDGE_CONNECTIONS: Env.schema.number.optional(),
|
NNTP_HEDGE_CONNECTIONS: Env.schema.number.optional(),
|
||||||
@@ -74,6 +77,7 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||||||
SPOOL_MAX_GB: Env.schema.number.optional(),
|
SPOOL_MAX_GB: Env.schema.number.optional(),
|
||||||
PREFETCH_PER_RANGE: Env.schema.number.optional(),
|
PREFETCH_PER_RANGE: Env.schema.number.optional(),
|
||||||
PROBE_CONNECTIONS: Env.schema.number.optional(),
|
PROBE_CONNECTIONS: Env.schema.number.optional(),
|
||||||
|
PROBE_MIN_CONNECTIONS: Env.schema.number.optional(),
|
||||||
HTTP_ROUTES: Env.schema.enum.optional(['all', 'node', 'public'] as const),
|
HTTP_ROUTES: Env.schema.enum.optional(['all', 'node', 'public'] as const),
|
||||||
PREFETCH_PER_GROUP: Env.schema.number.optional(),
|
PREFETCH_PER_GROUP: Env.schema.number.optional(),
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
import testUtils from '@adonisjs/core/services/test_utils'
|
||||||
|
import coordinatorConfig from '#config/coordinator'
|
||||||
|
import { grant, targets, NntpShare } from '#services/nntp_shares'
|
||||||
|
|
||||||
|
test.group('NNTP shares', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
|
test('targets: minimums first, then by priority what each wants', ({ assert }) => {
|
||||||
|
const coordinator = { name: 'coordinator', priority: 2, min: 8, want: 38, open: 0 }
|
||||||
|
const prober = { name: 'prober', priority: 1, min: 2, want: 40, open: 0 }
|
||||||
|
// History running: the coordinator all it wants, the prober the rest.
|
||||||
|
assert.deepEqual(Object.fromEntries(targets(45, [prober, coordinator])), { coordinator: 38, prober: 7 })
|
||||||
|
// History paused: the coordinator its minimum (or what it uses), the prober the rest.
|
||||||
|
assert.deepEqual(Object.fromEntries(targets(45, [prober, { ...coordinator, want: 8 }])), { coordinator: 8, prober: 37 })
|
||||||
|
// Wanting more than its minimum doesn't take another's minimum.
|
||||||
|
assert.deepEqual(Object.fromEntries(targets(45, [prober, { ...coordinator, want: 60 }])), { coordinator: 43, prober: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('grant: growing only into connections the others closed or gave up', ({ assert }) => {
|
||||||
|
const prober = { name: 'prober', priority: 1, min: 2, want: 40, open: 37, granted: 37 }
|
||||||
|
const coordinator = { name: 'coordinator', priority: 2, min: 8, want: 38, open: 8 }
|
||||||
|
// History came back: the prober still holds 37, the coordinator keeps its 8 for now.
|
||||||
|
assert.equal(grant(45, coordinator, [prober]), 8)
|
||||||
|
// The prober syncs: its target is 7, its grant goes down at once.
|
||||||
|
assert.equal(grant(45, { ...prober, open: 37 }, [{ ...coordinator, granted: 8 }]), 7)
|
||||||
|
// Its connections not closed yet: still held.
|
||||||
|
assert.equal(grant(45, coordinator, [{ ...prober, granted: 7, open: 30 }]), 15)
|
||||||
|
// Closed: the coordinator gets all it wants.
|
||||||
|
assert.equal(grant(45, coordinator, [{ ...prober, granted: 7, open: 7 }]), 38)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sync: each process its row; a process gone (no heartbeat) holds nothing', async ({ assert }) => {
|
||||||
|
const total = coordinatorConfig.nntpShares.total
|
||||||
|
const coordinator = new NntpShare('coordinator', 2, 8, 38)
|
||||||
|
const prober = new NntpShare('prober', 1, 2, total)
|
||||||
|
assert.equal(await coordinator.sync(8, 0), 8)
|
||||||
|
assert.equal(await prober.sync(total, 0), total - 8)
|
||||||
|
// The coordinator wants all: the prober holds its grant until it has synced and closed them.
|
||||||
|
assert.equal(await coordinator.sync(38, 8), 8)
|
||||||
|
assert.equal(await prober.sync(total, total - 8), total - 38)
|
||||||
|
assert.equal(await coordinator.sync(38, 8), 8)
|
||||||
|
assert.equal(await prober.sync(total, total - 38), total - 38)
|
||||||
|
assert.equal(await coordinator.sync(38, 8), 38)
|
||||||
|
|
||||||
|
// The prober's process died: after a minute without a sync its share is free.
|
||||||
|
await db.from('nntp_shares').where('name', 'prober').update({ open: total, granted: total, heartbeat_at: new Date(Date.now() - 120_000) })
|
||||||
|
assert.equal(await coordinator.sync(38, 38), 38)
|
||||||
|
await coordinator.leave()
|
||||||
|
assert.equal(await prober.sync(total, 0), total)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user