9 Commits
Author SHA1 Message Date
Daan MeijerandClaude Opus 5.5 53bfea8d15 Rebuild spec: measurements after the second performance round
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:37:04 +02:00
Daan MeijerandClaude Opus 5.5 69f1cc63b2 Rebuild: coordinator:run --shard=i/n splits prefetching over processes
The coordinator's event loop was 16.5% busy at ~10 MB/s of compressed
XOVER (socket reads, buffers, GC, spool writes; nothing stands out), so
one process tops out around 60 MB/s: less than 50 connections bring
(~1M articles/s, ~70-86 MB/s). A shard prefetches the ranges with
id % n = i - 1 with its own NNTP_CONNECTIONS; only shard 1 plans, merges
and settles. Two shards of 5 connections fetched what one of 10 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:36:24 +02:00
Daan MeijerandClaude Opus 5.5 462da5c225 Rebuild: a stopping worker notices it on keep-alive frames too
It checked only after a data frame, so a worker waiting for its range's
spool (keep-alive frames only) never stopped on SIGTERM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:26:20 +02:00
Daan MeijerandClaude Opus 5.5 883d85e91d Rebuild: the coordinator stops on SIGTERM
It waited for every chunk in flight, and a connection the server held
back (after killed coordinators left sessions open) waits up to the
10-minute reply timeout: it never seemed to stop. It now waits at most
15 s, then closes all connections, busy ones too; the greeting and login
time out after 30 s. And run() ends with terminate(), as edge:node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:20:11 +02:00
Daan MeijerandClaude Opus 5.5 cae6e4f60d Rebuild: prefetch stats a minute; the completeness check inflates off the event loop
The coordinator logs a line a minute: chunks fetched, articles/s,
seconds a chunk, how busy its connections were. It showed the
workstation's runs are bound by its 100 Mbit line now (10 connections
99% busy, ~89 Mbit/s: compressed XOVER is ~69 B an article on recent
boneless), not by the code.

The check that a compressed reply is complete (inflating it) ran on the
coordinator's event loop, ~60 ms a chunk; now on a thread-pool thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:14:49 +02:00
Daan MeijerandClaude Opus 5.5 9dc0a27bb4 Rebuild: a lease never waits for prefetching; one-shot hashes
A lease was only granted for a range with spooled chunks, and the
prefetcher kept ranges ahead per active lease: once the spool ran dry a
worker got none and slept 30 s, fewer ranges were leased, fewer
prefetched. Three workers held one lease between them (~50k headers/s).
A lease now takes a spooled range first, else the next planned one
(leased ranges are fetched first; the stream waits).

