31 KiB
Voyager 2: header-first indexer (concept)
The concept for what runs (since 2026-10-11; it started as the spec of a rebuild, then a draft for approval). Voyager 1's CONCEPT.md and its decisions D1–D42 are superseded; its domain model still holds, and its features not yet here are the phase 2 backlog below.
Branch rebuild; its successor is designed under Voyager 3 below (branch
voyager-3). Phase 1 finds releases from headers and makes their NZBs.
Everything learned from articles (names from yEnc headers, par2, archive
listings, encryption, spots) is phase 2 and adds to what phase 1 stored; it
never rewrites it. Sources: CONCEPT.md, LOG.md 2026-10-08 (13)–(14).
Status (2026-10-09)
Built on this branch (a fresh AdonisJS 7 app; phase 1 code imported from
the current app by pipeline stage): coordinator (planner, NNTP pool,
prefetch spool, leases, rule sets, fragment merger, settling), the node API
(lease, framed stream, batches, finish/release/heartbeat), the edge worker
(edge:node, --processes), the catalog and chunk store, NZBs and a name
search. Measured on the workstation (4 cores, 100 Mbit, Eweka):
- Edge worker, one core: ~210–215 k headers/s on recent a.b.boneless, ~330–340 k on a 2013 a.b.ath range; ~0.7–0.8 GB peak per process. (Start: 73–103 k/s, 1.0–1.2 GB.) What's left is spread out (the per-file key ~12%, GC ~10%, zlib ~10%, the scan ~6%).
- NNTP: Eweka takes ~1.5–2 s per XOVER command; ~22–30 k lines/s per connection in chunks of 100 k when the line allows; connections don't slow each other.
- 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.)
- On homelab (12 cores, 1 Gbit; 2026-10-09, separate containers and
database,
/mnt/bulk/usenet-rebuild): 20 connections fetched ~630 k articles/s (~31 k per connection, ~3 s per 100 k chunk, ~250 Mbit/s at ~50 B per article on older boneless) and 8 worker processes indexed all of it, waiting for the spool (~366 M headers in ~12 minutes). Server side at that rate: coordinator 0.4–0.5 cores (1.3–1.7 before it stopped inflating replies to check them), web 0.3–0.45, Postgres (on the HDD) under 0.15. Worker per core there: ~195 k headers/s (boneless 2026), ~275 k (ath 2013). - Storage: chunks 17.9–20.3 B per segment (format v2); a release ~1.1 KB.
- For the whole target (528 B overview lines): ~36 TB of compressed XOVER 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.
Where workers run: total speed is set by the account's NNTP connections
(~30 k articles/s each), not by worker CPU, so remote workers don't make
it faster; they take CPU off homelab (at 1.25 M/s ~7 worker cores) at the
cost of relaying the compressed XOVER (≤ ~0.7 Gbit/s up, homelab has 1
Gbit/s). Plan: coordinator, web, Postgres and a few workers on homelab,
most workers on batcave, a few on poweredge-kantoor with
edge:node --max-mbit (e.g. 50 of its 160 Mbit/s ≈ 100–125 k articles/s;
the cap is shared by the node's processes).
Also built (2026-10-09): the live tail (new articles planned every 2
minutes from one LIST ACTIVE per name prefix, at priority −1: leased and
fetched before history); the catalog API, Newznab (/api) and the web
interface, from voyager 1 (the current build), with categories from the
name; voyager 1's 29 learned garbage signatures (rules:signatures,
database/data/voyager1_signatures.tsv).
Deployment (2026-10-10): Dockerfile (targets app and edge),
deploy/compose.yaml (project usenet-rebuild on homelab), deploy/deploy.sh;
a push to rebuild that passes CI is deployed. See deploy/README.md.
Not built yet: re-running ranges for a retracted rule (the counts and rule versions are recorded), decision f, phase 2 (inspection of the probe queue: yEnc names, encryption, archives, par2; passwords; spots).
Target
7,000 days (all of Eweka's retention, back to 2008-08) of the 1000 largest
alt.binaries.* groups. Estimated: 528 B overview lines; 435 B in their
main group; 140 B kept segments in ~1.25 B files and ~115 M releases after
dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
≥ 25 B overview lines/day (~50 connections), so ~3 weeks.
Parts
-
Coordinator (server). Plans each group's history as ranges of 2 M articles, leases them to workers, tracks progress and retries. The group list (owner rule) and the garbage signature table go with every lease. Fetches XOVER for the workers (relay: workers have no NNTP account; the gzip stream is passed on as is).
Prefetch: the coordinator fetches the next ranges ahead of the 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 sequentially). It keeps about two ranges ahead per active lease, capped 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 that is already (partly) spooled, else to the next planned one (leased ranges are fetched first); a worker reads its chunks from the spool, waiting only for chunks still being fetched. A worker gets its whole range in one streaming request (
GET /ranges/:id/stream): the 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 per batch, moving the range's resume point. Chunks matter on the NNTP side: a compressed XOVER reply can't be resumed, old ranges answer slowly, and chunks of one range can be fetched in parallel. Spooled chunks are deleted when their range's results are stored, and fetched again if a range is leased again later. Order: oldest era first (2008–2018 holds 63% of the readable files on 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 withcoordinator:run --shard=i/n(ranges with id % n = i − 1, each with its own NNTP_CONNECTIONS; only shard 1 plans, merges and settles). -
Edge worker (node, TypeScript reusing
lib/). Measured (edge:bench, 2026-10-09, after two rounds of work): ~200–215 k headers/s per core on recent a.b.boneless, ~320–340 k on a 2013 a.b.ath range, ~0.7 GB peak per process. Rust (edge/, the same RangeIndexer path over the same captures;check.tsfinds every output identical, the decoded payloads included): 3.5–3.8× less CPU (boneless 2.9 vs 11.1 CPU-s per 2 M headers, ath 2.15 vs 7.5) and a third of the memory (0.20–0.24 GB). Its gains: no GC, hand-written matching instead of the regexes, mimalloc, libdeflate (half of zlib's inflate time) and SIMD line scanning. At the full target (~1.25 M/s) that is ~1.5–2 worker cores instead of ~6–7; the speed is still set by the NNTP connections. The cost: a second implementation of lib/headers, lib/release, lib/rules and the chunk format, kept identical (check.ts) for as long as both exist. Findings, how to run both and how to compare their outputs:edge/README.md. Since 2026-10-09 the Rust worker (edge-node, same crate) is the one running on homelab: ~8× less CPU per header and 20× less memory thanedge:nodein production. Per lease: compressed chunks of 10–50 k lines, in order → parse subjects → skip crossposts outside their owner group (of the indexed groups in the article's Xref, the one with the lowest priority number; see the group list) → group articles into files → files into releases (lib/releaserules) → drop garbage (random name + dropped signature or tool) → set aside per-article random subjects and obfuscated subjects (counted only; phase 2). Returns per batch: release chunks, edge fragments, probe candidates, counters. Holds no state beyond its lease.Probe candidates: per release, the articles phase 2 should read, as (release key, kind, message-id, file in its chunk): the first article of the first archive volume (
.part01.rar,.rar,.7z.001), of the par2 index (smallest.par2), and of a lone random-named file; for a 7z set also its last volume (the listing is in the tail). At most a few per release (~115 M releases → ~150–250 M articles), not one per file. Per- article and obfuscated subjects get none (they need every article's body). -
Chunk store (bulk, append-only). One record per release per batch: its files' names, sizes and segment lists, compactly encoded (template codec for message-ids, zstd). Written once, never updated. A batch is stored in one transaction and only when it starts at its range's resume point, so a batch sent twice or late is refused and the catalog's sums stay right (no per-file dedup); a recovery run for a retracted rule only adds what that rule had dropped. ~3 TB.
-
Release catalog (hot, Postgres on NVMe). One row per release: key, name, poster, groups, posted_at, file count, size, segments, expected file count (
[i/N]), has-par2, readable-name flag, state. Updated with aggregates once per batch (one upsert per release per batch), plus a small name index for search. ~115 M rows, ~50–100 GB. -
Rules and recovery. What a worker skips isn't stored (message-ids are the bulk: garbage alone would be ~2.5 TB); a skipped range is read again instead (headers are cheap: ~53 B per overview line compressed).
- Per range, per rule and signature: how many articles and files the rule dropped (garbage per (tool, group), per-article random subjects, obfuscated subjects, and crossposts per owner group). A few rows per range.
- Rule versions: every range records the version of the rule set it ran with. Retracting or changing a rule makes the ranges where it matched stale; the coordinator leases them again for that rule only, and the worker returns just what the rule had dropped.
- Sample: ~1% of what each rule drops (by a hash of the release key) is kept with full file details and probe candidates, so a rule can be checked, and its losses measured in phase 2, before it is retracted.
-
Probe queue. The candidates, in the catalog's database: one small row each (release, kind, message-id, chunk reference), priority by the release's state (settled first) and kind (random names before readable ones). Phase 2 takes them from here; phase 1 only fills it.
-
Fragments. Files crossing a range edge: kept by range and article number; merged when the neighbouring range is done, then written as a chunk. Never staged per segment.
-
Settling. A release is settled when nothing was added to it for 6 hours (30 minutes once its last post is that old: history), and its completeness decided: complete when every file its [i/N] counter announced was found. Without a counter, by its par2 sets once their index is probed (
par2_completeness): every file a set describes is there by name or by its first 16 KiB, or, renamed, as many files and bytes; decided again after a par2 merge and before the pruner drops a set's file list. Segments aren't compared. Phase 2 picks its work from settled releases. -
NZB and API. NZB built from a release's chunks, with a known password in its meta; Newznab search (names, inner names) and get; a web interface. Categories (Newznab's) by a trigger on releases (migration 5200): from the name, else the inner name the prober found, else a spot's category (spot_category, set by the spot inspector), else the groups (erotica, sounds, ebooks, …).
releases:recategorizerecomputes old rows in batches.
Group list
Editable at any time: an added group gets its ranges planned, a removed one
no new leases. Each group gets a priority number when it is added (the first
list by size, later additions after it); a crosspost belongs to the indexed
group in its Xref with the lowest number. So adding a group never takes
articles from groups already done, and removing one re-reads only the ranges
of other groups whose counts show crossposts skipped for it (part 5). Start:
the 1000 largest alt.binaries.* groups by LIST ACTIVE (~266 k ranges of
2 M articles; boneless alone 80 k).
Corpus
The grouping sweeps' dataset (2026-10-12; LOG.md 2026-10-12 (12)): the XOVER
chunks of ranges marked capture, kept on the HDD in
corpus/<group>/<start>-<end>/ (edge-bench's layout, range.json once
complete), up to 300 GB. The prefetcher stores them as it fetches and reads
them from there afterwards, so a reset re-indexes them without the news
server. deploy/corpus_sweep.py marks more ranges each sweep (capture,
spread over groups and eras, the top-100 groups first), replays every stored
range with the deployed build's edge-bench (replay, ~seconds a range) and
reports suspects and the change since the previous build (report). A rule
change is judged on the whole corpus before a reset.
Since 2026-10-23 also history fetched ahead of the indexing: while the
catalog is over its NVMe cap, ranges marked capture (priority >= 0, not
leased) are fetched into the corpus by idle loops, at most perGroup /
perRange of a group at a time, to be indexed from disk once there's room
(LOG.md 2026-10-23 (100)). Stored filtered (CORPUS_FILTER, default on):
without the lines the edge worker only counts, decided per line as it does
(a crosspost another indexed group owns, a subject that names no file part;
~48% of headers), the chunk compressed again as zlib; beside it a .drops
(gzip JSON: rule version, the owners' priorities, per rule the article
numbers as differences, crossposts by owner) and filtered in range.json.
The worker makes the same releases of a filtered chunk; rule_counts lack
those two rules' counts. corpus:filter filters chunks stored raw. A
corpus filtered this way no longer serves a sweep of those two rules.
Windows by posting time (2026-10-25; voyager 3's test data, LOG.md (104)):
ranges:dates samples when each active group's articles were posted (one
XOVER of 32 articles every 500M articles of the group, 16 … 1000 samples,
the median Date of each slice, kept monotone; group_dates; over plain
connections: Eweka cuts a compressed reply at the first ".\r\n" in its
output, which some small replies begin with, TruncatedReply, LOG.md
2026-10-25 (105)) and estimates
every range's posting date from the curve (ranges.est_date; the edges of
a window are off by up to one sample interval, three weeks in boneless).
ranges:capture --from 2025-01-01 --to 2026-01-01 marks every active
group's ranges posted in the window capture: planned ones not started at
--priority (0), done ones as they are, both fetched into the corpus by the
fill, which takes capture ranges in priority order, done ones too (they're
fetched again, corpus only), and skips those complete (ranges.stored_at,
set when it sees the range.json). A year of every group is the data to
replay voyager 3's 30-day blocks on: marked 2026-10-25, 2016 (priority 0,
30B articles, ~0.56 TB) and 2021 (priority 1, 87B, ~1.6 TB); a recent year
is ~200B articles, ~3.6 TB.
Phase 2
Built: the prober (2026-10-11; probe:run, LOG.md 2026-10-11 (2)). It
reads the probe queue's first articles (lone, par2, archive) and
stores per release whether it's passworded and its inner name, which search,
the web interface and Newznab use.
Built: the par2 merge (2026-10-12; releases:merge, the merger service,
LOG.md 2026-10-12 (13)). The prober stores per probed file start the MD5 of
its first 16 KiB (file_hashes) and per par2 body its set's file list
(par2_files, par2_sets), which holds the same hash per file whatever the
file was renamed to. Every minute the merger merges the releases of one
group, posted within 48 hours of a release holding the set's par2, whose
files the set describes, into the one with the most files (chunks,
candidates, inspections, counts; the par2's shared name when none was
readable); release_merges keeps what went where. Obfuscated uploads whose
files were each a release of their own (lone candidates) become one.
Readable files have no hash: a name pass (2026-10-23) joins the releases
of the set's poster in its group posted within 12 hours whose files the
set all names (no case or punctuation; sidecars, par2 and images aside),
as via = 'name': an album's tracks and its index, an index beside its
differently spelled video. releases:merge-names for sets from before.
Also merged: lone files whose inner names share a random stem (inner),
and small sidecar-only releases into their upload (sidecar).
It reads what's new an hour (created_at) at a time, its place kept after
each, and merges only the sets held or matched by two releases or more; a
file whose first 16 KiB and size more than 20 files share is a common one and
matches nothing (LOG.md 2026-10-20 (85)).
A set's file list is dropped once it can't merge anything more: complete (all
its files in its holder), judged (every holder junk), or final (every holder
inside a run of done ranges, those ranges' candidates probed, its hashes seen
by the merger); par2_sets stays (LOG.md 2026-10-20 (87)).
Backlog: voyager 1's features not in voyager 2 (CONCEPT.md decisions;
voyager 1's data exported to /mnt/bulk/usenet-rebuild/v1-export/ on
homelab: voyager1-tables.dump with 2.76 M spots, upload signatures,
signature probes, dropped releases and groups; voyager1-release-passwords.csv
with 68,032 releases with a password):
| Feature | Voyager 1 | In voyager 2 |
|---|---|---|
| Spotnet spots | D29 | the spotter and the spot inspector (below); new spots only, the export's not imported |
| Posted NZBs read into releases (also NZBs inside archives) | D26, D28 | not yet |
| Passwords (from spots, NZB meta, names) | D29, D26 | from spots (title, text) and their NZBs' meta, in release_passwords; else only whether passworded (the prober); the export's 68 k not imported |
| SFV grouping and naming | D34 | not yet (SFV files aren't probe candidates) |
| Complete RAR listings by walking volume headers | D40 | no: the prober reads the first volume's first article only |
| 7z listings | D19 | yes (2026-10-23): the prober reads the 7z-tail (the last segment of the highest volume, or of an unsplit multi-segment .7z) and lib/sevenzip lists it from the archive's end header (LZMA-packed, decoded in TypeScript); an encrypted header (-mhe) means passworded without names. One release keeps only the tails of its highest volume (seven_zip_tails). ZIP: the first segment's local headers (first entries, encryption) |
| Names from yEnc bodies for obfuscated subjects | D13, D21, D36 | no: obfuscated and per-article subjects are only counted (part 5); their message-ids aren't stored, so a body path needs those ranges read again |
| Garbage dropped after probing (no real name, no password) | D42 (its second filter) | the junk judge hides it (below); nothing dropped yet |
Live indexing (the tail) runs since 2026-10-09.
Junk (voyager 1's second filter, D42). After the header pass's rules
(garbage signatures, executables, per-article and obfuscated subjects), the
judge (releases:judge, service judge) judges releases by what the
prober, the merges and the spots found: every 2 minutes the releases whose
facts changed (noted by triggers in judge_dirty), daily every release (for
what time changes: a day after settling, ranges read beside it). In this
order (the first rule wins): dead (every probed first article gone), fake (a film, episode
or music name whose content is a program: archive entries all
executables, or the file itself one by name or by its first bytes, sniffed
exe), repeated (the same first 16 KiB and size in 20+ releases),
locked (passworded, no password from a spot, its NZB or the title),
unusable (random names, no inner name, no par2 or inner-name merge, no
spot, a day after settling). Verdicts (release_verdicts, with the rule
set's version) hide releases from search (judged shows them); a verdict
whose rule no longer matches is taken back. Nothing is dropped yet.
Sidecars (the merger's third pass). An upload's .nfo, .sfv or .nzb
that came apart from it (another name, another counter: "[00/80] … .nzb"
beside "[01/94] … .part01.rar") was a release of its own. The settler
queues small settled releases (≤ 3 files, < 1 MB) in fold_candidates;
one whose files are all sidecars joins the poster's bigger release in the
same group posted within 10 minutes: the one sharing a word of 4+ letters
with its subject, else the one under the same counter total, else none
(release_merges.via 'sidecar').
Spots (after voyager 1's D29; lib/spotnet ported as is). Two
processes, one NNTP connection each:
- The spotter (
spots:read, servicespotter) reads free.pt's overviews after the last article it saw (spot_state; the first time the lastSPOT_BACKFILL, 20,000, ~3 months), everySPOT_EVERY_SECONDS(120). For each spot it reads the full header: the signature is checked there as Spotweb does (Spotnet's keys 2–4; self-signed key 7 by hashcash), and the X-XML gives the description, the website and the NZB's articles. A password the title ({{pw}}) or the description names (passwordFromSpot) is kept with the spot, with its source. A delete/dispose message marks its targetremoved(a personal one only from the same spotter within 5 days) and takes its links and passwords back from the releases. - The spot inspector (
spots:inspect, servicespot-inspector) looks inside each listed spot, newest first: it reads the compressed NZB (≤SPOT_MAX_NZB_SEGMENTSarticles in alt.binaries.ftd), lists its files inspot_files(named as the header pass names them,parseSubject), and matches each file to the release the header pass built for it: the same release identity (releaseIdentityover the file's subject and poster) in one of its groups, the release posted nearest the file within 48 h. Matched releases are linked inrelease_spots. Placeholder NZBs (dummy), gone articles (nzb-gone) and unreadable ones are noted on the spot. Unmatched spots are matched again every hour; afterSPOT_STANDALONE_AFTER_HOURS(24) a spot becomes a release of its own (sourcespot), whose NZB is the spot's (kept gzipped inspots.nzb). - Passwords are kept apart in
release_passwords(release, password, source:nzbfor the NZB's<meta type="password">, elsespot-titleorspot-text, and the spot), not on the catalog row; the catalog only sayspassworded.
Voyager 3: window indexing (design, 2026-10-25)
Branch voyager-3 (a push to it only runs the tests; rebuild keeps
running on homelab until the reset). Goal: the whole history (~1,220 B
articles, 3% indexed on 2026-10-25) indexed and searchable on a 4 TB NVMe
and the 15 TB HDD. The catalog costs 3.9 B of NVMe per article today
(144 GB for 36.5 B), which puts the full history at ~4.7 TB; the target is
≤ 2.5 B per article (~3 TB) with room for the WAL and bloat. Sources:
LOG.md 2026-10-24 (102).
What is wrong today: a release is written as soon as one range sees it, updated by every later range that finds more parts, merged afterwards (fragments, par2, names) and settled by wall-clock quiet time, which means little in a backfill. That churn is most of the WAL (0.6 TB a day) and the 20 k write IOPS bursts, and leaves bloat. Half the rows are single-article releases, a third of them one poster's image dump (27.7 M rows, LOG.md (102)).
-
Blocks. A group's history is still fetched as ranges of 2 M articles in chunks of 100 k (the spool, the corpus, the prefetcher stay), but it is leased as blocks: consecutive ranges covering at least 30 days of posting time (
ranges.posted_low/high), so the window below is small against a block (≤ 3% of a block's releases touch an edge). A small group is one block; boneless has a block a week or so. Blocks of one group run in parallel on different workers, in any order. -
The window. The worker reads a block in article order and keeps its open files and releases in memory (counters, names, sizes; the segment lists spooled to a file per block on the worker's disk). The frontier is the median Date of the last 4,096 articles with a plausible date (inside the block's span ± 7 days); Date headers alone aren't trusted (clock skew, bogus years). A release is finished when the frontier is 24 h past its last part (
WINDOW_HOURS), or at the block's end: written once, with its completeness decided then (every announced file there; par2 decides later for those without a counter), never updated by the header pass again. Incomplete releases are kept (user, 2026-10-24). A fill posted after the window is a release of its own (the judge'srepeatedrule already knows reposts). Releases still open at a block's end are written flaggededge; the block merger (replaces the fragment merger) joins the edge releases of adjacent blocks of a group by identity key once both are done, and clears the flag. The settler goes:settled_atis the finish. The tail keeps its latency: a tail lease emits a release provisionally as today and the window finalizes it once (two writes, not one per range). -
Albums. Files of one poster in one owner group whose subjects differ only by a number sequence, or share a directory-like prefix, posted within the window, become one release (
source = 'album'): its name the shared template (or the prefix), its files kept by name so each stays findable; at least two files, else the single stays a release. This is for subjects that name no release (today'spost/stemsingles); a poster's ten different cracks in a day stay ten releases. -
A slimmer catalog.
releasesloses its text copies:poster_id→posters(id)(the table gets an id; 33 B → 4),group_set_id→group_sets(the ~2,600 distinct group combinations; 85 B jsonb → 4),release_keyandidentity_keyas 20-bytebytea(41 B each → 20),sourcea smallint. ~150 of ~430 B a row; the poster and key indexes shrink with them (~12 GB of 50 today);releases_category_newest_idx(5.3 GB, used once) goes, category browsing filters the newest index. Today's 85 GB would be ~55. Cold tables move to the HDD tablespace:release_inspectionsandprobe_candidates(28 GB, written once, not read by search);probe_cacheis there already. -
Memory and disk at the edge. The busiest group posts ~50 M articles a day; at a file per ~50 articles that is ~1 M open files of ~100 B in memory per block, and their segment lists (~25 B each) in the block's spool file, read back when a release is finished. Per worker thread a few hundred MB at worst.
-
The corpus stays as it is. Measured on 300 unfiltered chunks (30 M lines, LOG.md (102)): 43% of lines lack a "(k/N)" counter, and of those 73% are random 32-hex tokens, 27%
[PRiVATE]hashes, ~0.3% posts with a readable name and no counter. The filter (crosspost, counterless) loses nothing voyager 3 would index; albums work from filtered chunks (the image dump's subjects carry "(1/1)"). Indexing without a counter at all (obfuscated uploads, ~31% of all lines) would need bodies, and the raw corpus (2,328 ranges) for a sweep. -
Where it lives. The window, blocks and albums are in the Rust edge (
edge/), the batch store, the planner and leases; the TypeScript RangeIndexer keeps the scanner (the corpus filter, tests) and drops its parity with the Rust worker for the window (thecheck.tscomparison covered the range path, which goes). New migrations rebuild the schema from a reset, no in-place conversion of the 81 M rows. -
Test before the reset. Replay the corpus (the raw ranges and the captures, several B articles) with the new edge into a scratch database on bulk: NVMe bytes per article (the one number that decides whether 4 TB is enough), the share of albums and incomplete releases, and a sample of releases against today's. Speed is measured after the reset, on the NVMe.
-
The reset. Tail first, then history by priority, read from the corpus where stored. Survives:
probe_cache(HDD, by message-id),spots,groupsand their priorities,nntp_shares, the corpus. Recomputed: releases, chunks, inspections, candidates, par2 data, verdicts, file hashes. Resets are paused (2026-10-12); this one waits for the user's go-ahead, and for the new NVMe.
Decisions
a. Chunk payload: a write-once bytea table in Postgres, partitioned by
month, on the HDD; files on disk only if writes still limit. Accepted.
b. A separate database on the same server. Accepted.
c. Group list: editable, owner by priority number (above). Accepted.
d. Reposts: separate releases in phase 1, merged in phase 2. Accepted.
e. Headers through the coordinator's relay, with prefetch (part 1).
Accepted.
f. Random-named releases (no file with a readable name) keep no chunk in
the header pass: their file keys and probe candidates only, until
phase 2 names them (bodies) and fetches their segments again. The full
history is ~3 TB with them, ~1.2–1.4 TB without (725 M of the 1.25 B
kept files; LOG.md 2026-10-09). Accepted 2026-10-09, not built yet.
g. The account's NNTP connections shared by priority (nntp_shares): the
coordinator (headers) before the prober, each between its minimum and
maximum, 5 kept for the spotter, the spot inspector, the plain
connection and reconnects. A process grows only into connections the
others closed, so the account never goes over its limit. Replaces the
fixed split (38 + 8), under which the prober stayed at 8 while history
was paused and the coordinator's connections idle (LOG.md 2026-10-18
(75)). Accepted 2026-10-18.
h. Idle NNTP connections closed: one with nothing to do for
NNTP_IDLE_CLOSE_SECONDS (60) is closed and opened again with work
(the coordinator's pool on its next sync, the plain one too; a dry
prober lane; the spotter between rounds; the spot inspector with no
spots). Eweka dropped the quiet ones and the next command failed
("Spot inspection failed: NNTP connection closed"), and idle ones took
the account's connections from downloads. Accepted 2026-10-22.