Files
usenet-indexer/config/coordinator.ts
T
Daan MeijerandClaude Fable 5.1 680751e26a
Test and deploy / test (push) Successful in 3m14s
Test and deploy / deploy (push) Successful in 2m30s
Lease lanes for the tail, a mixed lease order, coordinator:status, the edge end to end
A group now has two lease lanes (migration 6600): `history` works the ranges the bounds hand out, `tail` the tail
planner's, each with its own holder, window, snapshot and takeover redo, so a thread on the tail never wades
through a group's history and a held group's tail isn't stuck behind it. The edge puts `--tail-threads`
(EDGE_TAIL_THREADS, 1 of 2+ by default) of its threads on the tail lane; the group routes take `?lane=`. The
lease order within a lane alternates the group with the most planned articles and the longest unheld one. The
takeover redo puts back only the lane's ranges the window did after the snapshot, and a group whose last range
is held by a holder gone quiet can be taken over.

`coordinator:status` prints the catalog's room, the ranges by band, the last hour's rates, the held groups with
their snapshots and the phase-2 backlogs. `tests/e2e` runs the real edge-node binary against the node API: the
corpus-fill race, a failed stream and the restart that restores the snapshot, a restart without the snapshot
file, a takeover (STREAM_CHUNK_WAIT_MINUTES is a setting now). CI builds the binary before the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 13:47:31 +01:00