crypto.hash instead of createHash().update().digest() for file and
release keys: same digests, ~2.5x faster (~4.6% of a worker's time).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:08:19 +02:00
Daan MeijerandClaude Opus 5.5 168605148f Rebuild: the worker joins stream pieces once per frame, gzips posts off its thread
decodeFrames joined each network piece (~64 KB) onto the frame so far:
a chunk of MBs copied over and over, 5.4% of a worker's time in an
end-to-end profile (the bench reads files, so it never showed). Pieces
are now joined once a frame is complete. A batch post's gzip (1.6%) runs
on a thread-pool thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:06:17 +02:00
Daan MeijerandClaude Opus 5.5 bd1c846f27 Rebuild: prefetching no longer waits for merging, nor stalls on finished ranges
The coordinator ran the prefetch tick in the same loop as the merger
(up to 30 s a minute): its connections went idle once their targets
were fetched, and the first leases after a start found nothing spooled.
The prefetcher now ticks in a loop of its own.

The prefetcher took the first planned ranges with nothing spooled, even
ones with nothing left to fetch (next_id past the end): two such ranges
(edge:bench's, set back to planned) kept it from fetching anything. It
and the lease query now skip those, and edge:bench marks its range done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:04:00 +02:00
Daan MeijerandClaude Opus 5.5 2b4f673ddb Rebuild: index lookups for the merger's neighbours and the prefetcher's ranges
The merger's neighbour query matched "end_id = ? OR start_id = ?", which
no index serves: it read every range of the group per file, 5.5 s a
round and most of Postgres's time in an end-to-end run. Now the range
holding the article before and after, by (group_id, start_id): 0.08 ms.
The prefetcher's leased and started ranges (every 2 s) get partial
indexes: 15-25 ms -> <0.1 ms with 145k planned ranges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:59:30 +02:00
16 changed files with 201 additions and 66 deletions

No files matched your search

+29 -14
View File
@@ -14,17 +14,27 @@ prefetch spool, leases, rule sets, fragment merger, settling), the node API
(`edge:node`, `--processes`), the catalog and chunk store, NZBs and a name (`edge:node`, `--processes`), the catalog and chunk store, NZBs and a name
search. Measured on the workstation (4 cores, 100 Mbit, Eweka): search. Measured on the workstation (4 cores, 100 Mbit, Eweka):
- Edge worker, one core: ~190–215 k headers/s on recent a.b.boneless, - Edge worker, one core: ~210–215 k headers/s on recent a.b.boneless,
~330 k on a 2013 a.b.ath range; ~0.66 GB peak per process. (Start: ~330–340 k on a 2013 a.b.ath range; ~0.7–0.8 GB peak per process.
73–103 k/s, 1.0–1.2 GB.) (Start: 73–103 k/s, 1.0–1.2 GB.) What's left is spread out (the
- NNTP: ~22–30 k overview lines/s per connection in chunks of 100 k per-file key ~12%, GC ~10%, zlib ~10%, the scan ~6%).
(Eweka takes ~1.5–2 s per command); connections don't slow each other. - NNTP: Eweka takes ~1.5–2 s per XOVER command; ~22–30 k lines/s per
- End to end (coordinator with 10 connections, web server, Postgres and 3 connection in chunks of 100 k when the line allows; connections don't
worker processes on one 4-core machine): 244 k headers/s; the server side slow each other.
used 10% (web) + 22% (coordinator) + 32% (Postgres) of a core. - Bandwidth: compressed XOVER is ~69 B per article on recent boneless
(100 k-article chunks of ~7 MB). The workstation's 100 Mbit line carries
~140–150 k articles/s, which is where its end-to-end runs now stop (10
connections 90–99% busy, ~89 Mbit/s).
- End to end at that rate (one 4-core machine): workers about half busy
each; server side ~17% (coordinator) + ~8% (web) + ~3% (Postgres) of a
core. (Before this round's fixes Postgres was at 32%: the merger's
neighbour query read every range per file.)
- Storage: chunks 17.9–20.3 B per segment (format v2); a release ~1.1 KB. - Storage: chunks 17.9–20.3 B per segment (format v2); a release ~1.1 KB.
- For the whole target (528 B overview lines): ~4–6 days at 50 connections, - For the whole target (528 B overview lines): ~36 TB of compressed XOVER
~3 worker cores. to download. At 50 connections (~1–1.25 M lines/s, ~0.6–0.7 Gbit/s)
~5–6 days, ~5–6 worker cores, two coordinator shards (one process
handles ~60 MB/s), under a core for web and Postgres. The line is the
limit below ~0.7 Gbit/s: at 1 Gbit, ~3.5 days at best.
Not built yet: re-running ranges for a retracted rule (the counts and rule Not built yet: re-running ranges for a retracted rule (the counts and rule
versions are recorded), deployment (Dockerfile, compose), phase 2. versions are recorded), deployment (Dockerfile, compose), phase 2.
@@ -46,14 +56,14 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
account; the gzip stream is passed on as is). account; the gzip stream is passed on as is).
**Prefetch**: the coordinator fetches the next ranges ahead of the **Prefetch**: the coordinator fetches the next ranges ahead of the
workers, in chunks of 50 k lines and in order, into a spool on disk workers, in chunks of 100 k lines and in order, into a spool on disk
(~106 MB per 2 M range at ~53 B per line; on the HDD, written and read (~106 MB per 2 M range at ~53 B per line; on the HDD, written and read
sequentially). It keeps about two ranges ahead per active lease, capped sequentially). It keeps about two ranges ahead per active lease, capped
by size (default 10 GB), and fetches with all its header connections all by size (default 10 GB), and fetches with all its header connections all
the time, independent of how fast workers are. A lease goes to a range the time, independent of how fast workers are. A lease goes to a range
that is already (partly) spooled; a worker reads its chunks from the that is already (partly) spooled, else to the next planned one (leased
spool, waiting only for chunks still being fetched, and falls back to a ranges are fetched first); a worker reads its chunks from the spool,
direct relay fetch for a range that isn't spooled. A worker gets its waiting only for chunks still being fetched. A worker gets its
whole range in one streaming request (`GET /ranges/:id/stream`): the whole range in one streaming request (`GET /ranges/:id/stream`): the
spooled chunks in order, waiting for those still being fetched. It still spooled chunks in order, waiting for those still being fetched. It still
parses chunk by chunk (2 M lines are ~560 MB of text) and posts results parses chunk by chunk (2 M lines are ~560 MB of text) and posts results
@@ -64,6 +74,11 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
range is leased again later. range is leased again later.
Order: oldest era first (2008–2018 holds 63% of the readable files on Order: oldest era first (2008–2018 holds 63% of the readable files on
17% of the lines), then forward. 17% of the lines), then forward.
One coordinator process handles ~60 MB/s of compressed XOVER (its event
loop was 16.5% busy at ~10 MB/s); beyond that, prefetching is split over
processes with `coordinator:run --shard=i/n` (ranges with id % n = i − 1,
each with its own NNTP_CONNECTIONS; only shard 1 plans, merges and
settles).
2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`, 2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`,
2026-10-09): 73 k headers/s per core on an a.b.ath range from 2013, 2026-10-09): 73 k headers/s per core on an a.b.ath range from 2013,
+10 -5
View File
@@ -120,17 +120,22 @@ class FragmentMerger {
}) })
const spans = lists.map(span) const spans = lists.map(span)
const groups = [...new Set(spans.map((s) => s.group))] const groups = [...new Set(spans.map((s) => s.group))]
// The range holding the article before and the one after, each by the (group_id, start_id) index:
// "end_id = ? OR start_id = ?" read every range of the group per file (5.5 s a round; LOG.md 2026-10-09).
const { rows } = await db.rawQuery( const { rows } = await db.rawQuery(
`SELECT r.group_id, r.start_id, r.end_id, r.status FROM ranges r `SELECT s.group_id, s.before_end, s.after_start, b.status AS before, a.status AS after
JOIN unnest(?::int[], ?::bigint[], ?::bigint[]) AS s(group_id, before_end, after_start) FROM unnest(?::int[], ?::bigint[], ?::bigint[]) AS s(group_id, before_end, after_start)
ON r.group_id = s.group_id AND (r.end_id = s.before_end OR r.start_id = s.after_start)`, LEFT JOIN LATERAL (SELECT r.status FROM ranges r WHERE r.group_id = s.group_id AND r.start_id <= s.before_end AND r.end_id >= s.before_end
ORDER BY r.start_id DESC LIMIT 1) b ON true
LEFT JOIN LATERAL (SELECT r.status FROM ranges r WHERE r.group_id = s.group_id AND r.start_id <= s.after_start AND r.end_id >= s.after_start
ORDER BY r.start_id DESC LIMIT 1) a ON true`,
[spans.map((s) => s.group), spans.map((s) => s.first - 1), spans.map((s) => s.last + 1)] [spans.map((s) => s.group), spans.map((s) => s.first - 1), spans.map((s) => s.last + 1)]
) )
const byEnd = new Map<string, string>() const byEnd = new Map<string, string>()
const byStart = new Map<string, string>() const byStart = new Map<string, string>()
for (const r of rows) { for (const r of rows) {
byEnd.set(`${r.group_id}:${r.end_id}`, r.status) if (r.before !== null) byEnd.set(`${r.group_id}:${r.before_end}`, r.before)
byStart.set(`${r.group_id}:${r.start_id}`, r.status) if (r.after !== null) byStart.set(`${r.group_id}:${r.after_start}`, r.after)
} }
const lows = new Map((await db.from('groups').whereIn('id', groups).select('id', 'low')).map((g) => [Number(g.id), g.low === null ? null : Number(g.low)])) const lows = new Map((await db.from('groups').whereIn('id', groups).select('id', 'low')).map((g) => [Number(g.id), g.low === null ? null : Number(g.low)]))
return (list: (typeof lists)[number]) => { return (list: (typeof lists)[number]) => {
+13 -6
View File
@@ -7,23 +7,30 @@ import type { Lease } from '#lib/edge/index'
export class LeaseLost extends Error {} export class LeaseLost extends Error {}
/** /**
* Ranges for workers (REBUILD.md, coordinator): the next range whose first * Ranges for workers (REBUILD.md, coordinator): the next range with chunks
* chunk is spooled, by priority (oldest era first); a lease whose worker * spooled, by priority (oldest era first), else the next planned one (the
* went quiet for LEASE_TTL_SECONDS is taken over and resumes at its resume * prefetcher fetches leased ranges first; the worker's stream waits). A
* point. * worker never waits for prefetching to choose its range: refused leases
* left workers idle for 30 s, so fewer ranges were leased and fewer
* prefetched (LOG.md 2026-10-09). A lease whose worker went quiet for
* LEASE_TTL_SECONDS is taken over and resumes at its resume point.
*/ */
class Leases { class Leases {
async lease(nodeId: number): Promise<Lease | null> { async lease(nodeId: number): Promise<Lease | null> {
const rules = await RuleSets.current() const rules = await RuleSets.current()
const { rows } = await db.rawQuery( const take = (spooled: boolean) =>
db.rawQuery(
`UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ? `UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ?
WHERE r.id = ( WHERE r.id = (
SELECT id FROM ranges SELECT id FROM ranges
WHERE spooled_chunks > 0 AND (status = 'planned' OR (status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?))) WHERE ${spooled ? 'spooled_chunks > 0' : 'true'} AND next_id <= end_id
AND (status = 'planned' OR (status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?)))
ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED) ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING r.id, r.group_id, r.start_id, r.end_id, r.next_id`, RETURNING r.id, r.group_id, r.start_id, r.end_id, r.next_id`,
[nodeId, rules.version, coordinatorConfig.leaseTtlSeconds] [nodeId, rules.version, coordinatorConfig.leaseTtlSeconds]
) )
let { rows } = await take(true)
if (rows.length === 0) ({ rows } = await take(false))
const row = rows[0] const row = rows[0]
if (!row) return null if (!row) return null
await db.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() }) await db.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
+6 -1
View File
@@ -10,6 +10,7 @@ import { XoverClient } from '#lib/nntp/index'
*/ */
class NntpPool { class NntpPool {
private idle: XoverClient[] = [] private idle: XoverClient[] = []
private busy = new Set<XoverClient>()
private open = 0 private open = 0
private waiting: ((client: XoverClient) => void)[] = [] private waiting: ((client: XoverClient) => void)[] = []
@@ -19,6 +20,7 @@ class NntpPool {
async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> { async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
const client = await this.acquire() const client = await this.acquire()
this.busy.add(client)
let broken = false let broken = false
try { try {
return await work(client) return await work(client)
@@ -26,6 +28,7 @@ class NntpPool {
broken = true broken = true
throw error throw error
} finally { } finally {
this.busy.delete(client)
this.release(client, broken) this.release(client, broken)
} }
} }
@@ -71,8 +74,10 @@ class NntpPool {
else this.idle.push(client) else this.idle.push(client)
} }
/** Closes every connection, also those in use: their commands fail. */
async close() { async close() {
for (const client of this.idle.splice(0)) client.close() for (const client of [...this.idle.splice(0), ...this.busy]) client.close()
this.busy.clear()
this.open = 0 this.open = 0
} }
} }
+32 -3
View File
@@ -28,6 +28,13 @@ const SIZE_MS = 30_000
* kept its worker waiting (LOG.md 2026-10-09). * kept its worker waiting (LOG.md 2026-10-09).
*/ */
export class Prefetcher { export class Prefetcher {
/**
* This process's share of the ranges, `id % count = index` (`coordinator:run --shard`): one
* process's event loop handles ~60 MB/s of compressed XOVER (16.5% of a core at ~10 MB/s;
* LOG.md 2026-10-09), less than 50 connections bring.
*/
constructor(private shard = { index: 0, count: 1 }) {}
private targets: Target[] = [] private targets: Target[] = []
private claimed = new Set<string>() private claimed = new Set<string>()
private running = 0 private running = 0
@@ -35,6 +42,8 @@ export class Prefetcher {
private stopped = false private stopped = false
private spoolBytes = 0 private spoolBytes = 0
private sizedAt = 0 private sizedAt = 0
/** Fetched since the last report: chunks, articles, ms spent fetching. */
private stats = { chunks: 0, articles: 0, fetchMs: 0, since: Date.now() }
/** One round: refreshes the targets now and then, and puts idle connections to work. */ /** One round: refreshes the targets now and then, and puts idle connections to work. */
async tick() { async tick() {
@@ -42,6 +51,7 @@ export class Prefetcher {
this.refreshedAt = Date.now() this.refreshedAt = Date.now()
this.targets = await this.readTargets() this.targets = await this.readTargets()
} }
this.report()
while (!this.stopped && this.running < NntpPool.size && this.peek()) { while (!this.stopped && this.running < NntpPool.size && this.peek()) {
this.running++ this.running++
this.work() this.work()
@@ -65,10 +75,14 @@ export class Prefetcher {
if (!next) return if (!next) return
const { target, span } = next const { target, span } = next
try { try {
const fetchedAt = Date.now()
const data = await NntpPool.use(async (client) => { const data = await NntpPool.use(async (client) => {
await client.group(target.group) await client.group(target.group)
return client.xoverRaw(span.from, span.to) return client.xoverRaw(span.from, span.to)
}) })
this.stats.chunks++
this.stats.articles += span.to - span.from + 1
this.stats.fetchMs += Date.now() - fetchedAt
const existed = await Spool.has(target.id, span) const existed = await Spool.has(target.id, span)
await Spool.write(target.id, span, data) await Spool.write(target.id, span, data)
if (existed) continue if (existed) continue
@@ -84,6 +98,20 @@ export class Prefetcher {
} }
} }
/** A line a minute: what the connections fetched, and how long a chunk took. */
private report() {
const seconds = (Date.now() - this.stats.since) / 1000
if (seconds < 60) return
const { chunks, articles, fetchMs } = this.stats
if (chunks > 0) {
logger.info(
`Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` +
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk, connections busy ${Math.round((fetchMs / (seconds * 1000 * NntpPool.size)) * 100)}%.`
)
}
this.stats = { chunks: 0, articles: 0, fetchMs: 0, since: Date.now() }
}
private peek(): boolean { private peek(): boolean {
return this.targets.some((t) => t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`))) return this.targets.some((t) => t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`)))
} }
@@ -105,16 +133,17 @@ export class Prefetcher {
this.sizedAt = Date.now() this.sizedAt = Date.now()
this.spoolBytes = await Spool.size() this.spoolBytes = await Spool.size()
} }
const rows = (q: any) => q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id') const mine = (q: any) => (this.shard.count > 1 ? q.whereRaw('r.id % ? = ?', [this.shard.count, this.shard.index]) : q)
const rows = (q: any) => mine(q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id'))
// Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them. // Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them.
const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at') const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at')
// Planned ranges ahead: those started already, then new ones up to SPOOL_AHEAD_PER_LEASE per lease. // Planned ranges ahead: those started already, then new ones up to SPOOL_AHEAD_PER_LEASE per lease.
const ahead = Math.max(coordinatorConfig.spool.minAhead, coordinatorConfig.spool.aheadPerLease * leased.length) const ahead = Math.max(coordinatorConfig.spool.minAhead, coordinatorConfig.spool.aheadPerLease * leased.length)
const started = await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', '>', 0).whereNull('r.spooled_at').orderBy([{ column: 'r.priority' }, { column: 'r.id' }]) const started = await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', '>', 0).whereNull('r.spooled_at').orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
const [{ ready }] = await db.from('ranges').where('status', 'planned').where('spooled_chunks', '>', 0).count('* as ready') const [{ ready }] = await mine(db.from('ranges as r').where('r.status', 'planned').where('r.spooled_chunks', '>', 0)).count('* as ready')
const room = this.spoolBytes < coordinatorConfig.spool.maxBytes const room = this.spoolBytes < coordinatorConfig.spool.maxBytes
const fresh = room && Number(ready) < ahead const fresh = room && Number(ready) < ahead
? await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', 0).orderBy([{ column: 'r.priority' }, { column: 'r.id' }]).limit(ahead - Number(ready)) ? await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', 0).whereRaw('r.next_id <= r.end_id').orderBy([{ column: 'r.priority' }, { column: 'r.id' }]).limit(ahead - Number(ready))
: [] : []
const targets: Target[] = [] const targets: Target[] = []
for (const row of [...leased, ...(room ? started : []), ...fresh]) { for (const row of [...leased, ...(room ? started : []), ...fresh]) {
+34 -9
View File
@@ -1,4 +1,4 @@
import { BaseCommand } from '@adonisjs/core/ace' import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace' import type { CommandOptions } from '@adonisjs/core/types/ace'
import { setTimeout as sleep } from 'node:timers/promises' import { setTimeout as sleep } from 'node:timers/promises'
import { Prefetcher } from '#services/prefetcher' import { Prefetcher } from '#services/prefetcher'
@@ -11,6 +11,7 @@ const TICK_MS = 2000
const PLAN_EVERY_MS = 60 * 60 * 1000 const PLAN_EVERY_MS = 60 * 60 * 1000
const MERGE_EVERY_MS = 60 * 1000 const MERGE_EVERY_MS = 60 * 1000
const MERGE_BUDGET_MS = 30 * 1000 const MERGE_BUDGET_MS = 30 * 1000
const STOP_WAIT_MS = 15 * 1000
/** /**
* The coordinator's background work (REBUILD.md, part 1): the prefetcher, * The coordinator's background work (REBUILD.md, part 1): the prefetcher,
@@ -23,30 +24,49 @@ export default class CoordinatorRun extends BaseCommand {
static description = 'Prefetches ranges into the spool and plans new ones' static description = 'Prefetches ranges into the spool and plans new ones'
static options: CommandOptions = { startApp: true, staysAlive: true } static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.string({ description: 'This process\'s share of the prefetching, "i/n" (ranges with id % n = i - 1); only 1/n plans, merges and settles' })
declare shard?: string
async run() { async run() {
const prefetcher = new Prefetcher() 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}"`)
const prefetcher = new Prefetcher({ index: index - 1, count })
const main = index === 1
let stopping = false let stopping = false
let stopped: () => void let stopped: () => void
const done = new Promise<void>((resolve) => (stopped = resolve)) const done = new Promise<void>((resolve) => (stopped = resolve))
// bin/console turns SIGTERM into app.terminate: wait for the spooling in flight first. // bin/console turns SIGTERM into app.terminate: wait for the spooling in flight first.
this.app.terminating(async () => { this.app.terminating(async () => {
this.logger.info('Stopping: waiting for the chunks in flight.')
stopping = true stopping = true
prefetcher.stop() prefetcher.stop()
await done await done
}) })
let plannedAt = 0 this.logger.info(count > 1 ? `Coordinator started (shard ${index}/${count}${main ? '' : ', prefetching only'}).` : 'Coordinator started.')
let mergedAt = 0 // Two loops: the prefetcher's ticks don't wait for merging or planning (a merge round
this.logger.info('Coordinator started.') // of up to MERGE_BUDGET_MS left the connections idle once their targets were fetched).
const prefetching = (async () => {
while (!stopping) { while (!stopping) {
try { try {
if (Date.now() - plannedAt >= PLAN_EVERY_MS) { await prefetcher.tick()
} catch (error: any) {
this.logger.error(`Prefetch round failed: ${error.message}`)
}
await sleep(TICK_MS)
}
})()
let plannedAt = 0
let mergedAt = 0
while (!stopping) {
try {
if (main && Date.now() - plannedAt >= PLAN_EVERY_MS) {
plannedAt = Date.now() plannedAt = Date.now()
const { groups, ranges } = await Planner.planAll() const { groups, ranges } = await Planner.planAll()
if (ranges > 0) this.logger.info(`Planned ${ranges} ranges over ${groups} groups.`) if (ranges > 0) this.logger.info(`Planned ${ranges} ranges over ${groups} groups.`)
const settled = await Settler.run() const settled = await Settler.run()
if (settled > 0) this.logger.info(`Settled ${settled} releases.`) if (settled > 0) this.logger.info(`Settled ${settled} releases.`)
} }
if (Date.now() - mergedAt >= MERGE_EVERY_MS) { if (main && Date.now() - mergedAt >= MERGE_EVERY_MS) {
mergedAt = Date.now() mergedAt = Date.now()
// Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS. // Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS.
// (Going on while only waiting files were left kept Postgres busy all the time.) // (Going on while only waiting files were left kept Postgres busy all the time.)
@@ -65,14 +85,19 @@ export default class CoordinatorRun extends BaseCommand {
} }
if (totals.completed || totals.incomplete) this.logger.info(`Fragments: ${totals.completed} files completed, ${totals.incomplete} incomplete, ${totals.waiting} waiting.`) if (totals.completed || totals.incomplete) this.logger.info(`Fragments: ${totals.completed} files completed, ${totals.incomplete} incomplete, ${totals.waiting} waiting.`)
} }
await prefetcher.tick()
} catch (error: any) { } catch (error: any) {
this.logger.error(`Coordinator round failed: ${error.message}`) this.logger.error(`Coordinator round failed: ${error.message}`)
} }
await sleep(TICK_MS) await sleep(TICK_MS)
} }
while (prefetcher.active > 0) await sleep(200) await prefetching
// 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
while (prefetcher.active > 0 && Date.now() < waitUntil) await sleep(200)
if (prefetcher.active > 0) this.logger.info(`Closing ${prefetcher.active} connections still fetching.`)
await NntpPool.close() await NntpPool.close()
this.logger.info('Coordinator stopped.')
stopped!() stopped!()
await this.terminate()
} }
} }
+2
View File
@@ -163,6 +163,8 @@ export default class EdgeBench extends BaseCommand {
peakDetail = `heap ${(m.heapUsed / 1e6).toFixed(0)}/${(m.heapTotal / 1e6).toFixed(0)} MB, external ${(m.external / 1e6).toFixed(0)} MB, array buffers ${(m.arrayBuffers / 1e6).toFixed(0)} MB` peakDetail = `heap ${(m.heapUsed / 1e6).toFixed(0)}/${(m.heapTotal / 1e6).toFixed(0)} MB, external ${(m.external / 1e6).toFixed(0)} MB, array buffers ${(m.arrayBuffers / 1e6).toFixed(0)} MB`
} }
} }
// Done, so nothing takes it for a range to work on (a leased one was set back to planned once).
if (this.store) await db.from('ranges').where('id', rangeId).update({ status: 'done', done_at: new Date() })
const cpu = process.cpuUsage(cpuStart) const cpu = process.cpuUsage(cpuStart)
const cpuS = (cpu.user + cpu.system) / 1e6 const cpuS = (cpu.user + cpu.system) / 1e6
const total = t.inflate + t.parse + t.index const total = t.inflate + t.parse + t.index
+5 -4
View File
@@ -4,11 +4,12 @@ import { setTimeout as sleep } from 'node:timers/promises'
import { spawn, type ChildProcess } from 'node:child_process' import { spawn, type ChildProcess } from 'node:child_process'
import { availableParallelism } from 'node:os' import { availableParallelism } from 'node:os'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { gzipSync, unzip } from 'node:zlib' import { gzip, unzip } from 'node:zlib'
import { RangeIndexer, decodeFrames, isKeepalive, toWire, type EdgeBatch, type Lease } from '#lib/edge/index' import { RangeIndexer, decodeFrames, isKeepalive, toWire, type EdgeBatch, type Lease } from '#lib/edge/index'
const IDLE_MS = 30_000 const IDLE_MS = 30_000
const unzipAsync = promisify(unzip) const unzipAsync = promisify(unzip)
const gzipAsync = promisify(gzip)
const RETRIES = 8 const RETRIES = 8
class LeaseLost extends Error {} class LeaseLost extends Error {}
@@ -147,12 +148,12 @@ export default class EdgeNode extends BaseCommand {
// Inflating runs on a thread-pool thread: the next chunk inflates while this one is indexed. // Inflating runs on a thread-pool thread: the next chunk inflates while this one is indexed.
let pending: { to: number; text: Promise<Buffer> } | null = null let pending: { to: number; text: Promise<Buffer> } | null = null
for await (const frame of decodeFrames(await api.stream(range.id))) { for await (const frame of decodeFrames(await api.stream(range.id))) {
if (this.stopping) break
if (isKeepalive(frame)) continue if (isKeepalive(frame)) continue
const next = { to: frame.to, text: unzipAsync(frame.data) } const next = { to: frame.to, text: unzipAsync(frame.data) }
next.text.catch(() => {}) next.text.catch(() => {})
if (pending) await handle(pending) if (pending) await handle(pending)
pending = next pending = next
if (this.stopping) break
} }
if (pending && !this.stopping) await handle(pending) if (pending && !this.stopping) await handle(pending)
await posting await posting
@@ -219,8 +220,8 @@ class Api {
} }
async batch(rangeId: number, batch: EdgeBatch): Promise<{ releases: number; candidates: number; bytes: number }> { async batch(rangeId: number, batch: EdgeBatch): Promise<{ releases: number; candidates: number; bytes: number }> {
// Gzipped: chunk payloads are compressed already, the rest (names, fragments, candidates) compresses well. // Gzipped (on a thread-pool thread): chunk payloads are compressed already, the rest compresses well.
const body = gzipSync(JSON.stringify(toWire(batch)), { level: 1 }) const body = await gzipAsync(JSON.stringify(toWire(batch)), { level: 1 })
const response = await this.request(`ranges/${rangeId}/batches`, { method: 'POST', body, headers: { 'Content-Encoding': 'gzip' } }) const response = await this.request(`ranges/${rangeId}/batches`, { method: 'POST', body, headers: { 'Content-Encoding': 'gzip' } })
if (!response.ok) throw new Error(`batch: HTTP ${response.status}`) if (!response.ok) throw new Error(`batch: HTTP ${response.status}`)
return { ...((await response.json()) as { releases: number; candidates: number }), bytes: body.length } return { ...((await response.json()) as { releases: number; candidates: number }), bytes: body.length }
@@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The prefetcher's ranges every 2 s (REBUILD.md, prefetch): the leased ones
* and the planned ones it started spooling. Without these it read all
* ranges each time (~15–25 ms with 145k planned; LOG.md 2026-10-09).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`CREATE INDEX IF NOT EXISTS ranges_leased_idx ON ranges (leased_at) WHERE status = 'leased'`)
this.schema.raw(`CREATE INDEX IF NOT EXISTS ranges_spooling_idx ON ranges (priority, id) WHERE status = 'planned' AND spooled_chunks > 0`)
}
async down() {
this.schema.raw(`DROP INDEX IF EXISTS ranges_spooling_idx`)
this.schema.raw(`DROP INDEX IF EXISTS ranges_leased_idx`)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto' import { hash } from 'node:crypto'
import type { Overview } from '../nntp/index.js' import type { Overview } from '../nntp/index.js'
import { FileAggregator, type AggregatedFile, isArchiveStart, isLaterVolume, sevenZipVolume } from '../files/index.js' import { FileAggregator, type AggregatedFile, isArchiveStart, isLaterVolume, sevenZipVolume } from '../files/index.js'
import { RuleSet, isRandomFile, uploadTool, type Rules } from '../rules/index.js' import { RuleSet, isRandomFile, uploadTool, type Rules } from '../rules/index.js'
@@ -281,7 +281,7 @@ export class RangeIndexer {
/** The release key; a file without a recognisable release becomes a release of its own. */ /** The release key; a file without a recognisable release becomes a release of its own. */
export function releaseKeyOf(s: EdgeSegment): string { export function releaseKeyOf(s: EdgeSegment): string {
return s.file.release?.key ?? createHash('sha1').update(`file\n${s.fileKey}`).digest('hex') return s.file.release?.key ?? hash('sha1', `file\n${s.fileKey}`)
} }
function postedOf(segments: EdgeSegment[]): number | null { function postedOf(segments: EdgeSegment[]): number | null {
+25 -9
View File
@@ -26,17 +26,33 @@ export function encodeFrame(frame: Frame): Buffer {
return Buffer.concat([head, frame.data]) return Buffer.concat([head, frame.data])
} }
/** The frames of a byte stream, as they complete. */ /**
* The frames of a byte stream, as they complete. Pieces are kept in a list
* and joined once a frame is complete: joining each piece onto what came
* before copied a chunk of MBs once per 64 KB (~5% of a worker's time;
* LOG.md 2026-10-09).
*/
export async function* decodeFrames(stream: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): AsyncGenerator<Frame> { export async function* decodeFrames(stream: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): AsyncGenerator<Frame> {
let buffer = Buffer.alloc(0) let pieces: Buffer[] = []
let length = 0
/** Bytes needed before the next frame can be cut: its header first, then all of it. */
let needed = HEADER
for await (const piece of stream) { for await (const piece of stream) {
buffer = buffer.length === 0 ? Buffer.from(piece) : Buffer.concat([buffer, piece]) pieces.push(Buffer.isBuffer(piece) ? piece : Buffer.from(piece.buffer, piece.byteOffset, piece.byteLength))
while (buffer.length >= HEADER) { length += piece.byteLength
const length = buffer.readUInt32BE(16) while (length >= needed) {
if (buffer.length < HEADER + length) break let buffer = pieces.length === 1 ? pieces[0] : Buffer.concat(pieces, length)
yield { from: Number(buffer.readBigUInt64BE(0)), to: Number(buffer.readBigUInt64BE(8)), data: buffer.subarray(HEADER, HEADER + length) } pieces = [buffer]
buffer = buffer.subarray(HEADER + length) if (needed === HEADER) {
needed = HEADER + buffer.readUInt32BE(16)
if (length < needed) break
}
yield { from: Number(buffer.readBigUInt64BE(0)), to: Number(buffer.readBigUInt64BE(8)), data: buffer.subarray(HEADER, needed) }
buffer = buffer.subarray(needed)
pieces = buffer.length > 0 ? [buffer] : []
length = buffer.length
needed = HEADER
} }
} }
if (buffer.length > 0) throw new Error(`Stream ended inside a frame (${buffer.length} bytes left)`) if (length > 0) throw new Error(`Stream ended inside a frame (${length} bytes left)`)
} }
+4 -6
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto' import { hash } from 'node:crypto'
/** /**
* Identity of one uploaded file: every segment of it has the same poster, the * Identity of one uploaded file: every segment of it has the same poster, the
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto'
* See CONCEPT.md (D12). * See CONCEPT.md (D12).
*/ */
export function fileKey(poster: string, base: string, totalSegments: number): string { export function fileKey(poster: string, base: string, totalSegments: number): string {
return createHash('sha1').update(`${poster}\n${base}\n${totalSegments}`).digest('hex') return hash('sha1', `${poster}\n${base}\n${totalSegments}`)
} }
/** True when a yEnc name looks like a real filename (it has an extension), not a random token. */ /** True when a yEnc name looks like a real filename (it has an extension), not a random token. */
@@ -26,7 +26,7 @@ export function looksLikeFilename(name: string): boolean {
*/ */
export function weakFileKey(name: string, size: number, totalParts: number): string { export function weakFileKey(name: string, size: number, totalParts: number): string {
const identity = looksLikeFilename(name) ? `name\n${name}` : 'shape' const identity = looksLikeFilename(name) ? `name\n${name}` : 'shape'
return createHash('sha1').update(`weak\n${identity}\n${size}\n${totalParts}`).digest('hex') return hash('sha1', `weak\n${identity}\n${size}\n${totalParts}`)
} }
/** /**
@@ -41,7 +41,5 @@ export function counterFileKey(segment: { poster: string; subject: string; total
const counter = /\[(\d+)\/(\d+)\]/.exec(segment.subject) const counter = /\[(\d+)\/(\d+)\]/.exec(segment.subject)
if (!counter || segment.totalParts < 2) return null if (!counter || segment.totalParts < 2) return null
const day = segment.postedAt && !Number.isNaN(segment.postedAt.getTime()) ? segment.postedAt.toISOString().slice(0, 10) : '' const day = segment.postedAt && !Number.isNaN(segment.postedAt.getTime()) ? segment.postedAt.toISOString().slice(0, 10) : ''
return createHash('sha1') return hash('sha1', `counter\n${segment.poster}\n${Number(counter[1])}/${Number(counter[2])}\n${segment.totalParts}\n${day}`)
.update(`counter\n${segment.poster}\n${Number(counter[1])}/${Number(counter[2])}\n${segment.totalParts}\n${day}`)
.digest('hex')
} }
+6 -3
View File
@@ -1,9 +1,10 @@
import { promisify } from 'node:util' import { promisify } from 'node:util'
import net from 'node:net' import net from 'node:net'
import tls from 'node:tls' import tls from 'node:tls'
import { deflate, deflateSync, unzipSync } from 'node:zlib' import { deflate, deflateSync, unzip } from 'node:zlib'
const deflateAsync = promisify(deflate) const deflateAsync = promisify(deflate)
const unzipAsync = promisify(unzip)
export interface XoverClientOptions { export interface XoverClientOptions {
host: string host: string
@@ -66,7 +67,8 @@ export class XoverClient {
? tls.connect({ host: options.host, port: options.port, servername: options.servername || options.host }) ? tls.connect({ host: options.host, port: options.port, servername: options.servername || options.host })
: net.connect({ host: options.host, port: options.port }) : net.connect({ host: options.host, port: options.port })
const client = new XoverClient(sock, options.timeoutMs ?? 120_000) const client = new XoverClient(sock, options.timeoutMs ?? 120_000)
sock.setTimeout(client.timeoutMs) // The greeting and login are short: a server holding a connection back (too many open) fails it in 30 s, not timeoutMs.
sock.setTimeout(Math.min(client.timeoutMs, 30_000))
try { try {
await client.expect(await client.line(), ['200', '201']) await client.expect(await client.line(), ['200', '201'])
if (options.user) { if (options.user) {
@@ -241,7 +243,8 @@ export class XoverClient {
const all = this.buffer() const all = this.buffer()
const raw = all.subarray(0, all.length - DOT_LINE.length) const raw = all.subarray(0, all.length - DOT_LINE.length)
try { try {
const inflated = unzipSync(raw) // On a thread-pool thread: a chunk's ~25 MB of text took the coordinator's event loop ~60 ms.
const inflated = await unzipAsync(raw)
this.take(all.length) this.take(all.length)
return { raw: Buffer.from(raw), text: wantText ? inflated.toString('utf8') : '' } return { raw: Buffer.from(raw), text: wantText ? inflated.toString('utf8') : '' }
} catch { } catch {
+2 -2
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto' import { hash as digest } from 'node:crypto'
import { looksLikeFilename } from '../headers/file_key.js' import { looksLikeFilename } from '../headers/file_key.js'
export interface ReleaseIdentity { export interface ReleaseIdentity {
@@ -103,7 +103,7 @@ export function namesRelease(remainder: string): boolean {
} }
function hash(...parts: (string | number | undefined)[]) { function hash(...parts: (string | number | undefined)[]) {
return createHash('sha1').update(parts.map((p) => p ?? '').join('\n')).digest('hex') return digest('sha1', parts.map((p) => p ?? '').join('\n'))
} }
/** /**
+2 -2
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto' import { hash } from 'node:crypto'
/** /**
* Upload signatures (CONCEPT.md D42): which tool posted an article, read from * Upload signatures (CONCEPT.md D42): which tool posted an article, read from
@@ -48,5 +48,5 @@ export function signatureKey(tool: string, group: string) {
*/ */
export function explored(key: string, percent: number): boolean { export function explored(key: string, percent: number): boolean {
if (percent <= 0) return false if (percent <= 0) return false
return createHash('md5').update(key).digest().readUInt32BE(0) % 10_000 < percent * 100 return hash('md5', key, 'buffer').readUInt32BE(0) % 10_000 < percent * 100
} }
+11
View File
@@ -25,4 +25,15 @@ test.group('edge / stream frames', () => {
} }
}, /inside a frame/) }, /inside a frame/)
}) })
test('frames come out whole whatever the piece sizes, also pieces holding several frames', async ({ assert }) => {
const sent = Array.from({ length: 12 }, (_, i) => ({ from: i * 10 + 1, to: i * 10 + 10, data: Buffer.alloc((i * 997) % 5000, i) }))
const whole = Buffer.concat(sent.map(encodeFrame))
for (const size of [1, 3, 20, 21, 64, 1000, 4096, whole.length]) {
const pieces = []
for (let at = 0; at < whole.length; at += size) pieces.push(new Uint8Array(whole.subarray(at, at + size)))
const frames = []
for await (const frame of decodeFrames(pieces)) frames.push(frame)
assert.deepEqual(frames.map((f) => [f.from, f.to, f.data.toString('hex')]), sent.map((f) => [f.from, f.to, f.data.toString('hex')]), `pieces of ${size}`)
}
})
}) })