190 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import env from '#start/env'
import app from '@adonisjs/core/services/app'
/** The coordinator (REBUILD.md, part 1). */
export default {
/** Articles per range. */
rangeArticles: env.get('RANGE_ARTICLES', 2_000_000),
/**
* Articles per XOVER chunk (spool files, stream frames). Eweka takes
* ~1.5–2 s per command whatever its size: one connection read 9–15k
* lines/s in chunks of 25k, ~20k in 50k, ~30k in 100k and 200k (LOG.md
* 2026-10-09).
*/
chunkArticles: env.get('CHUNK_ARTICLES', 100_000),
/**
* The live tail (REBUILD.md, coordinator): new articles become ranges
* every `everySeconds` (one LIST ACTIVE per name prefix), leased and
* fetched before any history. A group gets a tail range once it has
* `minArticles` new (a chunk) or its oldest unplanned article waited
* `maxDelayMinutes`, so a quiet group doesn't make a range every round.
*/
tail: {
everySeconds: env.get('TAIL_EVERY_SECONDS', 120),
minArticles: env.get('TAIL_MIN_ARTICLES', 100_000),
maxDelayMinutes: env.get('TAIL_MAX_DELAY_MINUTES', 15),
/**
* Before everything: the history's 0 (oldest) … 1000 (newest), the stored ranges' -2 and ranges raised to -1 (the
* short gaps between done blocks went before the tail at -1 and it fell ~4 h behind; LOG.md 2026-10-16 (70)).
*/
priority: -3,
},
/** A leased range without a heartbeat for this long goes back to the queue. */
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
/**
* The ranges group leases hand out (REBUILD.md design 1), by priority: the tail is negative, the capture windows
* 0–2 (2016, 2021, 2025), the history by its position in the group (0 oldest … 1000 newest: its first buckets
* share 0–2 with the windows) or 4000+. Bounds so a deployment can run one window first, with
* LEASE_CAPTURE_ONLY so the history's 0 isn't taken along (homelab 2026-10-26: 0..0 and capture only, the 2016
* window; LOG.md (118)); unbounded and any range by default.
*/
leasePriorityMin: env.get('LEASE_MIN_PRIORITY', -1_000_000),
leasePriorityMax: env.get('LEASE_MAX_PRIORITY', 1_000_000),
leaseCaptureOnly: env.get('LEASE_CAPTURE_ONLY', false),
/**
* How long a range's stream waits for a chunk the prefetcher hasn't spooled yet (app/controllers/node_controller.ts).
* Long: a chunk that keeps coming in isn't given up (only a stalled one is), and some groups' chunks take 20+
* minutes (a.b.0day.stuffz, 17 MB in 22 min; LOG.md 2026-10-10 (3)).
*/
streamChunkWaitMs: env.get('STREAM_CHUNK_WAIT_MINUTES', 60) * 60 * 1000,
/** NNTP connections the coordinator fetches headers with: at most (its share, nntpShares), and at least while sharing. */
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.
* Eweka sometimes stops answering on long-lived connections (all 40 quiet,
* a fresh one answers in seconds; LOG.md 2026-10-09 (14)): at 10 minutes
* the prefetcher nearly stopped. Data arrives within seconds otherwise.
*/
nntpTimeoutMs: env.get('NNTP_TIMEOUT_MS', 2 * 60 * 1000),
/**
* A request that got less than this in the last minute (kB) is given up,
* its connection closed, and the chunk fetched again: a reply that trickles
* (6,204 → 6,229 kB in a minute) never goes quiet long enough for
* nntpTimeoutMs, and kept a worker waiting on one chunk for an hour. Not a
* deadline: some groups send steadily but slowly (a.b.0day.stuffz ~400 kB a
* minute, 5–7 minutes a chunk), and a fixed 90–360 s gave those up again
* and again (LOG.md 2026-10-10 (3)).
*/
nntpStallBytesPerMinute: env.get('NNTP_STALL_KB_PER_MINUTE', 100) * 1000,
/**
* A chunk not fetched after this long is asked again on another
* connection; the first answer wins and the other connection is closed.
* Eweka stalls single requests: the same chunk took 27–52 s on one
* connection and 1–9 s on another at the same moment; median ~3 s
* (LOG.md 2026-10-09 (15)). Measured in production (40 + 6 connections vs
* 40, 15 min each): 280k vs 273k articles/s, within the noise; the second
* request won half the time. Off by default.
*/
nntpHedgeAfterMs: env.get('NNTP_HEDGE_AFTER_SECONDS', 10) * 1000,
/** Of nntpConnections, kept for those second requests (the prefetcher's loops are the rest). 0: no hedging. */
nntpHedgeConnections: env.get('NNTP_HEDGE_CONNECTIONS', 0),
/**
* A connection is closed after this long (when it's free), and a fresh one
* opened. 0: never. Requests were as often slow (>10 s) on connections
* under 2 minutes old as on ones over 10 (~25–35%; LOG.md 2026-10-09 (16)).
*/
nntpConnectionMaxMs: env.get('NNTP_CONNECTION_MAX_MINUTES', 0) * 60 * 1000,
/**
* A connection with nothing to do for this long is closed, and opened again
* when there's work. 0: kept. Eweka drops connections that stay quiet, and
* the next command on one failed ("Spot inspection failed: NNTP connection
* closed"); idle ones also take the account's connections from downloads.
*/
nntpIdleCloseMs: env.get('NNTP_IDLE_CLOSE_SECONDS', 60) * 1000,
probe: {
/**
* 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
* couple stay free for downloads. ~7 MB/s each (~10 articles/s).
*/
connections: env.get('PROBE_CONNECTIONS', 6),
/** Of them, always (its share's minimum; nntpShares). */
minConnections: env.get('PROBE_MIN_CONNECTIONS', 2),
/** No claims while the filesystem under `/` (the NVMe, in the container) has less free (app/services/disk_space.ts). 0: off. */
minFreeGb: env.get('PROBE_MIN_FREE_GB', 0),
/** Candidates claimed at a time. */
batch: 400,
/** BODY commands in flight per connection (pipelined; XoverClient.bodies). */
pipeline: env.get('PROBE_PIPELINE', 8),
/**
* The pending ladder (REBUILD.md decision f; app/services/pending_ladder.ts): the share of each claim taken
* from pending_releases at least (the catalog's own candidates come first, the rest of the claim is theirs).
*/
pendingShare: env.get('PENDING_SHARE', 0.25),
/** A signature (tool, group, poster) with this many climbs and at most pendingMaxNamed of them named is dropped. */
pendingMinProbes: env.get('PENDING_MIN_PROBES', 100),
pendingMaxNamed: env.get('PENDING_MAX_NAMED', 0.005),
},
/**
* The catalog's tablespace may grow to this many GB (app/services/catalog_space.ts); above it only the tail and
* the stored ranges are leased and prefetched. 0: no cap.
*/
catalogMaxNvmeGb: env.get('CATALOG_MAX_NVME_GB', 0),
/**
* Spotnet spots (app/services/spotter.ts, spot_inspector.ts): the group the spots are posted in, how many of
* its last articles the spotter reads the first time (~215 spots a day: ~3 months), how often it looks again,
* the most articles an NZB may take, and how long an inspected spot whose files match no release waits for
* the header pass before it becomes a release of its own.
*/
spots: {
group: env.get('SPOT_GROUP', 'free.pt'),
backfill: env.get('SPOT_BACKFILL', 20_000),
everySeconds: env.get('SPOT_EVERY_SECONDS', 120),
maxNzbSegments: env.get('SPOT_MAX_NZB_SEGMENTS', 64),
standaloneAfterHours: env.get('SPOT_STANDALONE_AFTER_HOURS', 24),
},
/** Stored XOVER chunks of ranges marked `capture`, for the grouping sweeps (app/services/corpus.ts). Off when unset. */
corpus: {
dir: env.get('CORPUS_DIR'),
/**
* Capture ranges fetched into the corpus by connections the spool leaves idle, this many at a time (8, 2 connections each, left 20 of 40 idle): the
* spool only fetches what workers lease soon, and the tail comes first, so after a reset the capture
* ranges waited behind it for hours (1,400 marked, none stored, 34 of 40 connections idle; LOG.md 2026-10-12 (27)).
*/
ahead: env.get('CORPUS_AHEAD', 16),
/**
* Stored without the lines the edge worker drops on their own (crossposts another indexed group owns, obfuscated
* subjects; ~48% of the indexed ranges' headers), what was left out in each chunk's `.drops`: twice the history
* in the same space (LOG.md 2026-10-23 (101)). Off: as the news server sent them.
*/
filter: env.get('CORPUS_FILTER', true),
},
/**
* The group windows' snapshots (REBUILD.md, "Voyager 3: window indexing", 5; app/services/group_leases.ts): a
* file a group on bulk, next to the spool by default.
*/
snapshotDir: env.get('SNAPSHOT_DIR', `${env.get('SPOOL_DIR', app.tmpPath('spool'))}/snapshots`),
spool: {
dir: env.get('SPOOL_DIR', app.tmpPath('spool')),
/** Ranges kept fetched ahead, per active lease. */
aheadPerLease: env.get('SPOOL_AHEAD_PER_LEASE', 4),
/** At least this many ranges ahead, so the first leases start at once. */
minAhead: env.get('SPOOL_MIN_AHEAD', 2),
maxBytes: env.get('SPOOL_MAX_GB', 10) * 1e9,
/**
* At most this many connections on one range, and on one group, at a
* time (0: no limit): spreads the requests over ranges and groups instead
* of all on the next chunks of the first ranges. Homelab, 40 connections,
* 12 min each, alternated: unlimited (on ~10 ranges) 281k and 167k
* articles/s, 2 per range and 4 per group (on ~22) 319k and 296k, with
* shorter chunk times (11.5 vs 13–24 s; LOG.md 2026-10-09 (18)). Needs
* targets for it: aheadPerLease 4.
*/
perRange: env.get('PREFETCH_PER_RANGE', 2),
perGroup: env.get('PREFETCH_PER_GROUP', 4),
},
}