57 Commits
Author SHA1 Message Date
Daan MeijerandClaude Fable 5.1 66980829dc Twins by content: at most 500 twins read per pair
Test and deploy / test (push) Successful in 3m3s
Test and deploy / deploy (push) Successful in 1m16s
With the hash typed as the column (f537add) the planner walks every twin of a
pair by index; 55 pairs have over 500 twins, up to 131k, and a flush with such
a pair ran the query for 37 s inside its transaction, the other flushes and
node-api's class counters waiting on it. A LATERAL per pair now reads its
lowest 500 release ids and takes the first named one: 49 ms for 305 pairs with
the five hottest among them. LOG.md (138).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 09:49:40 +01:00
Daan MeijerandClaude Fable 5.1 f537add573 Twins by content: type the hash as the column
Test and deploy / test (push) Successful in 3m49s
Test and deploy / deploy (push) Successful in 1m5s
jsonb_to_recordset declared hash16k as text; against the char(32) column the join
cast the column, so Postgres scanned all of file_hashes for every call (4 s, 20
calls a minute: 44% of the database's time). Declared char(32), the join walks
file_hashes_hash_size_release_idx: 24 ms for 300 pairs. LOG.md (138).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 09:31:06 +01:00
Daan MeijerandClaude Fable 5.1 d7171a5ca4 Pending ladder: the sweep and the record lock their rows in key order
Test and deploy / test (push) Successful in 3m3s
Test and deploy / deploy (push) Successful in 1m4s
discardKeys updated with `release_key = ANY(subquery)`, which locks rows in
the plan's scan order, and deadlocked against a batch upsert that takes its
rows by key (LOG.md (137)). It now updates from a subquery that selects the
rows ORDER BY release_key FOR UPDATE, so the locks are taken sorted (the
plan: LockRows above Sort). record() runs the same SELECT ... FOR UPDATE over
its taken rows before its UPDATE ... FROM jsonb_to_recordset, whose join
order the planner chooses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 00:32:09 +01:00
Daan MeijerandClaude Fable 5.1 eadae82b0c The prober's tables on the NVMe; the prober runs through the night
Test and deploy / test (push) Successful in 3m3s
Test and deploy / deploy (push) Successful in 1m30s
Migration 7400 moves pending_releases (with its two HDD indexes),
release_inspections and probe_cache to the default tablespace (nvme on
homelab): the edge's batch upserts into pending_releases took minutes each
on the HDD and the prober's flushes waited on them (LOG.md (137)). Homelab
moved them by hand with the services stopped: 66 GB in 10.5 minutes.

With everything the prober touches on the NVMe, the nightly pause leaves the
prober running (usenet-rebuild-pause.sh), and a deploy while paused
recreates it with node-api and web (deploy.sh).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 23:57:59 +01:00
Daan MeijerandClaude Fable 5.1 e02a7c89b4 REBUILD.md: probe_candidates back on the NVMe (migration 7300)
Test and deploy / test (push) Successful in 3m6s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 19:52:52 +01:00
Daan MeijerandClaude Fable 5.1 6ef7ddca92 Batch store: one lock order for the pending upsert; probe_candidates on the NVMe
Test and deploy / test (push) Successful in 3m0s
Test and deploy / deploy (push) Successful in 4m33s
writePending upserted pending_releases in chunks of 1,000 rows, each chunk
followed by its class upsert, so one transaction locked releases, classes,
releases, classes, and a later chunk's keys and classes could sort before an
earlier chunk's: the opposite of the ladder's record(), which holds all its
rows and then takes all its classes. Four deadlocks between the two class
upserts in six hours (LOG.md (136)). Now all rows are sorted once and the
classes upserted once, after the last chunk.

Migration 7300 moves probe_candidates and its indexes to the default
tablespace (nvme on homelab): the catalog claim's UPDATE from random points
took 2-3 minutes on the HDD beside the sweep and the autovacuum, with all
lanes waiting. Homelab moved it by hand (83 s, ~8 GB, services stopped); the
migration is a no-op there, and without a default tablespace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 19:52:09 +01:00
Daan MeijerandClaude Fable 5.1 a71de56a7c Judge: the tiny rule, the catalog's side of the edge's tiny-archive rule
Test and deploy / test (push) Successful in 2m57s
Test and deploy / deploy (push) Successful in 4m23s
One file of one article, 2–100 kB, under a readable release-like name outside the music, e-book and comic
groups, without an e-book, subtitle, crack or sidecar word: the 2015–17 flood's 1.5M rows that the edge
indexed before its rule. The same tests as isTinyArchive in Postgres's regex dialect; no join on the
candidates (98% of a sample have an archive candidate, the rest lone par2 files and scrambled names).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 13:15:03 +01:00
Daan MeijerandClaude Fable 5.1 411c36eec0 Prober: the signature index by the poster's md5
Test and deploy / test (push) Successful in 3m5s
Test and deploy / deploy (push) Successful in 1h2m47s
A poster of 2.7 kB broke the index build (a btree key is at most a third of a page) and left an invalid index
that IF NOT EXISTS would have taken for the real one: the migration drops it first and indexes md5(poster), and
the sweep asks by it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 12:08:53 +01:00
Daan MeijerandClaude Fable 5.1 8e008da364 Prober: the sweep by a signature index, pending rows locked in one order, deadlocked flushes kept
Test and deploy / test (push) Successful in 2m59s
Test and deploy / deploy (push) Failing after 21m32s
The discard sweep of a dropped signature read the whole pending_releases table per signature (a parallel seq
scan over the poster test, four minutes on the HDD that starved the lanes' claims): a partial index on
(group_id, tool, poster) WHERE status = 0 makes it a range, and the sweep queries a poster's rows by it and a
tool's and group's by the ladder index.

The batch store's pending upsert, the record, and the sweep deadlocked on pending_releases and pending_classes
rows locked in different orders, and a deadlocked flush dropped its outcomes: lockOrder() sorts every
multi-row statement's set the same way, and a flush that deadlocks anyway keeps its outcomes for the next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 10:36:19 +01:00
Daan MeijerandClaude Fable 5.1 5d3b578322 The discard sweep beside the lanes, each dropped signature swept once
Test and deploy / test (push) Successful in 2m57s
Test and deploy / deploy (push) Successful in 38m14s
After aa40222 the prober still idled: a dropped signature's range of the
ladder index is minutes of HDD (309 dropped, 242 with a poster tested on
the rows) and the sweep read the same ranges again every five minutes
inside the claim. Now each dropped class and signature is swept once
(swept_at, migration 7100; null again when the record drops it anew),
20 of each per sweep in rounds of 10k rows, in a loop of its own in the
prober beside the lanes and the namer (LOG.md (133)).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 21:12:51 +01:00
Daan MeijerandClaude Fable 5.1 aa4022295a Tiny-archive rule; the ladder's discard by index; the namer in chunks
Test and deploy / test (push) Successful in 3m1s
Test and deploy / deploy (push) Successful in 32m21s
The tiny-archive rule (dropTinyArchives, both indexers): a settled single
archive of one article and 2-100 kB under a release-like name, spaces
allowed, outside the exempt groups and without an e-book, subtitle,
crack, cover or sidecar word: the 2015-17 exe-stub flood in miniature,
1.66M catalog rows (LOG.md (131), (133)).

PendingLadder.discard scanned every waiting row against the dropped
signatures and classes, 9+ minutes a sweep inside the claim, stalling
every lane: now driven from the dropped classes and signatures into the
indexes. The namer feeds the indexer the span's overview text chunk by
chunk (one string of it topped V8's limit) and gives up spans over 3M
articles. Migration 7000: a partial index on the taken probe candidates
for the prober's start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 20:23:16 +01:00
Daan MeijerandClaude Fable 5.1 4d46db291e pending:classes without table scans; an index for the prober's startup reset
Test and deploy / test (push) Successful in 2m55s
Test and deploy / deploy (push) Successful in 25m6s
The first run scanned the 58M-row pending table on the HDD once per
6-hour window and starved the prober and the batch store. Now one scan
classes the finished rows and seeds the record, and the waiting rows go
by windows of created_at through their index. The prober's start puts a
previous run's claims back with a status scan of the same table, 20+
minutes after every deploy: a partial index on the taken rows (migration
6900) makes it instant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 13:33:48 +01:00
Daan MeijerandClaude Fable 5.1 3612bf3e43 Readable names: CamelCase product names and words with numbers stay readable
Test and deploy / test (push) Successful in 2m49s
Test and deploy / deploy (push) Successful in 3m24s
The production dry run of releases:readable flagged 5.37M of 19.8M rows,
and its first sample held real names: "CrossStitcher - Issue 440",
"SchiffsModell", "FastFilms 2.04", "BestCrypt NP v4.0", "SmithsWork",
"Laura15Juli2026". Two CamelCase words with few vowels have four runs and
no lone letter: a scramble now needs five runs, a lone letter or an inner
capital run besides its few vowels; the consonant-run clause is gone
(it counted y); a token whose letter runs are all words or acronyms with
numbers between them is neutral. On a 49k-row production sample the
flagged share went from 27.4% to 26.4% with the letters-only flags all
scrambles; the evaluation sets are unchanged (LOG.md (129)).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 13:22:54 +01:00
Daan MeijerandClaude Fable 5.1 44c0c73b05 Pending ladder by class, twins by content, dropped classes at the edge
Test and deploy / test (push) Successful in 3m9s
Test and deploy / deploy (push) Successful in 40m8s
The prober names 6% of the unnamed releases it finishes and the pending
queue (44M) grows eleven times faster than it probes. A sample of 44,530
finished pending rows shows the headers predict a name: size bucket, name
shape, completeness, tool and group separate 0% classes from 95% ones,
while the learner keyed on tool and group alone (LOG.md (130)).

- lib/release/classes.ts, names::pending_class, SQL pending_class():
  a release's class (size × shape × partial, 1..70), the three in step.
- Migration 6800: pending_releases.klass and target_bytes (the first
  target's article; candidates carry bytes now), pending_classes with
  each class's climbs, names, waiting and verdict, the claim's index.
- The ladder claims the classes with the best record first (a prior for
  the unexplored, at most a quarter of a claim from one), the cheapest
  target first within one, then the oldest of the rest; records classes
  like signatures and drops them at the same thresholds; discards their
  waiting releases. Claimed rows sorted in code: an UPDATE returns none.
- The rule set's droppedClasses: the edge keeps unnamed releases of a
  dropped class out of the pending store (counted as garbage), the
  explored sample excepted.
- hash_twins.ts: a probed file whose first 16 KiB and size match a named
  catalog release's names a pending release the probe didn't, and sets
  the inner name of a catalog release without one.
- pending:classes classes the rows from before and seeds the record from
  the finished ones; rules:classes publishes the dropped classes;
  releases:readable also marks the rows' candidates unreadable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 12:22:32 +01:00
Daan MeijerandClaude Fable 5.1 81a5dce45a Readable names: a token-by-token classifier for the readable flag
Test and deploy / test (push) Successful in 3m36s
Test and deploy / deploy (push) Skipped
Searches with the readable filter on returned scrambles: with a random
4-letter query nearly every hit was one ("Zx5AjNaK-mh7CFg6Hf8uu",
"Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ", "bd5b...-addon"), and
15% of a 6,000-row sample of the catalog's readable names were. looksRandom
knows single-token shapes only; separators, spaces, a random "extension"
of any length and a packaging word hid the rest.

isUnreadableName (lib/release/readable.ts) and names::is_unreadable_name
read a name token by token: words (one case or Capitalized), scrambles
(letter/digit and case switches, lone letters between capitals, inner
capital runs, few vowels, hex) and neutral tokens (digits, codes like
S02E10 and 1080p, CamelCase, packaging words); a name is unreadable when
its scrambles outweigh its words. Scene tags' "i" for "I" and the
NZBSPLIT hash are read as such. On the evaluation sets: 554 of 559
random-search names caught, 0 of 630 real names flagged (LOG.md (129)).

The edge's readable flag and the prober's inner names use it; the drop
rules keep looksRandom, which is narrower. An unnamed release goes to the
pending store (decision f), so more of the obfuscated groups' releases
wait for a probe now. releases:readable clears the flag on the existing
catalog's rows by name (a dry run by default).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 10:01:31 +01:00
Daan MeijerandClaude Fable 5.1 2643266559 Stub-archive rule: 100 MB cap on every leg, ROT13 volume names are volumes
Test and deploy / test (push) Successful in 2m59s
Test and deploy / deploy (push) Successful in 4m1s
The first five hours in production (LOG.md (128)) dropped 522 passworded
videos of one a.b.erotica poster: ROT13-named volumes (".cnegN.rar" is
".partN.rar") of 1-270 MB in 258 kB articles to two groups, which the
article-size leg took for stubs. The cap that X and W already had now
applies to A as well (the catalog sample has 6 A-leg hits over 100 MB,
4 of them exe-only), and a ROT13 volume name is no single archive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 05:49:37 +01:00
Daan MeijerandClaude Fable 5.1 e470e231d0 Single-archive rules: stub archives and nonsense pieces dropped at the edge
Test and deploy / test (push) Successful in 3m0s
Test and deploy / deploy (push) Successful in 4m21s
The judge gets each file's articles seen and bytes, and whether the window
closed on the release. A settled release of one unsplit archive posted to 2+
groups is dropped as a stub archive when its articles are <= 256 KiB with the
release >= 1 MB, or it is in 4+ groups, or by a webmail poster (those two
under 100 MB); releases touching music, e-book, comics, tabs or fonts groups
are exempt. A one-article archive of 100 kB-1 MB under a release-like,
unscrambled name without spaces or an e-book format is dropped as a nonsense
piece. Both switched by the rule set (dropStubArchives, dropNonsensePieces),
mirrored in lib/rules, sampled like every drop (LOG.md (123)-(127)).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 11:35:46 +01:00
Daan MeijerandClaude Opus 5.5 2f1bbf2538 REBUILD.md: lone articles under scrambled names
Test and deploy / test (push) Successful in 3m8s
Test and deploy / deploy (push) Successful in 2m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:20:04 +01:00
Daan MeijerandClaude Opus 5.5 cfce8884d4 Lone articles under scrambled names are per-article; inactive groups get no corpus fill
Test and deploy / test (push) Successful in 3m6s
Test and deploy / deploy (push) Skipped
One article of a multi-part file under a name like "nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr" or
"bslXlwgPiqZadhRa" (a random subject and poster per article, with fake counters) was a release:
looksRandom misses these names for their separators. 160k of alt.binaries.storage's 162k
non-Bob releases, and about 60% of all single-article releases in a 2% sample.

looks_scrambled / isScrambledFile: at least half the stem's letters and digits in tokens of 5+
that switch case or letter/digit 3 times or more (93% of the storage spam). Used for lone
articles given up before the block's end only; at the end the strict looksRandom stays, as the
rest of such a file can be in the next block. The namer's re-index (partialFiles) does the same.

The prefetcher's corpus fill skips groups that aren't active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:19:11 +01:00
Daan MeijerandClaude Opus 5.5 5132ab9083 Search: a word with a leading ^ matches at a word's start only
Test and deploy / test (push) Successful in 3m5s
Test and deploy / deploy (push) Successful in 2m7s
"^zed" finds "Zed Dead", "The_Zed_Files", not "Facialized": a regex on
release_texts, (^|[^[:alnum:]]) before the word, which the trigram index
serves (homelab: 551 texts for ^zed against 6,606 for zed, 35 ms warm).
Words without ^ match anywhere as before; the search box says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:19:52 +01:00
Daan MeijerandClaude Opus 5.5 3f0b5b8be1 Subject names lose the brackets the counters left unmatched
Test and deploy / test (push) Successful in 3m3s
Test and deploy / deploy (push) Successful in 2m13s
A poster's counter without its "[" ("… Facialized 002/477] - 0177.jpg")
left "… Facialized ]" as the release name (11286967). displayName (TS) and
display_name (Rust) drop unmatched brackets from subject and post names
only; the identity key keeps the remainder as it was, so no release splits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:04:06 +01:00
Daan MeijerandClaude Opus 5.5 c6f9409e7f REBUILD.md: the namer's failures were the partial releases, 52k of 210k, not nearly all
Test and deploy / test (push) Successful in 3m9s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:07:24 +01:00
Daan MeijerandClaude Opus 5.5 8cf965af80 The namer re-reads partial releases as the Rust window writes them
Test and deploy / test (push) Successful in 3m10s
Test and deploy / deploy (push) Successful in 5m38s
The pending namer's TypeScript re-index gave a file short of parts on the
server to the range path's fragment rule, so a partial release the edge
wrote (Bob's hex-named uploads in chello, ratcave, font, storage) never came
out again: 10 of 148k named releases moved. RangeIndexer's partialFiles
keeps such a file in its release, marked partial; tools/namer_check.ts
replays sample rows from the corpus (10 of 10 found with it, 1 without).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:41:50 +01:00
Daan MeijerandClaude Opus 5.5 0ccb850ea4 Prober: claim ahead of the lanes, not when they run dry
Test and deploy / deploy (push) Successful in 1m34s
Test and deploy / test (push) Successful in 3m6s
The next claim started at half a batch of four pipeline rounds a lane,
3-6 s of work, while a claim takes 1.5-4 s and more; at 20-24 lanes the
lanes waited for candidates up to 19% of their time. queueLevels: the
claim starts below eight rounds of every lane's pipeline (~13 s) and
fills the queue to twice that. Taken candidates go back to waiting at
start-up as before, so a bigger queue costs nothing on a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:52:48 +01:00
Daan MeijerandClaude Fable 5.1 68b480c97e An "ancient" range a group: articles 1 … low watermark − 1, never filled
Test and deploy / deploy (push) Successful in 3m49s
Test and deploy / test (push) Successful in 3m19s
Every group is tiled from article 1 now: one range with status 'ancient'
covers what had expired on the server before the group was planned.
Nothing selects it (leases, prefetcher, corpus fill, tail merger all take
'planned'), the status panel reports it apart, the search's history stats
leave it out. Migration 6700 adds it for the groups planned before;
Planner.historyRows (now a pure function, unit-tested) for new groups.
The status bands are ordered by an explicit key: the ancient row at
priority 0 tied "history 0-999" with "window 0".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 09:12:05 +01:00
Daan MeijerandClaude Fable 5.1 c18c320927 coordinator:status: the capture windows are priorities 0–2, the probe backlog from the planner's estimates
Test and deploy / test (push) Successful in 3m12s
Test and deploy / deploy (push) Successful in 2m5s
The sampling ranges in other years are capture too, at history priorities: they made a band each. The database
size walked the HDD partitions for minutes and the probe counts took 10 s on 11 M rows: dropped, and the partial
indexes' reltuples instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:00:08 +01:00
Daan MeijerandClaude Fable 5.1 680751e26a Lease lanes for the tail, a mixed lease order, coordinator:status, the edge end to end
Test and deploy / test (push) Successful in 3m14s
Test and deploy / deploy (push) Successful in 2m30s
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
Daan MeijerandClaude Fable 5.1 db96642a72 A complete provisional release quiet for six hours is settled
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m9s
Test and deploy / deploy (push) Successful in 1m52s
A release the window hadn't closed on at a lease's end is written provisionally
for its continuation to finish; the first run's range leases left 109k such
rows at their blocks' end edges, 606 of them complete, shown as still
collecting (release 10697). The coordinator now settles a provisional release
that is complete and quiet for six hours as the finish would: settled_at set,
its waiting probe candidates made claimable. Incomplete ones keep waiting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 11:04:31 +01:00
Daan MeijerandClaude Fable 5.1 1810abd7d8 The tail planner grows a group's open range; ranges:merge-tail packs the slivers
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 2m22s
Test and deploy / deploy (push) Successful in 2m53s
A round added a row a group whenever 100k articles or 15 minutes had passed,
tuned for a tail leased within minutes; with nothing leasing the tail the rows
piled up (127k planned tail ranges of a median 14k articles, 97% adjacent). Now
a round grows the group's last range when it is a planned, unspooled, untaken
row at the tail's priority, up to RANGE_ARTICLES, and only then adds rows; a
range taken meanwhile gets the slice as a row. ranges:merge-tail (--dry-run)
packs adjacent planned, uncaptured, unspooled, untaken tail ranges of a group
and priority into ranges of RANGE_ARTICLES under the lease lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 10:46:47 +01:00
Daan MeijerandClaude Fable 5.1 e3957e5af5 The edge posts the open batch before a failed stream gives the group back
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m13s
Test and deploy / deploy (push) Successful in 1m18s
A plain stop and a chunk that doesn't inflate already posted the batch so far,
so the stop snapshot stood at a stored resume point; a failed stream (the
stalled one's "Peer disconnected") skipped the post, left the batch open and
lost the window with the snapshot refused. Now only a batch the coordinator
refused stays open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 10:06:52 +01:00
Daan MeijerandClaude Fable 5.1 31fc204c76 The range stream polls the corpus as well as the spool while it waits
Test and deploy / test (push) Successful in 56s
Test and deploy / edge (push) Successful in 2m13s
Test and deploy / deploy (push) Successful in 4m11s
The corpus fill claims chunks of a leased range ahead of the leased run (the
prefetcher's claim keys are per range and chunk), stores them in the corpus
only, and the run leaves them there. The stream looked at the corpus once per
chunk and then polled the spool only: when the store came seconds after that
look, the stream waited its whole hour and the worker lost the group and its
open batch (three groups on 2026-10-27, LOG.md (118)). Now the wait loop checks
both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 09:19:16 +01:00
Daan MeijerandClaude Fable 5.1 4af5e06400 LEASE_CAPTURE_ONLY: group leases of the corpus windows' ranges only
Test and deploy / test (push) Successful in 1m6s
Test and deploy / edge (push) Successful in 2m44s
Test and deploy / deploy (push) Skipped
The lease bounds alone don't select a window: the history planner's oldest
buckets are priority 0-2 as well, so the window worker at 0..0 also took 2026
history at 0 (25k ranges in 40 groups, fetched live) while 1,739 ranges of the
2016 corpus window sat at voyager 2's leftover priorities (done when the window
was marked, planned again by the reset). With LEASE_CAPTURE_ONLY the lease, next
and the prefetcher's run-ahead take capture ranges only; the run-ahead now also
keeps to the lease bounds. Test: a group with history at 0 only isn't leased,
the history range after the window isn't handed out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 23:26:31 +01:00
Daan MeijerandClaude Fable 5.1 29e0cc8f6e The window worker keeps its window over a chunk that doesn't inflate
Test and deploy / test (push) Successful in 1m14s
Test and deploy / edge (push) Successful in 2m37s
Test and deploy / deploy (push) Successful in 3m7s
A live reply cut short (the server's compressed data ending at ".\r\n" at a
read boundary; the spool keeps what Corpus.write refuses) made the worker give
the group back without a snapshot, losing the window and the batch since its
last post (six times in the first 40 minutes on homelab). Now the batch before
the chunk goes out, the chunk is reported (fetched again, plain the second
time) and the range is asked for again from that chunk (RangeOutcome::Again):
the stream waits for the new copy and the window stays. Test: the coordinator
hands the same range back at its resume point after a corrupt report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 22:53:52 +01:00
Daan MeijerandClaude Fable 5.1 1f6e3316ec The window worker: group leases, the stream serving the corpus, the snapshot through the API (design 1 and 5)
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m9s
Test and deploy / deploy (push) Successful in 1m16s
A worker thread leases a group and takes its ranges in article order through
one carried window; the coordinator keeps one cursor a group (group_leases,
migration 6500) and hands out the next range, the rules and nothing about
where the chunks are: the range stream serves a stored range's chunks from the
corpus file and a live one's from the spool. The window's snapshot (bincode:
the frontier, the open files, releases and albums, the upload numbers, the
batch-counter table) goes to the coordinator every EDGE_SNAPSHOT_MINUTES and
when the group is given back, and the next holder restores it; a takeover
after LEASE_TTL_SECONDS plans the ranges from the snapshot's article again.
The prefetcher runs ahead of held groups' cursors and skips chunks the corpus
has. LEASE_MIN_PRIORITY..LEASE_MAX_PRIORITY bound what is leased. edge-node
holds groups by default; --ranges keeps the old per-range path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 18:22:18 +01:00
Daan MeijerandClaude Fable 5.1 3c4fe324a1 The namer inflates the XOVER reply and reads the corpus from the prober's container
Test and deploy / test (push) Successful in 57s
Test and deploy / edge (push) Successful in 2m19s
Test and deploy / deploy (push) Successful in 1m58s
xoverRaw gives one zlib/gzip stream, as the corpus stores it; the namer fed
the bytes as text and found nothing. The prober had no corpus mount, so every
move went to the server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 17:21:34 +01:00
Daan MeijerandClaude Fable 5.1 f1381a9b43 The namer makes sure of the chunk month partition before its write
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 1m4s
CI's fresh database had no release_chunks partition yet: the batch store
creates it on its own path, the namer's test wrote a chunk without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 17:08:19 +01:00
Daan MeijerandClaude Fable 5.1 8f5f83b4ea Deploying voyager 3 without its edge worker: the no-edge marker, history replayed into the catalog
Test and deploy / test (push) Failing after 51s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Skipped
deploy.sh and the nightly resume leave the edge stopped while .cache/no-edge
exists (range by range it would split releases at every edge with no merger
left). deploy/replay-window.sh takes DB and OUT and passes
REPLAY_INTO_CATALOG=1, which lets replay:corpus write a database not named
*_replay or *_scratch (never the test one).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 16:30:56 +01:00
Daan MeijerandClaude Fable 5.1 91984d3625 edge-bench --group: a range after a gap starts its batch at the range
Test and deploy / test (push) Failing after 56s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Skipped
a.b.storage's stored 2016 ranges are not contiguous (its 2016 window lies in two
article regions); the batch after a gap kept the previous range's end as its
start and the harness found no leased range for it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:45:08 +01:00
Daan MeijerandClaude Fable 5.1 e135076fcd The pending ladder in the prober: unnamed releases probed cheapest first, signatures learned, the named moved into the catalog
Test and deploy / test (push) Failing after 48s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Skipped
pending_ladder.ts claims the oldest waiting pending releases' next target
(par2, archive, lone, 7z-tail in that order) as at least PENDING_SHARE of each
claim; a usable name (the largest named file of a par2 set or a listing, or a
lone file's yEnc name, neither packaging nor random) hands the release to the
namer, no name the next target or the end of the climb; climbs count on
pending_signatures per (tool, group, poster) and (tool, group), and a signature
with 100 climbs and 0.5% named or fewer is dropped: its waiting releases are
discarded without a probe, the rules' sample excepted. pending_namer.ts reads
a named release's articles again from the corpus or by XOVER and writes it to
the catalog under the pending key (source weak-stem, the name as inner name)
with its chunk and candidates. Migration 6400; config PENDING_*.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:41:30 +01:00
Daan MeijerandClaude Fable 5.1 9cf85e09d8 Nothing enters the catalog without a usable name: the pending store (decision f amended)
Test and deploy / test (push) Successful in 56s
Test and deploy / edge (push) Successful in 2m2s
Test and deploy / deploy (push) Skipped
An unnamed release (no readable file name) is written to pending_releases
(migration 6300, the HDD) with its keys, counts, article span, upload tool
and probe targets, instead of releases, release_texts, posters and a chunk.
The edge sends it with an empty payload and its tool; the batch store splits
a batch by readable. The pending ladder (probes cheapest first, signature
verdicts, the move into the catalog) comes next. The replay report shows the
pending releases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:18:14 +01:00
Daan MeijerandClaude Fable 5.1 6860e6ccaf Albums: no numbered volumes or stray suffixes; the replay report counts the group's releases
Test and deploy / test (push) Successful in 1m10s
Test and deploy / edge (push) Successful in 2m1s
Test and deploy / deploy (push) Skipped
acs5152s1.11 is a volume, Broforce.vol00+01.par2.temp a par2: neither an
album's member. replay:corpus reported every release in the scratch catalog
as the group's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:28:40 +01:00
Daan MeijerandClaude Fable 5.1 4d0610ca06 Albums in the Rust edge, and the batch-counter rule (design 3)
Test and deploy / test (push) Successful in 1m4s
Test and deploy / edge (push) Successful in 2m4s
Test and deploy / deploy (push) Skipped
A release the window closed on with one file named by its stem or post, the
name with a number and no archive, volume, sidecar or random name, waits in
the album of its poster and template (the name's digit runs as #, the
extension apart) posted within the window of the album's first file; the
album closes a window after that span and is written as one release
(source album, no file total) named by the members' shared numbers, a lone
member as the release it was. The batch store writes an album's member
names as release_texts kind 6 (member). A counter total of 100 or more under
which one poster started 20 or more releases is a batch counter: those
releases get no file total.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:22:30 +01:00
Daan MeijerandClaude Fable 5.1 219fe4fd4c REBUILD.md: voyager 2's final numbers for group sets and albums (LOG 112)
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m47s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 08:12:03 +01:00
Daan MeijerandClaude Fable 5.1 f437929181 Homelab: the replay container's app environment; the nightly resume honours the v2 reset
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 1m50s
Test and deploy / deploy (push) Skipped
deploy/replay-window.sh passes the compose app environment (HOST, PORT, APP_URL,
SPOOL_DIR, CHUNK_TABLESPACE) the ace command validates. The resume script starts
only the coordinator (the corpus fill) while .cache/v2-reset exists: the voyager 2
catalog was reset on 2026-10-26 and its workers would fill it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 08:09:45 +01:00
Daan MeijerandClaude Fable 5.1 f300a2fd81 Dockerfile: the edge-build stage before the app stage, which copies edge-bench from it
Test and deploy / test (push) Successful in 52s
Test and deploy / edge (push) Successful in 2m28s
Test and deploy / deploy (push) Skipped
The app image failed to build ("cannot copy from stage edge-build, it needs to be defined before current stage app").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 08:00:14 +01:00
Daan MeijerandClaude Fable 5.1 ae735361a0 Replay harness: a --to bound and the homelab window driver
Test and deploy / test (push) Successful in 52s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Skipped
edge-bench --group and replay:corpus take --to <article> next to --from, so a
replay covers one window of a group's history while the corpus holds others
(the 2016 ranges next to 2021's and 2025's). deploy/replay-window.sh runs a
window on homelab: every group whose window ranges are all stored is replayed
once into usenet_replay through the voyager-3 app image, 4 groups at a time,
looping while the corpus fill completes the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 07:56:39 +01:00
Daan MeijerandClaude Fable 5.1 72e973d006 The replay harness: a group's corpus through one carried window into a scratch catalog
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m43s
Test and deploy / deploy (push) Skipped
replay:corpus runs edge-bench --group over a group's complete corpus ranges
in article order (one window, carried from range to range, finished after
the last range; edge/src/lease.rs holds the lease JSON the worker and the
bench share) and stores every batch through BatchStore as the worker
would, then reports releases finished, provisional, complete and the bytes
per header of the NVMe and HDD tables. Refuses a database not named
*_replay or *_scratch. The app image carries edge-bench; compose gets a
replay profile for homelab.

Design amended (REBUILD.md 1, 2, 5): the window is carried by one worker a
group, no block leases and no block merger; provisional writes stay for
the lease ends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 00:27:09 +01:00
Daan Meijer cf51db047f Merge branch 'rebuild' into voyager-3
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m51s
Test and deploy / deploy (push) Skipped
2026-10-25 22:43:14 +01:00
Daan MeijerandClaude Fable 5.1 e779f67c77 Prefetcher: corpus candidates a range of every group at a time
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Successful in 1m24s
The 512 candidates were the lowest ids at the lowest priority; a group's
ranges have consecutive ids, so once a window's small groups were stored
they were all one group's, two targets on two connections (10 ranges an
hour). Ordered by the per-group rank now: the window spans 85 groups.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 22:43:01 +01:00
Daan MeijerandClaude Fable 5.1 f8cb17903b voyager 3: the window in the Rust edge
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m47s
Test and deploy / deploy (push) Skipped
The indexer finishes files and releases by posting time instead of the
range path: the frontier is the median Date of the last 1,024 plausible
dates (within 7 days of it; a run of 1,024 agreeing dates moves it), and
a release is finished, judged and written once the frontier is 24 h past
its last part, flagged edge when posted within a day of the block's start.
At the block's end the rest is written provisionally as edges for the
block merger. Files short of parts stay in their release (partial: the
store writes complete = false). Gone: fragments and the cut-off,
uploads_of (online now), check.ts and the fixture comparison; CI replays
the fixtures through edge-bench instead. The wire carries finished, edge
and partial.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 21:28:35 +01:00
Daan MeijerandClaude Fable 5.1 2580f20a43 voyager 3: the batch store writes releases once, by id references and bytea keys
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m48s
Test and deploy / deploy (push) Skipped
Each release chunk is a release inserted once: release_key the SHA-1 of
the identity, the owner group and the first article (deterministic; no
numbering under a group lock), identity_key the identity's SHA-1 bytes,
poster_id and group_set_id through catalog_refs (cached per process),
source as a code (weak-stem added), the name into release_texts. A
finished release's candidates are claimable at once; a provisional tail
release is finished by a later chunk of its key, a finished one sent again
is left alone. Gone: joins into earlier releases and across groups, key
numbering, the group lock, the fragment merger. Interim: chunks, counts
and samples keyed by ranges.block_id, a range without a block gets one.

Readers follow: search (posters and group_sets joined, words through
release_texts, posters by id, source by code), the par2 merger, the
sidecar folder, the judge's scraped rule, the spot inspector, the spotter,
the NZB service, releases:regroup, edge:bench. Tests insert fixtures
through tests/functional/helpers/releases.ts; the suites pass again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 21:04:03 +01:00
Daan MeijerandClaude Fable 5.1 bc4e17e86f voyager 3: release_texts, the searchable strings of a release (migration 6200)
Test and deploy / test (push) Failing after 52s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Skipped
One table and one trigram index for every string search matches against:
the release name (kind 1, stored once more) and what anyone learns later
(the prober's file names inside an archive, NFO titles, par2 set names,
media titles), appended by insert, never an update of the releases row.
Kinds and the per-kind cap in lib/release/text_kind.ts (textsToStore).
The judge's dirty mark follows an insert or delete. Migration 6000 loses
its two trigram indexes on releases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 20:33:28 +01:00
Daan MeijerandClaude Fable 5.1 346493b6c3 Voyager 3 schema: the slim catalog (migration 6000, the reset) and blocks (6100)
Test and deploy / test (push) Failing after 50s
Test and deploy / edge (push) Successful in 1m53s
Test and deploy / deploy (push) Skipped
releases rebuilt without its text copies: poster_id, group_set_id,
bytea keys, source smallint, edge, settled_at as the finish. posters by
id, group_sets. blocks as the lease unit, ranges.block_id. fragments,
range_blocks, release_final, the settler and the fragment merger gone;
release_chunks, rule_counts and rule_samples keyed by block. Cold tables
on CHUNK_TABLESPACE. Migration 6000 truncates the catalog and refuses
without VOYAGER3_RESET=1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 13:44:54 +01:00
Daan Meijer 1a448f37f9 Merge branch 'rebuild' into voyager-3
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Skipped
2026-10-25 12:50:10 +01:00
Daan MeijerandClaude Fable 5.1 3cc7d9738e TruncatedReply: a compressed reply the server cut at a ".\r\n" fails in 2 s; ranges:dates samples plain
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 1m4s
Eweka stops a compressed XOVER reply at the first ".\r\n" in its own
output (78 01 2e 0d 0a and nothing more), which some small replies begin
with; the text path waited for more until the reply timeout. ranges:dates
uses plain connections of its own instead of the pool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:49:53 +01:00
Daan MeijerandClaude Fable 5.1 1e01702dff nntp:probe: shows the bytes a stalled reply left
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 1m14s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:40:02 +01:00
Daan MeijerandClaude Fable 5.1 d048c6cdb5 nntp:probe: --plain and --sizes
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 1m6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:31:01 +01:00
131 changed files with 10979 additions and 1777 deletions

No files matched your search

+12 -19
View File
@@ -59,35 +59,28 @@ jobs:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Rust toolchain
run: |
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The edge worker (edge/, Rust): built first, since the e2e suite runs edge/target/release/edge-node against
# the node API; then its own tests, and the Rust and TypeScript RangeIndexers compared on the committed
# captures (edge/fixtures).
- name: Build the edge worker
run: cargo build --release --manifest-path edge/Cargo.toml --bin edge-node
- name: Type check
run: npx tsc --noEmit
- name: Migrate
run: node ace migration:run --force
- name: Test
run: node ace test
# The edge worker (edge/, Rust): its tests, and the Rust and TypeScript
# RangeIndexers compared on the committed captures (edge/fixtures).
edge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Rust toolchain
run: |
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: cargo test
run: cargo test --release --manifest-path edge/Cargo.toml
- name: Rust and TypeScript identical on the fixtures
run: edge/check-fixtures.sh
- name: The Rust indexer over the fixtures
run: edge/bench-fixtures.sh
deploy:
needs: [test, edge]
needs: [test]
if: github.ref == 'refs/heads/rebuild'
runs-on: ubuntu-latest
steps:
+11 -9
View File
@@ -8,6 +8,15 @@
# validate commands) throws "Invalid URL" on $ref, so no ace command starts.
ARG NODE_VERSION=24.16
# The Rust worker (before the app: its image carries edge-bench). Registry and target dir are build caches, so a rebuild only compiles what changed.
FROM rust:1-bookworm AS edge-build
WORKDIR /src
COPY edge ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --locked --bin edge-node --bin edge-bench \
&& cp target/release/edge-node target/release/edge-bench /usr/local/bin/
# The app, compiled, with production dependencies only.
FROM node:${NODE_VERSION}-bookworm AS app-build
WORKDIR /src
@@ -22,19 +31,12 @@ FROM node:${NODE_VERSION}-bookworm-slim AS app
ENV NODE_ENV=production
WORKDIR /app
COPY --from=app-build /src/build ./
# The Rust indexer's bench: `node ace replay:corpus` runs a group's corpus through it (the replay harness).
COPY --from=edge-build /usr/local/bin/edge-bench /usr/local/bin/edge-bench
USER node
# Every service picks its own command, e.g. `node ace coordinator:run`.
CMD ["node", "ace", "list"]
# The Rust worker. Registry and target dir are build caches, so a rebuild only compiles what changed.
FROM rust:1-bookworm AS edge-build
WORKDIR /src
COPY edge ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --locked --bin edge-node --bin edge-bench \
&& cp target/release/edge-node target/release/edge-bench /usr/local/bin/
FROM debian:bookworm-slim AS edge
COPY --from=edge-build /usr/local/bin/edge-node /usr/local/bin/edge-node
# edge-bench replays the corpus for the grouping sweeps (deploy/corpus_sweep.py).
+522 -35
View File
@@ -60,9 +60,21 @@ 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).
Every group is tiled from article 1 (2026-10-28): one "ancient" range,
status `ancient`, covers 1 … low watermark − 1, what had expired on the
server before the group was planned. Never leased, fetched or filled —
nothing selects it — and the status panel reports it apart ("Ancient: …
articles below the low watermark"); migration 6700 added it for the
groups planned before, `Planner.historyRows` for groups planned since.
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
fetched before history; since 2026-10-27 a round grows the group's open
tail range, a planned one at the tail's priority nothing spooled or
took, up to RANGE_ARTICLES before it adds rows, as a tail nothing leased
had become a sliver a group a round, 127k rows of a median 14k articles;
`ranges:merge-tail` packs what such rounds left); 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`).
@@ -139,7 +151,9 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
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/release` rules) → drop garbage (random name +
dropped signature or tool) → set aside per-article random subjects and
dropped signature or tool), bare executables, dropped posters, and the
single-archive fakes (stub archives and nonsense pieces, below) → 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.
@@ -248,7 +262,10 @@ 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`) and estimates
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
@@ -324,8 +341,13 @@ 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
*tiny* (one file of one article, 2–100 kB, under a readable release-like
name outside the music, e-book and comic groups, without an e-book,
subtitle, crack or sidecar word: the catalog's side of the edge's
tiny-archive rule, 1.5M rows of the 2015–17 flood that the edge indexed
before the rule; LOG.md (131), (135)), *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.
@@ -387,32 +409,82 @@ little in a backfill. That churn is most of the WAL (0.6 TB a day) and the
releases, a third of them one poster's image dump (27.7 M rows, LOG.md
(102)).
1. **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.
1. **One cursor a group (amended 2026-10-26; was: 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) and leased as ranges, but
a group's ranges are indexed in article order by one worker at a time,
and the window below is *carried* from range to range: the next range
finishes what the previous one left open. The first design leased
blocks of 30 days of posting time, ran a group's blocks in parallel and
joined their edges afterwards (a block merger). The user asked why
(LOG.md 2026-10-25 (110)): downloading stays parallel through the
prefetcher and a Rust thread parses faster than a connection delivers,
so one worker a group loses nothing but the end of a reset, when the
last big group runs alone. The merger, the block cutting and the
`blocks` lease go. Costs: one seam a group where the history meets the
tail (the releases in flight then end as two incomplete halves; history
runs forward in one pass a group, priority only between groups), and
the carried state must survive a restart (5 below). Measured on six
consecutive ranges of a.b.ath (2016): one range is 15 hours of posting,
so without the carry no release would finish inside a range (LOG.md
2026-10-26 (111)).
**The group lease (2026-10-26, the user's question in LOG.md (117)):**
a worker thread leases a *group*, not a range, and asks the coordinator
for the group's next range each time it finished one; the coordinator
keeps one cursor a group (`group_leases`: the holder, its heartbeat,
the next range in article order, the snapshot it stands at). The
answer is the range (id, span, resume point), the rules version and the
stream URL — nothing about where the chunks are: the stream serves a
stored range's chunks from the corpus file and a live one's from the
spool, so a worker needs no mount and no corpus layout, and a remote
worker is the same binary with another URL. The overhead is a copy
through the node API: the corpus is ~30 B an article compressed (2016;
16–18 for 2021 and 2025), eight local workers at the bench's rate are
~170 MB/s, which the HDD under the corpus bounds either way; a remote
worker is bounded by its link (100 Mbit ≈ 400k articles/s). History
therefore no longer waits for the corpus fill: a group's stored ranges
are read from disk, an unstored one is fetched live and captured into
the corpus on the way, and the prefetcher runs ahead of each held
group's cursor (plus the capture work left). A group with no planned
range left is the tail: the worker keeps the group and asks again
every minute, the planner's tail ranges fall into its order. A lease
whose holder went quiet for LEASE_TTL_SECONDS is taken over: the new
holder downloads the group's snapshot (5) and redoes the ranges since
it; the batch store's keys make the redo idempotent. The replay
harness (8) stays for offline runs; the driver on homelab goes once the
worker runs.
2. **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's
`repeated` rule already knows reposts). Releases still open at a block's
end are written flagged `edge`; 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_at` is 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).
is the median Date of the last 1,024 articles with a plausible date
(within 7 days of the frontier; 1,024 such dates in a row that agree
among themselves move it, the group having paused); Date headers alone
aren't trusted (clock skew, bogus years). 1,024, not more: the median
trails the newest article by half the ring, a day in a group of 500
articles a day. A release is **finished** when the frontier is 24 h past
its last part (`WINDOW_MS`): written once, with its completeness decided
then (every announced file there and no file short of parts; 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's `repeated`
rule already knows reposts). At a lease's end (the tail's poll, a worker
stopping; amended 2026-10-26, was: the block's end) the releases the
window hasn't closed on are written **provisionally** (`settled_at`
null, `finished: false`, flagged `edge`): a release being uploaded
shows in the catalog, and the carried window finishes the same row
later, since the release key is the identity, the group and the first
article; the store's finish path adds the files not sent before. No
merger: the carry does the joining. The settler goes: `settled_at` is
the finish. The **tail** needs no path of its own: its lease ends leave
such provisional releases, the next lease's continuation finishes them.
One exception (2026-10-27): a provisional release that is complete
(every file its counter announced found) and quiet for six hours is
settled by the coordinator (`Settler.settleComplete`, every five
minutes), as the finish would: no counted file can still come, and the
continuation may be hours away or never (the first run's range leases
left 109k provisional rows at their blocks' end edges, 606 of them
complete, shown as still collecting; the user, release 10697).
3. **Albums.** Files of one poster in one owner group whose subjects differ
only by a number sequence, or share a directory-like prefix, posted
@@ -420,11 +492,35 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
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's `post`/`stem` singles); a
poster's ten different cracks in a day stay ten releases.
poster's ten different cracks in a day stay ten releases. The stake,
from voyager 2's final analysis (LOG.md (112)): 77% of its 83.7 M
releases had one file, 50% one segment (0.3% of the segments);
a.b.pictures.erotica.porn alone was 27.7 M single-file releases of one
poster. A counter total shared by many stems of one poster within the
window is a batch counter, not a file count (Yenc-PP-A&A's [n/68067]):
`file_total` null for them, not 68,067 incomplete singles.
**Built 2026-10-26** (edge/src/indexer.rs, LOG.md (113)): a release the
window closed on with one file named by its stem or post, the name with
a number and no archive, volume, sidecar or random name, waits in the
album of its poster and template (the name's digit runs as `#`, the
extension apart: `#_#.jpg.data`) posted within WINDOW_MS of the album's
first file, at most ALBUM_MAX_FILES (1,000) a album; the album closes a
window after that span and is written as one release (`source: album`,
no file total) named by the members' shared numbers (`1481439079025_#
.jpg.data`), a lone member as the release it was. The batch store writes
the members' names as `release_texts` kind 6 (`member`, the first 20).
The directory-like prefix variant is not built (no case seen in the
corpus samples). The batch counter: a total ≥ 100 under which one poster
started 20+ releases within 100 batches gives none of them a file
total. On six a.b.ath ranges of 2016: 46 albums of 790 files among
2,099 chunks, most of them a subject parser miss (`[n/60] Shogun I
(1980) Shogun I (1980).avi.012`: the counter text taken into the name,
a volume each) that the album rule glues back together.
4. **A slimmer catalog.** `releases` loses 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),
`group_sets` (the distinct group combinations: 139,927 in voyager 2's
83.7 M releases, LOG.md (112); 85 B jsonb → 4),
`release_key` and `identity_key` as 20-byte `bytea` (41 B each → 20),
`source` a smallint. ~150 of ~430 B a row; the poster and key indexes
shrink with them (~12 GB of 50 today); `releases_category_newest_idx`
@@ -437,7 +533,94 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
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.
few hundred MB at worst. The carried window (amended 2026-10-26) has to
survive a restart (deploys restart the workers daily): its counters and
names are small (kilobytes for most groups, ~100 MB for the busiest),
the open files' segment lists are the bulk (~1.25 GB for a day of the
busiest group). A snapshot at every 2 M range would be ~600 B/article
of writes, so the state is snapshotted at a lease's end and on a timer,
to a file on bulk, and a crash redoes the ranges since the last snapshot
from the corpus or the prefetch cache: the deterministic keys make the
redo idempotent. **The snapshot goes through the API** (2026-10-26):
`PUT /node/groups/:id/snapshot` with the serialized window (the
frontier, the open files and releases, the open albums, the upload
numbers, the batch-counter table; gzip) and the article it stands at
(the last batch's `next`), kept by the coordinator on bulk as
`<snapshot dir>/<group id>`; `GET` gives it back to the next holder. A
worker snapshots when it gives a group back (a stop) and every
SNAPSHOT_MINUTES while it holds one, always right after a batch was
stored, so the snapshot's article is a resume point. The replay
harness (8) keeps a group's window in one process without one.
**Built 2026-10-26** (LOG.md (117)): migration 6500 `group_leases`;
`app/services/group_leases.ts` (lease by the most urgent planned
range within LEASE_MIN_PRIORITY..LEASE_MAX_PRIORITY, the longest free
first; takeover after LEASE_TTL_SECONDS, the ranges from the snapshot's
article on planned again; next; heartbeat; release; the snapshot
streamed to `SNAPSHOT_DIR/<group id>.gz`); the node API's
`groups/lease`, `groups/:id/next|heartbeat|release|snapshot`; the
range stream serving a stored range's chunks from the corpus; the
prefetcher ahead of held groups' cursors, skipping chunks the corpus
has; `RangeIndexer::for_group/begin_range/snapshot/restore` (bincode,
the files a table, SNAPSHOT_VERSION 1); `edge-node` holding groups by
default (`--ranges` keeps the old path). Tests: the lease order,
takeover and redo, the endpoints with a corpus-served stream and the
snapshot through the API, the snapshot restoring a window between two
ranges (the two-range release one, the album joined). Deployed and
started 2026-10-26 22:37 (LOG.md (118)). A chunk that doesn't inflate
(a live reply cut short; the spool doesn't check) is reported after
the batch before it went out and the range asked for again from that
chunk: the window stays (the first run lost it, and the open batch).
The lease bounds alone don't select a window: the history planner's
oldest buckets are priority 0–2 too, so the first run also took 2026
history at 0 (25k ranges in 40 groups, fetched live) while 1,739 of
the 2016 corpus ranges sat at voyager 2's leftover priorities (done
when the window was marked, planned again by the reset): hence
`LEASE_CAPTURE_ONLY` (the lease, next and the prefetcher's run-ahead
take capture ranges only) and the one-off repair of the priorities.
The stream polls the corpus as well as the spool while it waits for a
chunk: the corpus fill claims chunks of the same range ahead of the
leased run (the prefetcher's claims are per range and chunk), stores
them in the corpus only, and the run leaves them there; a stream that
looked at the corpus once, seconds before that store, waited its whole
hour on the spool and the worker lost the group and its open batch
(three groups on the first morning). A failed stream now posts the
open batch before the group goes back, like a stop does, so the
window's snapshot survives it; only a batch the coordinator refused
(lease lost, too large) still loses the window.
**Two lanes a group (2026-10-27; migration 6600, LOG.md (119)).** A
group's ranges in article order through one window meant a thread
that leased a group for its tail range first waded through the
group's history (older articles come first), and a held history
group's tail waited behind days of its ranges; and the bounds that ran
the 2016 window alone (0..0, capture only) shut the tail out entirely.
Now `group_leases` has a row per group and *lane*: the `history` lane
works the ranges the bounds hand out (the windows, the history), the
`tail` lane the tail planner's (priority at or below the tail's,
whatever the bounds), each with its own holder, window, snapshot
(`<group>-tail.gz`) and takeover redo (the lane's ranges only, and
only those the window did after the snapshot: leased, done since it,
or read past it). The edge puts `--tail-threads` of its threads on the
tail lane (EDGE_TAIL_THREADS; 1 of 2 or more by default, which is
what homelab's 16 run with), the rest on history; the prefetcher runs ahead of every held
cursor. The seam a group is the same one as before, once: where the
history lane reaches the first range the tail lane did, it is caught
up. **The lease order** within a lane and priority is a mix (the
user, 2026-10-27): one lease starts from the group with the most
planned articles, the next from the longest unheld one (never held
first), alternating on a sequence (`group_lease_turns`), so a big
group snapshotted at a stop doesn't wait behind hundreds of small
never-held groups and a quiet group's tail is visited all the same.
**`coordinator:status`** (`app/services/status.ts`, `--json`) prints
the catalog's room against `CATALOG_MAX_NVME_GB`, the ranges by band
(tail, raised, each capture window, the history by thousand), the
last hour's indexed and stored articles, the spool, the tail backlog,
the held groups with their snapshots' age and size, the snapshot
files, and the phase-2 backlogs (provisional releases, probe
candidates, the pending ladder, the judge's dirty marks). **End to
end** (`tests/e2e`, suite `e2e`, CI builds the binary first): the Rust
worker against the real node API, database, spool and corpus — the
corpus-fill race, a failed stream and the restart that restores the
snapshot, a restart whose snapshot file is gone, a takeover.
6. **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
@@ -469,6 +652,237 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
verdicts, file hashes. Resets are paused (2026-10-12); this one waits
for the user's go-ahead, and for the new NVMe.
### Schema (built 2026-10-25; migrations 6000 and 6100)
Migration 6000 *is* the reset: it truncates every table the indexing
recomputes and refuses to run on a catalog with releases unless
`VOYAGER3_RESET=1`, so merging `voyager-3` into `rebuild` is the go-ahead.
Survives: `groups`, `ranges` (planned again from their start, with their
priorities, `capture`, `est_date`, `stored_at`, `plain_chunks` and measured
`posted_low/high`), the corpus, `probe_cache`, `spots` (unlinked from
releases; the inspector matches them again), `nntp_shares`, `nodes`,
`users`, the rule sets.
- `releases`: created anew, 8-byte columns first: `id`, `posted_at`,
`last_posted_at`, `first_article`, `last_article`, `segments`, `bytes`,
`settled_at` (the finish; null while a tail release is provisional),
`inspected_at`, `first_seen_at`; `group_id`, `group_set_id`, `poster_id`,
`file_total`, `files`, `chunks`, `category`; `source smallint`
(`lib/release/source.ts`: 1 subject, 2 post, 3 stem, 4 spot, 5 nzb, 6
album); `readable`, `has_par2`, `complete`, `passworded`, `sidecar_only`,
`edge` (open at its block's end); `release_key` and `identity_key` as
20-byte `bytea`; `name`, `inner_name`. Gone: `poster`, `groups`,
`last_seen_at`. Indexes: pkey, release_key unique, (identity_key,
group_id), newest, (poster_id, newest), passworded, `(group_id,
identity_key) WHERE edge` for the block merger, `id WHERE settled_at IS
NULL` for the provisional ones. No trigram indexes: search matches
`release_texts`. The category trigger reads the groups from
`group_sets`; the judge's dirty trigger watches `poster_id` instead of
`poster`.
- `pending_releases` (migrations 6300 and 6400, the HDD): the unnamed
releases outside the catalog (decision f): `release_key` (primary),
`identity_key`, `group_id`, `name` (the random stem), `poster` text, the
posting span, `first_article`, `last_article`, counts, `file_total`,
`partial`, `finished`, `tool`, `candidates` jsonb (the probe targets),
`inspections` jsonb (what each probe showed), `named`, `status` (0
waiting, 1 named and in the catalog, 2 discarded, 3 exhausted, 4 named
awaiting the move, 9 taken), `tries`, `created_at`, `probed_at`; partial
indexes on the waiting (by `created_at`) and the named (by `probed_at`).
- `pending_signatures` (6400): per (tool, group, poster; poster '' for the
tool and group together) `probed`, `named`, `dropped`, `updated_at`.
- `release_texts(release_id, kind smallint, text varchar(255))` (6200),
primary key on all three, one trigram index (fastupdate): every string
search matches against, appended by whoever learns one and never an
update of the releases row. Kinds (`lib/release/text_kind.ts`): 1 name
(the release name, stored once more so one index serves everything),
2 file (the prober's names inside an archive), 3 nfo (an NFO's title),
4 par2 (a set's name), 5 media (a container's title); at most 20 per
release and kind (`textsToStore`). A repeat insert is a no-op; the
judge's dirty mark follows an insert or delete, so a rule can react to a
name arriving later. Search: the trigram index yields release ids, joined
to `releases` by primary key (never an OR across the two tables, which
the planner cannot serve from indexes). `inner_name` stays on `releases`
as the one display string. Facets to filter on (resolution, codec,
language) are not texts: columns, or a `meta jsonb` with a
`jsonb_path_ops` index once one is proven useful.
- `posters(id serial, poster unique)` with its trigram index;
`group_sets(id serial, groups jsonb unique)`, a sorted array of names.
- `blocks` (6100): `group_id`, `start_id`, `end_id`, `next_id` (the resume
point), `posted_low/high`, `status`, `priority`, `node_id`, `attempts`,
`rule_version`, `headers`, `releases`, `leased_at`, `heartbeat_at`,
`done_at`, `error`; indexes todo (priority, id) and leased.
`ranges.block_id` points at the block; the ranges' own lease columns stay
until the leases move (then a migration drops them).
- `release_chunks`, `rule_counts`, `rule_samples`: `range_id` → `block_id`.
- Gone: `fragments`, `range_blocks`, `release_final()` (final = finished:
`settled_at IS NOT NULL`), the settler, the fragment merger,
`releases_category_newest_idx`.
- `release_inspections` and `probe_candidates` were on `CHUNK_TABLESPACE`
(the HDD) with their indexes; both are back on the NVMe (migrations 4800
and 7300). Note LOG.md 2026-10-19 (81): the judge's fake rule read all of
`release_inspections` every round, 17 min on the HDD; and 2026-11-03
(136): the prober's catalog claim updates `probe_candidates` from random
points every minute, 2–3 min on the HDD beside the pending ladder's sweep.
What a worker reads or updates by random key every minute stays on the NVMe.
### Batch store (built 2026-10-25)
`app/services/batch_store.ts`: every release chunk of a batch is one
release, inserted once. Its keys (`lib/release/keys.ts`): `identity_key`
the identity's SHA-1 as 20 bytes; `release_key` the SHA-1 of the identity,
the owner group and the release's first article, so it is deterministic
(no numbering under a group lock, no catalog lookups before writing) and a
repost, starting at another article, is a release of its own. Its poster
and group set by id (`app/services/catalog_refs.ts`: insert what's new,
read the ids, cache them per process; the tests forget the cache per
test), its source as a code, its name into `release_texts` (kind 1). The
chunk's own counts go in as the release's; `complete` is decided then from
the counter. A finished release's probe candidates are claimable at once
(`ready`, `unreadable`); a provisional tail release (`finished: false` on
the chunk, `settled_at` null) is finished by a later chunk of the same
release key, which adds the files not sent before (sums, one more chunk),
sets `settled_at` and makes its candidates claimable; a finished release
sent again is left as it is (`ON CONFLICT … WHERE settled_at IS NULL`).
Gone: the join of a chunk into an earlier release of its key (the window
keeps an upload together; the block merger joins what a block's end cut),
the join across groups (FTD's spread uploads: a gap, to be handled by the
block merger or the par2 merger across groups), the numbering of release
keys, the group lock, `fragments` (ignored when sent; the Rust edge still
sends them). The chunk type carries `finished` and `edge` (both optional;
the Rust edge doesn't send them yet, so its releases are finished, not
edges). Interim until blocks are leased: chunks, rule counts and samples
are keyed by `ranges.block_id`, and a range without a block gets a block
of its own in the store (`blockOf`).
Readers moved with it: search joins `posters` and `group_sets`, matches
words in `release_texts` (`r.id IN (SELECT release_id … ILIKE)`), searches
posters by id, filters `source` by code and returns the source name; the
par2 merger, the sidecar folder and the judge's scraped rule use
`poster_id` (`release_merges.from_key` holds the key as hex); the spot
inspector matches by bytea identity keys and inserts its spot releases by
id references with their title in `release_texts`; the spotter and the NZB
service compare the source code. Tests insert fixtures through
`tests/functional/helpers/releases.ts` (`insertRelease`: text keys, poster
and groups by name, source by name). The unit and functional suites pass.
### The window in the Rust edge (built 2026-10-25)
`edge/src/indexer.rs`: the scanner and the file aggregator as before; then
the window instead of the range path. `Frontier` keeps a ring of the last
`FRONTIER_ARTICLES` (1,024) plausible dates (within `PLAUSIBLE_MS`, 7
days, of the frontier; 1,024 implausible dates in a row that agree move
it), recomputed once per chunk; established after 64 dated articles
(`settle` at the block's end for smaller blocks); its first value is the
block's start in posting time. Per chunk: completed files, then the files
whose last part is before frontier − `WINDOW_MS` (24 h; undated ones idle
for `idle` chunks), go into the open release of their key whose posting
span is within `UPLOAD_GAP_MS` (3 h) of theirs, else start one (the
upload number per key, distinct within a batch); releases whose last part
is before frontier − `WINDOW_MS` are judged and written `finished`, as an
`edge` when posted within `WINDOW_MS` of the block's start. At the end
(`finish`) everything left is written: finished when the window closed on
it, else `finished: false, edge: true`. A file short of parts stays in
its release and sets `partial` (the store: `complete = false`). A lone
article of a multi-part random file is still counted `per-article`, not
written; before the block's end a scrambled name counts as random too
(`looks_scrambled`: a random subject and poster per article, 160k of
alt.binaries.storage's releases; LOG.md (122)).
**Single-archive rules (built 2026-10-31; LOG.md (123)-(127)).** The judge
(`names::judge`, `RuleSet.judge`) gets each file's articles seen and their
bytes, and whether the release is *settled* (the window closed on it: a
par2 can still join an open one; the start edge doesn't count, since a par2
follows its rar and 7% of the catalog's releases are start edges). A settled
release of one unsplit zip, rar or 7z and nothing else is dropped as a
**stub archive** (`stub-archive`, key: the legs tripped) when posted to
2+ groups, under 100 MB (the fakes stop at ~90 MB; hash-named uploads,
real albums and passworded videos above it don't), and (A) its articles
are ≤ 256 KiB with the release ≥ 1 MB (Power-Post's 250 kB default; a
one-article release always has small articles, which exempts e-books and
sidecars), or (X) posted to 4+ groups, or (W) by a webmail poster. A
ROT13 volume name (".cnegN.rar" is ".partN.rar": a.b.erotica's passworded
videos, LOG.md (128)) is a volume, not a single archive. A release
touching a music, e-book, comics, tabs or fonts group is exempt: those
communities post real single small archives the same way (karaoke zips to
a.b.sounds.karaoke + a.b.test with Power-Post's articles; 16 of 32,308
known fakes lost, LOG.md (127)). These are the
exe-stub and recycled-installer floods under film and software names:
55% of the catalog on 2026-10-30, 99.3% of the known fakes, 0 known
content in the 217k sample, verified on 1,500 decoded articles. A settled
single archive of one article and 100 kB–1 MB under a release-like,
unscrambled name without spaces and without an e-book format in it is
dropped as a **nonsense piece** (`nonsense-piece`, key: the upload tool):
the newznzb.* pieces (scene-styled or random with a "-XX" suffix), 9% of
the catalog; under 100 kB are cue/log sidecars and adverts, and the names
with spaces are books and albums. Below that, a settled single archive of
one article and 2–100 kB under a release-like name (spaces allowed), outside
the exempt groups and without an e-book, subtitle, crack/keygen/trainer,
cover or sidecar word, is dropped as a **tiny archive** (`tiny-archive`,
key: the upload tool; LOG.md (131), (133)): the 2015–17 flood's RARs
holding "<title>.exe" at 2–60 kB, 1.66M catalog rows from a dozen posters
(12 of 12 fetched fake); the word exemptions keep the real tiny archives
(subtitles, cracks, trainers) at the price of ~50k fakes named "Incl
Keygen" that the prober judges later. The rules are switched by the
rule set (`dropStubArchives`, `dropNonsensePieces`, `dropTinyArchives`),
sampled like every drop, and mirrored in `lib/rules/rule_set.ts` (the TypeScript indexer
judges as each batch completes files, so it passes settled without the
window's knowledge). Gone: fragments and the cut-off, `uploads_of` (online now),
`check.ts` and the fixture comparison (the parity with the TypeScript
indexer; CI runs `edge/bench-fixtures.sh` instead), the `incomplete` count
(incomplete releases are kept). The wire carries `finished`, `edge` and
`partial`; the store reads them (`partial` into `complete`). Memory: the
window holds a day of a group's segments in memory (the busiest group's
~50 M); the spool file per block of the design is not built yet.
### The replay harness (built 2026-10-26)
`node ace replay:corpus --group <name> [--dir CORPUS_DIR] [--from article]
[--to article] [--limit ranges] [--batch-chunks 5] [--no-finish]` (commands/
replay_corpus.ts; design 8): a group's complete corpus ranges in article
order through one window in the Rust edge (`edge-bench --group <dir>
--rules rules.json --wire -`: the window carried from range to range, the
end finished after the last range; `edge/src/lease.rs` has the lease JSON
the worker and the bench share) and the batch store into a scratch
catalog, as the worker would store them: each range leased to a node named
`replay`, its batches stored at its resume point (pulled from the bench's
stdout as the store keeps up), the range finished when its last batch is
in. It refuses a database not named `*_replay` or `*_scratch`.
The rules are the scratch catalog's current set and group priorities:
copy `groups` and `rule_sets` from the catalog for a faithful replay. The
report: releases finished, provisional, edges; complete, incomplete,
undecided; readable, with par2; files, segments as a share of the
headers; bytes per header of the NVMe tables (releases, release_texts,
posters, group_sets, rule counts and samples) and of those on
CHUNK_TABLESPACE (release_chunks, probe_candidates, release_inspections).
The app image carries `edge-bench` for it (Dockerfile); on homelab the
scratch catalog is a second database (`usenet_replay`, default tablespace
`nvme` like the catalog, the chunk tables on `pg_default`; migrated with
the voyager-3 image built from the deploy cache's repo, `groups` and
`rule_sets` copied over), the corpus mounted read-only.
`deploy/replay-window.sh` runs a window there: each group whose window
ranges are all stored is replayed once with the window's bounds as
`--from`/`--to` (the corpus holds other windows of the same group), 4
groups at a time, logs and `.done` markers in `.cache/replay/`, looping
until the window is done (LOG.md 2026-10-26 (111) has the first local
numbers, (112) the homelab run). **Deployed 2026-10-26** (LOG.md (116)):
voyager 3 runs on homelab without its edge worker (`.cache/no-edge`:
deploy.sh and the nightly resume leave it stopped, since range by range
it would split releases at every edge with no merger left), and history
comes from the corpus through this driver into the catalog itself
(`DB=usenet_rebuild OUT=.cache/replay-catalog`, the harness allowed by
`REPLAY_INTO_CATALOG=1`), the prober's ladder, the merger and the judge
working on it as it fills; the scratch run's numbers (63 groups) were
read before (LOG.md (115)). The tail waits for the window worker.
Next: the replay on homelab over the 2016 window (the NVMe bytes per
header for real), the window worker (design 1 and 5: group leases, the
stream serving stored chunks, the snapshot through the API; being built,
LOG.md (117)), the prober writing `release_texts` kind 2, the spot/NZB
match on pending keys (decision f), the tail's provisional writes at a
range end (the window's open releases shown while they upload: the files
sent provisionally marked, the finish sending the rest). Gone from the list: block
leases, the planner and the block merger (design 1, amended).
## Decisions
a. Chunk payload: a write-once `bytea` table in Postgres, partitioned by
@@ -478,11 +892,84 @@ 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.
f. **Nothing enters the catalog without a usable name** (amended
2026-10-26, the user's direction; before: random-named releases keep
no chunk but their rows). A release the window closed on whose files
all have random names (`isUnreadableName`, `names::is_unreadable_name`:
a name read token by token, so separators, spaces and random
"extensions" don't hide a scramble; since 2026-11-02, LOG.md (129),
when 15% of the catalog's "readable" names were "Zx5AjNaK-mh7CFg6Hf8uu"
and "Description - Lt5nHjlo3RiuBb3C") is no release yet: it goes to `pending_releases`
(migration 6300, the HDD) with its keys, counts, article span, upload
tool and probe targets, no row in `releases`, `release_texts` or
`posters`, no chunk. The pending ladder works them **from least to most
effort**: (0) no fetch: a signature verdict (tool and group, poster)
learned from earlier probes, a spot or NZB that names the message-ids;
(1) a par2 main file's first article (it names the files); (2) the first
article of the first file (the yEnc name, an archive's listing in its
first 16 KiB); (3) a 7z's tail; then nothing more. A probe that yields
a usable name moves the release into the catalog, its segments read
again from the corpus (the range stored) or the server; one that
yields none counts against its signature, and a signature with
≥100 probes and ≤0.5% named (voyager 2's garbage learner, LOG.md
2026-10-08 (5)) is **discarded** from then on, a sample of it still
probed to notice a change. **By class** (2026-11-02, LOG.md (130)): a
pending release's class is its tool, group, size bucket, name shape and
partial flag (`lib/release/classes.ts`, `names::pending_class`, the SQL
`pending_class()`; `pending_releases.klass`), and `pending_classes` keeps
each class's climbs and names; the claim takes the classes with the best
record first (`classScore`, a prior for the unexplored, at most a quarter
of a claim from one class), the cheapest target article first within one
(`target_bytes`), and the oldest of the rest; a class with the learner's
thresholds is dropped like a signature, and the rule set's
`droppedClasses` keep its releases out of the pending store at the edge,
the explored sample excepted. A probed file whose first 16 KiB and size
match a named catalog release's (`file_hashes`) takes that name without
another fetch (`app/services/hash_twins.ts`), on the ladder and for
catalog releases without one. The reason, in the 2016 replay: one poster's
hash-named pieces ("Bob", 11 aliases, 3.2 M releases in five small
groups) were 76% of the rows and three quarters of the NVMe bytes for
11% of the segments and nothing anyone can find; the full history has
725 M random-named files (LOG.md 2026-10-09). Built 2026-10-26: the
edge sends an unnamed release with an empty payload and its tool, the
batch store writes `pending_releases` (LOG.md (114)); the ladder in the
prober (`app/services/pending_ladder.ts`, LOG.md (115)): a claim takes
the oldest waiting releases' next target (par2, archive, lone, 7z-tail
in that order), at least PENDING_SHARE (25%) of each claim next to the
catalog's own candidates, answered from `probe_cache` when the article
was read before; `usableName` (lib/probe/inspect.ts) takes the largest
named file of a par2 set or a listing, or a lone file's yEnc name, that
is neither packaging nor random; a name sets status 4, no name the next
target or status 3 (exhausted); each finished climb counts on
`pending_signatures` for (tool, group, poster) and (tool, group), and a
signature with PENDING_MIN_PROBES (100) climbs and at most
PENDING_MAX_NAMED (0.5%) named is dropped: a sweep beside the lanes
(every five minutes, in its own loop) discards the waiting releases
of each dropped signature and class once (`swept_at`; the edge drops
their new releases itself), the rules' `samplePercent` of them left to
climb, each by an index (`pending_releases_signature_idx` for a
poster's rows: tested on the rows, a signature was a scan of the whole
table; LOG.md (133), (134)). The batch store, the record, the claim
and the sweep lock pending rows in one order, by key and the counters
after (`lockOrder` for the multi-row statements; the record and the
sweep lock their rows with `SELECT … ORDER BY release_key FOR UPDATE`
first, since an UPDATE's join or `= ANY(subquery)` locks in plan
order), and a flush that deadlocks anyway keeps its outcomes for the
next (LOG.md (134), (137)). The namer
(`app/services/pending_namer.ts`, in the prober's process): a named
release's article span read again from the corpus (the stored range's
chunks) or by XOVER on a connection of its own, a chunk of articles
at a time (one string of a wide span topped V8's limit; spans over 3M
articles are given up), through the TypeScript
indexer with `partialFiles` (a file given up short of parts stays a
partial file of its release, as the Rust window writes it; without it
the range path's fragment rule left nothing to find: by 2026-10-29
about 52k of 210k named releases failed, all partial), written as a release under the pending key with `source`
weak-stem, the name as `inner_name`, its chunk and its candidates (the
prober answers those from `probe_cache`: inspections, par2 sets, hashes,
the password flag). Not built: the spot/NZB match on pending keys (step
0 of the ladder beyond signatures), and a signature by poster alone
across groups.
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
+6
View File
@@ -91,6 +91,12 @@ export default defineConfig({
name: 'functional',
timeout: 30000,
},
// The Rust edge worker against the real node API (tests/e2e): needs edge/target/release/edge-node.
{
files: ['tests/e2e/**/*.spec.{ts,js}'],
name: 'e2e',
timeout: 120000,
},
],
forceExit: false,
},
+107 -13
View File
@@ -1,17 +1,15 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Readable } from 'node:stream'
import { setTimeout as sleep } from 'node:timers/promises'
import db from '@adonisjs/lucid/services/db'
import Leases, { LeaseLost } from '#services/leases'
import GroupLeases, { LANES } from '#services/group_leases'
import BatchStore, { StaleBatch } from '#services/batch_store'
import Spool from '#services/spool'
import { KEEPALIVE, encodeFrame, fromWire, type WireBatch } from '#lib/edge/index'
import Spool, { type ChunkSpan } from '#services/spool'
import Corpus from '#services/corpus'
import coordinatorConfig from '#config/coordinator'
import { KEEPALIVE, encodeFrame, fromWire, type Lane, type WireBatch } from '#lib/edge/index'
/**
* How long the stream waits for a chunk the prefetcher hasn't spooled yet. 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)).
*/
const CHUNK_WAIT_MS = 60 * 60 * 1000
/** While waiting, an empty frame this often: an HTTP client gives up on a quiet body (Node's after 5 minutes). */
const KEEPALIVE_MS = 20_000
@@ -30,18 +28,109 @@ export default class NodeController {
return lease
}
/** The range's chunks from its resume point, waiting for those still being fetched. */
// Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): a worker thread holds a group and takes its
// ranges in article order; the window's snapshot goes through here too, so a worker needs no disk of its own.
// `?lane=tail` is the group's tail cursor (its own holder, window and snapshot); history by default.
/** The lane the request names (`?lane=`), or null after a 400 for one that isn't a lane. */
private lane(ctx: HttpContext): Lane | null {
const lane = ctx.request.qs().lane ?? 'history'
if (LANES.includes(lane)) return lane
ctx.response.badRequest({ error: `lane: ${LANES.join(' or ')}, not "${lane}"` })
return null
}
async leaseGroup(ctx: HttpContext) {
const lane = this.lane(ctx)
if (!lane) return
const lease = await GroupLeases.lease(this.node(ctx), lane)
if (!lease) return ctx.response.noContent()
return lease
}
async nextRange(ctx: HttpContext) {
const lane = this.lane(ctx)
if (!lane) return
try {
const next = await GroupLeases.next(Number(ctx.params.id), this.node(ctx), lane)
if (!next) return ctx.response.noContent()
return next
} catch (error) {
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
throw error
}
}
async groupHeartbeat(ctx: HttpContext) {
const lane = this.lane(ctx)
if (!lane) return
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.heartbeat(groupId, nodeId, lane))
}
async releaseGroup(ctx: HttpContext) {
const lane = this.lane(ctx)
if (!lane) return
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.release(groupId, nodeId, lane))
}
/**
* PUT /groups/:id/snapshot?next=N: the window's serialized state standing at article N, streamed to disk as sent
* (gzip; the body parser leaves an octet-stream alone, so the request is still readable here).
*/
async putSnapshot(ctx: HttpContext) {
const lane = this.lane(ctx)
if (!lane) return
const next = Number(ctx.request.qs().next)
if (!Number.isFinite(next) || next <= 0) return ctx.response.badRequest({ error: 'next (the snapshot\'s article) is needed' })
const raw = ctx.request.raw()
const body = raw !== null ? Readable.from([Buffer.from(raw, 'binary')]) : ctx.request.request
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.putSnapshot(groupId, nodeId, next, body, lane))
}
async getSnapshot(ctx: HttpContext) {
const lane = this.lane(ctx)
if (!lane) return
try {
const snapshot = await GroupLeases.getSnapshot(Number(ctx.params.id), this.node(ctx), lane)
if (!snapshot) return ctx.response.notFound({ error: 'no snapshot' })
ctx.response.header('Content-Type', 'application/octet-stream')
ctx.response.header('Content-Length', String(snapshot.size))
ctx.response.header('X-Snapshot-Next', String(snapshot.next))
return ctx.response.stream(snapshot.stream)
} catch (error) {
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
throw error
}
}
/**
* The range's chunks from its resume point: a stored range's from the corpus (a worker needs no mount; REBUILD.md
* design 1), else from the spool, waiting for those still being fetched.
*/
async stream(ctx: HttpContext) {
const nodeId = this.node(ctx)
const rangeId = Number(ctx.params.id)
const range = await this.leased(ctx, rangeId, nodeId)
if (!range) return
const spans = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }, Number(range.next_id))
const group = range.capture ? await db.from('groups').where('id', range.group_id).select('name').first() : null
const corpusRange = group ? { group: group.name as string, start: Number(range.start_id), end: Number(range.end_id) } : null
// The chunk from the corpus or the spool, whichever has it. Both are polled while waiting: the corpus fill
// claims a chunk ahead of the leased run (the prefetcher's claims are per range and chunk), stores it in the
// corpus only, and the run then leaves it there — a stream that looked at the corpus once, before that
// store, waited the whole hour on the spool (three groups on 2026-10-27, LOG.md (118)).
const stored = async (span: ChunkSpan) =>
corpusRange && (await Corpus.has(corpusRange, span)) ? Corpus.read(corpusRange, span) : null
async function* frames() {
for (const span of spans) {
const waitUntil = Date.now() + CHUNK_WAIT_MS
const waitUntil = Date.now() + coordinatorConfig.streamChunkWaitMs
let quietSince = Date.now()
while (!(await Spool.has(rangeId, span))) {
let data = await stored(span)
while (!data) {
if (await Spool.has(rangeId, span)) {
data = await Spool.read(rangeId, span)
break
}
if (Date.now() > waitUntil) throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} wasn't spooled in time`)
if (Date.now() - quietSince >= KEEPALIVE_MS) {
quietSince = Date.now()
@@ -49,8 +138,9 @@ export default class NodeController {
await Leases.heartbeat(rangeId, nodeId)
}
await sleep(500)
data = await stored(span)
}
yield encodeFrame({ ...span, data: await Spool.read(rangeId, span) })
yield encodeFrame({ ...span, data })
// Streaming keeps the lease alive.
await Leases.heartbeat(rangeId, nodeId)
}
@@ -75,7 +165,11 @@ export default class NodeController {
}
async finish(ctx: HttpContext) {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.finish(rangeId, nodeId))
return this.lifecycle(ctx, async (rangeId, nodeId) => {
const range = await Leases.leased(rangeId, nodeId)
await Leases.finish(rangeId, nodeId)
await GroupLeases.rangeDone(Number(range.group_id), Number(range.headers), GroupLeases.laneOf(Number(range.priority)))
})
}
/** POST /ranges/:id/corrupt { from }: a chunk that doesn't inflate; the range goes back. */
+296 -200
View File
@@ -1,14 +1,20 @@
import db from '@adonisjs/lucid/services/db'
import { pruneSevenZipTails } from '#services/seven_zip_tails'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import { UPLOAD_GAP_MS, type EdgeBatch, type ProbeCandidate, type ReleaseChunk } from '#lib/edge/index'
import type { EdgeBatch, ProbeCandidate, ReleaseChunk } from '#lib/edge/index'
import ChunkPartitions from '#services/chunk_partitions'
import CatalogRefs, { groupSetKey, posterText } from '#services/catalog_refs'
import { decodeChunk } from '#lib/chunks/index'
import { SIDECAR } from '#services/sidecar_folder'
import { identityKey, releaseKey } from '#lib/release/keys'
import { sourceCode } from '#lib/release/source'
import { uploadTool } from '#lib/rules/index'
import { byEffort, lockOrder } from '#services/pending_ladder'
import { TEXT_KINDS, TEXT_MAX, textsToStore } from '#lib/release/text_kind'
/**
* A small new release (at most 3 files, under 1 MB) whose files are all sidecars (.nfo, .sfv, .nzb, …): the sidecar
* fold's candidates (releases.sidecar_only; the settler queues only these). Its file list decoded here, small as it is.
* fold's candidates (releases.sidecar_only). Its file list decoded here, small as it is.
*/
function sidecarOnly(r: ReleaseChunk): boolean | null {
if (r.files > 3 || r.bytes >= 1_000_000) return null
@@ -19,18 +25,34 @@ function sidecarOnly(r: ReleaseChunk): boolean | null {
export class StaleBatch extends Error {}
export interface StoredBatch {
/** Release chunks in the batch. */
releases: number
/** Releases the batch started. */
newReleases: number
/** Provisional (tail) releases the batch finished. */
finalized: number
candidates: number
fragments: number
/** Unnamed releases written to the pending store instead of the catalog (decision f). */
pending: number
}
/** A chunk's slot in its batch: the key and the upload the edge numbered it with (ProbeCandidate points at it). */
const slot = (key: string, upload: number) => `${key}\n${upload}`
/**
* Stores one batch of a leased range (REBUILD.md, parts 3–7), in one
* transaction: release aggregates in the catalog, one chunk per release,
* probe candidates, rule counts and samples, edge fragments, and the
* range's resume point. A batch that doesn't start at the resume point was
* Stores one batch of a leased range (REBUILD.md, "Voyager 3: window indexing", 2 and 4) in one transaction: each
* release chunk is a whole release the edge's window finished, written once (its poster and group set by id, its
* keys as bytea, its name into release_texts), with one chunk of its files, its probe candidates, and the batch's
* rule counts and samples; then the range's resume point moves. A batch that doesn't start at the resume point was
* sent twice or too late, and is refused (StaleBatch), so the sums stay right.
*
* A tail release comes twice: provisionally (finished: false, settled_at null, its candidates waiting) and then
* finished, under the same release key (the identity, the group and its first article: lib/release/keys.ts), with
* the files not sent before (a chunk's counts are its own; the finish adds them up); the finish is the one update a
* release gets from the header pass. A finished release sent again is left as it is.
*
* Interim, until blocks are leased (the next step): chunks, counts and samples are keyed by the range's block
* (ranges.block_id), and a range without one gets a block of its own here.
*/
class BatchStore {
async store(rangeId: number, nodeId: number, batch: EdgeBatch): Promise<StoredBatch> {
@@ -39,7 +61,9 @@ class BatchStore {
// One batch of a range at a time, without holding the range's row for the whole transaction: the
// prefetcher's and heartbeats' updates of it waited on it (0.5 s on average, NNTP connections idle;
// LOG.md 2026-10-09 (13)). The resume point is checked again when it moves, at the end.
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('range:' || ?::text, 0))`, [rangeId])
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('range:' || ?::text, 0))`, [
rangeId,
])
const range = await trx.from('ranges').where('id', rangeId).first()
if (!range || range.status !== 'leased' || Number(range.node_id) !== nodeId)
throw new StaleBatch(`Range ${rangeId} is not leased to this node`)
@@ -47,35 +71,48 @@ class BatchStore {
throw new StaleBatch(
`Range ${rangeId} is at ${range.next_id}, the batch starts at ${batch.from}`
)
const blockId = await this.blockOf(trx, range)
const result: StoredBatch = {
releases: batch.releases.length,
newReleases: 0,
finalized: 0,
candidates: 0,
fragments: batch.fragments.length,
pending: 0,
}
// Nothing enters the catalog without a usable name (decision f): an unnamed release (no readable file name)
// goes to the pending store with its probe targets, the named ones to the catalog.
const named = batch.releases.filter((r) => r.readable)
const unnamed = batch.releases.filter((r) => !r.readable)
const written = await this.writeReleases(
trx,
rangeId,
{ groupId: Number(range.group_id), blockId },
batch.from,
batch.releases,
named,
batch.candidates
)
result.newReleases = written.newReleases
result.finalized = written.finalized
result.candidates = written.candidates
result.pending = await this.writePending(
trx,
Number(range.group_id),
unnamed,
batch.candidates
)
if (batch.counts.length > 0) {
await trx.rawQuery(
`INSERT INTO rule_counts (range_id, rule, key, articles, files)
`INSERT INTO rule_counts (block_id, rule, key, articles, files)
SELECT ?, v.rule, v.key, v.articles, v.files FROM jsonb_to_recordset(?::jsonb) AS v(rule text, key text, articles bigint, files bigint)
ON CONFLICT (range_id, rule, key) DO UPDATE SET articles = rule_counts.articles + excluded.articles, files = rule_counts.files + excluded.files`,
[rangeId, JSON.stringify(batch.counts)]
ON CONFLICT (block_id, rule, key) DO UPDATE SET articles = rule_counts.articles + excluded.articles, files = rule_counts.files + excluded.files`,
[blockId, JSON.stringify(batch.counts)]
)
}
for (const s of batch.samples) {
await trx.insertQuery().table('rule_samples').insert({
range_id: rangeId,
block_id: blockId,
rule: s.rule,
rule_key: s.ruleKey,
release_key: s.key,
@@ -86,18 +123,6 @@ class BatchStore {
})
}
for (let i = 0; i < batch.fragments.length; i += 2000) {
const rows = batch.fragments.slice(i, i + 2000).map((f) => ({
range_id: rangeId,
file_key: f.fileKey,
// Not Math.min(...): a big file's fragment has more segments than the call stack takes arguments.
article_number: f.segments.reduce((min, s) => (s[3] < min ? s[3] : min), Infinity),
segments: f.segments.length,
fragment: JSON.stringify(f),
}))
await trx.insertQuery().table('fragments').multiInsert(rows)
}
const moved = await trx
.from('ranges')
.where('id', rangeId)
@@ -107,106 +132,171 @@ class BatchStore {
.update({
next_id: batch.next,
headers: db.raw('headers + ?', [batch.headers]),
// The range's posting time span (release_final): the batches' trimmed spans, min and max (least and greatest skip nulls).
posted_low: db.raw('least(posted_low, to_timestamp(?::double precision / 1000))', [batch.postedLow ?? null]),
posted_high: db.raw('greatest(posted_high, to_timestamp(?::double precision / 1000))', [batch.postedHigh ?? null]),
// The range's posting time span: the batches' trimmed spans, min and max (least and greatest skip nulls).
posted_low: db.raw('least(posted_low, to_timestamp(?::double precision / 1000))', [
batch.postedLow ?? null,
]),
posted_high: db.raw('greatest(posted_high, to_timestamp(?::double precision / 1000))', [
batch.postedHigh ?? null,
]),
heartbeat_at: new Date(),
})
if (Number(moved) !== 1 && !(Array.isArray(moved) && moved.length === 1)) throw new StaleBatch(`Range ${rangeId} moved on meanwhile`)
if (Number(moved) !== 1 && !(Array.isArray(moved) && moved.length === 1))
throw new StaleBatch(`Range ${rangeId} moved on meanwhile`)
return result
})
}
/**
* An upload spread over groups (FTD: par2 and part1 to a.b.music.misc,
* part2 to a.b.sounds.music, each article in one group only; LOG.md
* 2026-10-11 (9)): a chunk without a release in its own group joins one of
* its key in another group whose window is within UPLOAD_GAP_MS, when none
* of its file names are in that release yet. A post of the same files to
* another group (other message-ids, the same names) stays a release of its
* own, or the NZB would hold every file twice. Reads the candidate's file
* names from its chunks; candidates are few.
*/
private async joinAcrossGroups(
trx: TransactionClientContract,
part: ReleaseChunk[],
windows: { idx: number; key: string; first: number | null | undefined; last: number | null | undefined }[],
groupId: number,
joined: Map<number, number>
) {
const open = windows.filter((w) => !joined.has(w.idx) && w.first)
if (open.length === 0) return
const { rows: candidates } = await trx.rawQuery(
`SELECT v.idx, r.id
FROM jsonb_to_recordset(?::jsonb) AS v(idx int, key text, first bigint, last bigint)
JOIN releases r ON r.identity_key = v.key AND r.group_id <> ?
WHERE r.posted_at <= to_timestamp((v.last + ?) / 1000.0) AND coalesce(r.last_posted_at, r.posted_at) >= to_timestamp((v.first - ?) / 1000.0)
ORDER BY v.idx, r.last_posted_at DESC NULLS LAST, r.id DESC`,
[JSON.stringify(open), groupId, UPLOAD_GAP_MS, UPLOAD_GAP_MS]
)
if (candidates.length === 0) return
const ids: number[] = [...new Set<number>(candidates.map((c: { id: string }) => Number(c.id)))]
const names = new Map<number, Set<string>>()
const { rows: chunks } = await trx.rawQuery(`SELECT release_id, payload FROM release_chunks WHERE release_id = ANY(?)`, [ids])
for (const c of chunks) {
const set = names.get(Number(c.release_id)) ?? new Set<string>()
for (const f of decodeChunk(c.payload)) set.add(f.name)
names.set(Number(c.release_id), set)
}
for (const c of candidates as { idx: number; id: string }[]) {
if (joined.has(c.idx)) continue
const have = names.get(Number(c.id)) ?? new Set<string>()
const incoming = decodeChunk(part[c.idx].payload).map((f) => f.name)
if (incoming.every((name) => !have.has(name))) {
joined.set(c.idx, Number(c.id))
for (const name of incoming) have.add(name)
names.set(Number(c.id), have)
}
}
/** The range's block; a range without one (no planner yet) is a block of its own, leased as the range is. */
async blockOf(trx: TransactionClientContract, range: any): Promise<number> {
if (range.block_id) return Number(range.block_id)
const [block] = await trx
.table('blocks')
.insert({
group_id: range.group_id,
start_id: range.start_id,
end_id: range.end_id,
next_id: range.next_id,
status: 'leased',
priority: range.priority ?? 0,
node_id: range.node_id,
leased_at: range.leased_at ?? new Date(),
heartbeat_at: new Date(),
})
.returning('id')
await trx.from('ranges').where('id', range.id).update({ block_id: block.id })
return Number(block.id)
}
/**
* Release aggregates in the catalog, one write-once chunk per release, and
* probe candidates (a batch's, or files the fragment merger completed).
* A chunk joins the release of its key whose articles the range's group
* owns and whose posting window is within UPLOAD_GAP_MS of its own, else
* starts one: re-uploads are releases of their own (decision d). The
* group is locked, so concurrent writers don't both start one.
* The unnamed releases of a batch into pending_releases (migration 6300, the HDD): keys, counts, the upload
* tool and the probe targets, no chunk. The pending ladder (REBUILD.md decision f) probes them cheapest first,
* and a name moves one into the catalog; a provisional tail release sent again adds its counts up.
*/
async writePending(
trx: TransactionClientContract,
groupId: number,
releases: ReleaseChunk[],
candidates: ProbeCandidate[]
): Promise<number> {
if (releases.length === 0) return 0
const targets = new Map<
string,
{ kind: string; messageId: string; file: string; bytes?: number }[]
>()
for (const c of candidates) {
const key = slot(c.releaseKey, c.upload ?? 0)
const list = targets.get(key) ?? []
list.push({ kind: c.kind, messageId: c.messageId, file: c.file, bytes: c.bytes })
targets.set(key, list)
}
// In the record's lock order (LOG.md (136)): all rows sorted, then all classes sorted, in one transaction. The
// upsert locks a finished row too, before its WHERE passes it by; a chunk's classes before the next chunk's rows
// crossed the ladder's record(), which holds its rows and then takes its classes.
const rows = lockOrder(
releases.map((r) => {
const probes = targets.get(slot(r.key, r.upload)) ?? []
return {
release_key: releaseKey(r.key, groupId, r.firstArticle).toString('hex'),
identity_key: identityKey(r.key).toString('hex'),
name: r.name.slice(0, TEXT_MAX),
poster: r.poster || null,
first: r.postedAt,
last: r.postedLast ?? r.postedAt,
first_article: r.firstArticle,
last_article: r.lastArticle,
files: r.files,
segments: r.segments,
bytes: r.bytes,
partial: r.partial === true,
finished: r.finished !== false,
file_total: r.fileTotal,
tool: r.tool ?? (probes[0] ? uploadTool(probes[0].messageId) : 'unknown'),
candidates: probes,
// The first target's article (by effort): the cheapest probe first within a class (LOG.md (130)).
target_bytes: byEffort(probes)[0]?.bytes ?? null,
}
}),
(r) => r.release_key
)
let written = 0
const classes = new Map<string, { tool: string; klass: number; waiting: number }>()
for (let i = 0; i < rows.length; i += 1000) {
const { rows: done } = await trx.rawQuery(
`INSERT INTO pending_releases (release_key, identity_key, group_id, name, poster, posted_at, last_posted_at, first_article, last_article,
files, segments, bytes, partial, finished, file_total, tool, candidates, klass, target_bytes)
SELECT decode(v.release_key, 'hex'), decode(v.identity_key, 'hex'), ?, v.name, v.poster, to_timestamp(v.first / 1000.0), to_timestamp(v.last / 1000.0),
v.first_article, v.last_article, v.files, v.segments, v.bytes, v.partial, v.finished, v.file_total, v.tool, v.candidates,
pending_class(v.name, v.bytes, v.partial), v.target_bytes
FROM jsonb_to_recordset(?::jsonb) AS v(release_key text, identity_key text, name text, poster text, first bigint, last bigint, first_article bigint, last_article bigint,
files int, segments bigint, bytes bigint, partial boolean, finished boolean, file_total int, tool text, candidates jsonb, target_bytes bigint)
ON CONFLICT (release_key) DO UPDATE SET
files = pending_releases.files + excluded.files, segments = pending_releases.segments + excluded.segments, bytes = pending_releases.bytes + excluded.bytes,
last_posted_at = greatest(pending_releases.last_posted_at, excluded.last_posted_at), last_article = greatest(pending_releases.last_article, excluded.last_article),
partial = excluded.partial, finished = excluded.finished, file_total = coalesce(excluded.file_total, pending_releases.file_total),
candidates = CASE WHEN jsonb_array_length(pending_releases.candidates) = 0 THEN excluded.candidates ELSE pending_releases.candidates END,
klass = pending_class(pending_releases.name, pending_releases.bytes + excluded.bytes, excluded.partial),
target_bytes = coalesce(pending_releases.target_bytes, excluded.target_bytes)
WHERE NOT pending_releases.finished
RETURNING release_key, tool, klass, (xmax = 0) AS inserted`,
[groupId, JSON.stringify(rows.slice(i, i + 1000))]
)
written += done.length
// Each class seen gets its row in the ladder's record, so the claim explores it (pending_classes).
for (const d of done) {
const key = `${d.tool}\t${d.klass}`
const entry = classes.get(key) ?? { tool: d.tool, klass: Number(d.klass), waiting: 0 }
if (d.inserted) entry.waiting++
classes.set(key, entry)
}
}
if (classes.size > 0) {
await trx.rawQuery(
`INSERT INTO pending_classes (tool, group_id, klass, waiting)
SELECT v.tool, ?, v.klass, v.waiting FROM jsonb_to_recordset(?::jsonb) AS v(tool text, klass int, waiting int)
ON CONFLICT (tool, group_id, klass) DO UPDATE SET waiting = pending_classes.waiting + excluded.waiting`,
[
groupId,
JSON.stringify(lockOrder(classes.values(), (c) => `${c.tool}\t${groupId}\t${c.klass}`)),
]
)
}
return written
}
/**
* The releases of a batch into the catalog, one chunk each, and their probe candidates. Every chunk is one
* release: inserted, or (a provisional tail release sent again) finished.
*/
async writeReleases(
trx: TransactionClientContract,
rangeId: number,
where: { groupId: number; blockId: number },
batchFrom: number,
releases: ReleaseChunk[],
candidates: ProbeCandidate[]
) {
const out = { newReleases: 0, candidates: 0 }
const out = { newReleases: 0, finalized: 0, candidates: 0 }
if (releases.length === 0) return out
/** Release ids by slot, for the chunks and candidates; a slot not here was a finished release sent again. */
const ids = new Map<string, number>()
const slot = (key: string, upload: number) => `${key}\n${upload}`
const { group_id: groupId } = await trx.from('ranges').where('id', rangeId).select('group_id').firstOrFail()
// One writer per group at a time: a chunk only matches releases of its group, and a lock per key
// ran out of shared memory (a picture group's batch has 50k keys; LOG.md 2026-10-09 (12)).
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('releases:' || ?::text, 0))`, [groupId])
const finalized = new Set<number>()
const posters = await CatalogRefs.posterIds(trx, releases.map((r) => r.poster).filter(Boolean))
const groupSets = await CatalogRefs.groupSetIds(
trx,
releases.map((r) => r.groups)
)
const sorted = [...releases].sort((a, b) => a.key.localeCompare(b.key) || a.upload - b.upload)
for (let i = 0; i < sorted.length; i += 1000) {
const part = sorted.slice(i, i + 1000)
const windows = part.map((r, idx) => ({ idx, key: r.key, first: r.postedAt, last: r.postedLast ?? r.postedAt }))
const { rows: matches } = await trx.rawQuery(
`SELECT DISTINCT ON (v.idx) v.idx, r.id
FROM jsonb_to_recordset(?::jsonb) AS v(idx int, key text, first bigint, last bigint)
JOIN releases r ON r.identity_key = v.key AND r.group_id = ?
WHERE v.first IS NULL OR r.posted_at IS NULL
OR (r.posted_at <= to_timestamp((v.last + ?) / 1000.0) AND coalesce(r.last_posted_at, r.posted_at) >= to_timestamp((v.first - ?) / 1000.0))
ORDER BY v.idx, r.last_posted_at DESC NULLS LAST, r.id DESC`,
[JSON.stringify(windows), groupId, UPLOAD_GAP_MS, UPLOAD_GAP_MS]
)
const joined = new Map<number, number>(matches.map((m: { idx: number; id: string }) => [m.idx, Number(m.id)]))
await this.joinAcrossGroups(trx, part, windows, groupId, joined)
const row = (r: ReleaseChunk) => ({
const rows = part.map((r, idx) => ({
idx,
release_key: releaseKey(r.key, where.groupId, r.firstArticle).toString('hex'),
identity_key: identityKey(r.key).toString('hex'),
name: r.name,
source: r.source,
poster: r.poster,
groups: r.groups,
source: sourceCode(r.source),
poster_id: r.poster ? (posters.get(posterText(r.poster)) ?? null) : null,
group_set_id: groupSets.get(groupSetKey(r.groups))!,
first: r.postedAt,
last: r.postedLast ?? r.postedAt,
file_total: r.fileTotal,
@@ -217,107 +307,114 @@ class BatchStore {
last_article: r.lastArticle,
readable: r.readable,
has_par2: r.hasPar2,
})
const updates = part.flatMap((r, idx) => (joined.has(idx) ? [{ id: joined.get(idx)!, ...row(r) }] : []))
if (updates.length > 0) {
await trx.rawQuery(
`WITH v AS (SELECT * FROM jsonb_to_recordset(?::jsonb) AS v(id bigint, first bigint, last bigint, file_total int, files int, segments bigint, bytes bigint, first_article bigint, last_article bigint, readable boolean, has_par2 boolean)),
a AS (SELECT id, sum(files) AS files, sum(segments) AS segments, sum(bytes) AS bytes, count(*) AS chunks, min(first) AS first, max(last) AS last,
max(file_total) AS file_total, min(first_article) AS first_article, max(last_article) AS last_article, bool_or(readable) AS readable, bool_or(has_par2) AS has_par2
FROM v GROUP BY id)
UPDATE releases r SET
files = r.files + a.files,
segments = r.segments + a.segments,
bytes = r.bytes + a.bytes,
chunks = r.chunks + a.chunks,
posted_at = least(r.posted_at, to_timestamp(a.first / 1000.0)),
last_posted_at = greatest(r.last_posted_at, to_timestamp(a.last / 1000.0)),
file_total = coalesce(r.file_total, a.file_total),
first_article = least(r.first_article, a.first_article),
last_article = greatest(r.last_article, a.last_article),
readable = r.readable OR a.readable,
has_par2 = r.has_par2 OR a.has_par2,
-- As it stands (the counter's files all found); settling makes it final. Without a counter, what its par2
-- set decided stays (app/services/par2_completeness.ts).
complete = CASE WHEN coalesce(r.file_total, a.file_total) IS NULL THEN r.complete ELSE r.files + a.files >= coalesce(r.file_total, a.file_total) END,
last_seen_at = now()
FROM a WHERE r.id = a.id`,
[JSON.stringify(updates)]
finished: r.finished !== false,
edge: r.edge === true,
partial: r.partial === true,
sidecar_only: sidecarOnly(r),
}))
// Inserted, or a provisional release of the key finished (the chunk's files added up); a finished release
// sent again (no row returned) is left as it is.
const { rows: written } = await trx.rawQuery(
`INSERT INTO releases (release_key, identity_key, group_id, group_set_id, poster_id, name, source, posted_at, last_posted_at, file_total, files, segments, bytes, chunks,
first_article, last_article, readable, has_par2, complete, edge, sidecar_only, settled_at)
SELECT decode(v.release_key, 'hex'), decode(v.identity_key, 'hex'), ?, v.group_set_id, v.poster_id, v.name, v.source,
to_timestamp(v.first / 1000.0), to_timestamp(v.last / 1000.0), v.file_total, v.files, v.segments, v.bytes, 1,
v.first_article, v.last_article, v.readable, v.has_par2,
CASE WHEN v.partial THEN false WHEN v.file_total IS NULL THEN NULL ELSE v.files >= v.file_total END,
v.edge, v.sidecar_only, CASE WHEN v.finished THEN now() END
FROM jsonb_to_recordset(?::jsonb) AS v(idx int, release_key text, identity_key text, name text, source int, poster_id int, group_set_id int, first bigint, last bigint,
file_total int, files int, segments bigint, bytes bigint, first_article bigint, last_article bigint, readable boolean, has_par2 boolean,
finished boolean, edge boolean, partial boolean, sidecar_only boolean)
ON CONFLICT (release_key) DO UPDATE SET
files = releases.files + excluded.files, segments = releases.segments + excluded.segments, bytes = releases.bytes + excluded.bytes,
chunks = releases.chunks + CASE WHEN excluded.files > 0 THEN 1 ELSE 0 END,
posted_at = least(releases.posted_at, excluded.posted_at), last_posted_at = greatest(releases.last_posted_at, excluded.last_posted_at),
file_total = coalesce(excluded.file_total, releases.file_total),
first_article = least(releases.first_article, excluded.first_article), last_article = greatest(releases.last_article, excluded.last_article),
readable = releases.readable OR excluded.readable, has_par2 = releases.has_par2 OR excluded.has_par2,
complete = CASE WHEN releases.complete IS FALSE AND releases.file_total IS NULL THEN false
WHEN coalesce(excluded.file_total, releases.file_total) IS NULL THEN releases.complete
ELSE releases.files + excluded.files >= coalesce(excluded.file_total, releases.file_total) END,
edge = excluded.edge, sidecar_only = coalesce(releases.sidecar_only, excluded.sidecar_only), settled_at = excluded.settled_at
WHERE releases.settled_at IS NULL
RETURNING id, encode(release_key, 'hex') AS release_key, (xmax = 0) AS inserted, settled_at`,
[where.groupId, JSON.stringify(rows)]
)
const byKey = new Map<string, { id: number; inserted: boolean; settled: boolean }>(
written.map(
(w: { id: string; release_key: string; inserted: boolean; settled_at: Date | null }) => [
w.release_key,
{ id: Number(w.id), inserted: w.inserted, settled: w.settled_at !== null },
]
)
part.forEach((r, idx) => joined.has(idx) && ids.set(slot(r.key, r.upload), joined.get(idx)!))
}
const fresh = part.filter((_, idx) => !joined.has(idx))
if (fresh.length > 0) {
// The first upload of a key keeps the key; later ones are key:2, key:3, … after the highest number: merges
// delete releases, and a count fell behind it (83 releases of a key up to key:129: 20 numbers tried, all
// taken, the range's batch failed for good; LOG.md 2026-10-20 (89)). Spot releases (spot:key) aren't numbered.
const { rows: counts } = await trx.rawQuery(
`SELECT identity_key, max(CASE WHEN release_key = identity_key THEN 1
WHEN left(release_key, length(identity_key) + 1) = identity_key || ':' AND substr(release_key, length(identity_key) + 2) ~ '^[0-9]{1,9}$'
THEN substr(release_key, length(identity_key) + 2)::int END) AS n
FROM releases WHERE identity_key = ANY(?) GROUP BY 1`,
[[...new Set(fresh.map((r) => r.key))]]
)
const taken = new Map<string, number>(counts.map((c: { identity_key: string; n: string }) => [c.identity_key, Number(c.n)]))
const numbered = (r: ReleaseChunk) => {
const n = (taken.get(r.key) ?? 0) + 1
taken.set(r.key, n)
return { release_key: n === 1 ? r.key : `${r.key}:${n}`, key: r.key, upload: r.upload, ...row(r), sidecar_only: sidecarOnly(r) }
)
const texts: { release_id: number; kind: number; text: string }[] = []
for (const row of rows) {
const w = byKey.get(row.release_key)
if (!w) continue
const r = part[row.idx]
ids.set(slot(r.key, r.upload), w.id)
if (w.inserted) out.newReleases++
else if (w.settled) {
out.finalized++
finalized.add(w.id)
}
// Their posters, for poster search (posters: one row per poster, searched by trigram; migration 4000).
const posters = [...new Set(fresh.map((r) => r.poster).filter((p): p is string => !!p))]
if (posters.length > 0) await trx.rawQuery(`INSERT INTO posters (poster) SELECT DISTINCT left(p, 200) FROM unnest(?::text[]) AS p ON CONFLICT DO NOTHING`, [posters])
let inserts = fresh.map(numbered)
// Writers of other groups number the same key at the same time (a spam post crossposted to 67
// groups; only the group is locked): a number taken meanwhile is skipped, and the next one tried.
for (let attempt = 0; inserts.length > 0; attempt++) {
if (attempt === 20) throw new Error(`No free release key for ${inserts[0].key}`)
const { rows } = await trx.rawQuery(
`INSERT INTO releases (release_key, identity_key, group_id, name, source, poster, groups, posted_at, last_posted_at, file_total, files, segments, bytes, chunks, first_article, last_article, readable, has_par2, complete, sidecar_only)
SELECT v.release_key, v.key, ?, v.name, v.source, v.poster, v.groups, to_timestamp(v.first / 1000.0), to_timestamp(v.last / 1000.0), v.file_total, v.files, v.segments, v.bytes, 1,
v.first_article, v.last_article, v.readable, v.has_par2, CASE WHEN v.file_total IS NULL THEN NULL ELSE v.files >= v.file_total END, v.sidecar_only
FROM jsonb_to_recordset(?::jsonb) AS v(release_key text, key text, name text, source text, poster text, groups jsonb, first bigint, last bigint, file_total int, files int, segments bigint, bytes bigint, first_article bigint, last_article bigint, readable boolean, has_par2 boolean, sidecar_only boolean)
ON CONFLICT (release_key) DO NOTHING
RETURNING id, release_key`,
[groupId, JSON.stringify(inserts)]
)
const byReleaseKey = new Map<string, number>(rows.map((x: { id: string; release_key: string }) => [x.release_key, Number(x.id)]))
const missed: typeof inserts = []
for (const r of inserts) {
const id = byReleaseKey.get(r.release_key)
if (id === undefined) missed.push(r)
else ids.set(slot(r.key, r.upload), id)
texts.push({ release_id: w.id, kind: TEXT_KINDS.name, text: row.name.slice(0, TEXT_MAX) })
// An album's members stay findable by their own names (the first TEXTS_PER_KIND of them).
if (r.source === 'album' && r.files > 0) {
for (const text of textsToStore(decodeChunk(r.payload).map((f) => f.name))) {
texts.push({ release_id: w.id, kind: TEXT_KINDS.member, text })
}
out.newReleases += rows.length
const chunks = new Map(fresh.map((r) => [slot(r.key, r.upload), r]))
inserts = missed.map((r) => numbered(chunks.get(slot(r.key, r.upload))!))
}
}
// The name (and an album's members), searchable (release_texts; a text sent again is a no-op).
if (texts.length > 0) {
await trx.rawQuery(
`INSERT INTO release_texts (release_id, kind, text)
SELECT v.release_id, v.kind, v.text FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, kind int, text text) WHERE v.text <> ''
ON CONFLICT DO NOTHING`,
[JSON.stringify(texts)]
)
}
}
// One write-once chunk per release.
// One write-once chunk per release (a finish adds the files not sent before).
for (let i = 0; i < sorted.length; i += 500) {
const rows = sorted.slice(i, i + 500).map((r) => ({
release_id: ids.get(slot(r.key, r.upload))!,
range_id: rangeId,
batch_from: batchFrom,
files: r.files,
segments: r.segments,
payload: r.payload,
}))
const rows = sorted
.slice(i, i + 500)
.filter((r) => ids.has(slot(r.key, r.upload)) && r.files > 0)
.map((r) => ({
release_id: ids.get(slot(r.key, r.upload))!,
block_id: where.blockId,
batch_from: batchFrom,
files: r.files,
segments: r.segments,
payload: r.payload,
}))
if (rows.length > 0) await trx.insertQuery().table('release_chunks').multiInsert(rows)
}
// A finished release's candidates are claimable at once; a provisional one's wait for its finish.
const settled = new Set<number>()
for (const r of sorted) {
const id = ids.get(slot(r.key, r.upload))
if (id !== undefined && r.finished !== false) settled.add(id)
}
const chunks = new Map(sorted.map((r) => [slot(r.key, r.upload), r]))
const kept = candidates.filter((c) => ids.has(slot(c.releaseKey, c.upload ?? 0)))
for (let i = 0; i < kept.length; i += 2000) {
const rows = kept
.slice(i, i + 2000)
.map((c) => ({
release_id: ids.get(slot(c.releaseKey, c.upload ?? 0))!,
const rows = kept.slice(i, i + 2000).map((c) => {
const releaseId = ids.get(slot(c.releaseKey, c.upload ?? 0))!
const chunk = chunks.get(slot(c.releaseKey, c.upload ?? 0))!
return {
release_id: releaseId,
kind: c.kind,
message_id: c.messageId,
file: c.file,
}))
ready: settled.has(releaseId),
unreadable: !chunk.readable,
}
})
const added = await trx
.knexQuery()
.table('probe_candidates')
@@ -329,15 +426,14 @@ class BatchStore {
// A later piece of a split 7z: the tails of lower volumes go (app/services/seven_zip_tails.ts).
const tails = new Set(rows.filter((r) => r.kind === '7z-tail').map((r) => r.release_id))
if (added.length > 0 && tails.size > 0) await pruneSevenZipTails(trx, [...tails])
// Files added to a settled release (a later range of its upload): the settler made its candidates claimable
// already and won't again (93,642 waited since 2026-10-13; LOG.md 2026-10-20 (87)).
if (added.length > 0) {
await trx.rawQuery(
`UPDATE probe_candidates pc SET ready = true, unreadable = NOT r.readable FROM releases r
WHERE pc.id = ANY(?) AND r.id = pc.release_id AND r.settled_at IS NOT NULL`,
[added.map((a: { id: string | number }) => String(a.id))]
)
}
}
// The finish of a provisional release: its waiting candidates become claimable.
if (finalized.size > 0) {
await trx.rawQuery(
`UPDATE probe_candidates pc SET ready = true, unreadable = NOT r.readable FROM releases r
WHERE pc.release_id = ANY(?) AND r.id = pc.release_id AND pc.status = 'waiting' AND NOT pc.ready`,
[[...finalized]]
)
}
return out
+93
View File
@@ -0,0 +1,93 @@
import type db from '@adonisjs/lucid/services/db'
type Client = Pick<typeof db, 'rawQuery'>
/** Posters are kept to this length (as the trigram index and poster search always did; a btree entry holds 2,704 bytes). */
export const POSTER_MAX = 200
/** Cached ids per process: group sets are ~2,600 in all; posters many more, so the cache is emptied when it grows past this. */
const CACHE_MAX = 200_000
const posterCache = new Map<string, number>()
const groupSetCache = new Map<string, number>()
/** A group list as group_sets stores it: sorted, without repeats, as JSON. */
export function groupSetKey(groups: string[]): string {
return JSON.stringify([...new Set(groups)].sort())
}
/** A poster as posters stores it. */
export function posterText(poster: string): string {
return poster.slice(0, POSTER_MAX)
}
async function ids(
client: Client,
cache: Map<string, number>,
table: 'posters' | 'group_sets',
column: 'poster' | 'groups',
cast: 'text' | 'jsonb',
values: string[]
): Promise<Map<string, number>> {
const out = new Map<string, number>()
const missing: string[] = []
for (const v of values) {
const id = cache.get(v)
if (id === undefined) missing.push(v)
else out.set(v, id)
}
if (missing.length === 0) return out
// Insert what's new, then read every id (ON CONFLICT DO NOTHING returns nothing for the rows that existed).
await client.rawQuery(
`INSERT INTO ${table} (${column}) SELECT DISTINCT v::${cast} FROM unnest(?::text[]) AS v ON CONFLICT DO NOTHING`,
[missing]
)
const { rows } = await client.rawQuery(
`SELECT id, ${column}::text AS value FROM ${table} WHERE ${column} = ANY(?::${cast}[])`,
[missing]
)
if (cache.size + rows.length > CACHE_MAX) cache.clear()
for (const r of rows) {
// jsonb comes back in Postgres' spelling ("["a", "b"]"); the caller's key is the JSON we sent.
const key = cast === 'jsonb' ? JSON.stringify(JSON.parse(r.value)) : r.value
cache.set(key, Number(r.id))
out.set(key, Number(r.id))
}
return out
}
/**
* Ids in the catalog's lookup tables (voyager 3, migration 6000): a release's poster_id and group_set_id. New
* values are inserted; known ones come from a cache per process (the batch store sees the same posters and
* group sets batch after batch).
*/
export default {
/** Ids by poster (posterText applied), for a batch of posters. */
async posterIds(client: Client, posters: string[]): Promise<Map<string, number>> {
return ids(client, posterCache, 'posters', 'poster', 'text', [
...new Set(posters.map(posterText)),
])
},
async posterId(client: Client, poster: string): Promise<number> {
const found = await this.posterIds(client, [poster])
return found.get(posterText(poster))!
},
/** Ids by groupSetKey, for a batch of group lists. */
async groupSetIds(client: Client, sets: string[][]): Promise<Map<string, number>> {
return ids(client, groupSetCache, 'group_sets', 'groups', 'jsonb', [
...new Set(sets.map(groupSetKey)),
])
},
async groupSetId(client: Client, groups: string[]): Promise<number> {
const found = await this.groupSetIds(client, [groups])
return found.get(groupSetKey(groups))!
},
/** Forgets the cached ids (tests roll their transactions back, so the ids they saw are gone). */
forget() {
posterCache.clear()
groupSetCache.clear()
},
}
+8 -8
View File
@@ -42,13 +42,13 @@ class Corpus {
return coordinatorConfig.corpus.dir
}
private rangeDir(range: CorpusRange) {
return join(this.dir!, range.group, `${range.start}-${range.end}`)
private rangeDir(range: CorpusRange, dir = this.dir!) {
return join(dir, range.group, `${range.start}-${range.end}`)
}
private path(range: CorpusRange, span: ChunkSpan) {
private path(range: CorpusRange, span: ChunkSpan, dir = this.dir!) {
const index = Math.floor((span.from - range.start) / coordinatorConfig.chunkArticles)
return join(this.rangeDir(range), `${String(index).padStart(4, '0')}-${span.from}-${span.to}.gz`)
return join(this.rangeDir(range, dir), `${String(index).padStart(4, '0')}-${span.from}-${span.to}.gz`)
}
/** Whether every chunk of the range is stored (its range.json). */
@@ -69,10 +69,10 @@ class Corpus {
)
}
/** A stored chunk, or null. */
async read(range: CorpusRange, span: ChunkSpan): Promise<Buffer | null> {
if (!this.dir) return null
return readFile(this.path(range, span)).catch(() => null)
/** A stored chunk, or null (`dir`: another corpus than CORPUS_DIR's, the namer's tests). */
async read(range: CorpusRange, span: ChunkSpan, dir = this.dir): Promise<Buffer | null> {
if (!dir) return null
return readFile(this.path(range, span, dir)).catch(() => null)
}
/**
-153
View File
@@ -1,153 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import BatchStore from '#services/batch_store'
import ChunkPartitions from '#services/chunk_partitions'
import RuleSets from '#services/rule_sets'
import { RuleSet } from '#lib/rules/index'
import { candidatesOf, chunkOf, mergeFragments, releaseKeyOf, uploadsOf, type Fragment, type ProbeCandidate, type ReleaseChunk } from '#lib/edge/index'
import type { AggregatedFile } from '#lib/files/index'
import type { EdgeSegment } from '#lib/edge/index'
export interface MergeResult {
files: number
completed: number
incomplete: number
waiting: number
/** This round reached the end of the file keys (the next one starts over). */
wrapped: boolean
}
/** File keys per round. */
const BATCH = 20_000
/**
* Files cut by a range's edge (REBUILD.md, fragments), put together once
* the ranges around them are done: a complete file is judged by the rules
* like any other and written as a release chunk (with probe candidates); a
* file still missing parts while every neighbouring range is done is
* incomplete, and dropped (counted on its ranges). Others wait.
*/
class FragmentMerger {
/** Where the next round goes on: waiting files must not take every round's places. */
private cursor = ''
async run(): Promise<MergeResult> {
const result: MergeResult = { files: 0, completed: 0, incomplete: 0, waiting: 0, wrapped: false }
const { rows: keys } = await db.rawQuery(
`SELECT DISTINCT f.file_key FROM fragments f JOIN ranges r ON r.id = f.range_id
WHERE r.status = 'done' AND f.file_key > ? ORDER BY f.file_key LIMIT ?`,
[this.cursor, BATCH]
)
// At the end of the keys: the next round starts over.
result.wrapped = keys.length < BATCH
this.cursor = result.wrapped ? '' : keys.at(-1).file_key
if (keys.length === 0) return result
const rules = new RuleSet(await RuleSets.current())
const rows = await db
.from('fragments as f')
.join('ranges as r', 'r.id', 'f.range_id')
.whereIn('f.file_key', keys.map((k: { file_key: string }) => k.file_key))
.select('f.id', 'f.file_key', 'f.range_id', 'f.fragment', 'r.group_id', 'r.start_id', 'r.end_id', 'r.status')
const byKey = new Map<string, typeof rows>()
for (const row of rows) {
const list = byKey.get(row.file_key)
if (list) list.push(row)
else byKey.set(row.file_key, [row])
}
const completed: { file: AggregatedFile<EdgeSegment>; rangeId: number; ids: number[] }[] = []
const incomplete: { rangeId: number; ids: number[]; segments: number }[] = []
const neighbours = await this.neighbourStatus([...byKey.values()])
for (const list of byKey.values()) {
result.files++
const file = mergeFragments(list.map((r) => r.fragment as Fragment))
const ids = list.map((r) => Number(r.id))
const rangeId = Math.max(...list.map((r) => Number(r.range_id)))
if (file) {
completed.push({ file, rangeId, ids })
continue
}
if (list.every((r) => r.status === 'done') && neighbours(list)) {
incomplete.push({ rangeId, ids, segments: list.reduce((n, r) => n + (r.fragment as Fragment).segments.length, 0) })
} else {
result.waiting++
}
}
// Completed files, grouped into their releases, judged and written like a batch's.
const byRelease = new Map<string, { files: AggregatedFile<EdgeSegment>[]; rangeId: number }>()
for (const { file, rangeId } of completed) {
const key = releaseKeyOf(file.segments[0])
const entry = byRelease.get(key)
if (entry) entry.files.push(file)
else byRelease.set(key, { files: [file], rangeId })
}
const releases: { rangeId: number; chunk: ReleaseChunk; candidates: ProbeCandidate[] }[] = []
for (const [key, { files, rangeId }] of byRelease) {
const verdict = rules.judge(key, files.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId, poster: f.segments[0].file.poster })))
if (verdict.rule !== null) continue
uploadsOf(files).forEach((list, upload) => releases.push({ rangeId, chunk: chunkOf(key, list, upload), candidates: candidatesOf(key, list, upload) }))
}
await ChunkPartitions.ensure()
await db.transaction(async (trx) => {
// In key order (batches lock their group; the merger only ever waits on one batch at a time).
releases.sort((a, b) => a.chunk.key.localeCompare(b.chunk.key) || a.chunk.upload - b.chunk.upload)
for (const { rangeId, chunk, candidates } of releases) await BatchStore.writeReleases(trx, rangeId, 0, [chunk], candidates)
const done = [...completed.flatMap((c) => c.ids), ...incomplete.flatMap((c) => c.ids)]
for (let i = 0; i < done.length; i += 5000) await trx.from('fragments').whereIn('id', done.slice(i, i + 5000)).delete()
for (const { rangeId, segments } of incomplete) {
await trx.rawQuery(
`INSERT INTO rule_counts (range_id, rule, key, articles, files) VALUES (?, 'incomplete', 'edge', ?, 1)
ON CONFLICT (range_id, rule, key) DO UPDATE SET articles = rule_counts.articles + excluded.articles, files = rule_counts.files + 1`,
[rangeId, segments]
)
}
})
result.completed = completed.length
result.incomplete = incomplete.length
return result
}
/**
* For each file's fragments: whether the ranges right before and after
* their ranges (where a missing part could be) are done, read in one
* query for the whole round (per file it was three, ~2,000 files a
* minute). A neighbour not planned yet counts as not done (newer articles
* come later), except before the group's oldest article.
*/
private async neighbourStatus(lists: { group_id: number; start_id: number | string; end_id: number | string }[][]) {
const span = (list: (typeof lists)[number]) => ({
group: Number(list[0].group_id),
first: Math.min(...list.map((r) => Number(r.start_id))),
last: Math.max(...list.map((r) => Number(r.end_id))),
})
const spans = lists.map(span)
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(
`SELECT s.group_id, s.before_end, s.after_start, b.status AS before, a.status AS after
FROM unnest(?::int[], ?::bigint[], ?::bigint[]) AS s(group_id, before_end, 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)]
)
const byEnd = new Map<string, string>()
const byStart = new Map<string, string>()
for (const r of rows) {
if (r.before !== null) byEnd.set(`${r.group_id}:${r.before_end}`, r.before)
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)]))
return (list: (typeof lists)[number]) => {
const { group, first, last } = span(list)
const before = byEnd.get(`${group}:${first - 1}`)
const low = lows.get(group)
const beforeDone = before !== undefined ? before === 'done' : low === null || low === undefined || first <= low
return beforeDone && byStart.get(`${group}:${last + 1}`) === 'done'
}
}
}
export default new FragmentMerger()
+280
View File
@@ -0,0 +1,280 @@
import db from '@adonisjs/lucid/services/db'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdir, rename, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import coordinatorConfig from '#config/coordinator'
import RuleSets from '#services/rule_sets'
import CatalogSpace from '#services/catalog_space'
import { LeaseLost } from '#services/leases'
import type { GroupLease, Lane, NextRange } from '#lib/edge/index'
export const LANES: Lane[] = ['history', 'tail']
/**
* Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): a worker thread holds a group and asks for its
* next range each time it finished one, so a group's ranges are indexed in article order through one carried
* window. The coordinator keeps one row a group and lane (group_leases): the holder, its heartbeat, the range it
* works and the snapshot its window stands at.
*
* - Two lanes (2026-10-27): the `history` lane works the ranges the bounds hand out (LEASE_MIN/MAX_PRIORITY,
* LEASE_CAPTURE_ONLY: the windows, the history), the `tail` lane the tail planner's ranges (priority at or below
* the tail's). Each lane has its own holder, window and snapshot, so a thread on the tail never wades through a
* group's history first (a group's ranges come in article order, and a held group's history is days of ranges), and
* the tail of a group whose history is held isn't stuck behind it. The one seam a group is where the history lane
* reaches the tail lane's first range (its ranges are done: the history lane is caught up).
* - lease: the free group with the most urgent planned range of the lane (the ranges' priority), and among those a
* mix: the group with the most planned articles and the longest unheld one (never held first) take turns, so a
* big group snapshotted at a stop doesn't wait behind hundreds of small never-held ones, and a quiet group's tail
* is visited all the same. A holder quiet for LEASE_TTL_SECONDS is taken over. Taking a group resets what the
* lane's snapshot doesn't cover: the lane's ranges from the snapshot's article on that the window did after it
* (leased, done since the snapshot, or read past it) are planned again (the batch store's keys make the redo
* idempotent); without a snapshot the window is lost and the group continues at its resume point (one seam).
* - next: the group's lowest planned range of the lane (or the one this node already has), leased to the node; none
* when the lane is caught up: the worker snapshots and gives the group back, and gets it again when the tail
* planned more.
* - snapshot: the serialized window, kept as a file on bulk (SNAPSHOT_DIR; `<group>.gz`, the tail's
* `<group>-tail.gz`), given to the next holder of the lane.
*/
class GroupLeases {
get dir(): string {
return coordinatorConfig.snapshotDir
}
/**
* The lane's ranges, as a condition on `r`: the tail lane's are at or below the tail's priority, the history lane's
* above it — within the lease bounds when `bounds` (what is handed out), or all of them (what a lane's window did).
*/
private laneFilter(lane: Lane, bounds: boolean): { sql: string; bindings: number[] } {
const tail = coordinatorConfig.tail.priority
if (lane === 'tail') return { sql: 'r.priority <= ?', bindings: [tail] }
if (!bounds) return { sql: 'r.priority > ?', bindings: [tail] }
const { leasePriorityMin: min, leasePriorityMax: max, leaseCaptureOnly } = coordinatorConfig
return {
sql: `r.priority > ? AND r.priority BETWEEN ? AND ?${leaseCaptureOnly ? ' AND r.capture' : ''}`,
bindings: [tail, min, max],
}
}
async lease(nodeId: number, lane: Lane = 'history'): Promise<GroupLease | null> {
// The catalog at its cap on the NVMe: the tail only (CatalogSpace), as the range leases did.
if (lane === 'history' && (await CatalogSpace.full())) return null
const rules = await RuleSets.current()
const ttl = coordinatorConfig.leaseTtlSeconds
const todo = this.laneFilter(lane, true)
const all = this.laneFilter(lane, false)
return db.transaction(async (trx) => {
// One lease at a time: the choice reads several tables.
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('group-lease', 0))`)
// The free groups with planned work in the lane (a range leased by a holder gone quiet counts: a group whose
// last range that holder took would never be taken over), by the most urgent range's priority; within a priority the
// most planned articles and the longest unheld alternate: one lease starts from the biggest, the next from
// the longest unheld (the sequence counts the leases; ranked afresh each time, "biggest first" would always
// win).
const { rows: turns } = await trx.rawQuery(`SELECT nextval('group_lease_turns') % 2 AS odd`)
const [first, second] = Number(turns[0].odd) === 1 ? [1, 0] : [0, 1]
const { rows } = await trx.rawQuery(
`WITH todo AS (
SELECT r.group_id, min(r.priority) AS priority, sum(r.end_id - r.next_id + 1) AS articles
FROM ranges r
WHERE (r.status = 'planned' OR (r.status = 'leased' AND r.heartbeat_at < now() - make_interval(secs => ?)))
AND r.next_id <= r.end_id AND ${todo.sql}
GROUP BY r.group_id
), free AS (
SELECT g.id, g.name, g.priority AS group_priority, l.snapshot_next, l.snapshot_at, l.heartbeat_at,
todo.priority, todo.articles
FROM todo JOIN groups g ON g.id = todo.group_id AND g.active
LEFT JOIN group_leases l ON l.group_id = g.id AND l.lane = ?
WHERE l.node_id IS NULL OR l.heartbeat_at < now() - make_interval(secs => ?)
), turns AS (
SELECT free.*,
least(2 * row_number() OVER (PARTITION BY priority ORDER BY articles DESC, group_priority, id) - ?,
2 * row_number() OVER (PARTITION BY priority ORDER BY heartbeat_at NULLS FIRST, group_priority, id) - ?) AS turn
FROM free
)
SELECT * FROM turns ORDER BY priority, turn, group_priority, id LIMIT 1`,
[ttl, ...todo.bindings, lane, ttl, first, second]
)
const row = rows[0]
if (!row) return null
const groupId = Number(row.id)
// What the snapshot doesn't cover is read again: the lane's ranges from its article on that the window did
// after it (a range done before the snapshot was taken — by the old range leases, say — stays done).
if (row.snapshot_next !== null) {
await trx.rawQuery(
`UPDATE ranges r SET status = 'planned', node_id = NULL, next_id = greatest(r.start_id, ?::bigint)
WHERE r.group_id = ? AND r.end_id >= ?::bigint AND ${all.sql}
AND (r.status = 'leased' OR (r.status = 'done' AND (r.done_at IS NULL OR r.done_at >= ?)) OR (r.status = 'planned' AND r.next_id > ?::bigint))`,
[
row.snapshot_next,
groupId,
row.snapshot_next,
...all.bindings,
row.snapshot_at ?? new Date(0),
row.snapshot_next,
]
)
} else {
await trx.rawQuery(
`UPDATE ranges r SET status = 'planned', node_id = NULL WHERE r.group_id = ? AND r.status = 'leased' AND ${all.sql}`,
[groupId, ...all.bindings]
)
}
await trx.rawQuery(
`INSERT INTO group_leases (group_id, lane, node_id, leased_at, heartbeat_at, range_id)
VALUES (?, ?, ?, now(), now(), NULL)
ON CONFLICT (group_id, lane) DO UPDATE SET node_id = excluded.node_id, leased_at = now(), heartbeat_at = now(), range_id = NULL`,
[groupId, lane, nodeId]
)
await trx.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
return {
group: { id: groupId, name: row.name, priority: Number(row.group_priority) },
lane,
rules,
snapshot: row.snapshot_next !== null ? { next: Number(row.snapshot_next) } : null,
}
})
}
/** The group's lease row of the lane, when this node holds it. */
async held(groupId: number, nodeId: number, lane: Lane = 'history') {
const lease = await db
.from('group_leases')
.where('group_id', groupId)
.where('lane', lane)
.first()
if (!lease || Number(lease.node_id) !== nodeId)
throw new LeaseLost(`Group ${groupId} (${lane}) is not held by this node`)
return lease
}
/**
* The group's next range of the lane in article order (or the one this node has), leased to the node; null when
* the lane is caught up.
*/
async next(groupId: number, nodeId: number, lane: Lane = 'history'): Promise<NextRange | null> {
await this.held(groupId, nodeId, lane)
const rules = await RuleSets.current()
const todo = this.laneFilter(lane, true)
const all = this.laneFilter(lane, false)
const mine = (
await db.rawQuery(
`SELECT r.id, r.start_id, r.end_id, r.next_id FROM ranges r
WHERE r.group_id = ? AND r.status = 'leased' AND r.node_id = ? AND ${all.sql} ORDER BY r.start_id LIMIT 1`,
[groupId, nodeId, ...all.bindings]
)
).rows[0]
const row =
mine ??
(
await db.rawQuery(
`UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ?
WHERE r.id = (
SELECT r.id FROM ranges r WHERE r.group_id = ? AND r.status = 'planned' AND r.next_id <= r.end_id AND ${todo.sql}
ORDER BY r.start_id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING r.id, r.start_id, r.end_id, r.next_id`,
[nodeId, rules.version, groupId, ...todo.bindings]
)
).rows[0]
await db
.from('group_leases')
.where('group_id', groupId)
.where('lane', lane)
.update({ range_id: row ? Number(row.id) : null, heartbeat_at: new Date() })
if (!row) return null
return {
range: {
id: Number(row.id),
start: Number(row.start_id),
end: Number(row.end_id),
next: Number(row.next_id),
},
}
}
async heartbeat(groupId: number, nodeId: number, lane: Lane = 'history') {
await this.held(groupId, nodeId, lane)
await db
.from('group_leases')
.where('group_id', groupId)
.where('lane', lane)
.update({ heartbeat_at: new Date() })
await db.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
}
/** The lane a range belongs to, by its priority. */
laneOf(priority: number): Lane {
return priority <= coordinatorConfig.tail.priority ? 'tail' : 'history'
}
/** A range of the group finished by its holder: counted on the lane's lease (Leases.finish does the range). */
async rangeDone(groupId: number, headers: number, lane: Lane = 'history') {
await db.rawQuery(
`UPDATE group_leases SET ranges_done = ranges_done + 1, headers = headers + ?, range_id = NULL WHERE group_id = ? AND lane = ?`,
[headers, groupId, lane]
)
}
/** The worker gives the group back (after its snapshot): its range stays at its resume point for the next holder. */
async release(groupId: number, nodeId: number, lane: Lane = 'history') {
await this.held(groupId, nodeId, lane)
const all = this.laneFilter(lane, false)
await db.rawQuery(
`UPDATE ranges r SET status = 'planned', node_id = NULL
WHERE r.group_id = ? AND r.status = 'leased' AND r.node_id = ? AND ${all.sql}`,
[groupId, nodeId, ...all.bindings]
)
await db
.from('group_leases')
.where('group_id', groupId)
.where('lane', lane)
.update({ node_id: null, range_id: null })
}
snapshotPath(groupId: number, lane: Lane = 'history') {
return join(this.dir, lane === 'tail' ? `${groupId}-tail.gz` : `${groupId}.gz`)
}
/**
* The window's snapshot standing at `next` (the next article to index), streamed to disk (a busy group's is
* gigabytes) and written whole before it replaces the old one.
*/
async putSnapshot(
groupId: number,
nodeId: number,
next: number,
body: Readable,
lane: Lane = 'history'
) {
await this.held(groupId, nodeId, lane)
await mkdir(this.dir, { recursive: true })
const path = this.snapshotPath(groupId, lane)
await pipeline(body, createWriteStream(`${path}.part`))
await rename(`${path}.part`, path)
await db
.from('group_leases')
.where('group_id', groupId)
.where('lane', lane)
.update({ snapshot_next: next, snapshot_at: new Date(), heartbeat_at: new Date() })
}
/** The snapshot's stream and size, or null when there is none. */
async getSnapshot(
groupId: number,
nodeId: number,
lane: Lane = 'history'
): Promise<{ stream: Readable; size: number; next: number } | null> {
const lease = await this.held(groupId, nodeId, lane)
if (lease.snapshot_next === null) return null
const path = this.snapshotPath(groupId, lane)
try {
const { size } = await stat(path)
return { stream: createReadStream(path), size, next: Number(lease.snapshot_next) }
} catch {
return null
}
}
}
export default new GroupLeases()
+36
View File
@@ -0,0 +1,36 @@
import db from '@adonisjs/lucid/services/db'
/**
* Twins by content (LOG.md (130)): a probed file's first 16 KiB and size (file_hashes, what the par2 merge and the
* repeated verdict match) in a catalog release that has a name, readable or found inside. The same film posted
* under scrambles by other tools gets that name without another fetch. Releases with a junk verdict don't count.
*/
/** Twins read per pair: the hottest pairs have 131k. */
const TWIN_SCAN = 500
export async function twinNames(
pairs: { hash16k: string; size: number }[],
trx: { rawQuery: typeof db.rawQuery } = db
): Promise<Map<string, string>> {
const out = new Map<string, string>()
if (pairs.length === 0) return out
// Per pair the first named twin among its lowest TWIN_SCAN release ids. hash16k is typed as the column
// (char(32)): as text the join cast the column and scanned all of file_hashes, 4 s a call; and with the index
// the hottest pairs (55 with over 500 twins, up to 131k, all readable) made a call walk every twin, 37 s inside
// the flush's transaction (LOG.md (138)). One name is all a pair needs.
const { rows } = await trx.rawQuery(
`SELECT v.hash16k, v.size, t.name
FROM jsonb_to_recordset(?::jsonb) AS v(hash16k char(32), size bigint)
CROSS JOIN LATERAL (
SELECT coalesce(r.inner_name, r.name) AS name
FROM (SELECT release_id FROM file_hashes h WHERE h.hash16k = v.hash16k AND h.size = v.size ORDER BY release_id LIMIT ?) h
JOIN releases r ON r.id = h.release_id AND (r.readable OR r.inner_name IS NOT NULL)
WHERE NOT EXISTS (SELECT 1 FROM release_verdicts x WHERE x.release_id = r.id)
ORDER BY r.readable DESC, r.id LIMIT 1) t`,
[JSON.stringify(pairs), TWIN_SCAN]
)
for (const r of rows) out.set(`${r.hash16k}\t${r.size}`, r.name)
return out
}
export const twinKey = (hash16k: string, size: number) => `${hash16k}\t${size}`
+2 -1
View File
@@ -2,6 +2,7 @@ import { gunzipSync } from 'node:zlib'
import db from '@adonisjs/lucid/services/db'
import { decodeChunk, type ChunkFile } from '#lib/chunks/index'
import { buildNzb } from '#lib/nzb/index'
import { SOURCES } from '#lib/release/source'
/** Where a known password came from, the most trusted first: the spot's NZB meta, its title, its text. */
const PASSWORD_SOURCES = ['nzb', 'spot-title', 'spot-text']
@@ -35,7 +36,7 @@ class NzbService {
if (!release) return null
const password = (await this.passwords(releaseId))[0]?.password
// A release a spot made of its own (no files matched the header pass's): the spot's NZB as read.
if (release.source === 'spot') {
if (Number(release.source) === SOURCES.spot) {
const spot = await db.from('spots').where('release_id', releaseId).whereNotNull('nzb').select('nzb').first()
if (spot) return { name: release.name, xml: withPassword(gunzipSync(spot.nzb).toString('utf8'), password) }
}
+15 -13
View File
@@ -93,11 +93,12 @@ const HOLDERS = `SELECT c.release_id FROM (
interface Member {
id: number
release_key: string
group_id: number
files: number
posted_at: Date | null
readable: boolean
poster_id?: number | null
/** The poster's text (joined from posters) where a query needs it. */
poster?: string | null
file_total?: number | null
}
@@ -295,7 +296,8 @@ export class Par2Merger {
*/
async nameMatches(trx: any, setId: string): Promise<Map<string, Member> | null> {
const { rows: all } = await trx.rawQuery(
`SELECT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable, r.poster FROM par2_sets s JOIN releases r ON r.id = s.release_id
`SELECT r.id, r.group_id, r.files, r.posted_at, r.readable, r.poster_id, p.poster
FROM par2_sets s JOIN releases r ON r.id = s.release_id LEFT JOIN posters p ON p.id = r.poster_id
WHERE s.set_id = ? ORDER BY r.posted_at DESC NULLS LAST, r.id`,
[setId]
)
@@ -310,12 +312,12 @@ export class Par2Merger {
const names = new Set(described.map((d: { name: string }) => fileKey(d.name)))
const at = new Date(anchor.posted_at).getTime()
const { rows: candidates } = await trx.rawQuery(
`SELECT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable, r.poster FROM releases r
WHERE left(r.poster, 200) = left(?, 200) AND r.poster = ? AND r.group_id = ? AND r.posted_at BETWEEN ? AND ? AND r.id <> ALL(?)
`SELECT r.id, r.group_id, r.files, r.posted_at, r.readable, r.poster_id FROM releases r
WHERE r.poster_id = ? AND r.group_id = ? AND r.posted_at BETWEEN ? AND ? AND r.id <> ALL(?)
AND r.files <= ?
ORDER BY left(r.poster, 200), r.posted_at DESC NULLS LAST, r.id DESC LIMIT ?`,
ORDER BY r.posted_at DESC NULLS LAST, r.id DESC LIMIT ?`,
// More files than the set describes, with its sidecars, can't all be named in it.
[anchor.poster, anchor.poster, anchor.group_id, new Date(at - NAME_SPREAD_MS), new Date(at + NAME_SPREAD_MS), holders.map((h: Member) => h.id), described.length + SLACK_FILES, NAME_CANDIDATES]
[anchor.poster_id, anchor.group_id, new Date(at - NAME_SPREAD_MS), new Date(at + NAME_SPREAD_MS), holders.map((h: Member) => h.id), described.length + SLACK_FILES, NAME_CANDIDATES]
)
const found = new Map<string, Member>(holders.map((h: Member) => [String(h.id), h]))
let files = holders.reduce((n: number, h: Member) => n + Number(h.files), 0)
@@ -344,7 +346,7 @@ export class Par2Merger {
async mergeStem(stem: string): Promise<number> {
return db.transaction(async (trx) => {
const { rows } = await trx.rawQuery(
`SELECT DISTINCT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable, r.poster, r.file_total
`SELECT DISTINCT r.id, r.group_id, r.files, r.posted_at, r.readable, r.poster_id, r.file_total
FROM release_inspections i JOIN releases r ON r.id = i.release_id
WHERE i.kind = 'lone' AND ${INNER_STEM} = ?`,
[stem]
@@ -356,7 +358,7 @@ export class Par2Merger {
const total = (r: Member) => (r.file_total ? Number(r.file_total) : null)
const near = (r: Member) =>
r.group_id === anchor.group_id &&
(r.poster ?? '') === (anchor.poster ?? '') &&
(r.poster_id ?? null) === (anchor.poster_id ?? null) &&
(total(r) === null || total(anchor) === null || total(r) === total(anchor)) &&
(at(r) === null || at(anchor) === null || Math.abs(at(r)! - at(anchor)!) <= MAX_SPREAD_MS)
const found = new Map<string, Member>()
@@ -374,7 +376,7 @@ export class Par2Merger {
async mergeSet(setId: string): Promise<number> {
return db.transaction(async (trx) => {
const { rows: holders } = await trx.rawQuery(
`SELECT r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable FROM par2_sets s JOIN releases r ON r.id = s.release_id
`SELECT r.id, r.group_id, r.files, r.posted_at, r.readable FROM par2_sets s JOIN releases r ON r.id = s.release_id
WHERE s.set_id = ? ORDER BY r.id`,
[setId]
)
@@ -383,7 +385,7 @@ export class Par2Merger {
const at = (r: Member) => (r.posted_at ? new Date(r.posted_at).getTime() : null)
const near = (r: Member) => r.group_id === anchor.group_id && (at(r) === null || at(anchor) === null || Math.abs(at(r)! - at(anchor)!) <= MAX_SPREAD_MS)
const { rows: matches } = await trx.rawQuery(
`SELECT f.file_id, r.id, r.release_key, r.group_id, r.files, r.posted_at, r.readable
`SELECT f.file_id, r.id, r.group_id, r.files, r.posted_at, r.readable
FROM par2_files f CROSS JOIN LATERAL (${HOLDERS}) h JOIN releases r ON r.id = h.release_id
WHERE f.set_id = ?`,
[setId]
@@ -426,7 +428,7 @@ export class Par2Merger {
await trx.rawQuery(
`INSERT INTO release_merges (from_id, from_key, into_id, set_id, files, via)
SELECT id, release_key, ?, ?, files, ? FROM releases WHERE id = ANY(?)`,
SELECT id, encode(release_key, 'hex'), ?, ?, files, ? FROM releases WHERE id = ANY(?)`,
[target.id, key, via, others]
)
// A candidate of an article the target, or another member of lower id, has already (the same file in two releases)
@@ -462,13 +464,13 @@ export class Par2Merger {
files = t.files + s.files, segments = t.segments + s.segments, bytes = t.bytes + s.bytes, chunks = t.chunks + s.chunks,
first_article = least(t.first_article, s.first_article), last_article = greatest(t.last_article, s.last_article),
posted_at = least(t.posted_at, s.posted_at), last_posted_at = greatest(t.last_posted_at, s.last_posted_at),
first_seen_at = least(t.first_seen_at, s.first_seen_at), last_seen_at = greatest(t.last_seen_at, s.last_seen_at),
first_seen_at = least(t.first_seen_at, s.first_seen_at),
readable = t.readable OR s.readable, has_par2 = t.has_par2 OR s.has_par2, passworded = t.passworded OR s.passworded,
file_total = greatest(t.file_total, s.file_total), inner_name = coalesce(t.inner_name, s.inner_name),
name = coalesce(nullif(?, ''), t.name)
FROM (SELECT sum(files) AS files, sum(segments) AS segments, sum(bytes) AS bytes, sum(chunks) AS chunks,
min(first_article) AS first_article, max(last_article) AS last_article, min(posted_at) AS posted_at,
max(last_posted_at) AS last_posted_at, min(first_seen_at) AS first_seen_at, max(last_seen_at) AS last_seen_at,
max(last_posted_at) AS last_posted_at, min(first_seen_at) AS first_seen_at,
bool_or(readable) AS readable, bool_or(has_par2) AS has_par2, bool_or(passworded) AS passworded,
max(file_total) AS file_total, max(inner_name) AS inner_name
FROM releases WHERE id = ANY(?)) s
+1 -1
View File
@@ -75,7 +75,7 @@ export class Par2Pruner {
WHERE o.set_id = b.set_id AND v.release_id IS NULL) THEN 'judged'
WHEN NOT EXISTS (SELECT 1 FROM par2_sets s WHERE s.set_id = b.set_id AND s.created_at > ?)
AND NOT EXISTS (SELECT 1 FROM par2_sets s JOIN releases r ON r.id = s.release_id WHERE s.set_id = b.set_id AND (
release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete) IS NOT TRUE
r.settled_at IS NULL
OR EXISTS (SELECT 1 FROM recent x WHERE x.group_id = r.group_id
AND x.posted_low <= coalesce(r.last_posted_at, r.posted_at) + interval '6 hours' AND x.posted_high >= r.posted_at - interval '6 hours')))
AND NOT EXISTS (SELECT 1 FROM par2_files f JOIN unseen u ON u.hash16k = f.hash16k AND u.size = f.length WHERE f.set_id = b.set_id)
+514
View File
@@ -0,0 +1,514 @@
import db from '@adonisjs/lucid/services/db'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import coordinatorConfig from '#config/coordinator'
import { explored } from '#lib/rules/index'
import { usableName, type Inspection } from '#lib/probe/index'
import RuleSets from '#services/rule_sets'
import { twinKey, twinNames } from '#services/hash_twins'
/** pending_releases.status (migration 6400). */
export const PENDING = {
waiting: 0,
named: 1,
discarded: 2,
exhausted: 3,
naming: 4,
taken: 9,
} as const
/** The probe targets of a pending release in the order of effort: a par2 names the set's files outright, an archive's start its entries, a lone file only its yEnc name, a 7z's tail is the costliest find. */
export const EFFORT = ['par2', 'archive', 'lone', '7z-tail'] as const
export interface PendingTarget {
kind: string
messageId: string
file: string
/** The target article's bytes (candidates from 2026-11-02 on). */
bytes?: number
}
/** One probe the ladder hands the prober: the next target of a pending release. */
export interface PendingProbe {
/** pending_releases.release_key as hex. */
releaseKey: string
kind: string
messageId: string
file: string
tool: string
groupId: number
/** Its class (lib/release/classes.ts; 0 for a row from before the classes). */
klass: number
poster: string
/** Targets probed before this one. */
tries: number
targets: number
}
export type PendingOutcome = {
probe: PendingProbe
status: 'done' | 'missing' | 'error' | 'waiting'
inspection?: Inspection
}
export function byEffort(targets: PendingTarget[]): PendingTarget[] {
const rank = (kind: string) => {
const i = (EFFORT as readonly string[]).indexOf(kind)
return i < 0 ? EFFORT.length : i
}
return [...targets].sort((a, b) => rank(a.kind) - rank(b.kind))
}
/** A class's record (pending_classes), for the claim's order. */
interface ClassRow {
tool: string
groupId: number
klass: number
probed: number
named: number
waiting: number
}
/** The record is read again this often. */
const CLASSES_MS = 60_000
/** A class found short of a claim's share is left alone this long. */
const EMPTY_MS = 5 * 60_000
/**
* Rows in one order for a statement that locks many of them (pending_releases by key, pending_classes and
* pending_signatures by theirs): the batch store, the record, the claim and the sweep all lock the same way, so two
* of them can't deadlock on each other (LOG.md (134)).
*/
export function lockOrder<T>(rows: Iterable<T>, key: (row: T) => string): T[] {
return [...rows].sort((a, b) => {
const ka = key(a)
const kb = key(b)
return ka < kb ? -1 : ka > kb ? 1 : 0
})
}
/** The prior rate of an unexplored class and its weight in probes: the ladder's overall rate on 2026-11-02 (LOG.md (130)). */
const PRIOR_RATE = 0.15
const PRIOR_WEIGHT = 10
/** One class takes at most this share of a claim: the rest explores the classes after it. */
const CLASS_SHARE = 0.25
/** The expected naming rate of a class: its record shrunk towards the prior. */
export function classScore(c: { probed: number; named: number }): number {
return (c.named + PRIOR_RATE * PRIOR_WEIGHT) / (c.probed + PRIOR_WEIGHT)
}
/** An UPDATE returns its rows in no order: the claim's (the cheapest target, then the oldest) restored. */
function claimed(rows: any[]): any[] {
const bytes = (r: any) =>
r.target_bytes === null ? Number.POSITIVE_INFINITY : Number(r.target_bytes)
return [...rows].sort(
(a, b) =>
bytes(a) - bytes(b) ||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ||
Number(a.first_article) - Number(b.first_article)
)
}
function toProbe(r: any): PendingProbe {
const targets = byEffort(r.candidates as PendingTarget[])
const tries = Number(r.tries)
const target = targets[Math.min(tries, targets.length - 1)]
return {
releaseKey: r.release_key,
kind: target.kind,
messageId: target.messageId,
file: target.file,
tool: r.tool,
groupId: Number(r.group_id),
klass: Number(r.klass ?? 0),
poster: r.poster,
tries,
targets: targets.length,
}
}
/**
* The pending ladder (REBUILD.md decision f): pending_releases probed from least to most effort, a usable name
* handing the release to the namer (app/services/pending_namer.ts), no name after the last target exhausting it;
* every finished climb counted on its signature (tool, group, poster; and the tool and group together) and on its
* class (tool, group, size bucket × name shape × partial; lib/release/classes.ts, LOG.md (130)), and a signature
* or class with enough probes and next to no names dropped: its waiting releases are discarded without a probe,
* a sample of them (the rules' samplePercent) still climbing so the rate keeps being measured. The claim takes the
* classes with the best record first (classScore), the cheapest target first within one.
*/
class PendingLadder {
/** A previous prober's claims back in the queue. */
async release(): Promise<number> {
const { rowCount } = await db.rawQuery(
`UPDATE pending_releases SET status = ? WHERE status = ?`,
[PENDING.waiting, PENDING.taken]
)
return rowCount ?? 0
}
/** The ladder's record per class, cached CLASSES_MS between claims, best first; classes found empty, until when. */
private classes: ClassRow[] = []
private classesAt = 0
private empty = new Map<string, number>()
/** The record read again on the next claim (the tests). */
forget() {
this.classesAt = 0
this.empty.clear()
}
/** pending_classes with waiting releases and not dropped, by their expected rate (a prior for the unexplored). */
private async classRows(): Promise<ClassRow[]> {
if (Date.now() - this.classesAt < CLASSES_MS) return this.classes
const { rows } = await db.rawQuery(
`SELECT tool, group_id, klass, probed, named, waiting FROM pending_classes WHERE waiting > 0 AND NOT dropped`
)
this.classes = rows
.map((r: any) => ({
tool: r.tool as string,
groupId: Number(r.group_id),
klass: Number(r.klass),
probed: Number(r.probed),
named: Number(r.named),
waiting: Number(r.waiting),
}))
.sort((a: ClassRow, b: ClassRow) => classScore(b) - classScore(a))
this.classesAt = Date.now()
return this.classes
}
/**
* The next target of up to `n` waiting releases with a target left, claimed: by class, the best record first,
* at most CLASS_SHARE of the claim from one class (the rest explores the classes after it), the cheapest target
* and then the oldest within one; what the classes don't fill, the oldest of the rest (rows from before the
* classes, status 0 of a class without a row).
*/
async claim(n: number): Promise<PendingProbe[]> {
if (n <= 0) return []
const out: PendingProbe[] = []
const perClass = Math.max(1, Math.floor(n * CLASS_SHARE))
const now = Date.now()
for (const c of await this.classRows()) {
if (out.length >= n) break
const key = `${c.tool}\t${c.groupId}\t${c.klass}`
if ((this.empty.get(key) ?? 0) > now) continue
const want = Math.min(perClass, n - out.length)
const { rows } = await db.rawQuery(
`UPDATE pending_releases p SET status = ? FROM (
SELECT release_key FROM pending_releases
WHERE status = ? AND tool = ? AND group_id = ? AND klass = ? AND finished AND tries < jsonb_array_length(candidates)
ORDER BY target_bytes NULLS LAST, created_at, first_article LIMIT ? FOR UPDATE SKIP LOCKED) c
WHERE p.release_key = c.release_key
RETURNING encode(p.release_key, 'hex') AS release_key, p.candidates, p.tries, p.tool, p.group_id, p.klass, coalesce(p.poster, '') AS poster, p.target_bytes, p.created_at, p.first_article`,
[PENDING.taken, PENDING.waiting, c.tool, c.groupId, c.klass, want]
)
if (rows.length < want) this.empty.set(key, now + EMPTY_MS)
if (rows.length > 0) {
await db.rawQuery(
`UPDATE pending_classes SET waiting = greatest(0, waiting - ?) WHERE tool = ? AND group_id = ? AND klass = ?`,
[rows.length, c.tool, c.groupId, c.klass]
)
}
out.push(...claimed(rows).map(toProbe))
}
if (out.length < n) {
const { rows } = await db.rawQuery(
`UPDATE pending_releases p SET status = ? FROM (
SELECT release_key FROM pending_releases
WHERE status = ? AND finished AND tries < jsonb_array_length(candidates)
ORDER BY created_at, first_article LIMIT ? FOR UPDATE SKIP LOCKED) c
WHERE p.release_key = c.release_key
RETURNING encode(p.release_key, 'hex') AS release_key, p.candidates, p.tries, p.tool, p.group_id, p.klass, coalesce(p.poster, '') AS poster, p.target_bytes, p.created_at, p.first_article`,
[PENDING.taken, PENDING.waiting, n - out.length]
)
out.push(...claimed(rows).map(toProbe))
}
return out
}
/**
* What the probes showed: a usable name moves the release to the namer (status 4), else the next target, or the
* end of the ladder (status 3); the finished climbs counted on their signatures.
*/
async record(outcomes: PendingOutcome[], trx: TransactionClientContract | typeof db = db) {
if (outcomes.length === 0) return
// A twin by content names what the probe itself didn't (hash_twins.ts).
const twins = await twinNames(
outcomes
.filter((o) => o.status === 'done' && o.inspection?.hash16k && o.inspection.size)
.map((o) => ({ hash16k: o.inspection!.hash16k!, size: o.inspection!.size })),
trx
)
const rows = outcomes.map((o) => {
const p = o.probe
if (o.status === 'waiting') {
return {
release_key: p.releaseKey,
status: PENDING.waiting,
tries: p.tries,
named: null,
inspection: null,
final: null,
}
}
const probed = o.status === 'done' && o.inspection ? usableName(o.inspection, p.kind) : null
const twin =
o.status === 'done' && o.inspection?.hash16k
? twins.get(twinKey(o.inspection.hash16k, o.inspection.size))
: undefined
const name = probed ?? twin ?? null
const tries = p.tries + 1
const inspection = {
kind: p.kind,
status: o.status,
result: o.inspection ? { ...o.inspection, par2: undefined } : null,
}
if (name)
return {
release_key: p.releaseKey,
status: PENDING.naming,
tries: 0,
named: name,
inspection,
final: 'named',
}
if (tries >= p.targets)
return {
release_key: p.releaseKey,
status: PENDING.exhausted,
tries,
named: null,
inspection,
final: 'exhausted',
}
return {
release_key: p.releaseKey,
status: PENDING.waiting,
tries,
named: null,
inspection,
final: null,
}
})
// The rows locked in key order first: the UPDATE's join below takes them in whatever order the plan does.
await trx.rawQuery(
`SELECT 1 FROM pending_releases WHERE release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k)
ORDER BY release_key FOR UPDATE`,
[rows.map((r) => r.release_key).sort()]
)
await trx.rawQuery(
`UPDATE pending_releases p SET status = v.status, tries = v.tries, named = coalesce(v.named, p.named),
inspections = CASE WHEN v.inspection IS NULL THEN p.inspections ELSE p.inspections || v.inspection END,
probed_at = CASE WHEN v.inspection IS NULL THEN p.probed_at ELSE now() END
FROM jsonb_to_recordset(?::jsonb) AS v(release_key text, status int, tries int, named text, inspection jsonb)
WHERE p.release_key = decode(v.release_key, 'hex')`,
[
JSON.stringify(
lockOrder(
rows.map(({ final: _final, ...row }) => row),
(r) => r.release_key
)
),
]
)
// The signatures: the poster's own and the tool's and group's together.
const counts = new Map<
string,
{ tool: string; group_id: number; poster: string; probed: number; named: number }
>()
for (const [i, o] of outcomes.entries()) {
const final = rows[i].final
if (!final) continue
for (const poster of [o.probe.poster, '']) {
const key = `${o.probe.tool}\t${o.probe.groupId}\t${poster}`
const entry = counts.get(key) ?? {
tool: o.probe.tool,
group_id: o.probe.groupId,
poster,
probed: 0,
named: 0,
}
entry.probed++
if (final === 'named') entry.named++
counts.set(key, entry)
}
}
if (counts.size === 0) return
const { pendingMinProbes, pendingMaxNamed } = coordinatorConfig.probe
await trx.rawQuery(
`INSERT INTO pending_signatures (tool, group_id, poster, probed, named)
SELECT v.tool, v.group_id, v.poster, v.probed, v.named FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, poster text, probed int, named int)
ON CONFLICT (tool, group_id, poster) DO UPDATE SET probed = pending_signatures.probed + excluded.probed, named = pending_signatures.named + excluded.named, updated_at = now()`,
[JSON.stringify(lockOrder(counts.values(), (c) => `${c.tool}\t${c.group_id}\t${c.poster}`))]
)
await trx.rawQuery(
`UPDATE pending_signatures s SET dropped = (s.probed >= ?::int AND s.named::float <= s.probed * ?::float),
swept_at = CASE WHEN s.dropped THEN s.swept_at END
FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, poster text)
WHERE s.tool = v.tool AND s.group_id = v.group_id AND s.poster = v.poster`,
[
pendingMinProbes,
pendingMaxNamed,
JSON.stringify(
lockOrder(counts.values(), (c) => `${c.tool}\t${c.group_id}\t${c.poster}`).map(
({ tool, group_id, poster }) => ({ tool, group_id, poster })
)
),
]
)
// The classes: the same record per (tool, group, class), and a probe given back is waiting again.
const classes = new Map<
string,
{
tool: string
group_id: number
klass: number
probed: number
named: number
waiting: number
}
>()
for (const [i, o] of outcomes.entries()) {
const key = `${o.probe.tool}\t${o.probe.groupId}\t${o.probe.klass}`
const entry = classes.get(key) ?? {
tool: o.probe.tool,
group_id: o.probe.groupId,
klass: o.probe.klass,
probed: 0,
named: 0,
waiting: 0,
}
const final = rows[i].final
if (final) entry.probed++
if (final === 'named') entry.named++
if (o.status === 'waiting') entry.waiting++
classes.set(key, entry)
}
await trx.rawQuery(
`INSERT INTO pending_classes (tool, group_id, klass, probed, named, waiting)
SELECT v.tool, v.group_id, v.klass, v.probed, v.named, v.waiting FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, klass int, probed int, named int, waiting int)
ON CONFLICT (tool, group_id, klass) DO UPDATE SET probed = pending_classes.probed + excluded.probed, named = pending_classes.named + excluded.named,
waiting = pending_classes.waiting + excluded.waiting, updated_at = now()`,
[JSON.stringify(lockOrder(classes.values(), (c) => `${c.tool}\t${c.group_id}\t${c.klass}`))]
)
await trx.rawQuery(
`UPDATE pending_classes c SET dropped = (c.probed >= ?::int AND c.named::float <= c.probed * ?::float),
swept_at = CASE WHEN c.dropped THEN c.swept_at END
FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, klass int)
WHERE c.tool = v.tool AND c.group_id = v.group_id AND c.klass = v.klass`,
[
pendingMinProbes,
pendingMaxNamed,
JSON.stringify(
lockOrder(classes.values(), (c) => `${c.tool}\t${c.group_id}\t${c.klass}`).map(
({ tool, group_id, klass }) => ({ tool, group_id, klass })
)
),
]
)
}
/**
* Waiting releases of a dropped class or signature discarded without a probe, the explored sample excepted; how
* many. Each dropped class or signature is swept once (swept_at; null again when the record drops it anew),
* `perSweep` of them at most, each by its index (the claim's for a class, the signature index for a poster's
* rows, the ladder's for a tool's and group's) in rounds of `limit`. The prober runs this beside the lanes, not
* in the claim (LOG.md (133)); the edge drops new releases of dropped signatures and classes itself, so one
* sweep is all a drop needs.
*/
async discard(limit = 10_000, perSweep = 20): Promise<number> {
const { samplePercent } = await RuleSets.current()
let discarded = 0
const { rows: classes } = await db.rawQuery(
`SELECT tool, group_id, klass FROM pending_classes WHERE dropped AND swept_at IS NULL ORDER BY group_id, tool, klass LIMIT ?`,
[perSweep]
)
for (const c of classes) {
let rows: any[]
do {
;({ rows } = await db.rawQuery(
`SELECT encode(release_key, 'hex') AS release_key FROM pending_releases
WHERE status = ? AND tool = ? AND group_id = ? AND klass = ? LIMIT ?`,
[PENDING.waiting, c.tool, c.group_id, c.klass, limit]
))
discarded += await this.discardKeys(rows, samplePercent)
} while (rows.length === limit && rows.some((r) => !explored(r.release_key, samplePercent)))
await db.rawQuery(
`UPDATE pending_classes SET swept_at = now() WHERE tool = ? AND group_id = ? AND klass = ?`,
[c.tool, c.group_id, c.klass]
)
}
const { rows: signatures } = await db.rawQuery(
`SELECT tool, group_id, poster FROM pending_signatures WHERE dropped AND swept_at IS NULL ORDER BY group_id, tool, poster LIMIT ?`,
[perSweep]
)
for (const s of signatures) {
let rows: any[]
do {
// The poster's rows by the signature index (on the poster's md5: a poster can be longer than a btree
// key), a tool's and group's by the ladder's: never a test on the rows, which was a scan of the whole
// table per signature on the HDD (LOG.md (134)).
;({ rows } =
s.poster === ''
? await db.rawQuery(
`SELECT encode(release_key, 'hex') AS release_key FROM pending_releases
WHERE status = ? AND group_id = ? AND tool = ? LIMIT ?`,
[PENDING.waiting, s.group_id, s.tool, limit]
)
: await db.rawQuery(
`SELECT encode(release_key, 'hex') AS release_key FROM pending_releases
WHERE status = ? AND group_id = ? AND tool = ? AND md5(poster) = md5(?::text) AND poster = ? LIMIT ?`,
[PENDING.waiting, s.group_id, s.tool, s.poster, s.poster, limit]
))
discarded += await this.discardKeys(rows, samplePercent)
} while (rows.length === limit && rows.some((r) => !explored(r.release_key, samplePercent)))
await db.rawQuery(
`UPDATE pending_signatures SET swept_at = now() WHERE tool = ? AND group_id = ? AND poster = ?`,
[s.tool, s.group_id, s.poster]
)
}
return discarded
}
/** The rows discarded (status 2), the explored sample excepted, their classes' waiting counts lowered; how many. */
private async discardKeys(rows: any[], samplePercent: number): Promise<number> {
const keys = rows
.map((r: any) => r.release_key as string)
.filter((key: string) => !explored(key, samplePercent))
.sort()
if (keys.length === 0) return 0
// Locked in key order (the record's lock order, LOG.md (137)): a plain `key = ANY(subquery)` locks in scan
// order and deadlocked against a batch upsert; ORDER BY sorts before FOR UPDATE locks.
const { rows: gone } = await db.rawQuery(
`UPDATE pending_releases p SET status = ?
FROM (SELECT release_key FROM pending_releases
WHERE status = ? AND release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k)
ORDER BY release_key FOR UPDATE) k
WHERE p.release_key = k.release_key
RETURNING p.tool, p.group_id, p.klass`,
[PENDING.discarded, PENDING.waiting, keys]
)
const counts = new Map<string, { tool: string; group_id: number; klass: number; n: number }>()
for (const g of gone) {
const key = `${g.tool}\t${g.group_id}\t${g.klass}`
const entry = counts.get(key) ?? {
tool: g.tool,
group_id: Number(g.group_id),
klass: Number(g.klass),
n: 0,
}
entry.n++
counts.set(key, entry)
}
if (counts.size > 0) {
await db.rawQuery(
`UPDATE pending_classes c SET waiting = greatest(0, c.waiting - v.n)
FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, klass int, n int)
WHERE c.tool = v.tool AND c.group_id = v.group_id AND c.klass = v.klass`,
[JSON.stringify(lockOrder(counts.values(), (c) => `${c.tool}\t${c.group_id}\t${c.klass}`))]
)
}
return gone.length
}
}
export default new PendingLadder()
+253
View File
@@ -0,0 +1,253 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { setTimeout as sleep } from 'node:timers/promises'
import { promisify } from 'node:util'
import { unzip } from 'node:zlib'
import env from '#start/env'
import coordinatorConfig from '#config/coordinator'
import { XoverClient } from '#lib/nntp/index'
import { RangeIndexer, type ReleaseChunk } from '#lib/edge/index'
import { identityKey } from '#lib/release/keys'
import BatchStore from '#services/batch_store'
import ChunkPartitions from '#services/chunk_partitions'
import Corpus from '#services/corpus'
import RuleSets from '#services/rule_sets'
import { PENDING } from '#services/pending_ladder'
const inflate = promisify(unzip)
interface NamedRow {
release_key: Buffer
identity_key: Buffer
group_id: number
named: string
first_article: number
last_article: number
file_total: number | null
tries: number
}
/** The namer gives a release up after this many failed moves (its articles gone from the server and the corpus). */
const MAX_MOVES = 3
/**
* The widest article span the namer reads again (a busy group posts 50M articles a day, a large upload among
* them spans a million or two): one wider is a name the edge saw again much later ("v-lgpc.3ds"), and its span
* of segments would not fit the indexer's memory (LOG.md (133)).
*/
const MAX_SPAN = 3_000_000
/**
* The move into the catalog (REBUILD.md decision f): a pending release a probe named (status 4) gets its segments
* read again, from the corpus when its range is stored, else from the server (XOVER over its article span, one
* connection of its own, opened for it and closed after), through the TypeScript indexer, and is written as a
* release with the name the probe found (source weak-stem, the name as inner name), its chunk and its probe
* candidates, which the prober answers from probe_cache. Runs inside the prober.
*/
export class PendingNamer {
private client: XoverClient | null = null
private usedAt = 0
constructor(private corpusDir: string | undefined = coordinatorConfig.corpus.dir) {}
async run(stopped: () => boolean) {
while (!stopped()) {
let rows: NamedRow[] = []
try {
rows = await this.take(20)
for (const row of rows) {
if (stopped()) break
try {
await this.move(row)
} catch (error: any) {
logger.warn(
`Naming ${row.release_key.toString('hex')} "${row.named}" failed: ${error.message}`
)
await db.rawQuery(
`UPDATE pending_releases SET tries = tries + 1, probed_at = now() WHERE release_key = ?`,
[row.release_key]
)
}
}
} catch (error: any) {
logger.error(`Naming: ${error.message}`)
}
if (this.client && Date.now() - this.usedAt > coordinatorConfig.nntpIdleCloseMs) {
this.client.close()
this.client = null
}
if (rows.length === 0)
for (let waited = 0; waited < 30_000 && !stopped(); waited += 1000) await sleep(1000)
}
this.client?.close()
this.client = null
}
/** Named releases awaiting their move, the longest waiting first; those failed MAX_MOVES times stay as they are. */
async take(n: number): Promise<NamedRow[]> {
const { rows } = await db.rawQuery(
`SELECT release_key, identity_key, group_id, named, first_article, last_article, file_total, tries
FROM pending_releases WHERE status = ? AND tries < ? ORDER BY probed_at LIMIT ?`,
[PENDING.naming, MAX_MOVES, n]
)
return rows.map((r: any) => ({
...r,
group_id: Number(r.group_id),
first_article: Number(r.first_article),
last_article: Number(r.last_article),
tries: Number(r.tries),
}))
}
/** One named release into the catalog. */
async move(row: NamedRow): Promise<void> {
if (row.last_article - row.first_article > MAX_SPAN)
throw new Error(
`its articles ${row.first_article}–${row.last_article} span more than ${MAX_SPAN}`
)
const group = await db.from('groups').where('id', row.group_id).firstOrFail()
const range = await db
.from('ranges')
.where('group_id', row.group_id)
.where('start_id', '<=', row.first_article)
.where('end_id', '>=', row.first_article)
.orderBy('id')
.first()
if (!range) throw new Error(`no range of ${group.name} holds article ${row.first_article}`)
const rules = await RuleSets.current()
const ix = new RangeIndexer({
group: group.name,
start: row.first_article,
end: row.last_article,
rules: { ...rules, samplePercent: 0 },
// No file given up before the end: the span is read chunk by chunk (one text of it topped V8's string).
idleChunks: Number.MAX_SAFE_INTEGER,
edgeArticles: 0,
// The edge wrote it partial when its file is short of parts on the server: so must the re-read.
partialFiles: true,
})
for await (const piece of this.overviewPieces(group.name, row))
ix.addText(piece.text, piece.next)
ix.finish()
const batch = ix.take()
const found = batch.releases.find((r) => identityKey(r.key).equals(row.identity_key))
if (!found)
throw new Error(
`its articles ${row.first_article}–${row.last_article} of ${group.name} no longer show it`
)
// The catalog row under the pending key (the identity, the group, the first article the edge saw).
const chunk: ReleaseChunk = {
...found,
name: row.named,
source: 'weak-stem',
readable: true,
finished: true,
edge: false,
firstArticle: row.first_article,
fileTotal: row.file_total ?? found.fileTotal,
tool: undefined,
}
const candidates = batch.candidates
.filter((c) => c.releaseKey === found.key)
.map((c) => ({ ...c, upload: found.upload }))
// The chunk's month partition, as the batch store makes sure of before its writes.
await ChunkPartitions.ensure()
await db.transaction(async (trx) => {
const blockId = await BatchStore.blockOf(trx, range)
await BatchStore.writeReleases(
trx,
{ groupId: row.group_id, blockId },
row.first_article,
[chunk],
candidates
)
await trx.rawQuery(
`UPDATE releases SET inner_name = ?, inspected_at = now() WHERE release_key = ? AND inner_name IS NULL`,
[row.named.slice(0, 255), row.release_key]
)
await trx.rawQuery(
`UPDATE pending_releases SET status = ?, probed_at = now() WHERE release_key = ?`,
[PENDING.named, row.release_key]
)
})
}
/**
* The overview lines of the release's article span, a chunk of articles at a time with the first article after
* it: the stored corpus chunks when its ranges are stored and contiguous, else XOVER in windows of a chunk.
*/
private async *overviewPieces(
groupName: string,
row: NamedRow
): AsyncGenerator<{ text: string; next: number }> {
const size = coordinatorConfig.chunkArticles
const stored = await this.storedChunks(groupName, row)
if (stored) {
for (const { raw, next } of stored)
yield { text: (await inflate(raw)).toString('latin1'), next }
return
}
this.client ??= await XoverClient.connect({
host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: true,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
this.usedAt = Date.now()
try {
await this.client.group(groupName)
for (let from = row.first_article; from <= row.last_article; from += size) {
const to = Math.min(from + size - 1, row.last_article)
// One zlib/gzip stream, as the server sent it or deflated by the client (the corpus stores it so).
const raw = await this.client.xoverRaw(from, to)
yield { text: (await inflate(raw)).toString('latin1'), next: to + 1 }
}
} catch (error) {
this.client.close()
this.client = null
throw error
}
}
/** The span's stored corpus chunks (compressed, with the first article after each), or null when any is missing. */
private async storedChunks(
groupName: string,
row: NamedRow
): Promise<{ raw: Buffer; next: number }[] | null> {
if (!this.corpusDir) return null
const stored = await db
.from('ranges')
.where('group_id', row.group_id)
.whereNotNull('stored_at')
.where('start_id', '<=', row.last_article)
.where('end_id', '>=', row.first_article)
.orderBy('start_id')
if (
stored.length === 0 ||
Number(stored[0].start_id) > row.first_article ||
Number(stored[stored.length - 1].end_id) < row.last_article
)
return null
const size = coordinatorConfig.chunkArticles
const chunks: { raw: Buffer; next: number }[] = []
for (const r of stored) {
const range = { group: groupName, start: Number(r.start_id), end: Number(r.end_id) }
for (
let from =
range.start +
Math.floor((Math.max(row.first_article, range.start) - range.start) / size) * size;
from <= Math.min(row.last_article, range.end);
from += size
) {
const to = Math.min(from + size - 1, range.end)
const raw = await Corpus.read(range, { from, to }, this.corpusDir)
if (!raw) return null
chunks.push({ raw, next: to + 1 })
}
}
return chunks
}
}
+180 -34
View File
@@ -35,20 +35,8 @@ class Planner {
async planGroup(group: { id: number; name: string }): Promise<number> {
const info = await NntpPool.use((client) => client.group(group.name))
const size = coordinatorConfig.rangeArticles
const last = await db.from('ranges').where('group_id', group.id).max('end_id as end').first()
const from = Math.max(info.low, Number(last?.end ?? 0) + 1)
const span = Math.max(1, info.high - info.low)
const rows = []
for (let start = from; start <= info.high; start += size) {
rows.push({
group_id: group.id,
start_id: start,
end_id: Math.min(start + size - 1, info.high),
next_id: start,
priority: Math.floor((1000 * (start - info.low)) / span),
})
}
const rows = historyRows(group.id, info, Number(last?.end ?? 0), coordinatorConfig.rangeArticles)
for (let i = 0; i < rows.length; i += 2000) await db.table('ranges').multiInsert(rows.slice(i, i + 2000))
await db.from('groups').where('id', group.id).update({ low: info.low, high: info.high, planned_at: new Date() })
return rows.length
@@ -75,32 +63,99 @@ class Planner {
* numbers come from LIST ACTIVE, one command per name prefix (GROUP per
* group took minutes for 1000 groups).
*/
async planTail(now = new Date()): Promise<{ groups: number; ranges: number }> {
async planTail(now = new Date()): Promise<TailPlanned> {
const groups = await db.from('groups').where('active', true).where('tail', true).whereNotNull('planned_at').select('id', 'name', 'planned_at')
if (groups.length === 0) return { groups: 0, ranges: 0 }
const { rows: ends } = await db.rawQuery(`SELECT DISTINCT ON (group_id) group_id, end_id FROM ranges ORDER BY group_id, start_id DESC`)
const plannedTo = new Map<number, number>(ends.map((r: any) => [Number(r.group_id), Number(r.end_id)]))
if (groups.length === 0) return { groups: 0, ranges: 0, extended: 0 }
const { rows: ends } = await db.rawQuery(
`SELECT DISTINCT ON (group_id) group_id, id, start_id, end_id,
(status = 'planned' AND priority = ? AND NOT capture AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL) AS open
FROM ranges ORDER BY group_id, start_id DESC`,
[coordinatorConfig.tail.priority]
)
const last = new Map<number, { id: number; start: number; end: number; open: boolean }>(
ends.map((r: any) => [Number(r.group_id), { id: Number(r.id), start: Number(r.start_id), end: Number(r.end_id), open: r.open === true }])
)
const highs = new Map<string, number>()
await NntpPool.use(async (client) => {
for (const pattern of new Set(groups.map((g) => wildmatOf(g.name)))) {
for (const g of await client.listActive(pattern)) highs.set(g.name, g.high)
}
})
const rows = tailRanges(
groups.map((g) => ({ id: Number(g.id), plannedTo: plannedTo.get(Number(g.id)) ?? null, plannedAt: new Date(g.planned_at), high: highs.get(g.name) ?? null })),
const plan = tailRanges(
groups.map((g) => {
const l = last.get(Number(g.id))
return { id: Number(g.id), plannedTo: l?.end ?? null, plannedAt: new Date(g.planned_at), high: highs.get(g.name) ?? null, open: l?.open ? { id: l.id, start: l.start } : null }
}),
now,
{ rangeArticles: coordinatorConfig.rangeArticles, ...coordinatorConfig.tail }
)
if (rows.length === 0) return { groups: 0, ranges: 0 }
await db.transaction(async (trx) => {
await trx.table('ranges').multiInsert(rows)
for (const id of new Set(rows.map((r) => r.group_id))) {
const high = Math.max(...rows.filter((r) => r.group_id === id).map((r) => r.end_id))
await trx.from('groups').where('id', id).update({ high, planned_at: now })
}
})
return { groups: new Set(rows.map((r) => r.group_id)).size, ranges: rows.length }
return this.applyTail(plan, now)
}
/** The tail's plan written: the open ranges extended (one taken meanwhile gets the slice as a row instead), the rows added. */
async applyTail(plan: TailPlan, now = new Date()): Promise<TailPlanned> {
const rows = [...plan.rows]
let extended = 0
if (rows.length === 0 && plan.extended.length === 0) return { groups: 0, ranges: 0, extended: 0 }
await db.transaction(async (trx) => {
for (const e of plan.extended) {
const { rows: done } = await trx.rawQuery(
`UPDATE ranges SET end_id = ? WHERE id = ? AND status = 'planned' AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL AND end_id = ? RETURNING id`,
[e.end_id, e.id, e.from - 1]
)
if (done.length > 0) extended++
else rows.push({ group_id: e.group_id, start_id: e.from, end_id: e.end_id, next_id: e.from, priority: coordinatorConfig.tail.priority })
}
if (rows.length > 0) await trx.table('ranges').multiInsert(rows)
const highs = new Map<number, number>()
for (const r of [...rows, ...plan.extended]) highs.set(r.group_id, Math.max(highs.get(r.group_id) ?? 0, r.end_id))
for (const [id, high] of highs) await trx.from('groups').where('id', id).update({ high, planned_at: now })
})
return { groups: new Set([...rows, ...plan.extended].map((r) => r.group_id)).size, ranges: rows.length, extended }
}
/**
* Adjacent planned tail ranges of a group and priority, never spooled or taken, merged into ranges of at most
* RANGE_ARTICLES (ranges:merge-tail): the tail planned a sliver a group every round while nothing leased the tail
* (127k rows of a median 14k articles on 2026-10-27; LOG.md (118)). The first row of a run grows, the others go.
*/
async mergeTail(dryRun = false): Promise<{ candidates: number; merged: number; dropped: number }> {
const { rows: candidates } = await db.rawQuery(
`SELECT id, group_id, start_id, end_id, priority FROM ranges
WHERE status = 'planned' AND priority < 0 AND NOT capture AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL
ORDER BY group_id, start_id`
)
const merges = tailMerges(
candidates.map((r: any) => ({ id: Number(r.id), group_id: Number(r.group_id), start_id: Number(r.start_id), end_id: Number(r.end_id), priority: Number(r.priority) })),
coordinatorConfig.rangeArticles
)
const dropped = merges.reduce((n, m) => n + m.drop.length, 0)
if (dryRun || merges.length === 0) return { candidates: candidates.length, merged: merges.length, dropped }
await db.transaction(async (trx) => {
// The lease picker reads several tables under this lock; the rows' guards catch a range taken meanwhile.
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('group-lease', 0))`)
const { rows: gone } = await trx.rawQuery(
`DELETE FROM ranges WHERE id = ANY(?::bigint[]) AND status = 'planned' AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL RETURNING id`,
[merges.flatMap((m) => m.drop)]
)
if (gone.length !== dropped) throw new Error(`${dropped - gone.length} of the ranges to drop were taken meanwhile; nothing merged`)
const { rows: grown } = await trx.rawQuery(
`UPDATE ranges r SET end_id = v.end_id FROM (SELECT unnest(?::bigint[]) AS id, unnest(?::bigint[]) AS end_id) v
WHERE r.id = v.id AND r.status = 'planned' AND r.spooled_chunks = 0 AND r.attempts = 0 AND r.node_id IS NULL RETURNING r.id`,
[merges.map((m) => m.id), merges.map((m) => m.end_id)]
)
if (grown.length !== merges.length) throw new Error(`${merges.length - grown.length} of the ranges to grow were taken meanwhile; nothing merged`)
})
return { candidates: candidates.length, merged: merges.length, dropped }
}
}
export interface TailPlanned {
groups: number
/** Rows added. */
ranges: number
/** Open ranges grown. */
extended: number
}
/** A LIST ACTIVE pattern for a group: its first two name parts (`alt.binaries.*`), or the name itself. */
@@ -117,28 +172,119 @@ export interface TailGroup {
plannedAt: Date
/** The server's newest article (null: not listed). */
high: number | null
/**
* Its last range when the tail may grow it instead of adding a row: a planned range at the tail's priority that
* nothing spooled, took or tried (its start: the cut at rangeArticles counts from there).
*/
open?: { id: number; start: number } | null
}
export interface TailRow {
group_id: number
start_id: number
end_id: number
next_id: number
priority: number
}
export interface TailPlan {
/** New ranges. */
rows: TailRow[]
/** Open ranges grown to end_id (from: the first new article, the old end + 1). */
extended: { id: number; group_id: number; from: number; end_id: number }[]
}
/**
* The tail's new ranges: per group, the articles after its last range, in
* ranges of at most `rangeArticles`, once there are `minArticles` of them or
* the group's last planning is `maxDelayMinutes` ago.
* The tail's new ranges: per group, the articles after its last range, once there are `minArticles` of them or
* the group's last planning is `maxDelayMinutes` ago. An open last range grows up to `rangeArticles` first, so a
* tail nothing leases stays one range a group instead of a sliver a round; the rest is cut at `rangeArticles`.
*/
export function tailRanges(
groups: TailGroup[],
now: Date,
options: { rangeArticles: number; minArticles: number; maxDelayMinutes: number; priority: number }
) {
const rows: { group_id: number; start_id: number; end_id: number; next_id: number; priority: number }[] = []
): TailPlan {
const rows: TailRow[] = []
const extended: TailPlan['extended'] = []
for (const g of groups) {
if (g.plannedTo === null || g.high === null || g.high <= g.plannedTo) continue
const waiting = g.high - g.plannedTo
const waited = (now.getTime() - g.plannedAt.getTime()) / 60_000
if (waiting < options.minArticles && waited < options.maxDelayMinutes) continue
for (let start = g.plannedTo + 1; start <= g.high; start += options.rangeArticles) {
let start = g.plannedTo + 1
if (g.open && g.open.start + options.rangeArticles - 1 >= start) {
const end = Math.min(g.open.start + options.rangeArticles - 1, g.high)
extended.push({ id: g.open.id, group_id: g.id, from: start, end_id: end })
start = end + 1
}
for (; start <= g.high; start += options.rangeArticles) {
rows.push({ group_id: g.id, start_id: start, end_id: Math.min(start + options.rangeArticles - 1, g.high), next_id: start, priority: options.priority })
}
}
return { rows, extended }
}
export interface TailMerge {
/** The run's first range, grown to end_id. */
id: number
end_id: number
/** The ranges it absorbed. */
drop: number[]
}
/**
* Runs of adjacent ranges of one group and priority (sorted by group and start) packed into ranges of at most
* `rangeArticles`: the first range of each packed run grows, the others are dropped. Only runs that merge anything.
*/
export function tailMerges(
ranges: { id: number; group_id: number; start_id: number; end_id: number; priority: number }[],
rangeArticles: number
): TailMerge[] {
const merges: TailMerge[] = []
let cur: (TailMerge & { group_id: number; start_id: number; priority: number }) | null = null
const flush = () => {
if (cur && cur.drop.length > 0) merges.push({ id: cur.id, end_id: cur.end_id, drop: cur.drop })
}
for (const r of ranges) {
if (cur && r.group_id === cur.group_id && r.priority === cur.priority && r.start_id === cur.end_id + 1 && r.end_id - cur.start_id + 1 <= rangeArticles) {
cur.end_id = r.end_id
cur.drop.push(r.id)
continue
}
flush()
cur = { id: r.id, group_id: r.group_id, start_id: r.start_id, end_id: r.end_id, priority: r.priority, drop: [] }
}
flush()
return merges
}
/**
* The rows a group's history is cut into, from `lastEnd + 1` (0: the group has no ranges yet) up to the high
* watermark: RANGE_ARTICLES slices at the position priority. A group planned for the first time also gets one
* "ancient" range, articles 1 … low − 1: what had expired on the server before it was planned. Status
* 'ancient', never leased or fetched; it keeps the tiling complete from article 1 (migration 6700 added it for
* the groups planned before 2026-10-28).
*/
export function historyRows(
groupId: number,
info: { low: number; high: number },
lastEnd: number,
size: number
): Record<string, unknown>[] {
const rows: Record<string, unknown>[] = []
if (lastEnd === 0 && info.low > 1)
rows.push({ group_id: groupId, start_id: 1, end_id: info.low - 1, next_id: 1, priority: 0, status: 'ancient' })
const from = Math.max(info.low, lastEnd + 1)
const span = Math.max(1, info.high - info.low)
for (let start = from; start <= info.high; start += size) {
rows.push({
group_id: groupId,
start_id: start,
end_id: Math.min(start + size - 1, info.high),
next_id: start,
priority: Math.floor((1000 * (start - info.low)) / span),
})
}
return rows
}
+53 -8
View File
@@ -425,10 +425,23 @@ export class Prefetcher {
)
// 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')
// The groups workers hold (group_leases; REBUILD.md design 1), by lane: the prefetcher runs ahead of their
// cursors (a group's history and tail lanes are two cursors).
const held: { group: number; lane: string }[] = (
await db
.from('group_leases')
.whereNotNull('node_id')
.where(
'heartbeat_at',
'>=',
new Date(Date.now() - coordinatorConfig.leaseTtlSeconds * 1000)
)
.select('group_id', 'lane')
).map((l) => ({ group: Number(l.group_id), lane: l.lane }))
// 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
coordinatorConfig.spool.aheadPerLease * Math.max(leased.length, held.length)
)
const started = await rows(db.query())
.where('r.status', 'planned')
@@ -448,14 +461,37 @@ export class Prefetcher {
const want = Math.max(ahead - Number(ready), short)
// The catalog at its cap on the NVMe: no history fetched ahead (CatalogSpace).
const full = await CatalogSpace.full()
// Ahead of each held cursor: the lane's next ranges in article order (the history lane's within what the group
// leases hand out: LEASE_MIN/MAX_PRIORITY, LEASE_CAPTURE_ONLY, and none at the catalog's cap; the tail lane's
// at the tail's priority), a range of every held cursor before any cursor's second; without held groups (no
// window worker running) nothing is fetched ahead for workers.
const { leasePriorityMin, leasePriorityMax, leaseCaptureOnly } = coordinatorConfig
const tailPriority = coordinatorConfig.tail.priority
const heldHistory = full ? [] : held.filter((h) => h.lane === 'history').map((h) => h.group)
const heldTail = held.filter((h) => h.lane === 'tail').map((h) => h.group)
const fresh =
room && want > 0
room && want > 0 && heldHistory.length + heldTail.length > 0
? await rows(db.query())
.where('r.status', 'planned')
.where('r.spooled_chunks', 0)
.whereRaw('r.next_id <= r.end_id')
.where((q: any) => (full ? q.where('r.priority', '<', 0) : q))
.orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
.where((q: any) =>
q
.where((h: any) =>
h
.whereIn('r.group_id', heldHistory)
.where('r.priority', '>', tailPriority)
.whereBetween('r.priority', [leasePriorityMin, leasePriorityMax])
.where((c: any) => (leaseCaptureOnly ? c.where('r.capture', true) : c))
)
.orWhere((t: any) =>
t.whereIn('r.group_id', heldTail).where('r.priority', '<=', tailPriority)
)
)
.orderByRaw(
'row_number() OVER (PARTITION BY r.group_id, r.priority <= ? ORDER BY r.start_id), r.priority, r.start_id',
[tailPriority]
)
.limit(want)
: []
const targets: Target[] = []
@@ -464,9 +500,13 @@ export class Prefetcher {
const end = Number(row.end_id)
const total = Spool.chunksOf({ start, end }).length
const missing: ChunkSpan[] = []
// A chunk already in the corpus isn't spooled: the stream serves it from there (REBUILD.md design 1).
const corpusRange = row.capture === true ? { group: row.group as string, start, end } : null
for (const span of Spool.chunksOf({ start, end }, Number(row.next_id))) {
if (!this.claimed.has(`${row.id}:${span.from}`) && !(await Spool.has(Number(row.id), span)))
missing.push(span)
if (this.claimed.has(`${row.id}:${span.from}`) || (await Spool.has(Number(row.id), span)))
continue
if (corpusRange && (await Corpus.has(corpusRange, span))) continue
missing.push(span)
}
if (missing.length > 0) {
targets.push({
@@ -506,7 +546,10 @@ export class Prefetcher {
* Up to corpus.ahead capture ranges whose chunks aren't all in the corpus, by priority: planned ones not started
* (not leased soon), and done ones (a window of every group, ranges:capture, takes those indexed already too);
* as many of a group as its connections allow (perGroup / perRange): in id order the 16 were one group's, which
* took 4 of the loops and left the rest idle (30k articles/s; LOG.md 2026-10-23 (100)). A range found complete
* took 4 of the loops and left the rest idle (30k articles/s; LOG.md 2026-10-23 (100)). The candidates are taken
* a range of every group at a time (the n-th unstored range of each group before any group's n+1-th): a group's
* ranges have consecutive ids, and once the small groups of a window were stored the 512 lowest ids were all one
* group's, two targets on two connections (10 ranges an hour; LOG.md 2026-10-25 (110)). A range found complete
* gets stored_at and isn't read again.
*/
private async corpusTargets(rows: (q: any) => any, taken: Set<number>): Promise<Target[]> {
@@ -518,13 +561,15 @@ export class Prefetcher {
const groups = new Map<string, number>()
const candidates = await rows(db.query())
.where('r.capture', true)
// A group taken off the list (groups.active) isn't worth the connections.
.where('g.active', true)
.whereNull('r.stored_at')
.where((q: any) =>
q
.where((p: any) => p.where('r.status', 'planned').where('r.spooled_chunks', 0))
.orWhere('r.status', 'done')
)
.orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
.orderByRaw('r.priority, row_number() OVER (PARTITION BY r.group_id ORDER BY r.id), r.id')
.limit(ahead * 16)
const targets: Target[] = []
for (const row of candidates) {
+127 -15
View File
@@ -9,12 +9,17 @@ import { inspect, innerName, PROBE_VERSION, type Inspection } from '#lib/probe/i
import { NntpShare } from '#services/nntp_shares'
import { DiskSpace } from '#services/disk_space'
import { decidePar2Completeness } from '#services/par2_completeness'
import { twinKey, twinNames } from '#services/hash_twins'
import PendingLadder, { type PendingProbe } from '#services/pending_ladder'
import { PendingNamer } from '#services/pending_namer'
interface Candidate {
id: number
releaseId: number
kind: string
messageId: string
/** A pending release's target (the pending ladder), not a catalog candidate: id and releaseId are 0. */
pending?: PendingProbe
}
type Outcome = { candidate: Candidate; status: 'done' | 'missing' | 'error' | 'waiting'; inspection?: Inspection; cached?: boolean }
@@ -34,6 +39,10 @@ const TIERS: { kind: string; unreadable: boolean }[] = [
{ kind: '7z-tail', unreadable: false },
]
const FLUSH_MS = 2000
/** Between sweeps that discard the waiting releases of dropped signatures. */
const SWEEP_MS = 5 * 60_000
/** A flush that deadlocks (against the batch store's pending upsert) keeps its outcomes for the next, this often. */
const DEADLOCK_RETRIES = 5
/**
* JSON for a jsonb parameter, without NUL characters: jsonb can't hold \u0000 (a name read from an article had one),
@@ -43,6 +52,20 @@ function jsonb(value: unknown): string {
return JSON.stringify(value, (_key, v) => (typeof v === 'string' ? v.replaceAll('\0', '') : v))
}
/** Rounds of every lane's pipeline still queued when the next claim starts. */
const LOW_ROUNDS = 8
/**
* The candidate queue's levels: the next claim starts below `low`, LOW_ROUNDS rounds of every lane's pipeline (at
* ~0.2 s an article and a pipeline of 8, ~13 s of work), and fills it to `target`, twice that. A claim takes 1.5–4 s
* and more (random slices of an 11 M-row table); refilling at half of four rounds left 3–6 s, and at 20–24 lanes the
* lanes waited for candidates up to 19% of their time (LOG.md 2026-10-28 (120)). At least `minBatch` either way.
*/
export function queueLevels(lanes: number, pipeline: number, minBatch: number): { low: number; target: number } {
const low = Math.max(minBatch, lanes * pipeline * LOW_ROUNDS)
return { low, target: 2 * low }
}
/** Slices a claim takes from random points of the queue. */
const SLICES = 20
const REPORT_MS = 60_000
@@ -61,6 +84,8 @@ export class Prober {
private refilling: Promise<void> | null = null
private stopped = false
private stats = Prober.freshStats()
/** Flushes deadlocked in a row, their outcomes kept. */
private deadlocks = 0
/** 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
@@ -72,13 +97,29 @@ export class Prober {
/** Per minute: what was read, and where the lanes' time went (ms summed over lanes; flushes ms on their own). */
private static freshStats() {
return { articles: 0, bytes: 0, done: 0, missing: 0, error: 0, cached: 0, waitMs: 0, idleMs: 0, fetchMs: 0, inspectMs: 0, flushMs: 0, claimMs: 0, since: Date.now() }
return { articles: 0, bytes: 0, done: 0, missing: 0, error: 0, cached: 0, pending: 0, discarded: 0, waitMs: 0, idleMs: 0, fetchMs: 0, inspectMs: 0, flushMs: 0, claimMs: 0, since: Date.now() }
}
/** The pending ladder's namer: named pending releases into the catalog, alongside the lanes. */
private namer = new PendingNamer()
async run() {
// A previous run's claims: one prober at a time.
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.`)
const released = await PendingLadder.release()
if (released) logger.info(`Probing: ${released} pending releases from a previous run back on the ladder.`)
const namer = this.namer.run(() => this.stopped).catch((error) => logger.error(`Naming failed: ${error.message}`))
// The ladder's discard sweep beside the lanes: a dropped signature's rows take minutes on the HDD (LOG.md (133)).
const sweeper = (async () => {
while (!this.stopped) {
try {
this.stats.discarded += await PendingLadder.discard()
} catch (error: any) {
logger.error(`Probing: the discard sweep failed: ${error.message}`)
}
for (let waited = 0; waited < SWEEP_MS && !this.stopped; waited += 1000) await sleep(1000)
}
})()
logger.info(`Probing with ${this.share.min}–${this.share.max} connections (its share of the account's).`)
const flusher = (async () => {
while (!this.stopped || this.outcomes.length > 0) {
@@ -108,6 +149,8 @@ export class Prober {
for (let waited = 0; waited < coordinatorConfig.nntpShares.everyMs && !this.stopped; waited += 1000) await sleep(1000)
}
await Promise.all(this.lanes)
await namer
await sweeper
await this.share.leave().catch(() => {})
// What was claimed and not probed goes back.
for (const candidate of this.queue.splice(0)) this.outcomes.push({ candidate, status: 'waiting' })
@@ -196,13 +239,18 @@ export class Prober {
}
}
/** Candidates claimed at a time: a few rounds of every lane's pipeline (a claim takes seconds; 400 lasted ~2 s at 37 lanes). */
/** The queue's low-water mark and the level a claim fills it to (queueLevels). */
private get levels() {
return queueLevels(this.running, coordinatorConfig.probe.pipeline, coordinatorConfig.probe.batch)
}
/** Claims to the queue's target; the claim after starts when it's below its low-water mark (queueLevels). */
private get batch() {
return Math.max(coordinatorConfig.probe.batch, this.running * coordinatorConfig.probe.pipeline * 4)
return this.levels.target
}
private async next(): Promise<Candidate | undefined> {
if (this.queue.length < this.batch / 2) {
if (this.queue.length < this.levels.low) {
this.refilling ??= this.claim().finally(() => (this.refilling = null))
if (this.queue.length === 0) await this.refilling
}
@@ -227,6 +275,33 @@ export class Prober {
private async claimTiers() {
// The queue empties, the lanes go dry and the share shrinks to its minimum until there's room again.
if (await this.space.short()) return
await this.claimCatalog()
await this.claimPending()
}
/** The pending ladder's share of a claim: at least pendingShare of the batch, and whatever the catalog's tiers left. */
private async claimPending() {
const want = Math.max(Math.floor(this.batch * coordinatorConfig.probe.pendingShare), this.batch - this.queue.length)
const probes = await PendingLadder.claim(want)
if (probes.length === 0) return
const { rows: cached } = await db.rawQuery(`SELECT message_id, status, result FROM probe_cache WHERE message_id = ANY(?) AND version = ?`, [
probes.map((p) => p.messageId),
PROBE_VERSION,
])
const known = new Map(cached.map((c: any) => [c.message_id, c]))
for (const probe of probes) {
const candidate: Candidate = { id: 0, releaseId: 0, kind: probe.kind, messageId: probe.messageId, pending: probe }
const hit: any = known.get(probe.messageId)
if (hit) {
this.stats.cached++
this.outcomes.push({ candidate, status: hit.status, inspection: hit.result ?? undefined, cached: true })
} else {
this.queue.push(candidate)
}
}
}
private async claimCatalog() {
for (const { kind, unreadable } of TIERS) {
const want = this.batch - this.queue.length
if (want <= 0) return
@@ -275,20 +350,46 @@ export class Prober {
}
}
/** The outcomes so far: candidates' status, inspections, and the releases' passworded and inner name. */
/**
* The outcomes so far: candidates' status, inspections, and the releases' passworded and inner name. A deadlock
* (the batch store's pending upsert locks the same rows; LOG.md (134)) keeps the outcomes for the next flush
* instead of losing the probes.
*/
private async flush() {
const outcomes = this.outcomes.splice(0)
if (outcomes.length === 0) return
for (const o of outcomes) if (o.status !== 'waiting') this.stats[o.status]++
const taken = this.outcomes.splice(0)
if (taken.length === 0) return
try {
await this.store(taken)
} catch (error: any) {
if (error?.code === '40P01' && this.deadlocks < DEADLOCK_RETRIES) {
this.deadlocks++
this.outcomes.unshift(...taken)
logger.warn(`Probing: storing results deadlocked, kept for the next flush (${this.deadlocks}).`)
return
}
throw error
}
this.deadlocks = 0
for (const o of taken) if (o.status !== 'waiting') this.stats[o.status]++
this.stats.pending += taken.filter((o) => o.candidate.pending && o.status !== 'waiting').length
}
private async store(taken: Outcome[]) {
// The ladder's outcomes apart from the catalog's: the next target, a name for the namer, or the end of the climb.
const pending = taken.filter((o) => o.candidate.pending)
const outcomes = taken.filter((o) => !o.candidate.pending)
await db.transaction(async (trx) => {
await trx.rawQuery(
`UPDATE probe_candidates pc SET status = v.status FROM jsonb_to_recordset(?::jsonb) AS v(id bigint, status text) WHERE pc.id = v.id`,
[jsonb(outcomes.map((o) => ({ id: o.candidate.id, status: o.status })))]
)
if (outcomes.length > 0) {
await trx.rawQuery(
`UPDATE probe_candidates pc SET status = v.status FROM jsonb_to_recordset(?::jsonb) AS v(id bigint, status text) WHERE pc.id = v.id`,
[jsonb(outcomes.map((o) => ({ id: o.candidate.id, status: o.status })))]
)
}
await PendingLadder.record(pending.map((o) => ({ probe: o.candidate.pending!, status: o.status, inspection: o.inspection })), trx)
// Final answers read from the news server, kept by message-id for the next catalog (a reset).
// One per message-id: the same article is a candidate of more than one release, and ON CONFLICT can't take a
// row twice in one statement.
const fresh = [...new Map(outcomes.filter((o) => !o.cached && o.status !== 'waiting').map((o) => [o.candidate.messageId, o])).values()]
const fresh = [...new Map(taken.filter((o) => !o.cached && o.status !== 'waiting').map((o) => [o.candidate.messageId, o])).values()]
if (fresh.length > 0) {
await trx.rawQuery(
`INSERT INTO probe_cache (message_id, status, result, version)
@@ -322,10 +423,20 @@ export class Prober {
entry.all.push({ kind: r.kind, result: r.result })
byRelease.set(id, entry)
}
// A twin by content names a release its own inspections didn't (hash_twins.ts; LOG.md (130)).
const twins = await twinNames(
done.filter((o) => o.inspection!.hash16k && o.inspection!.size).map((o) => ({ hash16k: o.inspection!.hash16k!, size: o.inspection!.size })),
trx
)
const twinOf = new Map<number, string>()
for (const o of done) {
const twin = o.inspection!.hash16k ? twins.get(twinKey(o.inspection!.hash16k, o.inspection!.size)) : undefined
if (twin && twin !== byRelease.get(o.candidate.releaseId)?.name) twinOf.set(o.candidate.releaseId, twin)
}
const updates = [...byRelease].map(([id, { name, all }]) => ({
id,
passworded: all.some((i) => i.result.passworded === true),
inner_name: innerName(all, name),
inner_name: innerName(all, name) ?? twinOf.get(id) ?? null,
}))
await trx.rawQuery(
`UPDATE releases r SET passworded = v.passworded, inner_name = v.inner_name, inspected_at = now()
@@ -365,7 +476,8 @@ export class Prober {
const { articles, bytes, done, missing, error } = this.stats
logger.info(
`Probed ${articles} articles in ${seconds.toFixed(0)} s: ${(articles / seconds).toFixed(1)}/s, ${((bytes * 8) / seconds / 1e6).toFixed(0)} Mbit/s; ` +
`${done} inspected, ${missing} missing, ${error} not yEnc; ${this.stats.cached} from the probe cache. ` +
`${done} inspected, ${missing} missing, ${error} not yEnc; ${this.stats.cached} from the probe cache; ` +
`the pending ladder ${this.stats.pending} probed, ${this.stats.discarded} discarded by signature. ` +
`Lanes: fetching ${(this.stats.fetchMs / 1000).toFixed(0)} s, waiting for candidates ${(this.stats.waitMs / 1000).toFixed(0)} s, ` +
`idle ${(this.stats.idleMs / 1000).toFixed(0)} s, inspecting ${(this.stats.inspectMs / 1000).toFixed(1)} s; ` +
`claims ${(this.stats.claimMs / 1000).toFixed(1)} s, flushes ${(this.stats.flushMs / 1000).toFixed(1)} s.`
-31
View File
@@ -1,31 +0,0 @@
import db from '@adonisjs/lucid/services/db'
/**
* range_blocks (migration 3900): per group, each run of consecutive done ranges with its posting time span, read by
* release_final(). A few thousand rows from ~650k ranges, rebuilt whole in one statement each time; a run breaks at a
* range that isn't done or doesn't start where the one before ended.
*/
class RangeBlocks {
async rebuild(): Promise<number> {
return db.transaction(async (trx) => {
await trx.rawQuery(`DELETE FROM range_blocks`)
const result = await trx.rawQuery(
`WITH r AS (
SELECT group_id, start_id, end_id, status = 'done' AS done, posted_low, posted_high,
min(start_id) OVER (PARTITION BY group_id) AS group_first,
lag(status = 'done') OVER w AS done_before, lag(end_id) OVER w AS end_before
FROM ranges WINDOW w AS (PARTITION BY group_id ORDER BY start_id)),
runs AS (
SELECT *, sum(CASE WHEN done IS DISTINCT FROM done_before OR end_before IS DISTINCT FROM start_id - 1 THEN 1 ELSE 0 END)
OVER (PARTITION BY group_id ORDER BY start_id) AS run
FROM r)
INSERT INTO range_blocks (group_id, start_id, end_id, posted_low, posted_high, at_group_start)
SELECT group_id, min(start_id), max(end_id), min(posted_low), max(posted_high), min(start_id) = min(group_first)
FROM runs WHERE done GROUP BY group_id, run`
)
return result.rowCount ?? 0
})
}
}
export default new RangeBlocks()
+30 -6
View File
@@ -1,5 +1,7 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { SOURCES } from '#lib/release/source'
import { TINY_MIN_BYTES, NONSENSE_MIN_BYTES } from '#lib/rules/index'
/** Raised when a rule changes: verdicts of an older version are judged again (a full round, right away). */
export const JUDGE_VERSION = 1
@@ -27,6 +29,14 @@ const MEDIA =
const SCRAPED_POSTERS = `celeb[-_ ]{0,1}movie|celebwow|celebfx|iluvcelebs|nude[-_ ]{0,1}celebs|celeb-sex-tapes`
/** An executable's name. */
const EXECUTABLE = `\\.(exe|scr|com|bat|cmd|msi|lnk|vbs|js|jar|pif|cpl|hta|ps1|dll)$`
/**
* The tiny rule's name tests, lib/rules/rule_set.ts's RELEASE_LIKE_SPACED, EBOOK, TINY_CONTENT and
* STUB_EXEMPT_GROUPS in Postgres's dialect (and no "?", see MEDIA).
*/
const TINY_RELEASE_LIKE = `(\\y(19|20)[0-9][0-9]\\y|[._ -](720p|1080p|2160p|x264|x265|hevc|bluray|web-{0,1}dl|webrip|hdtv|xvid|s[0-9][0-9]e[0-9][0-9]|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v[0-9]+(\\.[0-9]+)+)\\y|-[a-z0-9]{2,}$|\\.[a-z0-9]+\\.[a-z0-9]+\\.)`
const TINY_EBOOK = `\\y(epub|mobi|azw3{0,1}|pdf|djvu|cbr|cbz|e-{0,1}books{0,1})\\y`
const TINY_CONTENT = `\\y(subs{0,1}|subtitles{0,1}|subpack|srt|untertitel|ondertitels{0,1}|ondertiteling|legendas{0,1}|napisy|sottotitoli|cracks{0,1}|cracked|keygen|keymaker|trainer|patch|patched|serials{0,1}|activator|loader|nocd|no-cd|fix|covers{0,1}|artwork|booklet|scans{0,1}|proof|sample|info|readme)\\y`
const TINY_EXEMPT_GROUPS = `(sound|mp3|karaoke|music|audio|flac|lossless|e-{0,1}book|epub|comic|guitar|font|magazine)`
export interface Rule {
name: string
@@ -88,10 +98,10 @@ export const RULES: Rule[] = [
// regex on all of releases was 45 s, every round (LOG.md 2026-10-19 (80)).
sql: (scoped) =>
scoped
? `SELECT r.id FROM releases r ${only(scoped, 'r.id')}
WHERE r.poster ~* '${SCRAPED_POSTERS}' AND r.source = 'subject' AND r.file_total IS NULL`
: `SELECT r.id FROM posters p JOIN releases r ON left(r.poster, 200) = p.poster
WHERE p.poster ~* '${SCRAPED_POSTERS}' AND r.poster ~* '${SCRAPED_POSTERS}' AND r.source = 'subject' AND r.file_total IS NULL`,
? `SELECT r.id FROM releases r ${only(scoped, 'r.id')} JOIN posters p ON p.id = r.poster_id
WHERE p.poster ~* '${SCRAPED_POSTERS}' AND r.source = ${SOURCES.subject} AND r.file_total IS NULL`
: `SELECT r.id FROM posters p JOIN releases r ON r.poster_id = p.id
WHERE p.poster ~* '${SCRAPED_POSTERS}' AND r.source = ${SOURCES.subject} AND r.file_total IS NULL`,
},
{
name: 'locked',
@@ -99,13 +109,27 @@ export const RULES: Rule[] = [
// releases_passworded_idx: a scan of all of releases was 30 s a round.
sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')} WHERE r.passworded AND NOT EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id)`,
},
{
name: 'tiny',
says: 'one file of one article, 2–100 kB, under a release-like name: the exe-stub flood in miniature',
// The catalog's side of the edge's tiny-archive rule (lib/rules/rule_set.ts isTinyArchive; LOG.md (131),
// (135)): the same name tests in Postgres's regex dialect ("\y" for "\b", "{0,1}" for "?"), the readable flag
// for its scrambled and random tests, the group set for its groups. The 2015–17 flood that the edge indexed
// before the rule: 1.5M rows, 98% of them with an archive candidate, the rest lone par2 files and scrambled
// names, so no join on the candidates. A scan of releases (9 GB) a full round.
sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')}
WHERE r.files = 1 AND r.segments = 1 AND r.bytes >= ${TINY_MIN_BYTES} AND r.bytes < ${NONSENSE_MIN_BYTES}
AND r.readable AND NOT coalesce(r.sidecar_only, false)
AND r.name ~* '${TINY_RELEASE_LIKE}' AND r.name !~* '${TINY_EBOOK}' AND r.name !~* '${TINY_CONTENT}'
AND NOT EXISTS (SELECT 1 FROM group_sets gs WHERE gs.id = r.group_set_id AND gs.groups::text ~* '${TINY_EXEMPT_GROUPS}')`,
},
{
name: 'unusable',
says: 'random names, no name inside, no par2 or inner-name merge, no spot, a day after settling, and final',
// Final (release_final): next to an unread range its par2 or the rest of its files may still come (LOG.md 2026-10-16 (67)).
// Finished (settled_at): a provisional tail release's par2 or the rest of its files may still come.
sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')}
WHERE r.settled_at < now() - interval '1 day' AND NOT r.readable AND r.inner_name IS NULL
AND release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete)
AND r.settled_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM release_merges m WHERE m.into_id = r.id)
AND NOT EXISTS (SELECT 1 FROM release_spots s WHERE s.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM par2_sets p WHERE p.release_id = r.id)`,
+58 -33
View File
@@ -1,6 +1,7 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import NzbService from '#services/nzb_service'
import { SOURCES, sourceName } from '#lib/release/source'
export type SortOrder = 'newest' | 'oldest' | 'largest' | 'files' | 'name'
@@ -51,7 +52,7 @@ export interface ReleaseSummary {
size: number
readable: boolean
complete: boolean | null
/** The complete verdict is definite: done ranges cover the release's posting times and 6 hours either side (release_final). */
/** The complete verdict is definite: the release is finished (voyager 3: the window closed on it; a tail release is provisional until then). */
final: boolean
category: number
settled: boolean
@@ -84,6 +85,19 @@ function contains(word: string): string {
return `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
}
/**
* A search word's condition on release_texts: anywhere in a text (ILIKE), or with a leading "^" at a word's start, the
* text's or after a character that's no letter or digit ("^zed" finds "Zed.Dead", "The_Zed", not "Facialized"). Both
* use the trigram index.
*/
function wordMatch(word: string): { sql: string; binding: string } {
if (word.startsWith('^') && word.length > 1) {
const rest = word.slice(1).replace(/[\\^$.|?*+()[\]{}]/g, (c) => `\\${c}`)
return { sql: 't.text ~* ?', binding: `(^|[^[:alnum:]])${rest}` }
}
return { sql: 't.text ILIKE ?', binding: contains(word) }
}
/** Searching and browsing the catalog, for the web interface and Newznab. */
class SearchService {
async search(params: SearchParams) {
@@ -91,27 +105,34 @@ class SearchService {
// Judged junk (app/services/release_judge.ts) is left out unless asked for.
if (!params.judged) where.push('NOT EXISTS (SELECT 1 FROM release_verdicts v WHERE v.release_id = r.id)')
const bindings: unknown[] = []
// Every word in the name or the inner name the prober found, through their trigram indexes; "The.Block.AU" and "the block au" alike.
// Every word in one of the release's texts (its name, the names the prober found inside; release_texts, by its
// trigram index, which yields ids: never an OR across the two tables); "The.Block.AU" and "the block au" alike;
// "^zed" only at a word's start.
for (const word of (params.q ?? '').split(/[\s._-]+/).filter(Boolean).slice(0, 8)) {
where.push('(r.name ILIKE ? OR r.inner_name ILIKE ?)')
bindings.push(contains(word), contains(word))
const match = wordMatch(word)
where.push(`r.id IN (SELECT t.release_id FROM release_texts t WHERE ${match.sql})`)
bindings.push(match.binding)
}
if (params.readable) where.push('(r.readable OR r.inner_name IS NOT NULL)')
if (params.complete) where.push('r.complete')
// Incomplete only when final: a release next to unread ranges may yet get its missing files (release_final).
if (params.notIncomplete) where.push('(r.complete IS NOT FALSE OR NOT release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete))')
// Incomplete only when final: a provisional tail release may yet get its missing files.
if (params.notIncomplete) where.push('(r.complete IS NOT FALSE OR r.settled_at IS NULL)')
if (params.unlocked) where.push('r.passworded IS NOT TRUE')
if (params.source) {
where.push('r.source = ?')
bindings.push(params.source)
const code = (SOURCES as Record<string, number>)[params.source]
if (code === undefined) where.push('false')
else {
where.push('r.source = ?')
bindings.push(code)
}
}
// A poster search: the matching posters first (posters, by trigram), each one's releases then by
// releases_poster_newest_idx (below). "poster ILIKE" on releases walked all 50M rows newest first: 30+ min for a
// poster with few releases; as a filter on the posters found the planner still walked them (LOG.md 2026-10-16 (70)).
let posters: string[] | null = null
let posters: number[] | null = null
if (params.poster) {
const { rows: matched } = await db.rawQuery(`SELECT poster FROM posters WHERE poster ILIKE ? LIMIT ${MAX_POSTERS}`, [contains(params.poster)])
posters = matched.map((m: { poster: string }) => m.poster)
const { rows: matched } = await db.rawQuery(`SELECT id FROM posters WHERE poster ILIKE ? LIMIT ${MAX_POSTERS}`, [contains(params.poster)])
posters = matched.map((m: { id: string }) => Number(m.id))
}
if (params.categories?.length) {
where.push('r.category = ANY(?)')
@@ -143,14 +164,14 @@ class SearchService {
const whereSql = where.join(' AND ')
const countLimit = offset + (params.countLimit ?? COUNT_LIMIT)
if (posters !== null && posters.length === 0) return { page, perPage, offset, total: 0, totalCapped: false, releases: [] as ReleaseSummary[] }
// Per poster found (by first 200 characters, as posters and the index keep them; a btree entry holds at most 2,704
// bytes): its releases matching everything else, the first offset + perPage in the order asked; then merged.
// Per poster found: its releases matching everything else (releases_poster_newest_idx), the first offset + perPage
// in the order asked; then merged.
const source =
posters === null
? { from: 'releases r', where: whereSql, bindings: [] as unknown[] }
: {
from: `unnest(?::text[]) AS p(poster) CROSS JOIN LATERAL (
SELECT r.* FROM releases r WHERE left(r.poster, 200) = p.poster AND ${whereSql} ORDER BY ${order} LIMIT ${offset + perPage}) r`,
from: `unnest(?::int[]) AS p(id) CROSS JOIN LATERAL (
SELECT r.* FROM releases r WHERE r.poster_id = p.id AND ${whereSql} ORDER BY ${order} LIMIT ${offset + perPage}) r`,
where: 'true',
bindings: [posters] as unknown[],
}
@@ -158,16 +179,17 @@ class SearchService {
posters === null
? db.rawQuery(`SELECT count(*) AS total FROM (SELECT 1 FROM releases r WHERE ${whereSql} LIMIT ${countLimit + 1}) x`, bindings)
: db.rawQuery(
`SELECT count(*) AS total FROM (SELECT 1 FROM unnest(?::text[]) AS p(poster) CROSS JOIN LATERAL (
SELECT 1 FROM releases r WHERE left(r.poster, 200) = p.poster AND ${whereSql} LIMIT ${countLimit + 1}) x LIMIT ${countLimit + 1}) y`,
`SELECT count(*) AS total FROM (SELECT 1 FROM unnest(?::int[]) AS p(id) CROSS JOIN LATERAL (
SELECT 1 FROM releases r WHERE r.poster_id = p.id AND ${whereSql} LIMIT ${countLimit + 1}) x LIMIT ${countLimit + 1}) y`,
[posters, ...bindings]
)
const [{ rows }, { rows: counted }] = await Promise.all([
db.rawQuery(
`SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.files, r.bytes, r.readable, r.complete, r.category, r.settled_at, r.passworded, r.inner_name,
`SELECT r.id, r.name, r.source, po.poster, gs.groups, r.posted_at, r.file_total, r.files, r.bytes, r.readable, r.complete, r.category, r.settled_at, r.passworded, r.inner_name,
EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id) AS password_known,
release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete) AS final
FROM ${source.from} WHERE ${source.where} ORDER BY ${order} LIMIT ? OFFSET ?`,
r.settled_at IS NOT NULL AS final
FROM ${source.from} LEFT JOIN posters po ON po.id = r.poster_id LEFT JOIN group_sets gs ON gs.id = r.group_set_id
WHERE ${source.where} ORDER BY ${order} LIMIT ? OFFSET ?`,
[...source.bindings, ...bindings, perPage, offset]
),
counting,
@@ -182,9 +204,9 @@ class SearchService {
releases: (rows as any[]).map((r): ReleaseSummary => ({
id: Number(r.id),
name: r.name,
source: r.source,
poster: r.poster,
groups: r.groups,
source: sourceName(r.source),
poster: r.poster ?? null,
groups: r.groups ?? [],
postedAt: r.posted_at,
fileTotal: r.file_total,
fileCount: Number(r.files),
@@ -203,20 +225,24 @@ class SearchService {
/** A release with its files, read from its chunks. */
async release(id: number) {
const r = await db.from('releases').where('id', id).first()
const {
rows: [r],
} = await db.rawQuery(
`SELECT r.*, po.poster, gs.groups FROM releases r LEFT JOIN posters po ON po.id = r.poster_id LEFT JOIN group_sets gs ON gs.id = r.group_set_id WHERE r.id = ?`,
[id]
)
if (!r) return null
const [files, { rows: inspections }, { rows: finality }, passwords] = await Promise.all([
const [files, { rows: inspections }, passwords] = await Promise.all([
NzbService.files(id),
db.rawQuery(`SELECT kind, result, created_at FROM release_inspections WHERE release_id = ? ORDER BY candidate_id`, [id]),
db.rawQuery(`SELECT release_final(?, ?, ?, ?, ?, ?) AS final`, [r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete]),
NzbService.passwords(id),
])
return {
id: Number(r.id),
name: r.name,
source: r.source,
poster: r.poster,
groups: r.groups,
source: sourceName(r.source),
poster: r.poster ?? null,
groups: r.groups ?? [],
postedAt: r.posted_at,
fileTotal: r.file_total,
fileCount: Number(r.files),
@@ -224,11 +250,10 @@ class SearchService {
segments: Number(r.segments),
readable: r.readable,
complete: r.complete,
final: finality[0].final,
final: r.settled_at !== null,
hasPar2: r.has_par2,
category: r.category,
firstSeenAt: r.first_seen_at,
lastSeenAt: r.last_seen_at,
settledAt: r.settled_at,
passworded: r.passworded,
/** Known passwords (spots, their NZBs), the most trusted first; the first is in the NZB. */
@@ -294,9 +319,9 @@ class SearchService {
// History by articles; the pass: the history ranges before those deferred behind them (priority
// 2000 apart; e.g. groups added later that go first).
const { rows: ranges } = await db.rawQuery(
`WITH h AS (SELECT status, priority, end_id - start_id + 1 AS n FROM ranges WHERE priority >= 0),
`WITH h AS (SELECT status, priority, end_id - start_id + 1 AS n FROM ranges WHERE priority >= 0 AND status <> 'ancient'),
pass AS (SELECT (floor(coalesce(min(priority) FILTER (WHERE status <> 'done'), 0) / 2000) + 1) * 2000 AS below FROM h)
SELECT (SELECT count(*) FILTER (WHERE status = 'done') FROM ranges) AS done, (SELECT count(*) FROM ranges) AS total,
SELECT (SELECT count(*) FILTER (WHERE status = 'done') FROM ranges) AS done, (SELECT count(*) FROM ranges WHERE status <> 'ancient') AS total,
coalesce(sum(n) FILTER (WHERE status = 'done'), 0) AS history_done, coalesce(sum(n), 0) AS history,
coalesce(sum(n) FILTER (WHERE status = 'done' AND priority < (SELECT below FROM pass)), 0) AS pass_done,
coalesce(sum(n) FILTER (WHERE priority < (SELECT below FROM pass)), 0) AS pass,
+25 -38
View File
@@ -1,48 +1,35 @@
import db from '@adonisjs/lucid/services/db'
/** Releases unchanged for this long are settled (ranges are read in order; the merger adds within minutes). */
const QUIET_HOURS = 6
/**
* History: a release whose last post is QUIET_HOURS old gets nothing new from the tail, only from the
* neighbouring ranges' fragments, which the merger adds within minutes: settled once unchanged this long.
* Waiting QUIET_HOURS after it was read held every history release back from phase 2 for six hours after
* each reset (the hourly sweeps' resets kept the prober idle; LOG.md 2026-10-12 (13)).
*/
const QUIET_HISTORY_MINUTES = 30
const BATCH = 50_000
/** Hours a complete provisional release stays open for its window's continuation before it is settled here. */
export const SETTLE_COMPLETE_HOURS = 6
/**
* Settles releases (REBUILD.md, part 8): once nothing has been added for
* QUIET_HOURS (QUIET_HISTORY_MINUTES once its last post is that old), its completeness is decided: complete when every file the
* [i/N] counter announced was found, unknown without a counter (phase 2
* decides those by their par2 sets: app/services/par2_completeness.ts).
* Phase 2 picks its work from settled releases.
* Provisional releases (REBUILD.md, "Voyager 3: window indexing", 2): a release the window hadn't closed on at a
* lease's end is written with `settled_at` null, for the window's continuation to finish. A continuation may
* never come (the range-lease path of the first run left 109k such rows, LOG.md (118); a group's next lease is
* hours away), and the catalog shows the release as still collecting. A complete one — every file its counter
* announced found — can't gain a counted file any more, so once it has been quiet for SETTLE_COMPLETE_HOURS it is
* settled here, as the finish would: `settled_at` set (the judge's trigger fires on it), its waiting probe
* candidates made claimable. Incomplete ones keep waiting: the continuation adds their files.
*/
class Settler {
/** Up to `batches` batches of BATCH (all there are without). */
async run(batches = Infinity): Promise<number> {
let settled = 0
for (let batch = 0; batch < batches; batch++) {
// Their probe candidates become claimable (Prober.claim), the unreadable ones first.
const { rows } = await db.rawQuery(
`WITH settled AS (
UPDATE releases SET settled_at = now(), complete = CASE WHEN file_total IS NULL THEN complete ELSE files >= file_total END
WHERE id IN (SELECT id FROM releases WHERE settled_at IS NULL AND last_seen_at < now() - make_interval(mins => ?)
AND (last_seen_at < now() - make_interval(hours => ?) OR coalesce(last_posted_at, posted_at) < now() - make_interval(hours => ?)) LIMIT ?)
RETURNING id, readable, files, bytes, poster, sidecar_only),
marked AS (UPDATE probe_candidates pc SET ready = true, unreadable = NOT s.readable FROM settled s WHERE pc.release_id = s.id),
-- A small one of sidecars only (the batch store's sidecar_only) may be an upload's .nfo or .nzb that came apart
-- from it: the merger's sidecar fold looks at them.
queued AS (INSERT INTO fold_candidates (release_id) SELECT id FROM settled WHERE sidecar_only AND files <= 3 AND bytes < 1000000 AND poster IS NOT NULL
ON CONFLICT DO NOTHING)
SELECT count(*)::int AS n FROM settled`,
[QUIET_HISTORY_MINUTES, QUIET_HOURS, QUIET_HOURS, BATCH]
async settleComplete(quietHours = SETTLE_COMPLETE_HOURS): Promise<number> {
return db.transaction(async (trx) => {
const { rows } = await trx.rawQuery(
`UPDATE releases SET settled_at = now()
WHERE settled_at IS NULL AND complete
AND first_seen_at < now() - make_interval(hours => ?) AND last_posted_at < now() - make_interval(hours => ?)
RETURNING id`,
[quietHours, quietHours]
)
const n = rows[0].n
settled += n
if (n < BATCH) return settled
}
return settled
if (rows.length > 0)
await trx.rawQuery(
`UPDATE probe_candidates pc SET ready = true, unreadable = NOT r.readable FROM releases r
WHERE pc.release_id = ANY(?::bigint[]) AND r.id = pc.release_id AND pc.status = 'waiting' AND NOT pc.ready`,
[rows.map((r: { id: string }) => Number(r.id))]
)
return rows.length
})
}
}
+4 -4
View File
@@ -56,7 +56,7 @@ export class SidecarFolder {
// A candidate whose release went (merged, deleted) leaves with its batch: a separate scan for those over the whole
// queue took 5+ min a round at 13M (LOG.md 2026-10-17 (72)).
const { rows: queued } = await db.rawQuery(
`SELECT f.release_id AS queued_id, r.id, r.release_key, r.group_id, r.poster, r.posted_at, r.files, r.file_total, r.readable
`SELECT f.release_id AS queued_id, r.id, r.group_id, r.poster_id, r.posted_at, r.files, r.file_total, r.readable
FROM fold_candidates f LEFT JOIN releases r ON r.id = f.release_id ORDER BY f.created_at LIMIT ?`,
[BATCH]
)
@@ -70,10 +70,10 @@ export class SidecarFolder {
round.sidecars++
if (!c.posted_at) continue
const { rows: siblings } = await db.rawQuery(
`SELECT r.id, r.release_key, r.group_id, r.files, r.file_total, r.posted_at, r.readable, r.name FROM releases r
`SELECT r.id, r.group_id, r.files, r.file_total, r.posted_at, r.readable, r.name FROM releases r
WHERE r.posted_at BETWEEN ?::timestamptz - make_interval(mins => ?) AND ?::timestamptz + make_interval(mins => ?)
AND r.group_id = ? AND r.poster = ? AND r.id <> ? AND r.files > ?`,
[c.posted_at, WINDOW_MINUTES, c.posted_at, WINDOW_MINUTES, c.group_id, c.poster, c.id, c.files]
AND r.group_id = ? AND r.poster_id = ? AND r.id <> ? AND r.files > ?`,
[c.posted_at, WINDOW_MINUTES, c.posted_at, WINDOW_MINUTES, c.group_id, c.poster_id, c.id, c.files]
)
const sibling = pickSibling(files, c.file_total ? Number(c.file_total) : null, siblings.map((s: any) => ({ ...s, id: Number(s.id), files: Number(s.files), file_total: s.file_total === null ? null : Number(s.file_total) })))
if (!sibling) continue
+16 -10
View File
@@ -9,6 +9,9 @@ import { decodeSpotBinary } from '#lib/spotnet/index'
import { parseNzb, type ParsedNzb } from '#lib/nzb/index'
import { parseSubject } from '#lib/headers/index'
import { releaseIdentity } from '#lib/release/index'
import { identityKey, releaseKey } from '#lib/release/keys'
import { SOURCES } from '#lib/release/source'
import CatalogRefs from '#services/catalog_refs'
/** Spots claimed per round, newest first. */
const BATCH = 20
@@ -251,8 +254,8 @@ export class SpotInspector {
}
if (wanted.length === 0) return 0
const { rows } = await db.rawQuery(
`SELECT id, identity_key, group_id, posted_at FROM releases WHERE identity_key = ANY(?) AND group_id = ANY(?)`,
[[...new Set(wanted.map((w) => w.key))], [...new Set(wanted.flatMap((w) => w.groups))]]
`SELECT id, encode(identity_key, 'hex') AS identity_key, group_id, posted_at FROM releases WHERE identity_key = ANY(?::bytea[]) AND group_id = ANY(?)`,
[[...new Set(wanted.map((w) => w.key))].map((k) => identityKey(k)), [...new Set(wanted.flatMap((w) => w.groups))]]
)
const byKey = new Map<string, { id: number; group: number; at: number | null }[]>()
for (const r of rows) {
@@ -275,7 +278,7 @@ export class SpotInspector {
await db.transaction(async (trx) => {
// A release of the spot's own goes now that the header pass built its files' releases.
if (spot.release_id) {
const own = await trx.from('releases').where('id', spot.release_id).where('source', 'spot').select('id').first()
const own = await trx.from('releases').where('id', spot.release_id).where('source', SOURCES.spot).select('id').first()
if (own) {
await trx.from('release_spots').where('release_id', own.id).delete()
await trx.from('release_passwords').where('release_id', own.id).delete()
@@ -335,16 +338,19 @@ export class SpotInspector {
const groups = [...new Set(files.flatMap((f: any) => f.groups ?? []))]
const groupId = groups.length > 0 ? await this.groupId(groups[0]) : null
const key = createHash('sha1').update(`spot\n${spotId}`).digest('hex')
// Its poster and group set by id (voyager 3, migration 6000), as the batch store stores its releases'.
const posterId = spot.poster ? await CatalogRefs.posterId(db, spot.poster) : null
const groupSetId = await CatalogRefs.groupSetId(db, groups)
const [row] = await db
.table('releases')
.insert({
release_key: `spot:${key}`,
identity_key: key,
release_key: releaseKey(key, groupId, null),
identity_key: identityKey(key),
group_id: groupId,
group_set_id: groupSetId,
poster_id: posterId,
name: spot.title.slice(0, 1000),
source: 'spot',
poster: spot.poster,
groups: JSON.stringify(groups),
source: SOURCES.spot,
posted_at: spot.posted_at,
last_posted_at: spot.posted_at,
files: files.length,
@@ -363,8 +369,8 @@ export class SpotInspector {
const releaseId = Number(row.id)
await db.transaction(async (trx) => {
await trx.table('release_spots').insert({ release_id: releaseId, spot_id: spotId, files: files.length }).onConflict(['release_id', 'spot_id']).ignore()
// Its poster for poster search, as the batch store adds its releases' (migration 4000).
if (spot.poster) await trx.rawQuery(`INSERT INTO posters (poster) VALUES (left(?, 200)) ON CONFLICT DO NOTHING`, [spot.poster])
// Its title, searchable (release_texts).
await trx.rawQuery(`INSERT INTO release_texts (release_id, kind, text) VALUES (?, 1, left(?, 255)) ON CONFLICT DO NOTHING`, [releaseId, spot.title])
if (spot.password) {
await trx.table('release_passwords').insert({ release_id: releaseId, password: spot.password, source: spot.password_source ?? 'spot-text', spot_id: spotId }).onConflict(['release_id', 'password']).ignore()
}
+2 -1
View File
@@ -1,5 +1,6 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { SOURCES } from '#lib/release/source'
import env from '#start/env'
import coordinatorConfig from '#config/coordinator'
import { MissingArticle, XoverClient } from '#lib/nntp/index'
@@ -183,7 +184,7 @@ export class Spotter {
await trx.from('release_spots').where('spot_id', targetId).delete()
await trx.from('release_passwords').where('spot_id', targetId).delete()
// A release the spot made of its own goes with it.
if (target.release_id) await trx.from('releases').where('id', target.release_id).where('source', 'spot').delete()
if (target.release_id) await trx.from('releases').where('id', target.release_id).where('source', SOURCES.spot).delete()
})
logger.info(`Spot "${target.title}" removed by ${personal ? 'its poster' : 'a moderator'}.`)
}
+343
View File
@@ -0,0 +1,343 @@
import db from '@adonisjs/lucid/services/db'
import { readdir, stat, statfs } from 'node:fs/promises'
import { join } from 'node:path'
import coordinatorConfig from '#config/coordinator'
import GroupLeases from '#services/group_leases'
import type { Lane } from '#lib/edge/index'
export interface RangeBand {
band: string
planned: number
plannedArticles: number
leased: number
done: number
doneArticles: number
failed: number
}
export interface HeldGroup {
node: string
group: string
lane: Lane
range: { start: number; end: number; next: number } | null
heartbeatSeconds: number
snapshotNext: number | null
snapshotSeconds: number | null
snapshotMb: number | null
rangesDone: number
headers: number
}
export interface SnapshotFile {
group: string
lane: Lane
mb: number
hours: number
}
export interface Status {
at: string
catalog: {
tablespace: string
gb: number
capGb: number
full: boolean
freeGb: number
}
bands: RangeBand[]
lastHour: {
indexedArticles: number
doneRanges: number
storedArticles: number
storedRanges: number
}
corpus: { pendingRanges: number; pendingArticles: number }
spool: { ranges: number; chunks: number }
tail: { groups: number; ranges: number; articles: number }
/** The 'ancient' ranges: articles below the low watermark the groups were planned at (expired, never fetched). */
ancient: { groups: number; articles: number }
held: HeldGroup[]
snapshots: { count: number; gb: number; oldestHours: number | null; largest: SnapshotFile[] }
releases: { estimated: number; provisional: number }
/** `ready` and `waiting` are the planner's estimates (the last analyze), the others counts. */
probes: { ready: number; waiting: number; pendingWaiting: number; pendingNamed: number }
judge: { dirty: number }
nodes: { name: string; lastSeenSeconds: number | null }[]
}
const seconds = (since: Date | string | null) =>
since ? Math.round((Date.now() - new Date(since).getTime()) / 1000) : null
/**
* What the indexer is doing, in one object (commands/coordinator_status.ts prints it): the catalog's room on the
* NVMe, the ranges by band (the tail, the capture windows, the history), the last hour's rates, the held groups
* with their snapshots, the phase-2 backlogs. Every number comes from the database, the snapshot directory or the
* filesystem; nothing is kept between calls.
*/
class StatusService {
async collect(): Promise<Status> {
const tail = coordinatorConfig.tail.priority
const cap = coordinatorConfig.catalogMaxNvmeGb
// The catalog's tablespace only (CatalogSpace measures it every minute anyway): pg_database_size walks the
// chunk partitions and the probe cache on the HDD, minutes on homelab.
const { rows: space } = await db.rawQuery(
`SELECT coalesce(nullif(current_setting('default_tablespace'), ''), 'pg_default') AS tablespace,
pg_tablespace_size(coalesce(nullif(current_setting('default_tablespace'), ''), 'pg_default')) AS bytes`
)
const tablespaceGb = Number(space[0].bytes) / 1e9
const fs = await statfs('/').catch(() => null)
const freeGb = fs ? (fs.bavail * fs.bsize) / 1e9 : 0
// The ranges by band: the tail, the capture windows (priorities 0–2; the sampling ranges in other years are
// capture too, at their history priorities), the history by its thousand.
const { rows: bands } = await db.rawQuery(
`SELECT CASE WHEN priority <= ? THEN 'tail'
WHEN priority < 0 THEN 'raised'
WHEN capture AND priority <= 2 THEN 'window ' || priority
WHEN priority < 1000 THEN 'history 0-999'
ELSE 'history ' || (priority / 1000) * 1000 || '+' END AS band,
count(*) FILTER (WHERE status = 'planned') AS planned,
coalesce(sum(end_id - next_id + 1) FILTER (WHERE status = 'planned'), 0) AS planned_articles,
count(*) FILTER (WHERE status = 'leased') AS leased,
count(*) FILTER (WHERE status = 'done') AS done,
coalesce(sum(end_id - start_id + 1) FILTER (WHERE status = 'done'), 0) AS done_articles,
count(*) FILTER (WHERE status = 'failed') AS failed
FROM ranges GROUP BY 1
ORDER BY min(CASE WHEN priority <= ? THEN 0 WHEN priority < 0 THEN 1 WHEN capture AND priority <= 2 THEN 2 + priority
WHEN priority < 1000 THEN 10 ELSE 11 + priority / 1000 END)`,
[tail, tail]
)
const { rows: hour } = await db.rawQuery(
`SELECT coalesce(sum(end_id - start_id + 1) FILTER (WHERE done_at > now() - interval '1 hour'), 0) AS indexed,
count(*) FILTER (WHERE done_at > now() - interval '1 hour') AS done_ranges,
coalesce(sum(end_id - start_id + 1) FILTER (WHERE stored_at > now() - interval '1 hour'), 0) AS stored,
count(*) FILTER (WHERE stored_at > now() - interval '1 hour') AS stored_ranges,
count(*) FILTER (WHERE capture AND stored_at IS NULL AND status <> 'done') AS corpus_pending,
coalesce(sum(end_id - start_id + 1) FILTER (WHERE capture AND stored_at IS NULL AND status <> 'done'), 0) AS corpus_pending_articles,
count(*) FILTER (WHERE status IN ('planned', 'leased') AND spooled_chunks > 0) AS spooled_ranges,
coalesce(sum(spooled_chunks) FILTER (WHERE status IN ('planned', 'leased')), 0) AS spooled_chunks,
count(DISTINCT group_id) FILTER (WHERE status = 'planned' AND priority <= ?) AS tail_groups,
count(*) FILTER (WHERE status = 'planned' AND priority <= ?) AS tail_ranges,
coalesce(sum(end_id - next_id + 1) FILTER (WHERE status = 'planned' AND priority <= ?), 0) AS tail_articles,
count(DISTINCT group_id) FILTER (WHERE status = 'ancient') AS ancient_groups,
coalesce(sum(end_id - start_id + 1) FILTER (WHERE status = 'ancient'), 0) AS ancient_articles
FROM ranges`,
[tail, tail, tail]
)
const h = hour[0]
const ttl = coordinatorConfig.leaseTtlSeconds
const { rows: held } = await db.rawQuery(
`SELECT n.name AS node, g.name AS "group", l.lane, l.heartbeat_at, l.snapshot_next, l.snapshot_at, l.ranges_done, l.headers,
r.start_id, r.end_id, r.next_id, l.group_id
FROM group_leases l JOIN groups g ON g.id = l.group_id JOIN nodes n ON n.id = l.node_id
LEFT JOIN ranges r ON r.id = l.range_id
WHERE l.node_id IS NOT NULL AND l.heartbeat_at >= now() - make_interval(secs => ?)
ORDER BY l.lane, g.name`,
[ttl]
)
const snapshotSizes = new Map<string, number>()
const files: SnapshotFile[] = []
const names = new Map<number, string>(
(await db.from('groups').select('id', 'name')).map((g) => [Number(g.id), g.name as string])
)
for (const file of await readdir(GroupLeases.dir).catch(() => [] as string[])) {
const m = /^(\d+)(-tail)?\.gz$/.exec(file)
if (!m) continue
const s = await stat(join(GroupLeases.dir, file)).catch(() => null)
if (!s) continue
const lane: Lane = m[2] ? 'tail' : 'history'
snapshotSizes.set(`${m[1]}:${lane}`, s.size)
files.push({
group: names.get(Number(m[1])) ?? m[1],
lane,
mb: s.size / 1e6,
hours: (Date.now() - s.mtimeMs) / 3600_000,
})
}
files.sort((a, b) => b.mb - a.mb)
// The probe backlog from the planner's estimates of the partial indexes (counting 11 M rows took 10 s on
// homelab); the rest are small partial indexes.
const { rows: releases } = await db.rawQuery(
`SELECT (SELECT reltuples::bigint FROM pg_class WHERE oid = 'releases'::regclass) AS estimated,
(SELECT count(*) FROM releases WHERE settled_at IS NULL) AS provisional,
(SELECT greatest(0, reltuples)::bigint FROM pg_class WHERE oid = 'probe_candidates_ready_idx'::regclass) AS ready,
(SELECT greatest(0, reltuples)::bigint FROM pg_class WHERE oid = 'probe_candidates_waiting_idx'::regclass) AS waiting,
(SELECT count(*) FROM pending_releases WHERE status = 0) AS pending_waiting,
(SELECT count(*) FROM pending_releases WHERE status = 4) AS pending_named,
(SELECT count(*) FROM judge_dirty) AS dirty`
)
const r = releases[0]
const nodes = await db.from('nodes').select('name', 'last_seen_at').orderBy('name')
return {
at: new Date().toISOString(),
catalog: {
tablespace: space[0].tablespace,
gb: tablespaceGb,
capGb: cap,
full: cap > 0 && tablespaceGb > cap,
freeGb,
},
bands: bands.map((b: any) => ({
band: b.band,
planned: Number(b.planned),
plannedArticles: Number(b.planned_articles),
leased: Number(b.leased),
done: Number(b.done),
doneArticles: Number(b.done_articles),
failed: Number(b.failed),
})),
lastHour: {
indexedArticles: Number(h.indexed),
doneRanges: Number(h.done_ranges),
storedArticles: Number(h.stored),
storedRanges: Number(h.stored_ranges),
},
corpus: {
pendingRanges: Number(h.corpus_pending),
pendingArticles: Number(h.corpus_pending_articles),
},
spool: { ranges: Number(h.spooled_ranges), chunks: Number(h.spooled_chunks) },
ancient: { groups: Number(h.ancient_groups), articles: Number(h.ancient_articles) },
tail: {
groups: Number(h.tail_groups),
ranges: Number(h.tail_ranges),
articles: Number(h.tail_articles),
},
held: held.map((l: any) => {
const size = snapshotSizes.get(`${l.group_id}:${l.lane}`)
return {
node: l.node,
group: l.group,
lane: l.lane,
range:
l.start_id !== null
? { start: Number(l.start_id), end: Number(l.end_id), next: Number(l.next_id) }
: null,
heartbeatSeconds: seconds(l.heartbeat_at) ?? 0,
snapshotNext: l.snapshot_next === null ? null : Number(l.snapshot_next),
snapshotSeconds: seconds(l.snapshot_at),
snapshotMb: size === undefined ? null : size / 1e6,
rangesDone: Number(l.ranges_done),
headers: Number(l.headers),
}
}),
snapshots: {
count: files.length,
gb: files.reduce((sum, f) => sum + f.mb, 0) / 1000,
oldestHours: files.length > 0 ? Math.max(...files.map((f) => f.hours)) : null,
largest: files.slice(0, 5),
},
releases: { estimated: Math.max(0, Number(r.estimated)), provisional: Number(r.provisional) },
probes: {
ready: Number(r.ready),
waiting: Number(r.waiting),
pendingWaiting: Number(r.pending_waiting),
pendingNamed: Number(r.pending_named),
},
judge: { dirty: Number(r.dirty) },
nodes: nodes.map((n) => ({ name: n.name, lastSeenSeconds: seconds(n.last_seen_at) })),
}
}
/** The status as a text panel. */
render(s: Status): string {
const n = (v: number) => Math.round(v).toLocaleString('en-US')
const b = (v: number) =>
v >= 1e9
? `${(v / 1e9).toFixed(2)} B`
: v >= 1e6
? `${(v / 1e6).toFixed(1)} M`
: v >= 1e3
? `${(v / 1e3).toFixed(0)} k`
: String(v)
const age = (sec: number | null) =>
sec === null
? '-'
: sec < 90
? `${sec} s`
: sec < 5400
? `${Math.round(sec / 60)} min`
: `${(sec / 3600).toFixed(1)} h`
const lines: string[] = []
lines.push(`Status at ${s.at}`)
lines.push('')
lines.push(
`Catalog: ${s.catalog.gb.toFixed(1)} GB in tablespace ${s.catalog.tablespace}${s.catalog.capGb ? ` of ${s.catalog.capGb} GB${s.catalog.full ? ' (FULL: history paused)' : ''}` : ' (no cap)'}; ${s.catalog.freeGb.toFixed(1)} GB free on /`
)
lines.push(
`Last hour: ${b(s.lastHour.indexedArticles)} articles indexed in ${n(s.lastHour.doneRanges)} ranges (${n(s.lastHour.indexedArticles / 3600)}/s); corpus fill ${b(s.lastHour.storedArticles)} in ${n(s.lastHour.storedRanges)} ranges, ${n(s.corpus.pendingRanges)} capture ranges (${b(s.corpus.pendingArticles)}) still to store`
)
lines.push(
`Spool: ${n(s.spool.chunks)} chunks in ${n(s.spool.ranges)} ranges. Tail: ${b(s.tail.articles)} planned in ${n(s.tail.ranges)} ranges of ${n(s.tail.groups)} groups. Ancient: ${b(s.ancient.articles)} articles below the low watermark of ${n(s.ancient.groups)} groups`
)
lines.push('')
const table = (header: string[], rows: string[][]) => {
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
const line = (r: string[]) =>
r.map((c, i) => (i === 0 ? c.padEnd(widths[i]) : c.padStart(widths[i]))).join(' ')
lines.push(line(header))
for (const r of rows) lines.push(line(r))
}
lines.push('Ranges by band:')
table(
['band', 'planned', 'articles', 'leased', 'done', 'articles', 'failed'],
s.bands.map((x) => [
x.band,
n(x.planned),
b(x.plannedArticles),
n(x.leased),
n(x.done),
b(x.doneArticles),
n(x.failed),
])
)
lines.push('')
lines.push(`Held groups (${s.held.length}):`)
if (s.held.length > 0)
table(
[
'group',
'lane',
'node',
'range',
'at',
'heartbeat',
'snapshot',
'size',
'done',
'headers',
],
s.held.map((x) => [
x.group,
x.lane,
x.node,
x.range ? `${x.range.start}-${x.range.end}` : '-',
x.range ? String(x.range.next) : '-',
age(x.heartbeatSeconds),
x.snapshotNext === null ? '-' : `${x.snapshotNext} (${age(x.snapshotSeconds)})`,
x.snapshotMb === null ? '-' : `${x.snapshotMb.toFixed(0)} MB`,
n(x.rangesDone),
b(x.headers),
])
)
lines.push('')
lines.push(
`Snapshots: ${n(s.snapshots.count)} files, ${s.snapshots.gb.toFixed(2)} GB${s.snapshots.oldestHours === null ? '' : `, oldest ${s.snapshots.oldestHours.toFixed(1)} h`}${s.snapshots.largest.length > 0 ? `; largest ${s.snapshots.largest.map((f) => `${f.group}${f.lane === 'tail' ? ' (tail)' : ''} ${f.mb.toFixed(0)} MB`).join(', ')}` : ''}`
)
lines.push(
`Releases: ~${n(s.releases.estimated)}, ${n(s.releases.provisional)} provisional. Judge: ${n(s.judge.dirty)} dirty`
)
lines.push(
`Probes: ~${n(s.probes.ready)} ready of ~${n(s.probes.waiting)} waiting; pending ladder ${n(s.probes.pendingWaiting)} waiting, ${n(s.probes.pendingNamed)} named`
)
lines.push(
`Nodes: ${s.nodes.map((x) => `${x.name} (${age(x.lastSeenSeconds)})`).join(', ') || 'none'}`
)
return lines.join('\n')
}
}
export default new StatusService()
+56 -62
View File
@@ -3,48 +3,46 @@ import type { CommandOptions } from '@adonisjs/core/types/ace'
import { setTimeout as sleep } from 'node:timers/promises'
import { Prefetcher } from '#services/prefetcher'
import Planner from '#services/planner'
import FragmentMerger from '#services/fragment_merger'
import Settler from '#services/settler'
import RangeBlocks from '#services/range_blocks'
import Leases from '#services/leases'
import Settler from '#services/settler'
import NntpPool from '#services/nntp_pool'
import { NntpShare } from '#services/nntp_shares'
import coordinatorConfig from '#config/coordinator'
const TICK_MS = 2000
const PLAN_EVERY_MS = 60 * 60 * 1000
const MERGE_EVERY_MS = 60 * 1000
const MERGE_BUDGET_MS = 30 * 1000
const STOP_WAIT_MS = 15 * 1000
/**
* Settling in rounds of at most SETTLE_BATCHES batches every few minutes: once an hour, ~3M releases rewrote their
* rows and index entries in one go (2.65M in 30 min), the NVMe saturated and the batch stores queued on their locks
* (LOG.md 2026-10-16 (67)).
*/
const SETTLE_EVERY_MS = 5 * 60 * 1000
const SETTLE_BATCHES = 4
/** range_blocks (release_final) after ranges finish. */
const BLOCKS_EVERY_MS = 5 * 60 * 1000
/** Leases whose worker went quiet after reading everything are finished every few minutes. */
const ABANDONED_EVERY_MS = 5 * 60 * 1000
/**
* The coordinator's background work (REBUILD.md, part 1): the prefetcher,
* merging fragments of finished ranges every minute, and every hour
* planning ranges for new articles and settling quiet releases. The API (leases, stream,
* batches) runs in the web server.
* The coordinator's background work (REBUILD.md, part 1): the prefetcher, the tail's ranges every few minutes,
* new groups' history every hour, and abandoned leases. The API (leases, stream, batches) runs in the web server.
* Voyager 3: the settler and the fragment merger are gone (the window finishes a release once; the block merger
* joins edge releases).
*/
export default class CoordinatorRun extends BaseCommand {
static commandName = 'coordinator:run'
static description = 'Prefetches ranges into the spool and plans new ones'
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' })
@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() {
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 share = new NntpShare(count > 1 ? `coordinator ${index}/${count}` : 'coordinator', coordinatorConfig.nntpShares.coordinatorPriority, coordinatorConfig.nntpMinConnections, coordinatorConfig.nntpConnections)
const share = new NntpShare(
count > 1 ? `coordinator ${index}/${count}` : 'coordinator',
coordinatorConfig.nntpShares.coordinatorPriority,
coordinatorConfig.nntpMinConnections,
coordinatorConfig.nntpConnections
)
const main = index === 1
let stopping = false
let stopped: () => void
@@ -56,7 +54,11 @@ export default class CoordinatorRun extends BaseCommand {
prefetcher.stop()
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)
@@ -69,11 +71,16 @@ export default class CoordinatorRun extends BaseCommand {
} 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)
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
// of up to MERGE_BUDGET_MS left the connections idle once their targets were fetched).
// The prefetcher's ticks in a loop of their own: planning doesn't hold them (a merge round of up to 30 s once
// left the connections idle once their targets were fetched).
const prefetching = (async () => {
while (!stopping) {
try {
@@ -84,24 +91,31 @@ export default class CoordinatorRun extends BaseCommand {
await sleep(TICK_MS)
}
})()
// range_blocks in a loop of its own: a settling or merging round doesn't hold release_final back.
const blocks = (async () => {
const abandoned = (async () => {
while (main && !stopping) {
try {
const abandoned = await Leases.finishAbandoned()
if (abandoned > 0) this.logger.info(`Finished ${abandoned} ranges read to their end whose worker went quiet.`)
await RangeBlocks.rebuild()
const finished = await Leases.finishAbandoned()
if (finished > 0)
this.logger.info(
`Finished ${finished} ranges read to their end whose worker went quiet.`
)
} catch (error: any) {
this.logger.error(`Range blocks failed: ${error.message}`)
this.logger.error(`Finishing abandoned leases failed: ${error.message}`)
}
for (let waited = 0; waited < BLOCKS_EVERY_MS && !stopping; waited += TICK_MS) await sleep(TICK_MS)
try {
const settled = await Settler.settleComplete()
if (settled > 0)
this.logger.info(`Settled ${settled} complete provisional releases quiet for hours.`)
} catch (error: any) {
this.logger.error(`Settling complete releases failed: ${error.message}`)
}
for (let waited = 0; waited < ABANDONED_EVERY_MS && !stopping; waited += TICK_MS)
await sleep(TICK_MS)
}
})()
// History for new groups hourly; the tail every few minutes.
let plannedAt = 0
let tailedAt = 0
let mergedAt = 0
let settledAt = 0
while (!stopping) {
try {
if (main && Date.now() - plannedAt >= PLAN_EVERY_MS) {
@@ -109,45 +123,25 @@ export default class CoordinatorRun extends BaseCommand {
const { groups, ranges } = await Planner.planAll()
if (ranges > 0) this.logger.info(`Planned ${ranges} ranges over ${groups} groups.`)
}
if (main && Date.now() - settledAt >= SETTLE_EVERY_MS) {
settledAt = Date.now()
const settled = await Settler.run(SETTLE_BATCHES)
if (settled > 0) this.logger.info(`Settled ${settled} releases.`)
}
if (main && Date.now() - tailedAt >= coordinatorConfig.tail.everySeconds * 1000) {
tailedAt = Date.now()
const { groups, ranges } = await Planner.planTail()
if (ranges > 0) this.logger.info(`Tail: ${ranges} ranges for ${groups} groups.`)
}
if (main && Date.now() - mergedAt >= MERGE_EVERY_MS) {
mergedAt = Date.now()
// 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.)
const totals = { completed: 0, incomplete: 0, waiting: 0 }
let decidedThisPass = 0
while (!stopping && Date.now() - mergedAt < MERGE_BUDGET_MS) {
const merged = await FragmentMerger.run()
totals.completed += merged.completed
totals.incomplete += merged.incomplete
totals.waiting = merged.waiting
decidedThisPass += merged.completed + merged.incomplete
if (merged.wrapped) {
if (decidedThisPass === 0) break
decidedThisPass = 0
}
}
if (totals.completed || totals.incomplete) this.logger.info(`Fragments: ${totals.completed} files completed, ${totals.incomplete} incomplete, ${totals.waiting} waiting.`)
const { groups, ranges, extended } = await Planner.planTail()
if (ranges + extended > 0)
this.logger.info(
`Tail: ${ranges} ranges added, ${extended} grown, for ${groups} groups.`
)
}
} catch (error: any) {
this.logger.error(`Coordinator round failed: ${error.message}`)
}
await sleep(TICK_MS)
}
await Promise.all([prefetching, blocks, sharing])
await Promise.all([prefetching, abandoned, sharing])
// 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.`)
if (prefetcher.active > 0)
this.logger.info(`Closing ${prefetcher.active} connections still fetching.`)
await NntpPool.close()
await share.leave().catch(() => {})
this.logger.info('Coordinator stopped.')
+22
View File
@@ -0,0 +1,22 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import StatusService from '#services/status'
/**
* What the indexer is doing (app/services/status.ts): the catalog's room, the ranges by band, the last hour's
* rates, the held groups and their snapshots, the phase-2 backlogs. Reads only.
*/
export default class CoordinatorStatus extends BaseCommand {
static commandName = 'coordinator:status'
static description =
'Prints what the indexer is doing: catalog room, ranges by band, rates, held groups, backlogs'
static options: CommandOptions = { startApp: true }
@flags.boolean({ description: 'JSON instead of the text panel' })
declare json: boolean
async run() {
const status = await StatusService.collect()
this.logger.log(this.json ? JSON.stringify(status, null, 2) : StatusService.render(status))
}
}
+2 -3
View File
@@ -108,7 +108,7 @@ export default class EdgeBench extends BaseCommand {
let text = 0
let peakRss = 0
let peakDetail = ''
const out = { headers: 0, releases: 0, files: 0, segments: 0, payload: 0, candidates: 0, fragments: 0, samples: 0 }
const out = { headers: 0, releases: 0, files: 0, segments: 0, payload: 0, candidates: 0, samples: 0 }
const counts = new Map<string, number>()
const keys = new Set<string>()
let batches = 0
@@ -164,7 +164,6 @@ export default class EdgeBench extends BaseCommand {
out.payload += r.payload.length
}
out.candidates += batch.candidates.length
out.fragments += batch.fragments.length
out.samples += batch.samples.length
for (const c of batch.counts) counts.set(`${c.rule} ${c.key}`.trim(), (counts.get(`${c.rule} ${c.key}`.trim()) ?? 0) + c.articles)
const m = process.memoryUsage()
@@ -182,7 +181,7 @@ export default class EdgeBench extends BaseCommand {
this.logger.info(`${out.headers.toLocaleString('en')} headers in ${(total / 1000).toFixed(1)} s (${Math.round(out.headers / (total / 1000)).toLocaleString('en')}/s on one core; CPU ${cpuS.toFixed(1)} s, wall ${((Date.now() - wall) / 1000).toFixed(1)} s)`)
this.logger.info(` inflate ${(t.inflate / 1000).toFixed(1)} s, parse ${(t.parse / 1000).toFixed(1)} s, index ${(t.index / 1000).toFixed(1)} s; peak RSS ${(peakRss / 1e6).toFixed(0)} MB (${peakDetail})`)
this.logger.info(` ${batches} batches of ${this.batchChunks} chunks: ${out.releases} release chunks for ${keys.size} releases (${(out.releases / Math.max(1, keys.size)).toFixed(2)} chunks per release)`)
this.logger.info(` kept: ${out.releases} release chunks, ${out.files} files, ${out.segments} segments, payload ${(out.payload / 1e6).toFixed(1)} MB (${(out.payload / Math.max(1, out.segments)).toFixed(1)} B/segment); ${out.candidates} candidates, ${out.fragments} fragments, ${out.samples} samples`)
this.logger.info(` kept: ${out.releases} release chunks, ${out.files} files, ${out.segments} segments, payload ${(out.payload / 1e6).toFixed(1)} MB (${(out.payload / Math.max(1, out.segments)).toFixed(1)} B/segment); ${out.candidates} candidates, ${out.samples} samples`)
for (const [k, v] of [...counts].sort((a, b) => b[1] - a[1]).slice(0, 8)) this.logger.info(` dropped ${k}: ${v.toLocaleString('en')} articles`)
if (this.store) this.logger.info(` stored ${batches} batches in ${(storeMs / 1000).toFixed(1)} s (${(storeMs / Math.max(1, out.releases)).toFixed(2)} ms per release chunk)`)
}
+15 -3
View File
@@ -30,6 +30,14 @@ export default class NntpProbe extends BaseCommand {
@flags.boolean({ description: 'Only the span itself and the wider ones, no bisection' })
declare noBisect: boolean
@flags.boolean({ description: 'Without XFEATURE COMPRESS GZIP' })
declare plain: boolean
@flags.string({
description: 'Only spans from the article of these sizes, e.g. "8,16,24,32,48,64"',
})
declare sizes?: string
private timeoutMs = 20_000
private info = { count: 0, low: 0, high: 0 }
@@ -41,10 +49,14 @@ export default class NntpProbe extends BaseCommand {
this.info = await client.group(this.group)
client.close()
this.logger.info(
`${this.group}: ${this.info.count} articles, ${this.info.low}–${this.info.high}.`
`${this.group}: ${this.info.count} articles, ${this.info.low}–${this.info.high}${this.plain ? '; plain' : '; compressed'}.`
)
const span = (from: number, to: number) =>
[Math.max(this.info.low, from), Math.min(this.info.high, to)] as const
if (this.sizes) {
for (const n of this.sizes.split(',').map(Number)) await this.probe(...span(a, a + n - 1))
return
}
this.logger.info('The span and wider ones:')
await this.probe(...span(a, a + size - 1))
await this.probe(...span(a, a + size * 32 - 1))
@@ -83,7 +95,7 @@ export default class NntpProbe extends BaseCommand {
} catch (error: any) {
const timedOut = /timed out/i.test(error.message)
this.logger.warning(
` XOVER ${from}-${to}: ${error.message} after ${Date.now() - startedAt} ms${client ? `, ${client.buffered} bytes buffered` : ''}`
` XOVER ${from}-${to}: ${error.message} after ${Date.now() - startedAt} ms${client ? `, ${client.buffered} bytes buffered: ${JSON.stringify(client.bufferedHead().toString('latin1'))} (${client.bufferedHead().toString('hex')})` : ''}`
)
return timedOut
} finally {
@@ -98,7 +110,7 @@ export default class NntpProbe extends BaseCommand {
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: true,
compress: !this.plain,
timeoutMs: this.timeoutMs,
})
}
+105
View File
@@ -0,0 +1,105 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import coordinatorConfig from '#config/coordinator'
import { describeClass } from '#lib/release/index'
/**
* The pending ladder's classes (LOG.md (130)) on the rows from before them: `klass` (the SQL pending_class) and
* `target_bytes` (the first target's bytes, when its candidates carry them). The finished rows (named, exhausted,
* naming) in one scan, seeding each class's record, which gets its verdict (dropped) as the ladder gives it; the
* waiting rows in windows of created_at by their index (a scan per window starved the prober on the HDD); then
* pending_classes' `waiting` counted afresh from the claim's index. --report shows the record.
*/
export default class PendingClasses extends BaseCommand {
static commandName = 'pending:classes'
static description =
'Classes the pending releases from before the classes and recounts pending_classes; --report shows the record'
static options: CommandOptions = { startApp: true }
@flags.number({ description: 'Hours of created_at per window' })
declare hours: number
@flags.boolean({ description: 'Show the classes with the best and worst records, write nothing' })
declare report: boolean
async run() {
if (this.report) return this.showReport()
const hours = this.hours ?? 6
const started = Date.now()
// The finished rows (named, exhausted, naming; 6.5M on 2026-11-02) in one pass: a scan of the table, no index
// reaches them by status; they seed each class's record.
const { rows: seeded } = await db.rawQuery(
`WITH classed AS (
UPDATE pending_releases SET klass = pending_class(name, bytes, partial),
target_bytes = (SELECT min((c->>'bytes')::bigint) FROM jsonb_array_elements(candidates) c WHERE c->>'bytes' IS NOT NULL)
WHERE klass = 0 AND status IN (1, 3, 4)
RETURNING tool, group_id, klass, status),
upserted AS (
INSERT INTO pending_classes (tool, group_id, klass, probed, named)
SELECT tool, group_id, klass, count(*), count(*) FILTER (WHERE status IN (1, 4)) FROM classed GROUP BY 1, 2, 3
ON CONFLICT (tool, group_id, klass) DO UPDATE SET probed = pending_classes.probed + excluded.probed, named = pending_classes.named + excluded.named
RETURNING 1)
SELECT (SELECT count(*) FROM classed) AS classed, (SELECT count(*) FROM upserted) AS classes`
)
this.logger.info(
`Finished rows: ${seeded[0].classed} classed into ${seeded[0].classes} classes (${Math.round((Date.now() - started) / 1000)} s).`
)
const { pendingMinProbes, pendingMaxNamed } = coordinatorConfig.probe
await db.rawQuery(
`UPDATE pending_classes SET dropped = true, swept_at = NULL WHERE NOT dropped AND probed >= ?::int AND named::float <= probed * ?::float`,
[pendingMinProbes, pendingMaxNamed]
)
// The waiting rows by windows of created_at (pending_releases_waiting_idx reaches them).
const { rows: span } = await db.rawQuery(
`SELECT (SELECT min(created_at) FROM pending_releases WHERE status = 0) AS lo, (SELECT max(created_at) FROM pending_releases WHERE status = 0) AS hi`
)
if (span[0].lo) {
let from = new Date(span[0].lo)
const hi = new Date(span[0].hi)
let classed = 0
while (from <= hi) {
const to = new Date(from.getTime() + hours * 3600_000)
const result = await db.rawQuery(
`UPDATE pending_releases SET klass = pending_class(name, bytes, partial),
target_bytes = (SELECT min((c->>'bytes')::bigint) FROM jsonb_array_elements(candidates) c WHERE c->>'bytes' IS NOT NULL)
WHERE status = 0 AND created_at >= ? AND created_at < ? AND klass = 0`,
[from.toISOString(), to.toISOString()]
)
classed += result.rowCount ?? 0
from = to
this.logger.info(
`Waiting rows up to ${to.toISOString().slice(0, 16)}: ${classed} classed (${Math.round((Date.now() - started) / 1000)} s).`
)
}
}
// The waiting counts from the rows themselves (the claim, the discard and the batch store keep them from here).
await db.rawQuery(`
INSERT INTO pending_classes (tool, group_id, klass, waiting)
SELECT tool, group_id, klass, count(*) FROM pending_releases WHERE status = 0 AND finished GROUP BY 1, 2, 3
ON CONFLICT (tool, group_id, klass) DO UPDATE SET waiting = excluded.waiting`)
await db.rawQuery(
`UPDATE pending_classes c SET waiting = 0 WHERE waiting > 0 AND NOT EXISTS (
SELECT 1 FROM pending_releases p WHERE p.status = 0 AND p.finished AND p.tool = c.tool AND p.group_id = c.group_id AND p.klass = c.klass)`
)
const { rows: totals } = await db.rawQuery(
`SELECT count(*) AS classes, coalesce(sum(waiting), 0) AS waiting, count(*) FILTER (WHERE dropped) AS dropped FROM pending_classes`
)
this.logger.info(
`Done: ${totals[0].classes} classes, ${totals[0].waiting} waiting, ${totals[0].dropped} dropped.`
)
}
private async showReport() {
const { rows } = await db.rawQuery(
`SELECT c.tool, g.name AS grp, c.klass, c.probed, c.named, c.waiting, c.dropped FROM pending_classes c JOIN groups g ON g.id = c.group_id
WHERE c.probed >= 20 ORDER BY c.named::float / c.probed DESC, c.probed DESC`
)
const line = (r: any) =>
` ${r.tool.padEnd(10)} ${r.grp.padEnd(32)} ${describeClass(Number(r.klass)).padEnd(26)} probed ${String(r.probed).padStart(7)} named ${String(Math.round((100 * r.named) / r.probed)).padStart(3)}% waiting ${String(r.waiting).padStart(8)}${r.dropped ? ' dropped' : ''}`
this.logger.info(`${rows.length} classes with 20+ climbs; the best:`)
for (const r of rows.slice(0, 25)) this.logger.info(line(r))
this.logger.info('The worst:')
for (const r of rows.slice(-25)) this.logger.info(line(r))
}
}
+50 -24
View File
@@ -1,8 +1,9 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import coordinatorConfig from '#config/coordinator'
import NntpPool from '#services/nntp_pool'
import { XoverClient } from '#lib/nntp/index'
import { NntpShare } from '#services/nntp_shares'
import { estimateDate, sampleGroup, type DateSample } from '#services/group_dates'
@@ -39,13 +40,9 @@ export default class RangesDates extends BaseCommand {
declare estimateOnly: boolean
async run() {
try {
if (!this.estimateOnly) await this.sample()
await this.estimate()
await this.report()
} finally {
await NntpPool.close()
}
if (!this.estimateOnly) await this.sample()
await this.estimate()
await this.report()
}
private groups(missing = this.missing) {
@@ -62,20 +59,26 @@ export default class RangesDates extends BaseCommand {
const every = this.every ?? 500_000_000
const connections = this.connections ?? 6
const groups = await this.groups()
// Connections of its own, without compression: Eweka cuts a compressed reply at the first ".\r\n" in its
// output, which 16 of 14k small XOVERs hit (TruncatedReply; LOG.md 2026-10-25 (105)); plain, every one
// answers, and 32 lines are a few kB. Taken from the account as a share (the coordinator and the prober make
// room for them), as many as asked for.
const workers = Math.max(1, Math.min(connections, groups.length))
const share = new NntpShare(
'dates',
coordinatorConfig.nntpShares.coordinatorPriority + 1,
1,
connections
)
const first = await NntpPool.share(share)
const sharing = setInterval(
() =>
NntpPool.share(share).catch((error) => this.logger.warning(`NNTP share: ${error.message}`)),
coordinatorConfig.nntpShares.everyMs
workers,
workers
)
let open = 0
const sync = () =>
share
.sync(workers, open)
.catch((error) => this.logger.warning(`NNTP share: ${error.message}`))
await sync()
const sharing = setInterval(sync, coordinatorConfig.nntpShares.everyMs)
this.logger.info(
`Sampling ${groups.length} groups over ${first.granted} connections, a sample every ${every / 1e6}M articles.`
`Sampling ${groups.length} groups over ${workers} plain connections, a sample every ${every / 1e6}M articles.`
)
const startedAt = Date.now()
let done = 0
@@ -83,12 +86,32 @@ export default class RangesDates extends BaseCommand {
let samples = 0
const queue = [...groups]
const worker = async () => {
let client: XoverClient | null = null
const use = async <T>(work: (c: XoverClient) => Promise<T>): Promise<T> => {
if (!client) {
client = await XoverClient.connect({
host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: false,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
open++
}
try {
return await work(client)
} catch (error) {
client.close()
client = null
open--
throw error
}
}
for (let g = queue.shift(); g; g = queue.shift()) {
try {
const result = await sampleGroup((work) => NntpPool.use(work), g.name, {
every,
size: 32,
})
const result = await sampleGroup(use, g.name, { every, size: 32 })
for (const f of result.failed) this.logger.warning(`${g.name} at ${f}`)
await db.transaction(async (trx) => {
await trx.from('group_dates').where('group_id', g.id).delete()
@@ -116,13 +139,16 @@ export default class RangesDates extends BaseCommand {
`${done} of ${groups.length} groups: ${samples} samples, ${commands} commands, ${((Date.now() - startedAt) / 1000).toFixed(0)} s.`
)
}
if (client) {
;(client as XoverClient).close()
open--
}
}
try {
await Promise.all(
Array.from({ length: Math.max(1, Math.min(connections, groups.length)) }, worker)
)
await Promise.all(Array.from({ length: workers }, worker))
} finally {
clearInterval(sharing)
await share.sync(0, 0).catch(() => {})
}
this.logger.info(
`Sampled ${groups.length} groups: ${samples} samples, ${commands} commands, ${((Date.now() - startedAt) / 1000).toFixed(0)} s.`
+23
View File
@@ -0,0 +1,23 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import Planner from '#services/planner'
/**
* Merges adjacent planned tail ranges (Planner.mergeTail): the tail planner added a sliver a group every round
* while nothing leased the tail; now it grows the group's open range instead, and this packs what is there.
*/
export default class RangesMergeTail extends BaseCommand {
static commandName = 'ranges:merge-tail'
static description = 'Merges adjacent planned tail ranges into ranges of RANGE_ARTICLES'
static options: CommandOptions = { startApp: true }
@flags.boolean({ description: 'Only count what would be merged' })
declare dryRun: boolean
async run() {
const { candidates, merged, dropped } = await Planner.mergeTail(this.dryRun)
this.logger.info(
`${candidates} planned tail ranges: ${dropped} ${this.dryRun ? 'would merge' : 'merged'} into ${merged} grown ranges (${candidates - dropped} left).`
)
}
}
+7 -2
View File
@@ -14,8 +14,13 @@ export default class RangesPlan extends BaseCommand {
async run() {
try {
const { groups, ranges } = this.tail ? await Planner.planTail() : await Planner.planAll()
this.logger.info(`${ranges} ${this.tail ? 'tail ' : ''}ranges planned over ${groups} groups.`)
if (this.tail) {
const { groups, ranges, extended } = await Planner.planTail()
this.logger.info(`Tail: ${ranges} ranges added, ${extended} grown, over ${groups} groups.`)
} else {
const { groups, ranges } = await Planner.planAll()
this.logger.info(`${ranges} ranges planned over ${groups} groups.`)
}
} finally {
await NntpPool.close()
}
+90
View File
@@ -0,0 +1,90 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import { isUnreadableName } from '#lib/release/index'
/** Its place in worker_cursors (the last release id done), for a run that was stopped. */
const CURSOR = 'readable'
/**
* The readable flag of the catalog's releases by today's classifier (isUnreadableName, LOG.md (129)): rows flagged
* readable whose name no one can read ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C") get readable =
* false, so the readable filter hides them (an inner name from the prober still shows them), and their probe
* candidates marked unreadable, the prober's first tiers. By the release name,
* which is the shared file stem for most rows; not by the chunks' file names, which the edge judges from now on.
* Batches by id, a dry run by default; --apply writes, and goes on from its cursor when run again.
*/
export default class ReleasesReadable extends BaseCommand {
static commandName = 'releases:readable'
static description =
'Clears the readable flag of catalog rows whose name is unreadable by the current classifier; counts only without --apply'
static options: CommandOptions = { startApp: true }
@flags.number({ description: 'Release ids per batch' })
declare batch: number
@flags.number({ description: 'Stop after this id (a sample)' })
declare toId: number
@flags.boolean({ description: 'Write the flag; without it, count and show a sample' })
declare apply: boolean
@flags.boolean({ description: 'Start from the first id, not the cursor' })
declare restart: boolean
async run() {
const batch = this.batch ?? 20_000
const { rows: ends } = await db.rawQuery(`SELECT min(id) AS lo, max(id) AS hi FROM releases`)
const hi = Math.min(Number(ends[0].hi), this.toId ?? Infinity)
const { rows: cursors } = await db.rawQuery(`SELECT key FROM worker_cursors WHERE name = ?`, [
CURSOR,
])
let after =
this.restart || !this.apply || !cursors[0] ? Number(ends[0].lo) - 1 : Number(cursors[0].key)
let looked = 0
let changed = 0
const sample: string[] = []
const started = Date.now()
while (after < hi) {
const to = Math.min(after + batch, hi)
const { rows } = await db.rawQuery(
`SELECT id, name FROM releases WHERE id > ? AND id <= ? AND readable`,
[after, to]
)
looked += rows.length
const ids: number[] = []
for (const r of rows) {
if (!isUnreadableName(r.name)) continue
ids.push(Number(r.id))
if (sample.length < 40) sample.push(r.name)
}
changed += ids.length
if (this.apply) {
if (ids.length > 0) {
await db.rawQuery(`UPDATE releases SET readable = false WHERE id = ANY(?::bigint[])`, [
ids,
])
// Their probe candidates to the unreadable tiers, which the prober takes first (LOG.md (130)).
await db.rawQuery(
`UPDATE probe_candidates SET unreadable = true WHERE release_id = ANY(?::bigint[]) AND NOT unreadable`,
[ids]
)
}
await db.rawQuery(
`INSERT INTO worker_cursors (name, at, key) VALUES (?, now(), ?) ON CONFLICT (name) DO UPDATE SET at = excluded.at, key = excluded.key`,
[CURSOR, String(to)]
)
}
after = to
if (Math.floor(after / 2_000_000) !== Math.floor((after - batch) / 2_000_000)) {
this.logger.info(
`Up to id ${after} of ${hi}: ${changed} unreadable of ${looked} readable (${Math.round((Date.now() - started) / 1000)} s).`
)
}
}
this.logger.info(
`${this.apply ? 'Cleared' : 'Would clear'} ${changed} of ${looked} readable rows.`
)
if (!this.apply) for (const n of sample) this.logger.info(` ${n}`)
}
}
+3 -2
View File
@@ -3,6 +3,7 @@ import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import { decodeChunk } from '#lib/chunks/index'
import { releaseIdentity } from '#lib/release/index'
import { sourceCode } from '#lib/release/source'
/**
* What lib/release's identity makes of releases already in the catalog: a
@@ -27,7 +28,7 @@ export default class ReleasesRegroup extends BaseCommand {
async run() {
for (const name of this.group ?? []) {
const { rows } = await db.rawQuery(
`SELECT DISTINCT c.release_id FROM release_chunks c JOIN ranges r ON r.id = c.range_id JOIN groups g ON g.id = r.group_id WHERE g.name = ?`,
`SELECT DISTINCT c.release_id FROM release_chunks c JOIN blocks b ON b.id = c.block_id JOIN groups g ON g.id = b.group_id WHERE g.name = ?`,
[name]
)
const ids = rows.map((r: { release_id: string }) => r.release_id)
@@ -54,7 +55,7 @@ export default class ReleasesRegroup extends BaseCommand {
}
if (this.show?.length) return
for (const source of ['subject', 'post', 'stem']) {
const rows = await db.from('releases').where('source', source).where('files', '>', 1).orderByRaw('random()').limit(this.sample).select('id', 'name', 'files')
const rows = await db.from('releases').where('source', sourceCode(source)).where('files', '>', 1).orderByRaw('random()').limit(this.sample).select('id', 'name', 'files')
let split = 0
let pieces = 0
const examples: string[] = []
+396
View File
@@ -0,0 +1,396 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { spawn } from 'node:child_process'
import { access, readdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import db from '@adonisjs/lucid/services/db'
import coordinatorConfig from '#config/coordinator'
import BatchStore from '#services/batch_store'
import Leases from '#services/leases'
import RuleSets from '#services/rule_sets'
import { fromWire, type WireBatch } from '#lib/edge/index'
/**
* Only a database named like one of these is written: the replay leases and finishes ranges and fills the catalog.
* Not the test database: the rows left behind failed 27 functional tests (LOG.md 2026-10-26 (111)). With
* REPLAY_INTO_CATALOG=1 any database but the test one: voyager 3 deployed, history replayed from the corpus into
* the catalog itself (deploy/replay-window.sh, LOG.md (116)).
*/
const SCRATCH_DB = /_(replay|scratch)$/
const TEST_DB = /_test$/
const NODE_NAME = 'replay'
interface StoredRange {
id: number
start: number
end: number
}
/**
* The replay harness (REBUILD.md, "Voyager 3", 8): a group's stored corpus ranges in article order through one
* window in the Rust edge (`edge-bench --group`, the window carried from range to range) and the batch store into a
* scratch catalog, as the worker would store them: each range leased to a node named "replay", its batches stored
* at its resume point, the range finished when its last batch is in. Then a report: releases finished, complete,
* partial and provisional, and the catalog's bytes per header (the NVMe tables apart from those on CHUNK_TABLESPACE).
*
* node ace replay:corpus --group alt.binaries.ath [--dir CORPUS_DIR] [--from A] [--to B] [--limit N] [--batch-chunks 5]
*
* Refuses a database not named *_replay or *_scratch. The rules are the catalog's current rule set
* (rule_sets, the groups' priorities): copy `groups` and `rule_sets` from the catalog for a faithful replay.
*/
export default class ReplayCorpus extends BaseCommand {
static commandName = 'replay:corpus'
static description =
"Replays a group's corpus ranges through the Rust edge and the batch store into a scratch catalog"
static options: CommandOptions = { startApp: true }
@flags.string({ description: 'The group (its directory under the corpus)', required: true })
declare group: string
@flags.string({ description: 'The corpus directory (default: CORPUS_DIR)' })
declare dir?: string
@flags.number({ description: 'Replay this many ranges at most (default: all stored)' })
declare limit?: number
@flags.number({ description: 'Skip ranges ending before this article (continue a replay)' })
declare from?: number
@flags.number({
description: "Skip ranges starting after this article (one window of the group's history)",
})
declare to?: number
@flags.number({
description: 'Chunks of 100k articles per batch, as the worker posts them',
default: 5,
})
declare batchChunks: number
@flags.string({
description:
'The edge-bench binary (default: EDGE_BENCH, edge/target/release/edge-bench, /usr/local/bin/edge-bench)',
})
declare bench?: string
@flags.boolean({
description:
'Leave the window open at the end (the last ranges provisional), to continue later',
})
declare noFinish: boolean
async run() {
const database = db.connection().getReadClient().client.config.connection.database as string
const intoCatalog = process.env.REPLAY_INTO_CATALOG === '1' && !TEST_DB.test(database)
if (!SCRATCH_DB.test(database) && !intoCatalog) {
this.logger.error(
`The replay writes the catalog: it only runs against a database named *_replay or *_scratch (this is "${database}"; REPLAY_INTO_CATALOG=1 allows it)`
)
this.exitCode = 1
return
}
const dir = this.dir ?? coordinatorConfig.corpus.dir
if (!dir) {
this.logger.error('No corpus directory: --dir or CORPUS_DIR')
this.exitCode = 1
return
}
const bench = await this.benchBinary()
if (!bench) {
this.logger.error(
'No edge-bench binary found: build it (cargo build --release --manifest-path edge/Cargo.toml) or set EDGE_BENCH'
)
this.exitCode = 1
return
}
const groupDir = join(dir, this.group)
const stored = await this.storedRanges(groupDir)
if (stored.length === 0) {
this.logger.error(`${groupDir}: no complete range`)
this.exitCode = 1
return
}
const nodeId = await this.nodeId()
const groupId = await this.groupId()
const ranges = await this.leaseRanges(groupId, nodeId, stored)
const rules = await RuleSets.current()
const rulesPath = join(tmpdir(), `replay-rules-${process.pid}.json`)
await writeFile(rulesPath, JSON.stringify(rules))
this.logger.info(
`${this.group}: ${ranges.length} ranges (${ranges[0].start}–${ranges[ranges.length - 1].end}) through ${bench}, rules v${rules.version}, into ${database}.`
)
// edge-bench walks the group directory in article order itself; --from, --to and --limit cut its list as ours.
const args = [
'--group',
groupDir,
'--rules',
rulesPath,
'--batch-chunks',
String(this.batchChunks),
'--wire',
'-',
]
if (this.from) args.push('--from', String(this.from))
if (this.to) args.push('--to', String(this.to))
if (this.limit) args.push('--limit', String(this.limit))
if (this.noFinish) args.push('--no-finish')
const started = Date.now()
const child = spawn(bench, args, { stdio: ['ignore', 'pipe', 'inherit'] })
const totals = {
batches: 0,
headers: 0,
releases: 0,
newReleases: 0,
finalized: 0,
candidates: 0,
storeMs: 0,
}
let current: StoredRange | null = null
const storeLine = async (line: string) => {
const batch = fromWire(JSON.parse(line) as WireBatch)
if (!current || batch.from < current.start || batch.from > current.end) {
current = ranges.find((r) => batch.from >= r.start && batch.from <= r.end) ?? null
if (!current) throw new Error(`Batch at ${batch.from} is in no leased range`)
}
const at = Date.now()
const result = await BatchStore.store(current.id, nodeId, batch)
totals.storeMs += Date.now() - at
totals.batches++
totals.headers += batch.headers
totals.releases += result.releases
totals.newReleases += result.newReleases
totals.finalized += result.finalized
totals.candidates += result.candidates
if (batch.next > current.end) {
await Leases.finish(current.id, nodeId)
const seconds = (Date.now() - started) / 1000
this.logger.info(
` range ${current.start}–${current.end} done: ${totals.headers.toLocaleString('en')} headers so far in ${seconds.toFixed(0)} s ` +
`(${Math.round(totals.headers / seconds).toLocaleString('en')}/s), ${totals.releases} releases stored (${totals.finalized} finished later), store ${(totals.storeMs / 1000).toFixed(0)} s`
)
}
}
// Lines pulled as the store keeps up: the child's stdout only moves when a chunk is read, so batches don't pile up.
let rest = ''
let failure: Error | null = null
try {
for await (const chunk of child.stdout as AsyncIterable<Buffer>) {
rest += chunk.toString('utf8')
let nl: number
while ((nl = rest.indexOf('\n')) >= 0) {
const line = rest.slice(0, nl)
rest = rest.slice(nl + 1)
if (line.length > 0) await storeLine(line)
}
}
if (rest.trim().length > 0) await storeLine(rest)
} catch (error: any) {
failure = error
child.kill('SIGTERM')
}
const code = await new Promise<number | null>((done) =>
child.exitCode !== null ? done(child.exitCode) : child.on('exit', done)
)
if (failure) {
this.logger.error(`Replay failed: ${failure.message}`)
this.exitCode = 1
return
}
if (code !== 0) {
this.logger.error(`edge-bench exited with ${code}`)
this.exitCode = 1
return
}
await this.report(groupId, ranges, Date.now() - started)
}
private async benchBinary(): Promise<string | null> {
const candidates = [
this.bench,
process.env.EDGE_BENCH,
resolve('edge/target/release/edge-bench'),
'/usr/local/bin/edge-bench',
].filter((p): p is string => !!p)
for (const path of candidates) {
if (
await access(path).then(
() => true,
() => false
)
)
return path
}
return null
}
/** The group directory's complete ranges (a range.json), in article order, cut by --from, --to and --limit. */
private async storedRanges(groupDir: string): Promise<{ start: number; end: number }[]> {
const names = await readdir(groupDir).catch(() => [] as string[])
const ranges: { start: number; end: number }[] = []
for (const name of names) {
const m = /^(\d+)-(\d+)$/.exec(name)
if (!m) continue
const json = await readFile(join(groupDir, name, 'range.json'), 'utf8').catch(() => null)
if (!json) continue
ranges.push({ start: Number(m[1]), end: Number(m[2]) })
}
ranges.sort((a, b) => a.start - b.start)
let chosen = this.from ? ranges.filter((r) => r.end >= this.from!) : ranges
if (this.to) chosen = chosen.filter((r) => r.start <= this.to!)
if (this.limit) chosen = chosen.slice(0, this.limit)
return chosen
}
private async nodeId(): Promise<number> {
const row = await db.from('nodes').where('name', NODE_NAME).first()
if (row) return Number(row.id)
const [inserted] = await db.table('nodes').insert({ name: NODE_NAME }).returning('id')
return Number(inserted.id)
}
/** The group's row (inserted after the last priority when the scratch catalog doesn't have it). */
private async groupId(): Promise<number> {
const row = await db.from('groups').where('name', this.group).first()
if (row) return Number(row.id)
const { rows } = await db.rawQuery(`SELECT coalesce(max(priority), 0) + 1 AS next FROM groups`)
const [inserted] = await db
.table('groups')
.insert({ name: this.group, priority: Number(rows[0].next), active: true })
.returning('id')
this.logger.info(`Group ${this.group} added with priority ${rows[0].next}.`)
return Number(inserted.id)
}
/** The ranges as leased to the replay node, from their start (an earlier replay's rows are taken over). */
private async leaseRanges(
groupId: number,
nodeId: number,
stored: { start: number; end: number }[]
): Promise<StoredRange[]> {
const out: StoredRange[] = []
const now = new Date()
for (const r of stored) {
const lease = {
status: 'leased',
node_id: nodeId,
next_id: r.start,
headers: 0,
posted_low: null,
posted_high: null,
leased_at: now,
heartbeat_at: now,
done_at: null,
error: null,
capture: true,
}
const existing = await db
.from('ranges')
.where('group_id', groupId)
.where('start_id', r.start)
.first()
if (existing) {
await db
.from('ranges')
.where('id', existing.id)
.update({ ...lease, end_id: r.end, attempts: Number(existing.attempts) + 1 })
out.push({ id: Number(existing.id), start: r.start, end: r.end })
continue
}
const [inserted] = await db
.table('ranges')
.insert({
...lease,
group_id: groupId,
start_id: r.start,
end_id: r.end,
priority: 0,
attempts: 1,
})
.returning('id')
out.push({ id: Number(inserted.id), start: r.start, end: r.end })
}
return out
}
/** What the replay left in the catalog (this group's releases; the sizes are the whole catalog's), per article. */
private async report(groupId: number, ranges: StoredRange[], elapsedMs: number) {
const ids = ranges.map((r) => r.id)
const { rows: counts } = await db.rawQuery(
`SELECT count(*)::bigint AS releases,
count(settled_at)::bigint AS finished,
count(*) FILTER (WHERE settled_at IS NULL)::bigint AS provisional,
count(*) FILTER (WHERE edge)::bigint AS edges,
count(*) FILTER (WHERE complete)::bigint AS complete,
count(*) FILTER (WHERE complete = false)::bigint AS incomplete,
count(*) FILTER (WHERE complete IS NULL)::bigint AS undecided,
count(*) FILTER (WHERE readable)::bigint AS readable,
count(*) FILTER (WHERE has_par2)::bigint AS with_par2,
coalesce(sum(files), 0)::bigint AS files,
coalesce(sum(segments), 0)::bigint AS segments,
coalesce(sum(bytes), 0)::bigint AS bytes
FROM releases WHERE group_id = ?`,
[groupId]
)
const { rows: sources } = await db.rawQuery(
`SELECT source, count(*)::bigint AS n FROM releases WHERE group_id = ? GROUP BY source ORDER BY source`,
[groupId]
)
const { rows: pending } = await db.rawQuery(
`SELECT count(*)::bigint AS n, coalesce(sum(segments), 0)::bigint AS segments, count(*) FILTER (WHERE jsonb_array_length(candidates) = 0)::bigint AS unprobable
FROM pending_releases WHERE group_id = ?`,
[groupId]
)
const { rows: headerRows } = await db.rawQuery(
`SELECT coalesce(sum(headers), 0)::bigint AS headers FROM ranges WHERE id = ANY(?::bigint[])`,
[ids]
)
const headers = Number(headerRows[0].headers)
const { rows: sizes } = await db.rawQuery(
`SELECT CASE WHEN c.relname LIKE 'release_chunks%' THEN 'release_chunks' ELSE c.relname END AS name,
sum(pg_total_relation_size(c.oid))::bigint AS bytes
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
AND (c.relname IN ('releases', 'release_texts', 'posters', 'group_sets', 'probe_candidates', 'rule_counts', 'rule_samples', 'blocks', 'pending_releases')
OR c.relname LIKE 'release_chunks%')
GROUP BY 1 ORDER BY 2 DESC`
)
// On CHUNK_TABLESPACE (the HDD) in the catalog: the chunks (migration 3200), inspections and candidates (6000).
const hdd = new Set([
'release_chunks',
'probe_candidates',
'release_inspections',
'pending_releases',
])
let nvme = 0
let other = 0
const c = counts[0]
const fmt = (n: unknown) => Number(n).toLocaleString('en')
this.logger.info(
`Replay of ${ranges.length} ranges: ${fmt(headers)} headers in ${(elapsedMs / 1000).toFixed(0)} s (${fmt(Math.round(headers / Math.max(1, elapsedMs / 1000)))}/s).`
)
this.logger.info(
`Releases ${fmt(c.releases)}: finished ${fmt(c.finished)}, provisional ${fmt(c.provisional)}, edges ${fmt(c.edges)}; ` +
`complete ${fmt(c.complete)}, incomplete ${fmt(c.incomplete)}, undecided ${fmt(c.undecided)}; readable ${fmt(c.readable)}, with par2 ${fmt(c.with_par2)}; ` +
`${fmt(c.files)} files, ${fmt(c.segments)} segments (${((Number(c.segments) / Math.max(1, headers)) * 100).toFixed(1)}% of the headers), ${(Number(c.bytes) / 1e12).toFixed(2)} TB.`
)
this.logger.info(
`By source: ${sources.map((s: any) => `${s.source}: ${fmt(s.n)}`).join(', ')}.`
)
this.logger.info(
`Pending (unnamed, outside the catalog): ${fmt(pending[0].n)} releases, ${fmt(pending[0].segments)} segments, ${fmt(pending[0].unprobable)} without a probe target.`
)
for (const s of sizes) {
const bytes = Number(s.bytes)
if (hdd.has(s.name)) other += bytes
else nvme += bytes
this.logger.info(
` ${s.name}: ${(bytes / 1e6).toFixed(1)} MB, ${(bytes / Math.max(1, headers)).toFixed(2)} B/header`
)
}
this.logger.info(
`NVMe tables: ${(nvme / 1e6).toFixed(1)} MB, ${(nvme / Math.max(1, headers)).toFixed(2)} B/header; HDD tables (CHUNK_TABLESPACE): ${(other / 1e6).toFixed(1)} MB, ${(other / Math.max(1, headers)).toFixed(2)} B/header (the whole catalog's sizes over this group's headers: only the first group's are its own).`
)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import RuleSets from '#services/rule_sets'
import { classKey, describeClass } from '#lib/release/index'
/**
* Publishes a rule set whose dropped classes are the pending ladder's (pending_classes.dropped; LOG.md (130)): the
* edge keeps unnamed releases of those classes out of the pending store from then on, their explored sample
* excepted. Run again as the record grows; unchanged classes publish nothing.
*/
export default class RulesClasses extends BaseCommand {
static commandName = 'rules:classes'
static description = "Publishes a rule set with the pending ladder's dropped classes"
static options: CommandOptions = { startApp: true }
@flags.boolean({ description: 'Show what would be published, write nothing' })
declare dryRun: boolean
async run() {
const { rows } = await db.rawQuery(
`SELECT c.tool, g.name AS grp, c.klass, c.probed, c.named FROM pending_classes c JOIN groups g ON g.id = c.group_id WHERE c.dropped ORDER BY 1, 2, 3`
)
const next = new Set<string>(rows.map((r: any) => classKey(r.tool, r.grp, Number(r.klass))))
const { version, priorities: _p, ...current } = await RuleSets.current()
const before = new Set(current.droppedClasses ?? [])
const added = [...next].filter((c) => !before.has(c))
const removed = [...before].filter((c) => !next.has(c))
if (added.length === 0 && removed.length === 0) {
this.logger.info(
`Unchanged: rule set ${version} has the ${before.size} dropped classes already.`
)
return
}
for (const r of rows.slice(0, 40)) {
this.logger.info(
` ${r.tool.padEnd(10)} ${r.grp.padEnd(32)} ${describeClass(Number(r.klass)).padEnd(26)} ${r.named} of ${r.probed} named`
)
}
if (this.dryRun) {
this.logger.info(
`Would publish ${next.size} dropped classes (${added.length} added, ${removed.length} removed).`
)
return
}
const published = await RuleSets.publish(
{ ...current, droppedClasses: [...next].sort() },
`${next.size} dropped classes from the pending ladder`
)
this.logger.info(
`Rule set ${published}: ${next.size} dropped classes (${added.length} added, ${removed.length} removed).`
)
}
}
+29
View File
@@ -31,6 +31,22 @@ export default {
},
/** 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),
@@ -103,6 +119,14 @@ export default {
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
@@ -138,6 +162,11 @@ export default {
*/
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. */
@@ -0,0 +1,177 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import env from '#start/env'
/**
* Voyager 3's catalog (REBUILD.md, "Voyager 3: window indexing", 4 and 7), built from a reset, not converted in
* place. This migration IS the reset: it empties every table the indexing recomputes (releases, chunks,
* inspections, candidates, par2 data, file hashes, verdicts, merges, rule counts and samples; what survives:
* groups, ranges, the corpus, probe_cache, spots, nntp_shares, nodes, users), so it refuses to run on a catalog
* with releases unless VOYAGER3_RESET=1 (the user's go-ahead; resets are paused since 2026-10-12). Then:
* - posters get an id; group_sets holds each distinct group combination once (~140k); releases is created anew
* without its text copies (poster_id, group_set_id, the keys as 20-byte bytea, source a smallint), 8-byte
* columns first, with `edge` (still open at its block's end, for the block merger) and settled_at as the finish
* (null: a tail release still provisional). The category and judge triggers follow the new columns;
* releases_category_newest_idx (5.3 GB, used once) goes, and the trigram indexes: search matches release_texts
* (migration 6200), which holds the name and every searchable string learned later.
* - fragments and range_blocks go with the fragment merger and the settler (the window finishes a release once);
* release_final() goes: a release is final once finished.
* - release_chunks, rule_counts and rule_samples are keyed by the block that wrote them (block_id; migration 6100).
* - release_inspections and probe_candidates move to CHUNK_TABLESPACE (the HDD) when set: written once, read per
* release, not by search.
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
const { rows } = await db.rawQuery(`SELECT count(*)::int AS n FROM releases`)
if (rows[0].n > 0 && process.env.VOYAGER3_RESET !== '1') {
throw new Error(
`The voyager 3 catalog migration empties the catalog (${rows[0].n} releases): run it with VOYAGER3_RESET=1 after the go-ahead`
)
}
})
this.schema.raw(`
TRUNCATE releases, release_chunks, release_inspections, probe_candidates, file_hashes, par2_files, par2_sets,
release_verdicts, release_merges, release_spots, release_passwords, rule_counts, rule_samples, judge_dirty,
judge_full, fold_candidates, posters, worker_cursors RESTART IDENTITY`)
// Spots matched to releases that are gone: the spot inspector matches them again.
this.schema.raw(`UPDATE spots SET release_id = NULL WHERE release_id IS NOT NULL`)
this.schema.raw(`UPDATE spot_files SET release_id = NULL WHERE release_id IS NOT NULL`)
this.schema.raw(`DROP TABLE IF EXISTS fragments`)
this.schema.raw(`DROP TABLE IF EXISTS range_blocks`)
this.schema.raw(
`DROP FUNCTION IF EXISTS release_final(integer, bigint, bigint, timestamptz, timestamptz, boolean)`
)
this.schema.raw(
`DROP FUNCTION IF EXISTS release_final(integer, bigint, bigint, timestamptz, timestamptz)`
)
// Posters by id (a release's poster_id); searched by trigram as before (poster_search).
this.schema.raw(`DROP TABLE IF EXISTS posters`)
this.schema.raw(`
CREATE TABLE posters (
id serial PRIMARY KEY,
poster text NOT NULL UNIQUE
)`)
this.schema.raw(`CREATE INDEX posters_trgm ON posters USING gin (poster gin_trgm_ops)`)
// Each distinct combination of groups a release was posted to, once: a sorted jsonb array of names.
this.schema.raw(`
CREATE TABLE group_sets (
id serial PRIMARY KEY,
groups jsonb NOT NULL UNIQUE
)`)
this.schema.raw(`DROP TABLE IF EXISTS releases CASCADE`)
this.schema.raw(`
CREATE TABLE releases (
id bigserial PRIMARY KEY,
-- The upload's posting window (first and last article's Date).
posted_at timestamptz,
last_posted_at timestamptz,
first_article bigint,
last_article bigint,
segments bigint NOT NULL DEFAULT 0,
bytes bigint NOT NULL DEFAULT 0,
-- The finish: the window closed on it (or the block ended); null while a tail release is provisional.
settled_at timestamptz,
inspected_at timestamptz,
first_seen_at timestamptz NOT NULL DEFAULT now(),
-- The owner group (null: a spot's release), the set of groups it was posted to, its poster.
group_id integer,
group_set_id integer NOT NULL,
poster_id integer,
file_total integer,
files integer NOT NULL DEFAULT 0,
chunks integer NOT NULL DEFAULT 0,
category integer,
-- lib/release/source.ts: 1 subject, 2 post, 3 stem, 4 spot, 5 nzb, 6 album.
source smallint NOT NULL,
readable boolean NOT NULL DEFAULT false,
has_par2 boolean NOT NULL DEFAULT false,
complete boolean,
passworded boolean,
sidecar_only boolean,
-- Still open when its block ended: the block merger joins it with the neighbour block's part by identity.
edge boolean NOT NULL DEFAULT false,
-- SHA-1s (lib/release/identity.ts): the identity (poster, name, group), and the release (identity and upload).
release_key bytea NOT NULL,
identity_key bytea NOT NULL,
name text NOT NULL,
inner_name varchar(255)
)`)
this.schema.raw(`CREATE UNIQUE INDEX releases_release_key_unique ON releases (release_key)`)
this.schema.raw(`CREATE INDEX releases_identity_idx ON releases (identity_key, group_id)`)
// No trigram indexes here: search matches release_texts (migration 6200), where the name is stored once more.
this.schema.raw(
`CREATE INDEX releases_newest_idx ON releases (posted_at DESC NULLS LAST, id DESC)`
)
this.schema.raw(
`CREATE INDEX releases_poster_newest_idx ON releases (poster_id, posted_at DESC NULLS LAST, id DESC)`
)
this.schema.raw(`CREATE INDEX releases_passworded_idx ON releases (id) WHERE passworded`)
this.schema.raw(
`CREATE INDEX releases_edge_idx ON releases (group_id, identity_key) WHERE edge`
)
this.schema.raw(
`CREATE INDEX releases_provisional_idx ON releases (id) WHERE settled_at IS NULL`
)
// The category from the name, the inner name, or the groups (migration 5200), the groups now in group_sets.
this.schema.raw(`
CREATE OR REPLACE FUNCTION releases_set_category() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.category := release_category_for(NEW.name, NEW.inner_name, (SELECT groups FROM group_sets WHERE id = NEW.group_set_id),
CASE WHEN TG_OP = 'UPDATE' THEN OLD.category ELSE NEW.category END);
RETURN NEW;
END $$`)
this.schema.raw(`
CREATE TRIGGER releases_category BEFORE INSERT OR UPDATE OF name, inner_name, group_set_id ON releases
FOR EACH ROW EXECUTE FUNCTION releases_set_category()`)
// The judge's dirty marks (migration 4900), on the columns its rules read.
this.schema.raw(`
CREATE TRIGGER judge_dirty_insert AFTER INSERT ON releases REFERENCING NEW TABLE AS new_rows
FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_releases()`)
this.schema.raw(`
CREATE TRIGGER judge_dirty_delete AFTER DELETE ON releases REFERENCING OLD TABLE AS old_rows
FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_releases()`)
this.schema.raw(`
CREATE TRIGGER judge_dirty_update AFTER UPDATE ON releases FOR EACH ROW
WHEN (old.name IS DISTINCT FROM new.name OR old.poster_id IS DISTINCT FROM new.poster_id OR old.source IS DISTINCT FROM new.source
OR old.file_total IS DISTINCT FROM new.file_total OR old.passworded IS DISTINCT FROM new.passworded
OR old.settled_at IS DISTINCT FROM new.settled_at OR old.readable IS DISTINCT FROM new.readable
OR old.inner_name IS DISTINCT FROM new.inner_name OR old.group_id IS DISTINCT FROM new.group_id
OR old.first_article IS DISTINCT FROM new.first_article OR old.last_article IS DISTINCT FROM new.last_article
OR old.posted_at IS DISTINCT FROM new.posted_at OR old.last_posted_at IS DISTINCT FROM new.last_posted_at
OR old.complete IS DISTINCT FROM new.complete)
EXECUTE FUNCTION judge_dirty_releases()`)
// Chunks, rule counts and samples by the block that wrote them.
this.schema.raw(`ALTER TABLE release_chunks RENAME COLUMN range_id TO block_id`)
this.schema.raw(
`ALTER INDEX IF EXISTS release_chunks_range_idx RENAME TO release_chunks_block_idx`
)
this.schema.raw(`ALTER TABLE rule_counts RENAME COLUMN range_id TO block_id`)
this.schema.raw(`ALTER TABLE rule_samples RENAME COLUMN range_id TO block_id`)
this.schema.raw(
`ALTER INDEX IF EXISTS rule_samples_range_id_index RENAME TO rule_samples_block_id_index`
)
// Cold tables to the HDD (CHUNK_TABLESPACE), with their indexes; empty now, so at once.
const hdd = env.get('CHUNK_TABLESPACE')
if (hdd) {
for (const table of ['release_inspections', 'probe_candidates']) {
this.schema.raw(`ALTER TABLE ${table} SET TABLESPACE ${hdd}`)
this.schema.raw(`
DO $$ DECLARE i record; BEGIN
FOR i IN SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = '${table}' LOOP
EXECUTE format('ALTER INDEX %I SET TABLESPACE %I', i.indexname, '${hdd}');
END LOOP;
END $$`)
}
}
}
async down() {
throw new Error('The voyager 3 catalog migration cannot be undone (it emptied the catalog)')
}
}
@@ -0,0 +1,61 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Blocks (REBUILD.md, "Voyager 3: window indexing", 1): what a worker leases. A block is a run of consecutive
* ranges of one group covering at least 30 days of posting time (planned from ranges.est_date and posted_low/high),
* read in article order through the 24 h window; blocks of a group run in parallel. Ranges stay the unit the
* prefetcher fetches (the spool, the corpus) and point at their block; the lease, resume point, heartbeat and
* counts move here. Part of the reset (migration 6000): every range is read again, from the corpus where stored.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE blocks (
id bigserial PRIMARY KEY,
group_id integer NOT NULL REFERENCES groups (id) ON DELETE CASCADE,
-- The first range's start and the last range's end.
start_id bigint NOT NULL,
end_id bigint NOT NULL,
-- The resume point: the first article not stored yet (batches are refused out of order, as ranges' were).
next_id bigint NOT NULL,
-- The posting time the block covers: estimated when planned, the worker's measured span once done.
posted_low timestamptz,
posted_high timestamptz,
-- planned, leased, done, failed.
status varchar(8) NOT NULL DEFAULT 'planned',
-- As ranges': the tail before everything, then the history by era.
priority integer NOT NULL DEFAULT 0,
node_id integer,
attempts integer NOT NULL DEFAULT 0,
rule_version integer,
headers bigint NOT NULL DEFAULT 0,
releases integer NOT NULL DEFAULT 0,
leased_at timestamptz,
heartbeat_at timestamptz,
done_at timestamptz,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (group_id, start_id)
)`)
this.schema.raw(
`CREATE INDEX blocks_todo_idx ON blocks (priority, id) WHERE status IN ('planned', 'leased')`
)
this.schema.raw(`CREATE INDEX blocks_leased_idx ON blocks (leased_at) WHERE status = 'leased'`)
this.schema.raw(
`ALTER TABLE ranges ADD COLUMN IF NOT EXISTS block_id bigint REFERENCES blocks (id) ON DELETE SET NULL`
)
this.schema.raw(`CREATE INDEX IF NOT EXISTS ranges_block_idx ON ranges (block_id)`)
// The reset: every range planned again from its start (the corpus serves the stored ones). Priorities, capture,
// est_date, stored_at, plain_chunks and the measured posted_low/high stay.
this.schema.raw(`
UPDATE ranges SET status = 'planned', next_id = start_id, node_id = NULL, attempts = 0, spooled_chunks = 0,
spooled_at = NULL, leased_at = NULL, heartbeat_at = NULL, done_at = NULL, headers = 0, error = NULL,
rule_version = NULL
WHERE status <> 'planned' OR next_id <> start_id OR spooled_chunks <> 0 OR node_id IS NOT NULL`)
}
async down() {
this.schema.raw(`ALTER TABLE ranges DROP COLUMN IF EXISTS block_id`)
this.schema.raw(`DROP TABLE IF EXISTS blocks`)
}
}
@@ -0,0 +1,35 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* release_texts (REBUILD.md, "Voyager 3 … Schema"): every string search matches against, one table, one trigram
* index. The release name goes in (kind 1) with whatever anyone learns later: the prober's file names inside an
* archive, NFO titles, par2 set names, media titles (lib/release/text_kind.ts). Appending is an insert, never an
* update of the wide releases row; a repeat insert is a no-op by the primary key; the judge's dirty mark follows
* (judge_dirty_by_release_id, migration 4900), so a rule can react to a name arriving later. On the NVMe: search
* reads it. releases keeps name and inner_name for display, without trigram indexes (migration 6000).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE release_texts (
release_id bigint NOT NULL,
-- lib/release/text_kind.ts: 1 name, 2 file, 3 nfo, 4 par2, 5 media.
kind smallint NOT NULL,
text varchar(255) NOT NULL,
PRIMARY KEY (release_id, kind, text)
)`)
this.schema.raw(
`CREATE INDEX release_texts_trgm ON release_texts USING gin (text gin_trgm_ops) WITH (fastupdate = on, gin_pending_list_limit = 65536)`
)
this.schema.raw(
`CREATE TRIGGER judge_dirty_insert AFTER INSERT ON release_texts REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_by_release_id()`
)
this.schema.raw(
`CREATE TRIGGER judge_dirty_delete AFTER DELETE ON release_texts REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION judge_dirty_by_release_id()`
)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS release_texts`)
}
}
@@ -0,0 +1,60 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import env from '#start/env'
/**
* pending_releases (REBUILD.md decision f, 2026-10-26): nothing enters the catalog without a usable name. A release
* the window closed on whose files all have random names is written here instead of releases: its keys (as the
* catalog would store them), its counts and article span, the upload tool from its message-id (with the group its
* signature), and its probe targets by effort (the edge's candidates: a par2, an archive start, a lone file, a 7z
* tail), no chunk. The pending ladder probes them cheapest first and moves a named one into the catalog, reading its
* segments again from the corpus or the server; a signature that names nothing is discarded. On CHUNK_TABLESPACE
* (the HDD): written once, read by the ladder, never by search.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE pending_releases (
-- lib/release/keys.ts: the identity, the group and the first article; the identity alone.
release_key bytea PRIMARY KEY,
identity_key bytea NOT NULL,
group_id integer NOT NULL,
-- The stem or post the edge keyed it by (a random name: no release name, but the inner-stem merge's handle).
name varchar(255) NOT NULL,
poster text,
posted_at timestamptz,
last_posted_at timestamptz,
first_article bigint NOT NULL,
last_article bigint NOT NULL,
files integer NOT NULL DEFAULT 0,
segments bigint NOT NULL DEFAULT 0,
bytes bigint NOT NULL DEFAULT 0,
-- A file misses parts.
partial boolean NOT NULL DEFAULT false,
-- False: a tail release still open; a later chunk of its key adds its counts up.
finished boolean NOT NULL DEFAULT true,
-- lib/rules uploadTool: ngPost, nyuu, hex32, random, other, …
tool varchar(16) NOT NULL,
-- [{kind, messageId, file}] by effort (lib/edge ProbeCandidate); empty: nothing to read, only a signature.
candidates jsonb NOT NULL DEFAULT '[]',
-- 0 waiting, 1 named (in the catalog), 2 discarded (its signature's verdict, or every probe gave nothing).
status smallint NOT NULL DEFAULT 0,
tries smallint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
probed_at timestamptz
)`)
// The ladder's order: a group's and tool's waiting releases, oldest first.
this.schema.raw(
`CREATE INDEX pending_releases_ladder_idx ON pending_releases (group_id, tool, status, created_at)`
)
const hdd = env.get('CHUNK_TABLESPACE')
if (hdd) {
this.schema.raw(`ALTER TABLE pending_releases SET TABLESPACE ${hdd}`)
this.schema.raw(`ALTER INDEX pending_releases_pkey SET TABLESPACE ${hdd}`)
this.schema.raw(`ALTER INDEX pending_releases_ladder_idx SET TABLESPACE ${hdd}`)
}
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS pending_releases`)
}
}
@@ -0,0 +1,49 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The pending ladder (REBUILD.md decision f, 2026-10-26): what the prober needs to work pending_releases cheapest
* first and to learn which signatures never name anything.
* - pending_releases: `file_total` (the counter's, for the catalog row once named), `inspections` (what each probe
* showed, by kind), `named` (the usable name a probe found; status 4 until the namer moved it into the catalog).
* Status: 0 waiting, 1 named and in the catalog, 2 discarded (its signature's verdict), 3 exhausted (every
* target probed, no name), 4 named, awaiting the move, 9 taken by a running prober.
* - pending_signatures: per (tool, group, poster) how many pending releases were probed to the end and how many
* got a name; `dropped` once probed ≥ PENDING_MIN_PROBES and named ≤ PENDING_MAX_NAMED of them (voyager 2's
* garbage learner, LOG.md 2026-10-08 (5)). The poster '' row is the tool's and group's together.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS file_total integer`)
this.schema.raw(
`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS inspections jsonb NOT NULL DEFAULT '[]'`
)
this.schema.raw(`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS named varchar(255)`)
// The ladder's claim: the oldest waiting ones; the namer's: the named ones.
this.schema.raw(
`CREATE INDEX IF NOT EXISTS pending_releases_waiting_idx ON pending_releases (created_at) WHERE status = 0`
)
this.schema.raw(
`CREATE INDEX IF NOT EXISTS pending_releases_naming_idx ON pending_releases (probed_at) WHERE status = 4`
)
this.schema.raw(`
CREATE TABLE pending_signatures (
tool varchar(16) NOT NULL,
group_id integer NOT NULL,
poster text NOT NULL DEFAULT '',
probed integer NOT NULL DEFAULT 0,
named integer NOT NULL DEFAULT 0,
dropped boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tool, group_id, poster)
)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS pending_signatures`)
this.schema.raw(`DROP INDEX IF EXISTS pending_releases_naming_idx`)
this.schema.raw(`DROP INDEX IF EXISTS pending_releases_waiting_idx`)
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS named`)
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS inspections`)
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS file_total`)
}
}
@@ -0,0 +1,33 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5; 2026-10-26): a worker thread holds a group, not
* a range, and indexes its ranges in article order through one carried window. One row a group: the holder and
* its heartbeat, the range it works, and the snapshot of its window (the article it stands at: the next one to
* index) that the coordinator keeps on bulk. Ranges stay what is planned, fetched and finished.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE group_leases (
group_id integer PRIMARY KEY REFERENCES groups (id) ON DELETE CASCADE,
-- The holder; null between holders. A holder quiet for LEASE_TTL_SECONDS is taken over.
node_id integer,
leased_at timestamptz,
heartbeat_at timestamptz,
-- The range the holder works (null between ranges).
range_id bigint,
-- The window's snapshot: the article it stands at (a resume point), and when it was taken; null: none.
snapshot_next bigint,
snapshot_at timestamptz,
ranges_done integer NOT NULL DEFAULT 0,
headers bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
)`)
this.schema.raw(`CREATE INDEX group_leases_held_idx ON group_leases (heartbeat_at) WHERE node_id IS NOT NULL`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS group_leases`)
}
}
@@ -0,0 +1,27 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Lease lanes (REBUILD.md, "Voyager 3: window indexing", 1, amended 2026-10-27): a group has two cursors, each with
* its own holder, window and snapshot. The `history` lane works the ranges the lease bounds hand out (the windows,
* the history), the `tail` lane the tail planner's (priority at or below the tail's): a thread on the tail never
* waits behind a group's history, and a held history group's tail isn't stuck behind its days of ranges.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(
`ALTER TABLE group_leases ADD COLUMN lane varchar(8) NOT NULL DEFAULT 'history'`
)
this.schema.raw(`ALTER TABLE group_leases DROP CONSTRAINT group_leases_pkey`)
this.schema.raw(`ALTER TABLE group_leases ADD PRIMARY KEY (group_id, lane)`)
// Counts the leases handed out: odd and even ones start from the other half of the order's mix.
this.schema.raw(`CREATE SEQUENCE group_lease_turns`)
}
async down() {
this.schema.raw(`DROP SEQUENCE IF EXISTS group_lease_turns`)
this.schema.raw(`DELETE FROM group_leases WHERE lane <> 'history'`)
this.schema.raw(`ALTER TABLE group_leases DROP CONSTRAINT group_leases_pkey`)
this.schema.raw(`ALTER TABLE group_leases DROP COLUMN lane`)
this.schema.raw(`ALTER TABLE group_leases ADD PRIMARY KEY (group_id)`)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* One "ancient" range a group, articles 1 … (its first range − 1): what had expired on the server before the
* group was planned (the planner cuts from the low watermark). Status 'ancient': nothing to fetch, never leased,
* so the tiling is complete from article 1 and the span below the low watermark is on record (user, 2026-10-28).
* Planner.historyRows adds it for groups planned from now on.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(
`INSERT INTO ranges (group_id, start_id, end_id, next_id, priority, status)
SELECT group_id, 1, min(start_id) - 1, 1, 0, 'ancient' FROM ranges GROUP BY group_id HAVING min(start_id) > 1`
)
}
async down() {
this.schema.raw(`DELETE FROM ranges WHERE status = 'ancient'`)
}
}
@@ -0,0 +1,57 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The pending ladder by class (REBUILD.md decision f; LOG.md (130)): a pending release's class (lib/release/classes.ts:
* size bucket × name shape × partial, 1..70; 0 for rows from before) and the bytes of its first target, so the
* claim takes the classes with the best record first and the cheapest article within one; pending_classes per
* (tool, group, class) how many climbed to the end and how many got a name, `waiting` how many wait (kept by the
* batch store, the claim and the discard; recounted by pending:classes), `dropped` as pending_signatures.
* The SQL pending_class() is the TypeScript's pendingClass, for the batch store's insert and the backfill.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`
CREATE OR REPLACE FUNCTION pending_class(name text, bytes bigint, partial boolean) RETURNS smallint LANGUAGE sql IMMUTABLE AS $$
SELECT ((CASE WHEN bytes < 1000000 THEN 0 WHEN bytes < 10000000 THEN 1 WHEN bytes < 100000000 THEN 2 WHEN bytes < 1000000000 THEN 3 ELSE 4 END) * 14
+ (CASE WHEN s ~ '^[0-9a-fA-F]{16,}$' THEN 1
WHEN s ~ '^[0-9]{6,}$' THEN 2
WHEN s ~ '^[a-z2-7]{20,}$' AND s ~ '[2-7]' THEN 3
WHEN s ~ '^[A-Za-z]+$' THEN 4
WHEN s ~ '^[A-Za-z0-9]+$' THEN 5
WHEN s ~ '^[A-Za-z0-9._-]+$' THEN 6
ELSE 7 END - 1) * 2
+ (CASE WHEN partial THEN 1 ELSE 0 END) + 1)::smallint
FROM (SELECT regexp_replace(name, '\\.[A-Za-z0-9]{1,4}$', '') AS s) x
$$`)
this.schema.raw(
`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS klass smallint NOT NULL DEFAULT 0`
)
this.schema.raw(`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS target_bytes bigint`)
this.schema.raw(`
CREATE TABLE IF NOT EXISTS pending_classes (
tool varchar(16) NOT NULL,
group_id integer NOT NULL,
klass smallint NOT NULL,
probed integer NOT NULL DEFAULT 0,
named integer NOT NULL DEFAULT 0,
waiting integer NOT NULL DEFAULT 0,
dropped boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tool, group_id, klass)
)`)
// The claim by class: the cheapest target first within one, the oldest among equals.
this.schema.raw(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pending_releases_class_idx ON pending_releases (tool, group_id, klass, target_bytes, created_at) WHERE status = 0`
)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_class_idx`)
this.schema.raw(`DROP TABLE IF EXISTS pending_classes`)
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS target_bytes`)
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS klass`)
this.schema.raw(`DROP FUNCTION IF EXISTS pending_class(text, bigint, boolean)`)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The prober's start puts a previous run's claims back (PendingLadder.release: status 9 → 0), which scanned the
* 58M-row pending table on the HDD for 20+ minutes after every deploy (LOG.md (130)): a partial index on the few
* taken rows makes it instant.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pending_releases_taken_idx ON pending_releases (release_key) WHERE status = 9`
)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_taken_idx`)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The prober's start puts a previous run's claims back (probe_candidates status taken → waiting), which scanned
* the 7.8 GB candidates table on the HDD for 4 minutes after every deploy (LOG.md (133)): a partial index on the
* few taken rows makes it instant.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS probe_candidates_taken_idx ON probe_candidates (id) WHERE status = 'taken'`
)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS probe_candidates_taken_idx`)
}
}
@@ -0,0 +1,26 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The ladder's discard sweep (PendingLadder.discard) reads a dropped signature's or class's waiting rows once:
* `swept_at` marks it done (null again when the record drops it anew). Before, every sweep read every dropped
* signature's rows over again, minutes of HDD each, inside the claim (LOG.md (133)).
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('pending_signatures', (table) => {
table.timestamp('swept_at', { useTz: true }).nullable()
})
this.schema.alterTable('pending_classes', (table) => {
table.timestamp('swept_at', { useTz: true }).nullable()
})
}
async down() {
this.schema.alterTable('pending_signatures', (table) => {
table.dropColumn('swept_at')
})
this.schema.alterTable('pending_classes', (table) => {
table.dropColumn('swept_at')
})
}
}
@@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The discard sweep of a dropped signature (tool, group, poster) read the whole pending_releases table per
* signature: the planner took a parallel seq scan over the poster test, four minutes on the HDD that starved the
* lanes' claims (LOG.md (134)). The waiting rows by signature make it an index range. By the poster's md5: a
* poster of 2.7 kB broke the first build (a btree key is at most a third of a page), which left an invalid index
* behind that IF NOT EXISTS would have taken for the real one; the drop clears it.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_signature_idx`)
this.schema.raw(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pending_releases_signature_idx ON pending_releases (group_id, tool, md5(poster)) WHERE status = 0`
)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_signature_idx`)
}
}
@@ -0,0 +1,34 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* probe_candidates, with its indexes, in the catalog's tablespace (default_tablespace: nvme on homelab), out of
* CHUNK_TABLESPACE where migration 6000 put it: the prober's catalog claim updates it from random points every
* minute, and on the HDD, beside the pending ladder's sweep and autovacuum, a claim took 2–3 minutes with all the
* lanes waiting (LOG.md 2026-11-03 (136)). Homelab moved it by hand (edge, node-api and the workers stopped; ~8 GB);
* a no-op where it's there already, or without a default tablespace (tests).
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
const { rows: setting } = await db.rawQuery(
`SELECT current_setting('default_tablespace') AS target`
)
const target = setting[0].target
if (!target) return
const { rows } = await db.rawQuery(
`SELECT c.relname, c.relkind FROM pg_class c LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
WHERE (c.relname = 'probe_candidates' OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'probe_candidates'::regclass))
AND c.relkind IN ('r', 'i') AND coalesce(t.spcname, 'pg_default') <> ?
ORDER BY c.relkind DESC, c.relname`,
[target]
)
for (const { relname, relkind } of rows) {
await db.rawQuery(
`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${target}`
)
}
})
}
async down() {}
}
@@ -0,0 +1,37 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Everything the prober reads or writes in the catalog's tablespace (default_tablespace: nvme on homelab), out of
* CHUNK_TABLESPACE (the HDD): pending_releases with its two HDD indexes (migration 6300), release_inspections
* (6000), probe_cache with its toast. The edge's batch upserts into pending_releases took minutes each on the HDD and
* the prober's flushes waited on them (LOG.md 2026-11-03 (137)); and with these on the NVMe the prober can run
* through the night without the HDD. Homelab moved them by hand (edge, node-api and the workers stopped; ~66 GB);
* a no-op where they're there already, or without a default tablespace (tests).
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
const { rows: setting } = await db.rawQuery(
`SELECT current_setting('default_tablespace') AS target`
)
const target = setting[0].target
if (!target) return
for (const table of ['pending_releases', 'release_inspections', 'probe_cache']) {
const { rows } = await db.rawQuery(
`SELECT c.relname, c.relkind FROM pg_class c LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
WHERE (c.relname = ? OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = ?::regclass))
AND c.relkind IN ('r', 'i') AND coalesce(t.spcname, 'pg_default') <> ?
ORDER BY c.relkind DESC, c.relname`,
[table, table, target]
)
for (const { relname, relkind } of rows) {
await db.rawQuery(
`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${target}`
)
}
}
})
}
async down() {}
}
+23 -1
View File
@@ -126,7 +126,9 @@ services:
prober:
<<: *app
command: ['node', 'ace', 'probe:run']
volumes: []
# The pending namer reads a named release's articles again from the corpus (no spool).
volumes:
- ./corpus:/corpus:ro
environment:
<<: *app-env
PROBE_CONNECTIONS: ${PROBE_CONNECTIONS:-8}
@@ -173,6 +175,11 @@ services:
environment:
COORDINATOR_URL: http://node-api:3335
NODE_TOKEN: ${NODE_TOKEN}
# The window worker (REBUILD.md design 1 and 5): a group per thread, the window's snapshot to the coordinator
# this often; the coordinator keeps them under SNAPSHOT_DIR (the spool's snapshots/ on bulk by default).
EDGE_SNAPSHOT_MINUTES: ${EDGE_SNAPSHOT_MINUTES:-30}
# Of the threads, this many on the groups' tail lane (the live tail; the rest work the history lane).
EDGE_TAIL_THREADS: ${EDGE_TAIL_THREADS:-1}
stop_grace_period: 60s
depends_on:
node-api:
@@ -190,6 +197,21 @@ services:
node-api:
condition: service_started
# The replay harness into the scratch catalog usenet_replay on bulk (REBUILD.md, "The replay harness"):
# docker compose run --rm --no-deps replay --group alt.binaries.ath
replay:
<<: *app
profiles: [replay]
restart: 'no'
entrypoint: ['node', 'ace', 'replay:corpus']
command: ['--help']
environment:
<<: *app-env
DB_DATABASE: usenet_replay
volumes:
- ./corpus:/corpus:ro
depends_on: []
# One-off ace commands: docker compose run --rm ace releases:duplicates
ace:
<<: *app
+11 -3
View File
@@ -47,11 +47,19 @@ echo "== recreating app services"
docker compose stop edge
if [ -e "$CACHE/paused" ]; then
# Paused for the night (~/bin/usenet-rebuild-pause.sh): the workers stay stopped, the resume starts the new build.
echo "== paused: coordinator, prober, merger, judge, spotter, spot-inspector and edge left stopped"
docker compose up -d --no-deps node-api web
# The prober runs through the night (2026-11-03, LOG.md (137)), so it gets the new build now.
echo "== paused: coordinator, merger, judge, spotter, spot-inspector and edge left stopped"
docker compose up -d --no-deps node-api web prober
else
docker compose up -d --no-deps node-api web coordinator prober merger judge spotter spot-inspector
docker compose up -d --no-deps edge
if [ -e "$CACHE/no-edge" ]; then
# Voyager 3 (2026-10-26, LOG.md (116)): the worker indexes range by range, without the carried window, and
# would split releases at every range edge with nothing to join them; history comes from the corpus through
# deploy/replay-window.sh until the window worker is built. Remove the marker to start it again.
echo "== no-edge: the edge worker left stopped"
else
docker compose up -d --no-deps edge
fi
fi
echo "$SHA $(date -Is)" >> "$ROOT/deployed.log"
# The images this replaced (321 MB each; the root filesystem is the NVMe, with a 10 GB floor).
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# A corpus window replayed on homelab (REBUILD.md, "The replay harness"): every group whose window ranges (the
# ranges marked `capture` at PRIORITY in the catalog, 0 for 2016) are all stored in the corpus is replayed once
# into the scratch catalog usenet_replay through the voyager-3 app image, PARALLEL groups at a time, the
# window's bounds passed as --from/--to (the corpus holds other windows of the same group). The group's log
# ends in .cache/replay/<group>.log, a .done marker next to it (.failed when the replay failed: remove it to
# retry). Loops until every group of the window is done (the corpus fill runs meanwhile), so run it detached
# from the deployment root:
# setsid nohup deploy/replay-window.sh > .cache/replay/driver.log 2>&1 &
# No new group starts while the nightly pause is on (.cache/paused); the running ones finish.
# DB=usenet_rebuild replays into the catalog itself (voyager 3 deployed, 2026-10-26: history comes from the corpus
# this way until the window worker is built); the harness then needs REPLAY_INTO_CATALOG=1, passed here. OUT keeps
# a run's logs and markers apart (.cache/replay-catalog for the catalog's).
set -u
ROOT=${ROOT:-/mnt/bulk/usenet-rebuild}
PRIORITY=${PRIORITY:-0}
PARALLEL=${PARALLEL:-4}
MEMORY=${MEMORY:-12g}
IMAGE=${IMAGE:-usenet-voyager3:latest}
DB=${DB:-usenet_replay}
OUT=${OUT:-$ROOT/.cache/replay}
mkdir -p "$OUT"
cd "$ROOT" || exit 1
say() { echo "$(date -Is) $*"; }
catalog() { docker compose exec -T postgres psql -U rebuild -d usenet_rebuild -X -q -At -F ' ' -c "$1"; }
# The window's groups: name, first article, last article, whether every range is stored; smallest first.
window() {
catalog "SELECT g.name, min(r.start_id), max(r.end_id), (count(*) = count(r.stored_at))::int
FROM ranges r JOIN groups g ON g.id = r.group_id
WHERE r.capture AND r.priority = $PRIORITY
GROUP BY g.name ORDER BY sum(r.end_id - r.start_id + 1)"
}
replay() {
local group=$1 from=$2 to=$3
say "$group: replaying $from–$to"
if docker run --rm --name "replay-${group//./-}" --network usenet-rebuild --env-file .env \
-e NODE_ENV=production -e HOST=0.0.0.0 -e PORT=3334 -e APP_URL=http://web:3334 -e SPOOL_DIR=/spool -e CORPUS_DIR=/corpus \
-e DB_HOST=postgres -e DB_PORT=5432 -e DB_DATABASE="$DB" -e CHUNK_TABLESPACE=pg_default -e REPLAY_INTO_CATALOG=1 \
-v "$ROOT/corpus:/corpus:ro" --memory "$MEMORY" "$IMAGE" \
node ace replay:corpus --group "$group" --from "$from" --to "$to" > "$OUT/$group.log" 2>&1; then
touch "$OUT/$group.done"
say "$group: done ($(grep -o 'Releases [0-9,]*' "$OUT/$group.log" | tail -1))"
else
touch "$OUT/$group.failed"
say "$group: FAILED (see $OUT/$group.log)"
fi
rm -f "$OUT/$group.running"
}
while true; do
todo=0
while read -r group from to complete; do
[ -n "$group" ] || continue
[ -e "$OUT/$group.done" ] && continue
[ -e "$OUT/$group.failed" ] && continue
todo=$((todo + 1))
[ "$complete" = 1 ] || continue
[ -e "$OUT/$group.running" ] && continue
[ -e "$ROOT/.cache/paused" ] && continue
while [ "$(find "$OUT" -name '*.running' | wc -l)" -ge "$PARALLEL" ]; do sleep 30; done
touch "$OUT/$group.running"
replay "$group" "$from" "$to" &
sleep 5
done <<< "$(window)"
if [ "$todo" = 0 ]; then
wait
say "every group of the window at priority $PRIORITY is replayed"
exit 0
fi
sleep 300
done
+4 -3
View File
@@ -1,12 +1,13 @@
#!/bin/sh
# Installed as ~/bin/usenet-rebuild-pause.sh (by hand). Nightly (homelab crontab, 23:45): voyager 2's processing paused for the night; usenet-rebuild-resume.sh (07:45)
# starts it again. Postgres, web and node-api stay up. Takes the deploy lock, so it never runs during a CI deploy, and
# leaves .cache/paused, so a deploy in the night doesn't start the workers again.
# starts it again. Postgres, web, node-api and the prober stay up: since 2026-11-03 (LOG.md (137)) everything the
# prober touches is on the NVMe, so it probes through the night without the HDD. Takes the deploy lock, so it never
# runs during a CI deploy, and leaves .cache/paused, so a deploy in the night doesn't start the workers again.
cd /mnt/bulk/usenet-rebuild || exit 1
exec 9>.cache/deploy.lock
flock 9
touch .cache/paused
date "+%F %T pausing"
docker compose stop edge
docker compose stop coordinator prober merger judge spotter spot-inspector
docker compose stop coordinator merger judge spotter spot-inspector
docker compose ps --format "{{.Service}} {{.Status}}"
+6 -1
View File
@@ -7,5 +7,10 @@ flock 9
rm -f .cache/paused
date "+%F %T resuming"
docker compose up -d --no-deps coordinator prober merger judge spotter spot-inspector
docker compose up -d --no-deps edge
if [ -e .cache/no-edge ]; then
# Voyager 3 without its window worker yet (deploy.sh, LOG.md (116)): the edge stays stopped.
echo "no-edge: the edge worker left stopped"
else
docker compose up -d --no-deps edge
fi
docker compose ps --format "{{.Service}} {{.Status}}"
+10
View File
@@ -44,6 +44,15 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -145,6 +154,7 @@ name = "edge"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"bincode",
"chrono",
"flate2",
"libdeflater",
+2 -1
View File
@@ -2,7 +2,7 @@
name = "edge"
version = "0.1.0"
edition = "2021"
description = "Voyager 2's edge worker (edge-node) and its benchmark (edge-bench): the RangeIndexer in Rust, kept identical to lib/edge (check.ts)"
description = "The edge worker (edge-node) and its benchmark (edge-bench): voyager 3's window indexing in Rust"
[[bin]]
name = "edge-bench"
@@ -14,6 +14,7 @@ path = "src/bin/edge-node.rs"
[dependencies]
base64 = "0.22"
bincode = "1.3"
chrono = "0.4"
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
libdeflater = "1.26.1"
+74 -18
View File
@@ -6,18 +6,21 @@ from the node API, reads their prefetched XOVER chunks, finds releases and
posts batches. `edge-bench` (`src/main.rs`) replays a captured range through
the same indexing (`src/lib.rs`) for timing and for comparing.
The TypeScript `RangeIndexer` (`lib/edge`, `node ace edge:node`) is the
reference: `check.ts` runs both on a capture and compares every output field
by field. An identity or rule change goes into both and has to come out
identical. CI (`.gitea/workflows/deploy.yaml`, job `edge`) runs `cargo test`
and `check-fixtures.sh`, which compares both on the committed captures in
`fixtures/` (25k overview lines each of a.b.models, a.b.ath and
a.b.boneless, in 5 chunks); a push to `rebuild` deploys only when they
match.
Since voyager 3 (2026-10-25) the Rust indexer is the one implementation of
the window (REBUILD.md, "Voyager 3: window indexing"; `src/indexer.rs`):
files and releases are finished when the frontier (the median Date of the
last 1,024 plausibly dated articles) is 24 h past their last part, or at
the block's end (provisionally, as edges), and written once. The
TypeScript `RangeIndexer` (`lib/edge`) keeps the range path for the corpus
scanner and the tests; the field-by-field comparison (`check.ts`) went
with the parity. CI (`.gitea/workflows/deploy.yaml`, job `edge`) runs
`cargo test` and `bench-fixtures.sh`, which replays the committed captures
in `fixtures/` (25k overview lines each of a.b.ath, a.b.boneless, a.b.fta
and a.b.models, in 5 chunks).
```sh
cargo test --release --manifest-path edge/Cargo.toml
edge/check-fixtures.sh # from the repository root
edge/bench-fixtures.sh # from the repository root
```
In production since 2026-10-09: 122M headers in 10 minutes with 0.37 cores
@@ -110,11 +113,30 @@ files, segments, payload size, candidates, fragments and samples. The
summary can hide compensating differences, so compare the outputs
themselves.
## Comparing the output
## Replaying a group through one window
A corpus group directory (`<corpus>/<group>/<start>-<end>/`, each range with
its `range.json`) is replayed in article order with the window carried from
range to range, the end finished after the last range (REBUILD.md, "The
replay harness"):
```sh
./target/release/edge-bench --group /corpus/alt.binaries.ath --rules rules.json --batch-chunks 5 --wire - > batches.jsonl
# --from <article> skips ranges ending before it, --to <article> ranges starting after it (one window of the
# group's history), --limit <n> stops after n ranges, --no-finish leaves the window open
```
`--rules` takes the lease's rules JSON (`src/lease.rs`: the catalog's rule
set and group priorities, as `replay:corpus` writes it); without it the
defaults with only the group indexed apply. `--wire -` writes each batch as
the worker posts it to stdout, one JSON per line, and the report to stderr;
`node ace replay:corpus` reads them into the batch store. Each range end
prints the frontier, the releases out and the releases and files still open.
## The dump
```sh
./target/release/edge-bench /path/to/capture --dump /tmp/out.jsonl
cd .. && node --import=@poppinss/ts-exec edge/check.ts /path/to/capture /tmp/out.jsonl
```
`--dump` writes every output of every batch as JSON lines:
@@ -212,17 +234,45 @@ them:
```sh
cargo build --release
COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M]
COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-node [--threads N] [--tail-threads T] [--batch-articles 500000] [--snapshot-minutes 30] [--groups N] [--max-mbit M]
```
- **One range per thread** (default one per core): one thread reads the
coordinator's stream and inflates a frame ahead, another posts the
batches in order (one may wait), the indexer goes on meanwhile.
- **Two lanes** (2026-10-27): `--tail-threads T` (EDGE_TAIL_THREADS; one of
two or more threads by default) puts that many threads on the groups'
*tail* lane (`?lane=tail` on the group routes): the tail planner's
ranges, through a window and snapshot of their own
(`<group>-tail.gz`). The other threads work the history lane: the
windows and the history the coordinator's bounds hand out. A tail thread
never wades through a group's history first, and a held history group's
tail isn't stuck behind its days of ranges; the coordinator orders each
lane's leases by alternating the group with the most planned articles
and the longest unheld one.
- **One group per thread** (default one per core; REBUILD.md, "Voyager 3:
window indexing", 1 and 5): a thread leases a group (`POST
groups/lease`), takes its ranges in article order (`POST
groups/:id/next`), streams each range's chunks (`GET ranges/:id/stream`:
the corpus or the spool, the worker can't tell) through one carried
window, posts a batch at least at every range end and finishes the
range; one thread reads the stream and inflates a frame ahead, another
posts the batches in order, the indexer goes on meanwhile. The window's
snapshot (bincode, gzip) goes to the coordinator every
`--snapshot-minutes` (EDGE_SNAPSHOT_MINUTES, 30) right after a batch,
and when the group is given back: caught up (`next` answers 204: the
tail), a stop, a lost lease or a failure; a stop or a failed stream
posts the open batch first, so the snapshot stands at its resume point
(a batch the coordinator refused can't, and that window is lost); the
next holder restores it (`GET groups/:id/snapshot`) and continues at
its article. `--groups N`
stops after N groups; `--ranges N` is the old per-range lease
(`POST lease`), one range per thread without a carried window.
- **As `commands/edge_node.ts`:** retries for network trouble and 5xx;
409 means the lease is lost (the range stops, isn't given back); a
batch once 500k articles or 100k files wait (a picture group's batches
went over the server's 256 MB limit); a chunk that doesn't inflate is
reported (the coordinator fetches it plain next time); SIGTERM/SIGINT
reported (the coordinator fetches it plain next time) after the batch
before it went out, and a held group asks for the range again from
that chunk, its window kept; SIGTERM/SIGINT
give each range back after its current chunk; `--max-mbit` is the
node's download cap, shared by its threads. Plus a heartbeat when no
batch went out for a minute.
@@ -234,5 +284,11 @@ COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-nod
same (releases with every column, chunks, candidates, rule counts,
fragments with their JSON).
- **On homelab:** compose service `edge` (deploy/compose.yaml), the
`edge` image target of the Dockerfile, `--threads $EDGE_THREADS` (8);
it talks to `node-api`.
`edge` image target of the Dockerfile, `--threads $EDGE_THREADS` (8),
EDGE_TAIL_THREADS (1); it talks to `node-api`.
- **End to end:** `tests/e2e/edge_node.spec.ts` runs this binary
(`edge/target/release/edge-node`, or EDGE_NODE_BIN) against the test
server, database, spool and corpus: a chunk the corpus fill stores while
the stream waits, a failed stream and the restart that restores the
snapshot, a restart whose snapshot file is gone, a takeover. `node ace
test e2e` after `cargo build --release`; CI builds the binary first.
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# The Rust indexer over the committed captures (edge/fixtures: 25k overview lines each of a.b.ath, a.b.boneless,
# a.b.fta and a.b.models, in 5 chunks): a smoke test of the window on real headers, exiting non-zero when the bench
# fails on one. CI runs it after cargo test; run it from the repository root after a change to the indexer.
set -eu
cd "$(dirname "$0")/.."
cargo build --release --quiet --manifest-path edge/Cargo.toml --bin edge-bench
for capture in edge/fixtures/*/; do
echo "== $(basename "$capture")"
edge/target/release/edge-bench "$capture" --batch-chunks 2 --idle 2 | tail -1
done
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# The Rust and TypeScript RangeIndexers on the committed captures (edge/fixtures: 25k
# overview lines each of a.b.models, a.b.ath and a.b.boneless, in 5 chunks), compared
# by check.ts field by field: exits non-zero on any difference. CI runs it; run it from
# the repository root after any change to either side.
set -eu
cd "$(dirname "$0")/.."
cargo build --release --quiet --manifest-path edge/Cargo.toml --bin edge-bench
out=$(mktemp -d)
trap 'rm -rf "$out"' EXIT
status=0
for capture in edge/fixtures/*/; do
name=$(basename "$capture")
edge/target/release/edge-bench "$capture" --batch-chunks 2 --idle 2 --dump "$out/$name.jsonl" --wire "$out/$name.wire.jsonl" >/dev/null 2>&1
echo "== $name"
node --import=@poppinss/ts-exec edge/check.ts "$capture" "$out/$name.jsonl" --wire "$out/$name.wire.jsonl" --batch-chunks 2 --idle 2 || status=1
done
exit $status
-139
View File
@@ -1,139 +0,0 @@
/**
* Compares edge-bench's output (--dump) with the TypeScript RangeIndexer
* on the same captured range, item by item: releases (every field, and the
* payload decoded with lib/chunks), samples, probe candidates, rule counts,
* fragments and batch boundaries.
*
* node --import=@poppinss/ts-exec edge/check.ts <dir> <dump.jsonl> [--wire batches.jsonl] [--batch-chunks 10] [--idle 5]
*
* With --wire (edge-bench --wire): also each batch as the Rust worker
* posts it, against toWire of the TypeScript batch, field by field.
*/
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { unzipSync } from 'node:zlib'
import { isDeepStrictEqual } from 'node:util'
import { RangeIndexer, toWire, type EdgeBatch } from '#lib/edge/index'
import { DEFAULT_RULES, type Rules } from '#lib/rules/index'
import { decodeChunk } from '#lib/chunks/index'
const [dir, dumpPath] = process.argv.slice(2)
const flag = (name: string, fallback: number) => {
const i = process.argv.indexOf(name)
return i === -1 ? fallback : Number(process.argv[i + 1])
}
const batchChunks = flag('--batch-chunks', 10)
const idle = flag('--idle', 5)
const { group, start, end } = JSON.parse(await readFile(join(dir, 'range.json'), 'utf8'))
const rules: Rules = { ...DEFAULT_RULES, priorities: { [group]: 1 }, droppedSignatures: [] }
const indexer = new RangeIndexer({ group, start, end, rules, idleChunks: idle })
const files = (await readdir(dir)).filter((f) => f.endsWith('.gz')).sort()
const batches: EdgeBatch[] = []
for (const [i, file] of files.entries()) {
const next = Number(file.split('-')[2].replace('.gz', '')) + 1
indexer.addText(unzipSync(await readFile(join(dir, file))).toString('utf8'), next)
const last = i === files.length - 1
if (last) indexer.finish()
if (last || (i + 1) % batchChunks === 0) batches.push(indexer.take())
}
/** Canonical lines per kind, in a stable order, from both sides. */
type Item = Record<string, unknown>
const sortFiles = (decoded: ReturnType<typeof decodeChunk>) => [...decoded].sort((a, b) => (a.name + a.subject < b.name + b.subject ? -1 : 1))
const payloadBytes = { ts: 0, rs: 0 }
let side: 'ts' | 'rs' = 'ts'
const release = (r: Item, b: number) => {
const { payload, ...rest } = r as Item & { payload: Buffer }
// The compressed size isn't compared (node's zstd and the zstd crate frame alike data a little
// differently); what the payload decodes to is.
payloadBytes[side] += payload.length
return { b, ...rest, decoded: sortFiles(decodeChunk(payload)) }
}
const ts: Record<string, Item[]> = { b: [], r: [], s: [], c: [], n: [], f: [] }
batches.forEach((batch, b) => {
ts.b.push({ b, from: batch.from, next: batch.next, headers: batch.headers, postedLow: batch.postedLow, postedHigh: batch.postedHigh })
for (const r of batch.releases) ts.r.push(release({ upload: r.upload, key: r.key, name: r.name, source: r.source, fileTotal: r.fileTotal, poster: r.poster, groups: r.groups, postedAt: r.postedAt, postedLast: r.postedLast, files: r.files, segments: r.segments, bytes: r.bytes, firstArticle: r.firstArticle, lastArticle: r.lastArticle, readable: r.readable, hasPar2: r.hasPar2, payload: r.payload }, b))
for (const s of batch.samples) ts.s.push({ b, key: s.key, upload: s.upload, rule: s.rule, ruleKey: s.ruleKey, segments: s.segments })
for (const c of batch.candidates) ts.c.push({ b, upload: c.upload, releaseKey: c.releaseKey, kind: c.kind, messageId: c.messageId, file: c.file })
for (const n of batch.counts) ts.n.push({ b, rule: n.rule, key: n.key, articles: n.articles, files: n.files })
for (const f of batch.fragments) ts.f.push({ b, fileKey: f.fileKey, name: f.name, totalParts: f.totalParts, postedAt: f.postedAt, segments: f.segments })
})
const rs: Record<string, Item[]> = { b: [], r: [], s: [], c: [], n: [], f: [] }
side = 'rs'
let b = -1
for (const line of (await readFile(dumpPath, 'utf8')).split('\n')) {
if (!line) continue
const { t, ...item } = JSON.parse(line)
if (t === 'b') b++
if (t === 'r') rs.r.push(release({ ...item, payload: Buffer.from(item.payload, 'base64') }, b))
else if (t === 's') rs.s.push({ b, key: item.key, upload: item.upload, rule: item.rule, ruleKey: item.ruleKey, segments: item.segments })
else rs[t].push({ b, ...item })
}
const keyOf: Record<string, (i: Item) => string> = {
b: (i) => String(i.b),
r: (i) => `${i.b} ${i.key} ${i.upload}`,
s: (i) => `${i.b} ${i.key} ${i.upload}`,
c: (i) => `${i.b} ${i.releaseKey} ${i.upload} ${i.kind} ${i.messageId}`,
n: (i) => `${i.b} ${i.rule} ${i.key}`,
f: (i) => `${i.b} ${i.fileKey}`,
}
const names: Record<string, string> = { b: 'batches', r: 'releases', s: 'samples', c: 'candidates', n: 'rule counts', f: 'fragments' }
let failed = false
for (const kind of Object.keys(keyOf)) {
const a = new Map(ts[kind].map((i) => [keyOf[kind](i), i]))
const z = new Map(rs[kind].map((i) => [keyOf[kind](i), i]))
const missing = [...a.keys()].filter((k) => !z.has(k))
const extra = [...z.keys()].filter((k) => !a.has(k))
const differ = [...a.keys()].filter((k) => z.has(k) && !isDeepStrictEqual(a.get(k), z.get(k)))
const ok = missing.length === 0 && extra.length === 0 && differ.length === 0
failed ||= !ok
console.log(`${ok ? 'same' : 'DIFF'} ${names[kind]}: ${a.size} TypeScript, ${z.size} Rust${ok ? '' : `; ${missing.length} only in TypeScript, ${extra.length} only in Rust, ${differ.length} differ`}`)
for (const k of [...missing.slice(0, 2), ...extra.slice(0, 2)]) console.log(` only one side: ${k}`)
for (const k of differ.slice(0, 2)) {
const x = a.get(k)!
const y = z.get(k)!
const fields = Object.keys(x).filter((f) => !isDeepStrictEqual(x[f], y[f]))
console.log(` ${k}: ${fields.map((f) => `${f}: ${JSON.stringify(x[f]).slice(0, 160)} vs ${JSON.stringify(y[f]).slice(0, 160)}`).join('; ')}`)
}
}
console.log(`payload bytes: ${payloadBytes.ts} TypeScript, ${payloadBytes.rs} Rust`)
const wireIndex = process.argv.indexOf('--wire')
if (wireIndex !== -1) {
// Order within a batch doesn't count; payloads by what they decode to.
const by = (f: (x: any) => string) => (a: any, b: any) => (f(a) < f(b) ? -1 : f(a) > f(b) ? 1 : 0)
const normal = (w: any) => ({
...w,
releases: w.releases.map((r: any) => ({ ...r, payload: sortFiles(decodeChunk(Buffer.from(r.payload, 'base64'))) })).sort(by((r) => `${r.key} ${r.upload}`)),
samples: w.samples.map((r: any) => ({ ...r, payload: sortFiles(decodeChunk(Buffer.from(r.payload, 'base64'))) })).sort(by((r) => `${r.key} ${r.upload}`)),
candidates: [...w.candidates].sort(by((c) => JSON.stringify(c))),
counts: [...w.counts].sort(by((c) => `${c.rule} ${c.key}`)),
fragments: [...w.fragments].sort(by((f) => `${f.fileKey} ${f.segments[0]?.[1]}`)),
})
const rust = (await readFile(process.argv[wireIndex + 1], 'utf8')).split('\n').filter(Boolean).map((l) => normal(JSON.parse(l)))
const typescript = batches.map((b) => normal(JSON.parse(JSON.stringify(toWire(b)))))
let wireSame = rust.length === typescript.length
const firstDiff = (x: any, y: any, path: string): string | null => {
if (isDeepStrictEqual(x, y)) return null
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null) return `${path}: ${JSON.stringify(x)?.slice(0, 120)} vs ${JSON.stringify(y)?.slice(0, 120)}`
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
const d = firstDiff(x[k], y[k], `${path}.${k}`)
if (d) return d
}
return path
}
for (let i = 0; i < Math.max(rust.length, typescript.length); i++) {
const d = firstDiff(typescript[i], rust[i], `batch ${i}`)
if (d) {
wireSame = false
console.log(`DIFF wire: ${d}`)
break
}
}
if (wireSame) console.log(`same wire batches: ${typescript.length} TypeScript, ${rust.length} Rust`)
failed ||= !wireSame
}
process.exit(failed ? 1 : 0)
+396 -72
View File
@@ -1,16 +1,24 @@
//! The edge worker in Rust (REBUILD.md, part 2; commands/edge_node.ts): leases
//! ranges from the coordinator, reads their XOVER chunks from its stream,
//! indexes them (the same RangeIndexer as edge-bench, kept identical to
//! the TypeScript by check.ts) and posts the batches. One range per thread.
//! The window worker in Rust (REBUILD.md, "Voyager 3: window indexing", 1 and 5): each thread leases a *group*
//! from the coordinator, takes its ranges in article order, reads each range's XOVER chunks from the coordinator's
//! stream (the corpus or the spool, the worker can't tell), indexes them through one carried window and posts the
//! batches, a batch at least at every range end; the window's snapshot goes to the coordinator every
//! --snapshot-minutes and when the group is given back, and the next holder restores it. A group with no range
//! left (caught up: the tail) is given back and leased again later.
//!
//! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M]
//! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--tail-threads T] [--batch-articles 500000] [--snapshot-minutes 30] [--groups N] [--max-mbit M]
//!
//! SIGTERM/SIGINT: each thread gives its range back after the current chunk
//! (the next worker resumes at the last stored batch).
//! `--tail-threads T` (EDGE_TAIL_THREADS; 1 of 2 or more threads by default): that many of the threads lease the
//! groups' *tail* lane (`?lane=tail`: the tail planner's ranges, with a window and snapshot of their own), the rest
//! the history lane (the windows and the history the coordinator's bounds hand out).
//!
//! SIGTERM/SIGINT: each thread finishes the chunk, posts its batch, snapshots and gives its group back.
//! `--ranges`: the old range leases (POST lease), one range per thread without a carried window (kept for a
//! coordinator without group leases).
use edge::indexer::{Batch, RangeIndexer};
use edge::names::Rules;
use edge::lease::{GroupLeaseJson, LeaseJson, NextRangeJson, RangeSpanJson};
use edge::wire;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use serde::Deserialize;
use std::collections::HashMap;
@@ -34,63 +42,6 @@ fn log(level: &str, message: &str) {
println!("[ {level} ] {message}");
}
#[derive(Deserialize)]
struct LeaseJson {
range: RangeJson,
rules: RulesJson,
}
#[derive(Deserialize)]
struct RangeJson {
id: i64,
group: String,
start: i64,
end: i64,
next: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RulesJson {
version: i64,
priorities: HashMap<String, u32>,
#[serde(default)]
dropped_signatures: Vec<String>,
#[serde(default)]
dropped_tools: Option<Vec<String>>,
#[serde(default)]
per_article_max_parts: Option<u32>,
#[serde(default)]
sample_percent: Option<f64>,
#[serde(default)]
drop_executables: Option<bool>,
#[serde(default)]
dropped_posters: Option<Vec<String>>,
}
impl RulesJson {
fn rules(self) -> Rules {
let mut rules = Rules::defaults(self.priorities.into_iter().collect());
rules.version = self.version;
rules.dropped_signatures = self.dropped_signatures.into_iter().collect();
if let Some(tools) = self.dropped_tools {
rules.dropped_tools = tools.into_iter().collect();
}
if let Some(n) = self.per_article_max_parts {
rules.per_article_max_parts = n;
}
if let Some(p) = self.sample_percent {
rules.sample_percent = p;
}
if let Some(e) = self.drop_executables {
rules.drop_executables = e;
}
if let Some(posters) = self.dropped_posters {
rules.dropped_posters = posters;
}
rules
}
}
#[derive(Deserialize)]
struct StoredJson {
@@ -220,6 +171,81 @@ impl Api {
}
}
// Group leases (REBUILD.md design 1 and 5), per lane (`history` or `tail`).
fn lease_group(&self, lane: &str) -> Result<Option<GroupLeaseJson>, ApiError> {
let mut response = self.request("POST", &format!("groups/lease?lane={lane}"), None, false, "application/json")?;
match response.status().as_u16() {
204 => Ok(None),
200 => {
let text = response.body_mut().read_to_string().map_err(|e| ApiError::Other(format!("groups/lease: {e}")))?;
serde_json::from_str(&text).map(Some).map_err(|e| ApiError::Other(format!("groups/lease: {e}")))
}
status => Err(ApiError::Other(format!("groups/lease: HTTP {status}"))),
}
}
fn next_range(&self, group: i64, lane: &str) -> Result<Option<RangeSpanJson>, ApiError> {
let mut response = self.request("POST", &format!("groups/{group}/next?lane={lane}"), None, false, "application/json")?;
match response.status().as_u16() {
204 => Ok(None),
200 => {
let text = response.body_mut().read_to_string().map_err(|e| ApiError::Other(format!("next: {e}")))?;
serde_json::from_str::<NextRangeJson>(&text).map(|n| Some(n.range)).map_err(|e| ApiError::Other(format!("next: {e}")))
}
status => Err(ApiError::Other(format!("next: HTTP {status}"))),
}
}
fn group_call(&self, group: i64, lane: &str, action: &str) -> Result<(), ApiError> {
let response = self.request("POST", &format!("groups/{group}/{action}?lane={lane}"), None, false, "application/json")?;
match response.status().as_u16() {
200..=299 => Ok(()),
status => Err(ApiError::Other(format!("{action}: HTTP {status}"))),
}
}
/// The window's snapshot (bincode), gzipped, standing at `next`.
fn put_snapshot(&self, group: i64, lane: &str, next: i64, snapshot: &[u8]) -> Result<usize, ApiError> {
let mut gz = GzEncoder::new(Vec::with_capacity(snapshot.len() / 3), flate2::Compression::new(1));
gz.write_all(snapshot).unwrap();
let body = gz.finish().unwrap();
let url = format!("{}/api/v1/node/groups/{group}/snapshot?next={next}&lane={lane}", self.base);
let auth = format!("Bearer {}", self.token);
for attempt in 1..=RETRIES {
let result = self.agent.put(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(&body[..]);
match result {
Ok(response) => match response.status().as_u16() {
200..=299 => return Ok(body.len()),
409 => return Err(ApiError::LeaseLost("snapshot: the group is no longer this node's".into())),
status if status < 500 => return Err(ApiError::Other(format!("snapshot: HTTP {status}"))),
status if attempt == RETRIES => return Err(ApiError::Other(format!("snapshot: HTTP {status}"))),
_ => {}
},
Err(error) if attempt == RETRIES => return Err(ApiError::Other(format!("snapshot: {error}"))),
Err(_) => {}
}
std::thread::sleep(Duration::from_millis(60_000.min(1000 * 2u64.pow(attempt))));
}
unreachable!()
}
/// The group's snapshot: its bytes (inflated) and the article it stands at; None without one.
fn get_snapshot(&self, group: i64, lane: &str) -> Result<Option<(i64, Vec<u8>)>, ApiError> {
let response = self.request("GET", &format!("groups/{group}/snapshot?lane={lane}"), None, false, "application/octet-stream")?;
match response.status().as_u16() {
404 => Ok(None),
200 => {
let next: i64 = response.headers().get("x-snapshot-next").and_then(|v| v.to_str().ok()).and_then(|v| v.parse().ok()).ok_or_else(|| ApiError::Other("snapshot: no X-Snapshot-Next".into()))?;
let mut gz = GzDecoder::new(response.into_body().into_reader());
let mut bytes = Vec::new();
gz.read_to_end(&mut bytes).map_err(|e| ApiError::Other(format!("snapshot: {e}")))?;
Ok(Some((next, bytes)))
}
status => Err(ApiError::Other(format!("snapshot: HTTP {status}"))),
}
}
fn corrupt(&self, range: i64, from: i64) -> Result<(), ApiError> {
let body = format!("{{\"from\":{from}}}");
let response = self.request("POST", &format!("ranges/{range}/corrupt"), Some(body.as_bytes()), false, "application/json")?;
@@ -332,6 +358,8 @@ fn inflate(raw: &[u8], d: &mut libdeflater::Decompressor) -> Result<Vec<u8>, Str
struct Config {
batch_articles: i64,
bytes_per_second: Option<f64>,
/// Between two snapshots of a held group's window.
snapshot_every: Duration,
/// Ranges whose batch was too large (413), and how much smaller their batches are now (halved each time).
smaller: Mutex<HashMap<i64, i64>>,
}
@@ -509,6 +537,272 @@ fn run_range(api: &Arc<Api>, lease: LeaseJson, config: &Config, stopping: &Atomi
}
}
/// What one range of a held group came to.
enum RangeOutcome {
Done,
/// A chunk didn't inflate: reported (the coordinator fetches it again, plain the second time) after the batch
/// before it went out, and the range is asked for again from there; the window stays.
Again,
/// The group is given back (a stop, a lost lease, a failure): no more ranges.
Stop,
}
/// One range of a held group through the carried window: the chunks streamed, batches posted at the range's end at
/// least, the range finished. The indexer keeps the window for the next range. `group_name` is the label for the
/// log (the tail lane's says so).
#[allow(clippy::too_many_arguments)]
fn run_group_range(api: &Arc<Api>, group_id: i64, lane: &str, group_name: &str, range: RangeSpanJson, indexer: &mut RangeIndexer, config: &Config, stopping: &AtomicBool, totals: &Arc<Mutex<Totals>>) -> RangeOutcome {
let started = Instant::now();
indexer.begin_range(range.next, range.end);
let post_error: Arc<Mutex<Option<ApiError>>> = Arc::new(Mutex::new(None));
let last_contact = Arc::new(AtomicI64::new(0));
let since = Instant::now();
let (post_tx, post_rx) = sync_channel::<Batch>(1);
let poster = {
let (api, totals, post_error, last_contact) = (api.clone(), totals.clone(), post_error.clone(), last_contact.clone());
std::thread::spawn(move || {
for batch in post_rx {
if post_error.lock().unwrap().is_some() {
continue;
}
let at = Instant::now();
match api.batch(range.id, &batch) {
Ok((releases, candidates, bytes)) => {
let mut t = totals.lock().unwrap();
t.post_ms += at.elapsed().as_millis();
t.bytes += bytes;
t.headers += batch.headers;
t.releases += releases;
t.candidates += candidates;
last_contact.store(since.elapsed().as_millis() as i64, Ordering::Relaxed);
}
Err(e) => *post_error.lock().unwrap() = Some(e),
}
}
})
};
let frames = match api.stream(range.id) {
Ok(stream) => read_frames(stream, config.bytes_per_second),
Err(e) => {
log("error", &format!("{group_name} {}–{}: stream failed: {e}; the group goes back.", range.start, range.end));
drop(post_tx);
let _ = poster.join();
return RangeOutcome::Stop;
}
};
let (batch_articles, max_pending) = config.limits(range.id);
let mut batch_from = range.next;
let mut reached_end = false;
let mut corrupt: Option<(i64, i64, String)> = None;
let mut failure: Option<String> = None;
let headers_before = totals.lock().unwrap().headers;
for frame in frames.iter() {
if stopping.load(Ordering::Relaxed) || post_error.lock().unwrap().is_some() {
break;
}
if since.elapsed().as_millis() as i64 - last_contact.load(Ordering::Relaxed) > HEARTBEAT.as_millis() as i64 {
last_contact.store(since.elapsed().as_millis() as i64, Ordering::Relaxed);
let _ = api.group_call(group_id, lane, "heartbeat");
}
match frame {
Frame::KeepAlive => continue,
Frame::End => break,
Frame::Failed(e) => {
failure = Some(e);
break;
}
Frame::Chunk { from, to, text } => {
let data = match text {
Ok(data) => data,
Err(e) => {
corrupt = Some((from, to, e));
break;
}
};
indexer.add_text(&String::from_utf8_lossy(&data), to + 1);
reached_end = to >= range.end;
// The window carries on past the range's end: no finish here; a batch at the end at least.
if reached_end || to + 1 - batch_from >= batch_articles || indexer.pending() >= max_pending {
if post_tx.send(indexer.take()).is_err() {
break;
}
batch_from = to + 1;
}
if reached_end {
break;
}
}
}
}
// Stopped mid-range (a stop, a stream that failed, a chunk that doesn't inflate): the batch so far goes out,
// so the snapshot stands at a stored resume point and the window survives the group going back; a chunk
// reported corrupt is read again from its own start. Only a batch the coordinator refused stays open.
if !reached_end && post_error.lock().unwrap().is_none() && indexer.next() > batch_from {
let _ = post_tx.send(indexer.take());
}
drop(post_tx);
let _ = poster.join();
let post_error = post_error.lock().unwrap().take();
if let Some((from, to, e)) = corrupt {
// The server's compressed reply cut short (LOG.md 2026-10-11): the coordinator fetches the chunk again,
// plain the second time, and sets the range aside after that; the window carries on either way.
log("warn", &format!("{group_name} {}–{}: chunk {from}–{to} doesn't inflate ({e}); reported, the range is asked for again", range.start, range.end));
if let Err(e) = api.corrupt(range.id, from) {
log("warn", &format!("{group_name} {}–{}: reporting the chunk: {e}; the group goes back", range.start, range.end));
return RangeOutcome::Stop;
}
if post_error.is_none() {
return RangeOutcome::Again;
}
}
match post_error {
Some(ApiError::LeaseLost(m)) => {
log("warn", &format!("{group_name} {}–{}: {m}", range.start, range.end));
return RangeOutcome::Stop;
}
Some(ApiError::TooLarge(m)) => {
let divisor = {
let mut smaller = config.smaller.lock().unwrap();
let d = smaller.entry(range.id).or_insert(1);
*d = (*d * 2).min(64);
*d
};
log("warn", &format!("{group_name} {}–{}: {m}; the group goes back, its batches 1/{divisor} the size from now on.", range.start, range.end));
return RangeOutcome::Stop;
}
Some(ApiError::Other(m)) => {
log("error", &format!("{group_name} {}–{}: {m}; the group goes back.", range.start, range.end));
return RangeOutcome::Stop;
}
None => {}
}
if let Some(e) = failure {
log("error", &format!("{group_name} {}–{}: {e}; the group goes back.", range.start, range.end));
return RangeOutcome::Stop;
}
if !reached_end {
return RangeOutcome::Stop;
}
match api.call(range.id, "finish") {
Ok(()) => {
let t = totals.lock().unwrap();
let seconds = started.elapsed().as_secs_f64();
let headers = t.headers - headers_before;
log(
"info",
&format!(
"{group_name} {}–{}: {} headers in {:.0} s ({:.0}/s); {} releases and {} files open in the window.",
range.start,
range.end,
headers,
seconds,
headers as f64 / seconds.max(0.001),
indexer.open_releases(),
indexer.open_files()
),
);
RangeOutcome::Done
}
Err(e) => {
log("warn", &format!("{group_name} {}–{}: finish: {e}", range.start, range.end));
RangeOutcome::Stop
}
}
}
/// The window's snapshot to the coordinator, standing at the indexer's next article.
fn snapshot(api: &Api, group_id: i64, lane: &str, group_name: &str, indexer: &RangeIndexer) {
match indexer.snapshot() {
Ok(bytes) => match api.put_snapshot(group_id, lane, indexer.next(), &bytes) {
Ok(gz) => log("info", &format!("{group_name}: snapshot at {} ({:.1} MB, {:.1} MB gzipped).", indexer.next(), bytes.len() as f64 / 1e6, gz as f64 / 1e6)),
Err(e) => log("warn", &format!("{group_name}: snapshot failed: {e}")),
},
Err(e) => log("warn", &format!("{group_name}: no snapshot: {e}")),
}
}
/// One held group (one lane of it): its window restored from the snapshot (or new), its ranges one after another
/// until it is caught up or the worker stops; then a snapshot, and the group given back.
fn run_group(api: &Arc<Api>, lease: GroupLeaseJson, lane: &str, config: &Config, stopping: &AtomicBool) {
let group_id = lease.group.id;
let name = lease.group.name.clone();
// The log's label; the indexer gets the group's name itself (keys, xrefs).
let group_name = if lane == "tail" { format!("{name} (tail)") } else { name.clone() };
let rules = lease.rules.rules();
let mut indexer = match lease.snapshot {
Some(s) => match api.get_snapshot(group_id, lane) {
Ok(Some((next, bytes))) => match RangeIndexer::restore(&bytes, rules.clone(), 5) {
Ok(ix) => {
log("info", &format!("{group_name}: window restored from the snapshot at {next} ({:.1} MB).", bytes.len() as f64 / 1e6));
ix
}
Err(e) => {
log("warn", &format!("{group_name}: the snapshot at {} can't be restored ({e}); the window starts empty.", s.next));
RangeIndexer::for_group(&name, rules.clone(), 5)
}
},
Ok(None) => {
log("warn", &format!("{group_name}: the lease names a snapshot at {} but there is none; the window starts empty.", s.next));
RangeIndexer::for_group(&name, rules.clone(), 5)
}
Err(e) => {
log("warn", &format!("{group_name}: snapshot: {e}; the group goes back."));
let _ = api.group_call(group_id, lane, "release");
return;
}
},
None => RangeIndexer::for_group(&name, rules.clone(), 5),
};
let totals = Arc::new(Mutex::new(Totals::default()));
let started = Instant::now();
let mut snapshotted = Instant::now();
let mut ranges = 0;
loop {
if stopping.load(Ordering::Relaxed) {
break;
}
let range = match api.next_range(group_id, lane) {
Ok(Some(range)) => range,
Ok(None) => {
log("info", &format!("{group_name}: caught up after {ranges} ranges; the group goes back."));
break;
}
Err(e) => {
log("warn", &format!("{group_name}: next range: {e}; the group goes back."));
break;
}
};
if range.next != indexer.next() && ranges > 0 {
log("warn", &format!("{group_name}: the window stands at {} but the next range resumes at {}: the window carries on from there.", indexer.next(), range.next));
}
match run_group_range(api, group_id, lane, &group_name, range, &mut indexer, config, stopping, &totals) {
RangeOutcome::Done => ranges += 1,
// The same range again from the reported chunk: the stream waits for its new copy.
RangeOutcome::Again => {}
RangeOutcome::Stop => break,
}
if snapshotted.elapsed() >= config.snapshot_every {
snapshot(api, group_id, lane, &group_name, &indexer);
snapshotted = Instant::now();
}
}
snapshot(api, group_id, lane, &group_name, &indexer);
let _ = api.group_call(group_id, lane, "release");
let t = totals.lock().unwrap();
log(
"info",
&format!(
"{group_name}: {ranges} ranges, {} headers in {:.0} s, {} release chunks, {} probe candidates; posted {:.1} MB in {:.1} s.",
t.headers,
started.elapsed().as_secs_f64(),
t.releases,
t.candidates,
t.bytes as f64 / 1e6,
t.post_ms as f64 / 1000.0
),
);
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
@@ -520,9 +814,16 @@ fn main() {
.filter(|&n| n > 0)
.unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1));
let ranges: Option<usize> = flag("--ranges").map(|v| v.parse().unwrap());
// Of the threads, this many on the groups' tail lane: one by default when there are two or more.
let tail_threads: usize = flag("--tail-threads")
.or_else(|| std::env::var("EDGE_TAIL_THREADS").ok())
.map(|v| v.parse().unwrap())
.unwrap_or(if threads >= 2 { 1 } else { 0 });
assert!(tail_threads <= threads, "--tail-threads {tail_threads} is more than the {threads} threads");
let max_mbit: Option<f64> = flag("--max-mbit").or_else(|| std::env::var("EDGE_MAX_MBIT").ok()).map(|v| v.parse().unwrap()).filter(|&m: &f64| m > 0.0);
let config = Arc::new(Config {
batch_articles: flag("--batch-articles").map(|v| v.parse().unwrap()).unwrap_or(500_000),
snapshot_every: Duration::from_secs(60 * flag("--snapshot-minutes").or_else(|| std::env::var("EDGE_SNAPSHOT_MINUTES").ok()).map(|v| v.parse::<u64>().unwrap()).unwrap_or(30)),
// The node's cap, shared by its threads.
bytes_per_second: max_mbit.map(|m| m * 1e6 / 8.0 / threads as f64),
smaller: Mutex::new(HashMap::new()),
@@ -531,22 +832,41 @@ fn main() {
signal_hook::flag::register(signal_hook::consts::SIGTERM, stopping.clone()).unwrap();
signal_hook::flag::register(signal_hook::consts::SIGINT, stopping.clone()).unwrap();
let api = Arc::new(Api::new(&url, &token));
log("info", &format!("Starting {threads} worker threads{}.", max_mbit.map(|m| format!(" (download cap {m} Mbit/s)")).unwrap_or_default()));
log("info", &format!("Starting {threads} worker threads{}{}.", if range_leases_wanted(&ranges) { String::new() } else { format!(", {tail_threads} on the tail") }, max_mbit.map(|m| format!(" (download cap {m} Mbit/s)")).unwrap_or_default()));
let groups: Option<usize> = flag("--groups").map(|v| v.parse().unwrap());
let range_leases = ranges.is_some();
let workers: Vec<_> = (0..threads)
.map(|_| {
.map(|i| {
let (api, config, stopping) = (api.clone(), config.clone(), stopping.clone());
let lane = if i < tail_threads { "tail" } else { "history" };
std::thread::spawn(move || {
let mut done = 0;
while !stopping.load(Ordering::Relaxed) && ranges.is_none_or(|n| done < n) {
match api.lease() {
if range_leases {
while !stopping.load(Ordering::Relaxed) && ranges.is_none_or(|n| done < n) {
match api.lease() {
Ok(Some(lease)) => {
run_range(&api, lease, &config, &stopping);
done += 1;
}
Ok(None) => break,
Err(e) => {
log("warn", &format!("Lease failed: {e}"));
idle(&stopping);
}
}
}
return;
}
while !stopping.load(Ordering::Relaxed) && groups.is_none_or(|n| done < n) {
match api.lease_group(lane) {
Ok(Some(lease)) => {
run_range(&api, lease, &config, &stopping);
run_group(&api, lease, lane, &config, &stopping);
done += 1;
}
Ok(None) if ranges.is_some() => break,
Ok(None) if groups.is_some() => break,
Ok(None) => idle(&stopping),
Err(e) => {
log("warn", &format!("Lease failed: {e}"));
log("warn", &format!("Group lease failed: {e}"));
idle(&stopping);
}
}
@@ -559,6 +879,10 @@ fn main() {
}
}
fn range_leases_wanted(ranges: &Option<usize>) -> bool {
ranges.is_some()
}
fn idle(stopping: &AtomicBool) {
let until = Instant::now() + IDLE;
while Instant::now() < until && !stopping.load(Ordering::Relaxed) {
+1057 -151
View File
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
//! A lease as the coordinator sends it (lib/edge/lease.ts): the range and the rules, JSON with the TypeScript
//! names. The edge-node worker takes one per range; edge-bench's replay takes the rules alone (`--rules`).
use crate::names::Rules;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
pub struct LeaseJson {
pub range: RangeJson,
pub rules: RulesJson,
}
/// A group lease (lib/edge/lease.ts GroupLease): the group a worker thread holds, the rules, the window's snapshot if any.
#[derive(Deserialize)]
pub struct GroupLeaseJson {
pub group: GroupJson,
pub rules: RulesJson,
pub snapshot: Option<SnapshotJson>,
}
#[derive(Deserialize)]
pub struct GroupJson {
pub id: i64,
pub name: String,
pub priority: i64,
}
#[derive(Deserialize)]
pub struct SnapshotJson {
pub next: i64,
}
/// The group's next range (lib/edge/lease.ts NextRange).
#[derive(Deserialize)]
pub struct NextRangeJson {
pub range: RangeSpanJson,
}
#[derive(Deserialize, Clone, Copy)]
pub struct RangeSpanJson {
pub id: i64,
pub start: i64,
pub end: i64,
pub next: i64,
}
#[derive(Deserialize)]
pub struct RangeJson {
pub id: i64,
pub group: String,
pub start: i64,
pub end: i64,
pub next: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RulesJson {
pub version: i64,
pub priorities: HashMap<String, u32>,
#[serde(default)]
pub dropped_signatures: Vec<String>,
#[serde(default)]
pub dropped_classes: Vec<String>,
#[serde(default)]
pub dropped_tools: Option<Vec<String>>,
#[serde(default)]
pub per_article_max_parts: Option<u32>,
#[serde(default)]
pub sample_percent: Option<f64>,
#[serde(default)]
pub drop_executables: Option<bool>,
#[serde(default)]
pub dropped_posters: Option<Vec<String>>,
#[serde(default)]
pub drop_stub_archives: Option<bool>,
#[serde(default)]
pub drop_nonsense_pieces: Option<bool>,
#[serde(default)]
pub drop_tiny_archives: Option<bool>,
}
impl RulesJson {
/// The rules with today's defaults under them (a field the stored set doesn't have keeps the default).
pub fn rules(self) -> Rules {
let mut rules = Rules::defaults(self.priorities.into_iter().collect());
rules.version = self.version;
rules.dropped_signatures = self.dropped_signatures.into_iter().collect();
rules.dropped_classes = self.dropped_classes.into_iter().collect();
if let Some(tools) = self.dropped_tools {
rules.dropped_tools = tools.into_iter().collect();
}
if let Some(n) = self.per_article_max_parts {
rules.per_article_max_parts = n;
}
if let Some(p) = self.sample_percent {
rules.sample_percent = p;
}
if let Some(e) = self.drop_executables {
rules.drop_executables = e;
}
if let Some(posters) = self.dropped_posters {
rules.dropped_posters = posters;
}
if let Some(d) = self.drop_stub_archives {
rules.drop_stub_archives = d;
}
if let Some(d) = self.drop_nonsense_pieces {
rules.drop_nonsense_pieces = d;
}
if let Some(d) = self.drop_tiny_archives {
rules.drop_tiny_archives = d;
}
rules
}
}
+1
View File
@@ -5,5 +5,6 @@
pub mod codec;
pub mod indexer;
pub mod js;
pub mod lease;
pub mod names;
pub mod wire;
+192 -90
View File
@@ -1,10 +1,11 @@
//! The edge worker's indexing in Rust, on a range captured by `edge:bench
//! --capture` (REBUILD.md, part 2: TypeScript or Rust). Same work as
//! edge:bench's replay: inflate each chunk, scan it, aggregate files, cut off
//! idle ones, and every N chunks judge and encode the releases. `--dump`
//! writes every output as JSON lines for edge/check.ts.
//! The Rust indexer over stored chunks (REBUILD.md, part 2 and "Voyager 3"): a range captured by `edge:bench
//! --capture` or stored in the corpus, or a group's corpus ranges in order through one window (`--group`, the
//! replay harness behind `replay:corpus`). Same work as the edge-node worker: inflate each chunk, scan it,
//! aggregate files, finish releases through the window, and every N chunks judge and encode them. `--dump`
//! writes every output as JSON lines; `--wire` the batches as the worker posts them (`-`: stdout).
//!
//! edge-bench <dir> [--batch-chunks 10] [--idle 5] [--dump out.jsonl] [--wire batches.jsonl]
//! edge-bench <range dir> | --group <group dir> [--from article] [--to article] [--limit ranges] [--rules rules.json]
//! [--batch-chunks 10] [--idle 5] [--no-finish] [--dump out.jsonl] [--wire batches.jsonl|-] [--releases out.jsonl]
use base64::Engine;
use edge::indexer::{self, Batch, RangeIndexer};
@@ -123,7 +124,7 @@ fn dump_batch(batch: &Batch, out: &mut impl Write) {
line.push_str(",\"postedLast\":");
opt_num(r.posted_last, line);
line.push_str(&format!(
",\"files\":{},\"segments\":{},\"bytes\":{},\"firstArticle\":{},\"lastArticle\":{},\"readable\":{},\"hasPar2\":{},\"payload\":\"{}\"",
",\"files\":{},\"segments\":{},\"bytes\":{},\"firstArticle\":{},\"lastArticle\":{},\"readable\":{},\"hasPar2\":{},\"finished\":{},\"edge\":{},\"partial\":{},\"payload\":\"{}\"",
r.files,
r.segments,
r.bytes,
@@ -131,6 +132,9 @@ fn dump_batch(batch: &Batch, out: &mut impl Write) {
r.last_article,
r.readable,
r.has_par2,
r.finished,
r.edge,
r.partial,
b64.encode(&r.payload)
));
};
@@ -162,122 +166,220 @@ fn dump_batch(batch: &Batch, out: &mut impl Write) {
line.push_str(&format!(",\"articles\":{articles},\"files\":{files}}}"));
emit(&mut line);
}
for f in &batch.fragments {
line.push_str("{\"t\":\"f\",\"fileKey\":");
json_str(&f.file_key, &mut line);
line.push_str(",\"name\":");
json_str(&f.name, &mut line);
line.push_str(&format!(",\"totalParts\":{},\"postedAt\":", f.total_parts));
opt_num(f.posted_at, &mut line);
line.push_str(",\"segments\":[");
for (i, (n, id, bytes, article)) in f.segments.iter().enumerate() {
if i > 0 {
line.push(',');
}
line.push_str(&format!("[{n},"));
json_str(id, &mut line);
line.push_str(&format!(",{bytes},{article}]"));
}
/// A stored range: its directory and article span (range.json).
struct Range {
dir: String,
start: i64,
end: i64,
}
/// The range in a directory with a range.json, and its group.
fn range_in(dir: &str) -> Option<(String, Range)> {
let json = std::fs::read_to_string(format!("{dir}/range.json")).ok()?;
Some((json_field(&json, "group"), Range { dir: dir.to_string(), start: json_field(&json, "start").parse().ok()?, end: json_field(&json, "end").parse().ok()? }))
}
/// A group directory's complete ranges in article order (the corpus layout: `<group>/<start>-<end>/`).
fn ranges_in(group_dir: &str) -> (String, Vec<Range>) {
let mut group = String::new();
let mut ranges: Vec<Range> = Vec::new();
for entry in std::fs::read_dir(group_dir).unwrap_or_else(|e| panic!("{group_dir}: {e}")) {
let path = entry.unwrap().path();
if !path.is_dir() {
continue;
}
line.push_str("]}");
emit(&mut line);
if let Some((g, range)) = range_in(path.to_str().unwrap()) {
group = g;
ranges.push(range);
}
}
ranges.sort_by_key(|r| r.start);
(group, ranges)
}
fn date(ms: Option<i64>) -> String {
match ms.and_then(chrono::DateTime::from_timestamp_millis) {
Some(t) => t.format("%Y-%m-%d %H:%M").to_string(),
None => "-".to_string(),
}
}
/// Files waiting in finished releases after which a batch goes out whatever its chunks (edge-node's limit).
const MAX_PENDING_FILES: usize = 100_000;
fn main() {
let args: Vec<String> = std::env::args().collect();
let dir = args.get(1).expect("usage: edge-bench <dir> [--batch-chunks N] [--idle N] [--dump file]").clone();
let usage = "usage: edge-bench <range dir> | --group <group dir> [--from article] [--to article] [--limit ranges] [--rules rules.json] [--batch-chunks N] [--idle N] [--no-finish] [--dump file] [--wire file|-] [--releases file]";
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
let has = |name: &str| args.iter().any(|a| a == name);
let batch_chunks: usize = flag("--batch-chunks").map(|v| v.parse().unwrap()).unwrap_or(10);
let idle: u64 = flag("--idle").map(|v| v.parse().unwrap()).unwrap_or(5);
let mut dump = flag("--dump").map(|p| std::io::BufWriter::new(std::fs::File::create(p).unwrap()));
// Each batch as the worker posts it (wire.rs), one JSON per line: check.ts compares it with toWire.
let mut wire_out = flag("--wire").map(|p| std::io::BufWriter::new(std::fs::File::create(p).unwrap()));
// Each batch as the worker posts it (wire.rs), one JSON per line: a file, or stdout (`-`) for replay:corpus.
let wire_path = flag("--wire");
let wire_stdout = wire_path.as_deref() == Some("-");
let mut wire_out: Option<Box<dyn Write>> = match wire_path.as_deref() {
Some("-") => Some(Box::new(std::io::BufWriter::with_capacity(1 << 20, std::io::stdout().lock()))),
Some(p) => Some(Box::new(std::io::BufWriter::new(std::fs::File::create(p).unwrap()))),
None => None,
};
// One line per release chunk without its payload, with its first subjects (deploy/corpus_sweep.py).
let mut releases_out = flag("--releases").map(|p| std::io::BufWriter::new(std::fs::File::create(p).unwrap()));
let range = std::fs::read_to_string(format!("{dir}/range.json")).unwrap();
let group = json_field(&range, "group");
let start: i64 = json_field(&range, "start").parse().unwrap();
let end: i64 = json_field(&range, "end").parse().unwrap();
let mut files: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).filter(|f| f.ends_with(".gz")).collect();
files.sort();
// One range, or a group's ranges in order through one window (the window carries over from range to range;
// the end is only finished after the last one, unless --no-finish: a replay to be continued).
let (group, ranges) = match flag("--group") {
Some(dir) => {
let (group, mut ranges) = ranges_in(&dir);
// --from: ranges ending before it are skipped (a replay continued); --to: ranges starting after it (one
// window of the group's history, the corpus holding others); --limit: this many at most.
if let Some(from) = flag("--from").map(|v| v.parse::<i64>().unwrap()) {
ranges.retain(|r| r.end >= from);
}
if let Some(to) = flag("--to").map(|v| v.parse::<i64>().unwrap()) {
ranges.retain(|r| r.start <= to);
}
if let Some(limit) = flag("--limit").map(|v| v.parse::<usize>().unwrap()) {
ranges.truncate(limit);
}
(group, ranges)
}
None => {
let positional = (1..args.len()).find(|&i| !args[i].starts_with("--") && !args[i - 1].starts_with("--")).map(|i| args[i].clone());
let dir = positional.expect(usage);
let (group, range) = range_in(&dir).unwrap_or_else(|| panic!("{dir}: no range.json"));
(group, vec![range])
}
};
if ranges.is_empty() {
eprintln!("no complete range found");
std::process::exit(2);
}
let finish = !has("--no-finish");
let rules = match flag("--rules") {
Some(path) => {
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
serde_json::from_str::<edge::lease::RulesJson>(&json).unwrap_or_else(|e| panic!("{path}: {e}")).rules()
}
None => {
let mut priorities = FxHashMap::default();
priorities.insert(group.clone(), 1);
names::Rules::defaults(priorities)
}
};
// The report goes to stderr when the wire takes stdout.
let say = |line: String| {
if wire_stdout {
eprintln!("{line}");
} else {
println!("{line}");
}
};
let mut priorities = FxHashMap::default();
priorities.insert(group.clone(), 1);
let mut indexer = RangeIndexer::new(&group, start, end, start, names::Rules::defaults(priorities), idle);
let (start, end) = (ranges[0].start, ranges[ranges.len() - 1].end);
let mut indexer = RangeIndexer::new(&group, start, end, start, rules, idle);
let (mut inflate_s, mut index_s) = (0.0, 0.0);
let (mut compressed, mut text_bytes) = (0usize, 0usize);
let mut out = (0u64, 0usize, 0usize, 0usize, 0usize, 0usize, 0usize, 0usize); // headers, releases, files, segments, payload, candidates, fragments, samples
let (mut compressed, mut text_bytes, mut chunks) = (0usize, 0usize, 0usize);
let mut out = (0u64, 0usize, 0usize, 0usize, 0usize, 0usize, 0usize); // headers, releases, files, segments, payload, candidates, samples
let mut finished = 0usize;
let mut keys = std::collections::HashSet::new();
let mut decompressor = libdeflater::Decompressor::new();
let wall = Instant::now();
for (i, file) in files.iter().enumerate() {
let raw = std::fs::read(format!("{dir}/{file}")).unwrap();
compressed += raw.len();
let t = Instant::now();
let data = inflate(&raw, &mut decompressor);
inflate_s += t.elapsed().as_secs_f64();
text_bytes += data.len();
let t = Instant::now();
let text = String::from_utf8_lossy(&data);
let next: i64 = file.split('-').nth(2).unwrap().trim_end_matches(".gz").parse::<i64>().unwrap() + 1;
indexer.add_text(&text, next);
let last = i + 1 == files.len();
if last {
indexer.finish();
let mut since_batch = 0usize;
for (ri, range) in ranges.iter().enumerate() {
let range_wall = Instant::now();
let headers_before = out.0;
// After a gap between stored ranges the batch starts at this range (every range ends with a batch taken).
if ri > 0 {
indexer.skip_to(range.start);
}
if !last && (i + 1) % batch_chunks != 0 {
let mut files: Vec<String> = std::fs::read_dir(&range.dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).filter(|f| f.ends_with(".gz")).collect();
files.sort();
for (i, file) in files.iter().enumerate() {
let raw = std::fs::read(format!("{}/{file}", range.dir)).unwrap();
compressed += raw.len();
let t = Instant::now();
let data = inflate(&raw, &mut decompressor);
inflate_s += t.elapsed().as_secs_f64();
text_bytes += data.len();
chunks += 1;
let t = Instant::now();
let text = String::from_utf8_lossy(&data);
let next: i64 = file.split('-').nth(2).unwrap().trim_end_matches(".gz").parse::<i64>().unwrap() + 1;
indexer.add_text(&text, next);
since_batch += 1;
let range_end = i + 1 == files.len();
let last = range_end && ri + 1 == ranges.len();
if last && finish {
indexer.finish();
}
if !range_end && since_batch < batch_chunks && indexer.pending() < MAX_PENDING_FILES {
index_s += t.elapsed().as_secs_f64();
continue;
}
let batch = indexer.take();
since_batch = 0;
index_s += t.elapsed().as_secs_f64();
continue;
out.0 += batch.headers;
out.1 += batch.releases.len();
for r in &batch.releases {
keys.insert(r.key.clone());
out.2 += r.files;
out.3 += r.segments;
out.4 += r.payload.len();
finished += r.finished as usize;
}
out.5 += batch.candidates.len();
out.6 += batch.samples.len();
if let Some(d) = dump.as_mut() {
dump_batch(&batch, d);
}
if let Some(w) = wire_out.as_mut() {
w.write_all(&wire::to_json(&batch)).unwrap();
w.write_all(b"\n").unwrap();
w.flush().unwrap();
}
if let Some(w) = releases_out.as_mut() {
dump_releases(&batch, &group, w);
}
}
let batch = indexer.take();
index_s += t.elapsed().as_secs_f64();
out.0 += batch.headers;
out.1 += batch.releases.len();
for r in &batch.releases {
keys.insert(r.key.clone());
out.2 += r.files;
out.3 += r.segments;
out.4 += r.payload.len();
}
out.5 += batch.candidates.len();
out.6 += batch.fragments.len();
out.7 += batch.samples.len();
if let Some(d) = dump.as_mut() {
dump_batch(&batch, d);
}
if let Some(w) = wire_out.as_mut() {
w.write_all(&wire::to_json(&batch)).unwrap();
w.write_all(b"\n").unwrap();
}
if let Some(w) = releases_out.as_mut() {
dump_releases(&batch, &group, w);
if ranges.len() > 1 {
eprintln!(
"{} {}–{}: {} headers in {:.1} s; frontier {}; {} releases out ({} finished), {} releases and {} files open; RSS {} MB",
group,
range.start,
range.end,
out.0 - headers_before,
range_wall.elapsed().as_secs_f64(),
date(indexer.frontier()),
out.1,
finished,
indexer.open_releases(),
indexer.open_files(),
peak_rss_mb()
);
}
}
let total = inflate_s + index_s;
#[allow(static_mut_refs)]
let p = unsafe { indexer::PROF };
eprintln!(" of which aggregate {:.2} s, evict+cutoff {:.2} s, judge+encode {:.2} s", p[3] as f64 / 1e9, p[4] as f64 / 1e9, p[5] as f64 / 1e9);
println!("{group} {start}–{end}: {} chunks, {} MB compressed, {} MB text", files.len(), compressed / 1_000_000, text_bytes / 1_000_000);
println!(
"{} headers in {:.2} s ({:.0}/s on one core; wall {:.2} s)",
out.0,
total,
out.0 as f64 / total,
wall.elapsed().as_secs_f64()
);
println!(" inflate {inflate_s:.2} s, index {index_s:.2} s; peak RSS {} MB", peak_rss_mb());
println!(
" kept: {} release chunks ({} releases), {} files, {} segments, payload {:.1} MB ({:.1} B/segment); {} candidates, {} fragments, {} samples",
say(format!("{group} {start}–{end}: {} ranges, {chunks} chunks, {} MB compressed, {} MB text", ranges.len(), compressed / 1_000_000, text_bytes / 1_000_000));
say(format!("{} headers in {:.2} s ({:.0}/s on one core; wall {:.2} s)", out.0, total, out.0 as f64 / total, wall.elapsed().as_secs_f64()));
say(format!(" inflate {inflate_s:.2} s, index {index_s:.2} s; peak RSS {} MB", peak_rss_mb()));
say(format!(
" kept: {} release chunks ({} releases, {} finished), {} files, {} segments, payload {:.1} MB ({:.1} B/segment); {} candidates, {} samples",
out.1,
keys.len(),
finished,
out.2,
out.3,
out.4 as f64 / 1e6,
out.4 as f64 / out.3.max(1) as f64,
out.5,
out.6,
out.7
);
out.6
));
}
+570 -6
View File
@@ -449,6 +449,43 @@ fn finish_remainder(s: &str) -> String {
js::trim(&out).to_string()
}
/// displayName: a subject name as shown, brackets the counters left unmatched removed ("… Facialized 002/477] - …"
/// gave "… Facialized ]"). Only the name: the key keeps the remainder.
pub fn display_name(remainder: &str) -> String {
let b = remainder.as_bytes();
if !b.iter().any(|c| matches!(c, b'[' | b']' | b'(' | b')')) {
return remainder.to_string();
}
let mut drop = vec![false; b.len()];
for (open, close) in [(b'[', b']'), (b'(', b')')] {
let mut opens: Vec<usize> = Vec::new();
for (i, &c) in b.iter().enumerate() {
if c == open {
opens.push(i);
} else if c == close && opens.pop().is_none() {
drop[i] = true;
}
}
for i in opens {
drop[i] = true;
}
}
if !drop.contains(&true) {
return remainder.to_string();
}
let kept: Vec<u8> = b.iter().zip(&drop).filter(|(_, &d)| !d).map(|(&c, _)| c).collect();
let kept = String::from_utf8(kept).expect("only ASCII brackets removed");
let mut out = String::with_capacity(kept.len());
for c in kept.chars() {
if c == ' ' && out.ends_with(' ') {
continue;
}
out.push(c);
}
let name = js::trim(&out);
if name.is_empty() { remainder.to_string() } else { name.to_string() }
}
/// counterTotal: the subject's file counter total, first form found first.
pub fn counter_total(subject: &str) -> Option<String> {
let s = replace(&QUOTED, subject, " ", true);
@@ -841,11 +878,11 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release {
if !image && test(&RANDOM_VOLUME, name) && stem.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') && looks_random(&stripped) {
parts.push(&stem);
}
return Release { key: hash(&parts), name: remainder, source: "subject", file_total };
return Release { key: hash(&parts), name: display_name(&remainder), source: "subject", file_total };
}
if let Some(total) = post_total.filter(|&n| n != 0 && !tag) {
if is_post_token(&remainder) {
return Release { key: hash(&["post", poster, &remainder, &total.to_string()]), name: remainder, source: "post", file_total };
return Release { key: hash(&["post", poster, &remainder, &total.to_string()]), name: display_name(&remainder), source: "post", file_total };
}
}
// A scene music post's index files ("00-ammar-turk_uit_de_kroeg-single-web-nl-2022-cover.jpg") under a bare counter go
@@ -1028,6 +1065,228 @@ pub fn is_random_file(name: &str) -> bool {
looks_random(&file_stem(name))
}
/// Readable or not (lib/release/readable.ts): whether a name says what a release is, read token by token, so
/// separators, spaces and random "extensions" don't hide a scramble that looks_random's single-token shapes miss
/// (LOG.md (129)). The readable flag; the drop rules keep looks_random, which is narrower.
/// Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed words.
static NOISE_TOKEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)^(addon|samples?|proof|subs?|nfo|part[0-9]+|vol[0-9]+|description|repost|rep|extras?|bonus|cd[0-9]*|dvd[0-9]*|dis[ck][0-9]*|par2|rar|zip|7z|mkv|mp4|avi|vol)$"));
/// Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th.
static CODE_TOKEN: LazyLock<Regex> = LazyLock::new(|| re(r"^([sS][0-9]{1,2}[eE][0-9]{1,3}|[xh]26[45]|[A-Za-z]+[0-9]+([a-z]{1,3}|x(64|86))?|[0-9]+[A-Za-z]{1,3}|[0-9]+[A-Za-z]+[0-9]+)$"));
/// The NZBSPLIT tools' hash between their markers is no part of the name.
static NZBSPLIT_HASH: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)__NZBS[P]LIT__[0-9a-f]{32}__NZBS[P]LIT__")); // "SP" is re()'s space class
/// An inner run of 2+ capitals with lowercase on both sides ("tBWTuz", "YDoaROv"; not "3DMark03").
static INNER_UPPER: LazyLock<Regex> = LazyLock::new(|| re(r"[a-z][A-Z]{2,}[a-z]"));
/// Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there.
fn scene_case(t: &[u8]) -> Vec<u8> {
let mut out = t.to_vec();
for i in 1..t.len().saturating_sub(1) {
if t[i] == b'i' && t[i - 1].is_ascii_uppercase() && t[i + 1].is_ascii_uppercase() {
out[i] = b'I';
}
}
out
}
/// The signals of a scrambled token (readable.ts signals()).
struct TokenSignals {
/// Letter/digit and lower-to-upper switches.
switches: usize,
/// Case switches in both directions.
case_switches: usize,
/// Lone lowercase letters (not "i") between capitals ("NtpUgCmS").
lone: usize,
inner_upper: bool,
max_consonants: usize,
/// Vowels, a lowercase y too ("McCartney").
vowels: usize,
/// Runs of one character class.
runs: usize,
letters: usize,
/// The longest lowercase run.
max_lower: usize,
}
fn token_signals(t: &[u8]) -> TokenSignals {
let is_vowel = |c: u8| matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u') || c == b'y';
let class = |c: u8| if c.is_ascii_digit() { 0 } else if c.is_ascii_lowercase() { 1 } else { 2 };
let mut s = TokenSignals { switches: 0, case_switches: 0, lone: 0, inner_upper: false, max_consonants: 0, vowels: 0, runs: 0, letters: 0, max_lower: 0 };
let mut consonants = 0;
let mut lower = 0;
for i in 0..t.len() {
let c = t[i];
if c.is_ascii_alphabetic() && !is_vowel(c) && c.to_ascii_lowercase() != b'y' {
consonants += 1;
s.max_consonants = s.max_consonants.max(consonants);
} else {
consonants = 0;
}
if is_vowel(c) {
s.vowels += 1;
}
if c.is_ascii_alphabetic() {
s.letters += 1;
}
if c.is_ascii_lowercase() {
lower += 1;
s.max_lower = s.max_lower.max(lower);
} else {
lower = 0;
}
if i == 0 || class(t[i - 1]) != class(c) {
s.runs += 1;
}
if i == 0 {
continue;
}
let a = t[i - 1];
if a.is_ascii_digit() != c.is_ascii_digit() {
s.switches += 1;
} else if a.is_ascii_lowercase() && c.is_ascii_uppercase() {
s.switches += 1;
}
if (a.is_ascii_lowercase() && c.is_ascii_uppercase()) || (a.is_ascii_uppercase() && c.is_ascii_lowercase()) {
s.case_switches += 1;
}
if i < t.len() - 1 && c.is_ascii_lowercase() && c != b'i' && a.is_ascii_uppercase() && t[i + 1].is_ascii_uppercase() {
s.lone += 1;
}
}
s.inner_upper = INNER_UPPER.is_match(t);
s
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum TokenKind {
Word,
Random,
Neutral,
}
/// What one alphanumeric token of a name is: a word (letters only, in one case or Capitalized, 3+ long, not a
/// packaging word), a scramble, or neither (digits, codes, CamelCase, short tokens). Kept in step with readable.ts.
pub fn token_kind(t: &[u8]) -> TokenKind {
let n = t.len();
if t.iter().all(u8::is_ascii_digit) || NOISE_TOKEN.is_match(t) || CODE_TOKEN.is_match(t) {
return TokenKind::Neutral;
}
if t.iter().all(u8::is_ascii_alphabetic) {
let s = token_signals(&scene_case(t));
let capitalized = t[0].is_ascii_uppercase() && t[1..].iter().all(u8::is_ascii_lowercase);
if s.case_switches == 0 || (s.case_switches == 1 && capitalized) {
// A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word.
if n >= 10 && s.vowels * 6 < n {
return TokenKind::Random;
}
return if n >= 3 { TokenKind::Word } else { TokenKind::Neutral };
}
// Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs.
// Two CamelCase words with few vowels (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone
// letter; a scramble has more runs, a lone letter or an inner capital run.
if n >= 6 && s.case_switches >= 2 && s.vowels * 4 < n && (s.runs >= 5 || s.lone >= 1 || s.inner_upper) {
return TokenKind::Random;
}
return TokenKind::Neutral;
}
if n >= 8 && t.iter().all(u8::is_ascii_hexdigit) && t.iter().filter(|c| c.is_ascii_digit()).count() >= 2 {
return TokenKind::Random;
}
if t.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
// 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay.
return if n >= 12 && token_signals(t).switches >= 6 { TokenKind::Random } else { TokenKind::Neutral };
}
// Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym.
if t.split(|c: &u8| !c.is_ascii_alphabetic()).filter(|w| !w.is_empty()).all(|w| {
let vowels = w.iter().filter(|c| matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u' | b'y')).count();
(w.len() >= 2 && w.iter().all(u8::is_ascii_uppercase))
|| (w.len() >= 2 && w[1..].iter().all(u8::is_ascii_lowercase) && (w.len() >= 3 || w[0].is_ascii_lowercase()) && vowels * 5 >= w.len())
}) {
return TokenKind::Neutral;
}
let s = token_signals(&scene_case(t));
if n >= 5 && s.switches >= 3 {
return TokenKind::Random;
}
if n >= 6 && s.switches >= 2 && (s.lone >= 1 || s.inner_upper || s.max_consonants >= 4) {
return TokenKind::Random;
}
// Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus").
if n >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.max_lower <= 2 {
return TokenKind::Random;
}
TokenKind::Neutral
}
/// A file or release name no one can read (readable.ts isUnreadableName): looks_random's shapes, or more scrambled
/// tokens than words by letter count ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ"; not
/// "Paul McCartney - Band on the Run", "ShesSoWild - Ester Ana 1080p").
pub fn is_unreadable_name(name: &str) -> bool {
let stem = file_stem(name);
let stem = NZBSPLIT_HASH.replace(stem.as_bytes(), &b" "[..]);
if looks_random(&String::from_utf8_lossy(&stem)) {
return true;
}
let mut words = 0;
let mut random = 0;
for t in stem.split(|c| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()) {
match token_kind(t) {
TokenKind::Word => words += t.len(),
TokenKind::Random => random += t.len(),
TokenKind::Neutral => {}
}
}
random > 0 && random > words
}
/// The class of an unnamed release (lib/release/classes.ts pendingClass; LOG.md (130)): size bucket × name shape ×
/// partial, 1..70. The lease's dropped classes ("tool\tgroup\tklass") are skipped at the edge.
pub fn pending_class(name: &str, bytes: u64, partial: bool) -> u8 {
let size = [1_000_000u64, 10_000_000, 100_000_000, 1_000_000_000].iter().filter(|&&b| bytes >= b).count() as u8;
let s = match name.rfind('.') {
Some(i) if (1..=4).contains(&(name.len() - i - 1)) && name[i + 1..].bytes().all(|b| b.is_ascii_alphanumeric()) => &name[..i],
_ => name,
};
let b = s.as_bytes();
let all = |f: fn(&u8) -> bool| !b.is_empty() && b.iter().all(f);
let shape: u8 = if b.len() >= 16 && all(u8::is_ascii_hexdigit) {
1
} else if b.len() >= 6 && all(u8::is_ascii_digit) {
2
} else if b.len() >= 20 && all(|c| c.is_ascii_lowercase() || (b'2'..=b'7').contains(c)) && b.iter().any(|c| (b'2'..=b'7').contains(c)) {
3
} else if all(u8::is_ascii_alphabetic) {
4
} else if all(u8::is_ascii_alphanumeric) {
5
} else if all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')) {
6
} else {
7
};
size * 14 + (shape - 1) * 2 + u8::from(partial) + 1
}
/// A class as droppedClasses lists it.
pub fn class_key(tool: &str, group: &str, klass: u8) -> String {
format!("{tool}\t{group}\t{klass}")
}
/// looksScrambled: at least half the letters and digits of a file's stem in tokens of 5 or more that switch case
/// (lower to upper) or between letters and digits 3 times or more ("nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa").
/// The names a random-subject-per-article upload gives each article that looksRandom misses for their separators.
pub fn looks_scrambled(name: &str) -> bool {
let stem = file_stem(name);
let (mut total, mut scrambled) = (0, 0);
for token in stem.as_bytes().split(|c| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()) {
total += token.len();
let switches = token.windows(2).filter(|w| (w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase()) || w[0].is_ascii_digit() != w[1].is_ascii_digit()).count();
if token.len() >= 5 && switches >= 3 {
scrambled += token.len();
}
}
total >= 8 && scrambled * 2 >= total
}
static SEVEN_SPLIT: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z\.([0-9]{1,4})$"));
static SEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z$"));
static PART1: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part0*1\.rar$"));
@@ -1237,7 +1496,45 @@ pub fn explored(key: &str, percent: f64) -> bool {
}
static SIDECAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$"));
/// A sidecar by its extension (the judge's SIDECAR): no release of its own, and no album's member.
pub fn is_sidecar(name: &str) -> bool {
test(&SIDECAR, name)
}
static EXECUTABLE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(exe|scr|com|pif|bat|cmd|vbs|lnk)SP*$"));
/// An unsplit archive (the judge's ARCHIVE and VOLUME): one zip, rar or 7z that is not a volume of a set.
static ARCHIVE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(zip|rar|7z)$"));
/// "cneg" is "part" in ROT13: the a.b.erotica posts named that way are volumes of passworded videos (LOG.md (128)).
static VOLUME: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.((part|cneg)[0-9]+\.rar|r[0-9][0-9]|z[0-9][0-9]|7z\.[0-9][0-9][0-9]|[0-9][0-9][0-9])$"));
/// A poster at a free mail provider (the judge's WEBMAIL): the stub floods' throwaway accounts.
static WEBMAIL: LazyLock<Regex> = LazyLock::new(|| {
re(r"(?i)@(gmail|googlemail|yahoo|hotmail|outlook|live|msn|aol|gmx|web|t-online|freenet|mail|yandex|icloud|me|hushmail|protonmail|libero|orange|free|arcor|wanadoo|ymail|rocketmail|inbox|mail\.ru|bk|list)\.")
});
/// A name that reads as a release (the judge's RELEASE_LIKE): a year, a scene tag, a group suffix or dotted words.
static RELEASE_LIKE: LazyLock<Regex> = LazyLock::new(|| {
re(r"(?i)(\b(19|20)[0-9][0-9]\b|[._-](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s[0-9][0-9]e[0-9][0-9]|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v[0-9]+(\.[0-9]+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)")
});
/// RELEASE_LIKE with spaces as separators too: the tiny fakes are posted under names written either way.
static RELEASE_LIKE_SPACED: LazyLock<Regex> = LazyLock::new(|| {
re(r"(?i)(\b(19|20)[0-9][0-9]\b|[._ -](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s[0-9][0-9]e[0-9][0-9]|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v[0-9]+(\.[0-9]+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)")
});
/// The stub-archive rule (LOG.md (123)-(126), (128)): one unsplit archive, no par2, settled, posted to 2+ groups,
/// under STUB_MAX_BYTES (the fakes stop at ~90 MB; the hash-named uploads, real albums and passworded videos above
/// it don't), and its articles at most STUB_ARTICLE_BYTES with the release at least STUB_MIN_BYTES (Power-Post's
/// 250 kB default; a one-article release always has small articles, so e-books and sidecars are exempt), or 4+
/// groups, or a webmail poster.
pub const STUB_ARTICLE_BYTES: u64 = 262_144;
pub const STUB_MIN_BYTES: u64 = 1_000_000;
pub const STUB_MAX_BYTES: u64 = 100_000_000;
pub const STUB_WIDE_GROUPS: usize = 4;
/// The nonsense-piece rule: one article holding one archive of NONSENSE_MIN_BYTES to NONSENSE_MAX_BYTES under a
/// release-like name that isn't scrambled (the newznzb.* pieces; under 100 kB are cue/log sidecars and adverts).
pub const NONSENSE_MIN_BYTES: u64 = 100_000;
pub const NONSENSE_MAX_BYTES: u64 = 1_000_000;
/// The tiny-archive rule's floor: a RAR holding one exe stub is 2 kB and up; below are empty archives and nfo rars.
pub const TINY_MIN_BYTES: u64 = 2_000;
pub struct JudgedFile<'a> {
pub name: &'a str,
@@ -1245,6 +1542,79 @@ pub struct JudgedFile<'a> {
pub groups: &'a [String],
pub first_message_id: &'a str,
pub poster: &'a str,
/// Articles seen and their bytes (the overview's :bytes).
pub segments: u32,
pub bytes: u64,
}
/// One unsplit archive and nothing else.
fn single_archive<'a, 'b>(files: &'b [JudgedFile<'a>]) -> Option<&'b JudgedFile<'a>> {
match files {
[f] if test(&ARCHIVE, f.name) && !test(&VOLUME, f.name) => Some(f),
_ => None,
}
}
/// Groups whose communities post real single small archives (karaoke zips, e-books, comics, tabs, fonts) the way
/// the stub floods do (Power-Post defaults, a crosspost or two): a release touching one is exempt from the
/// stub-archive rule. Costs 16 of 32,308 known fakes in the 217k sample (LOG.md (127)).
static STUB_EXEMPT_GROUPS: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)(sound|mp3|karaoke|music|audio|flac|lossless|e-?book|epub|comic|guitar|font|magazine)"));
/// The legs of the stub-archive rule a settled single archive under STUB_MAX_BYTES trips ("A" article size, "X" 4+
/// groups, "W" webmail), empty when none.
pub fn stub_archive_legs(f: &JudgedFile) -> String {
let mut legs = String::new();
if f.groups.len() < 2 || f.bytes >= STUB_MAX_BYTES || f.groups.iter().any(|g| test(&STUB_EXEMPT_GROUPS, g)) {
return legs;
}
let article = f.bytes / u64::from(f.segments.max(1));
if article <= STUB_ARTICLE_BYTES && f.bytes >= STUB_MIN_BYTES {
legs.push('A');
}
if f.groups.len() >= STUB_WIDE_GROUPS {
legs.push('X');
}
if test(&WEBMAIL, f.poster) {
legs.push('W');
}
legs
}
/// An e-book by its name ("Author - Title (2020) (epub).rar"): one small article is a book, not a nonsense piece.
static EBOOK: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(epub|mobi|azw3?|pdf|djvu|cbr|cbz|e-?books?)\b"));
/// A nonsense piece: one article, one small archive under a release-like, unscrambled name without spaces (the
/// newznzb.* pieces are scene-styled or random with a "-XX" suffix; books and albums are written with spaces).
pub fn is_nonsense_piece(f: &JudgedFile) -> bool {
f.total_parts == 1
&& f.segments == 1
&& (NONSENSE_MIN_BYTES..NONSENSE_MAX_BYTES).contains(&f.bytes)
&& !f.name.contains(' ')
&& test(&RELEASE_LIKE, &file_stem(f.name))
&& !test(&EBOOK, f.name)
&& !looks_scrambled(f.name)
&& !is_random_file(f.name)
}
/// Words of the real tiny archives under release-like names: subtitles, cracks, trainers and keygens (a few kB of
/// exe is the content), covers and sidecars.
static TINY_CONTENT: LazyLock<Regex> = LazyLock::new(|| {
re(r"(?i)\b(subs?|subtitles?|subpack|srt|untertitel|ondertitels?|ondertiteling|legendas?|napisy|sottotitoli|cracks?|cracked|keygen|keymaker|trainer|patch|patched|serials?|activator|loader|nocd|no-cd|fix|covers?|artwork|booklet|scans?|proof|sample|info|readme)\b")
});
/// A tiny archive: one article of TINY_MIN_BYTES to NONSENSE_MIN_BYTES holding one archive under a release-like,
/// unscrambled name (spaces allowed), outside the groups that post real small archives, and without an e-book,
/// subtitle, crack or sidecar word: the 2015-17 exe-stub flood in miniature (LOG.md (131)).
pub fn is_tiny_archive(f: &JudgedFile) -> bool {
f.total_parts == 1
&& f.segments == 1
&& (TINY_MIN_BYTES..NONSENSE_MIN_BYTES).contains(&f.bytes)
&& !f.groups.iter().any(|g| test(&STUB_EXEMPT_GROUPS, g))
&& test(&RELEASE_LIKE_SPACED, &file_stem(f.name))
&& !test(&EBOOK, f.name)
&& !test(&TINY_CONTENT, f.name)
&& !looks_scrambled(f.name)
&& !is_random_file(f.name)
}
pub enum Verdict {
@@ -1260,12 +1630,19 @@ pub struct Rules {
pub priorities: rustc_hash::FxHashMap<String, u32>,
/// "tool\tgroup"
pub dropped_signatures: rustc_hash::FxHashSet<String>,
/// "tool\tgroup\tklass" (pending_class): unnamed releases of these classes don't go to the pending store.
pub dropped_classes: rustc_hash::FxHashSet<String>,
pub dropped_tools: rustc_hash::FxHashSet<String>,
pub per_article_max_parts: u32,
pub sample_percent: f64,
pub drop_executables: bool,
/// Fragments of the poster lowercased without punctuation (droppedPosters).
pub dropped_posters: Vec<String>,
/// The stub-archive rule (dropStubArchives), the nonsense-piece rule (dropNonsensePieces) and the tiny-archive
/// rule (dropTinyArchives).
pub drop_stub_archives: bool,
pub drop_nonsense_pieces: bool,
pub drop_tiny_archives: bool,
}
impl Rules {
@@ -1275,11 +1652,15 @@ impl Rules {
version: 1,
priorities,
dropped_signatures: Default::default(),
dropped_classes: Default::default(),
dropped_tools: ["ngPost", "hex32", "yuun", "JBinUp", "random"].iter().map(|t| t.to_string()).collect(),
per_article_max_parts: 3,
sample_percent: 1.0,
drop_executables: true,
dropped_posters: ["celebmovie", "celebwow", "celebfx", "iluvcelebs", "nudecelebs", "celebsextapes"].iter().map(|p| p.to_string()).collect(),
drop_stub_archives: true,
drop_nonsense_pieces: true,
drop_tiny_archives: true,
}
}
}
@@ -1289,8 +1670,9 @@ pub fn poster_words(poster: &str) -> String {
poster.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
}
/// RuleSet.judge.
pub fn judge(release_key: &str, files: &[JudgedFile], rules: &Rules) -> Verdict {
/// RuleSet.judge. `settled`: the window closed on the release (a par2 can still join an open one), which the
/// single-archive rules need.
pub fn judge(release_key: &str, files: &[JudgedFile], settled: bool, rules: &Rules) -> Verdict {
let sample = explored(release_key, rules.sample_percent);
if let (false, Some(first)) = (rules.dropped_posters.is_empty(), files.first()) {
let words = poster_words(first.poster);
@@ -1304,6 +1686,20 @@ pub fn judge(release_key: &str, files: &[JudgedFile], rules: &Rules) -> Verdict
let key = std::str::from_utf8(&c[1]).unwrap().to_lowercase();
return Verdict::Drop { rule: "executable", key, sample };
}
if let (true, Some(f)) = (settled, single_archive(files)) {
if rules.drop_stub_archives {
let legs = stub_archive_legs(f);
if !legs.is_empty() {
return Verdict::Drop { rule: "stub-archive", key: legs, sample };
}
}
if rules.drop_nonsense_pieces && is_nonsense_piece(f) {
return Verdict::Drop { rule: "nonsense-piece", key: upload_tool(f.first_message_id).to_string(), sample };
}
if rules.drop_tiny_archives && is_tiny_archive(f) {
return Verdict::Drop { rule: "tiny-archive", key: upload_tool(f.first_message_id).to_string(), sample };
}
}
if files.iter().any(|f| !is_random_file(f.name)) {
return Verdict::Keep;
}
@@ -1334,6 +1730,29 @@ mod tests {
assert_eq!(file_stem_re("Morning Lovers.wmv.058"), "Morning Lovers");
}
#[test]
fn scrambled_names() {
for n in ["nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa", "PNSVWSV-xLaNv6o5qS840e3lwSZyH", "Biq-YsXu8G33ktt"] {
assert!(looks_scrambled(n), "{n}");
}
// Short ones of the spam too (8% of alt.binaries.storage's): too few switches to tell.
for n in ["A1Monitor v6.2.2", "PDF2Word v1.3", "Some.Show.S01E01.1080p.WEB.part1.rar", "MacGyver.S01E01.mkv", "Jimmy and the Crawler (mobi)", "Q25m.PDUYtbA", "82WH.2iuTK"] {
assert!(!looks_scrambled(n), "{n}");
}
}
#[test]
fn display_names() {
let porn4you = "webmaster@hdbangvidz.com (Porn4You)";
let a = release_identity(porn4you, "Babe with Plump Bubble Butt Fucked and Facialized 002/477] - 0178.jpg", "0178.jpg");
let b = release_identity(porn4you, "Babe with Plump Bubble Butt Fucked and Facialized 003/477] - 0179.jpg", "0179.jpg");
assert_eq!(a.name, "Babe with Plump Bubble Butt Fucked and Facialized");
assert_eq!(a.key, b.key);
assert_eq!(display_name("[Group] Show (2020)"), "[Group] Show (2020)");
assert_eq!(display_name("( Show [x] name"), "Show [x] name");
assert_eq!(display_name("]"), "]");
}
#[test]
fn remainders() {
assert_eq!(subject_remainder(r#"Some.Release [01/45] - "x.rar" yEnc (1/50) 7168"#), "Some Release");
@@ -1446,6 +1865,46 @@ mod tests {
assert_eq!(id(r#"[2/6] - "x.jpg" yEnc"#, "x.jpg").key, id(r#"[1/6] - "x.part01.rar" yEnc"#, "x.part01.rar").key);
}
#[test]
fn pending_classes() {
use super::pending_class;
// Size bucket × shape × partial: a 5 MB hex name whole, the same partial, a 500 MB scramble with separators.
assert_eq!(pending_class("c04212a389cf69edc54767355ab5285f", 5_000_000, false), 15);
assert_eq!(pending_class("c04212a389cf69edc54767355ab5285f", 5_000_000, true), 16);
assert_eq!(pending_class("Zx5AjNaK-mh7CFg6Hf8uu", 500_000_000, false), 3 * 14 + 5 * 2 + 1);
assert_eq!(pending_class("1650777167", 100, false), 3);
assert_eq!(pending_class("mrrtmzrzmi4dgmrzmyytqmlbztczbx", 2_000_000_000, false), 4 * 14 + 2 * 2 + 1);
assert_eq!(pending_class("PsNdnYiugfZ.rar", 20_000_000, false), 2 * 14 + 3 * 2 + 1);
assert_eq!(pending_class("n5d1ueIf34X", 20_000_000, true), 2 * 14 + 4 * 2 + 2);
assert_eq!(pending_class("sgkra vU7u2yuw9LDLCjDu04pOez", 999, false), 13);
}
#[test]
fn unreadable_names() {
use super::is_unreadable_name;
// Scrambles with separators, spaces, a random "extension", a packaging word, a hex with an addon (LOG.md (129)).
for n in [
"Zx5AjNaK-mh7CFg6Hf8uu.part01.rar", "yWjfnT2ujG4wJj3fG46o0FLzsgkvhmrTsAfW2.E0WsOU", "PsNdnYiugfZ.rar", "phfplC9y07U2yrj", "n5d1ueIf34X",
"tJqr1tq7xK", "Description - Lt5nHjlo3RiuBb3C.par2", "bd5b4a59f3aa87114dcf75c0-addon.rar", "mrrtmzrzmi4dgmrzmyytqmlbztczbx",
"AA-NaR16302830434zxP32108309f3Ab", "_QBs01_MqZYnYwM_iAyRquhc-", "sgkra vU7u2yuw9LDLCjDu04pOez", "Jk5Pud.zQdNj", "7uKsD_NRgT-G_Uc_p8",
"NtpUgCmS_Xa1B2Pk", "{E11BF8E9-E2C9-4DAE-A1B2-8027BC465F3F}", "IkYovjrtZrItjQ.mkv", "ff75ce3f16c746c18b2d78cf.6c52f",
] {
assert!(is_unreadable_name(n), "{n}");
}
// Real names: scene tags, CamelCase, codes, leet, an acronym, a hash beside words.
for n in [
"Dark.Matter.2024.S02E10.No.Quiet.Life.1080p.ATVP.WEB-DL.DDP5.1.Atmos.H.264-Kitsune", "Paul McCartney - Band on the Run",
"ShesSoWild - Ester Ana 1080p", "MyDadsHotGirlfriend.13.02.01.Luna.Star.XXX.1080p.x264-SEXORS", "cFosSpeed 11.0 Build 2420 x86 x64 incl Patch.rar",
"Rocketship-Hurt_Feelings-WEB-2026-PreMaxXxing", "Drum-and-BassDJ_Samurai-Sound_With_Fire_Artemis-(FQY026)-WEB-2006-PRESCRiPTiON",
"Kleverig-H4rd-N12uk3b18-luc16037-720p-smpwr", "Eclipse_IDE_2023-12_R_win32_x86_64.zip", "PartsHPGL 1.06c", "Go4Customer Fax Machine v1.2b",
"AdBeGone 1.2", "3DMark03 320", "COD4MW2REMASTERED", "Movie.2020.1080p-GRP [a3f9e2b1]", "EPL__NZBSPLIT__f1fdb433772a1a7771d6b73c875009ff__NZBSPLIT__EPL.2026.Liverpool.vs.Brighton.1080pEN50fps",
"iPhone", "Some.Show.S01E01.1080p.mkv", "FastFilms 2.04", "BestCrypt NP v4.0", "EasyHTML 2.35 German", "SchiffsModell - September 2026",
"CrossStitcher - Issue 440, October 2026", "Laura15Juli2026", "IHaveAWife - Isa Bella", "DickHDDaily - Santana Red", "1904-SmithsWork-Hasluck",
] {
assert!(!is_unreadable_name(n), "{n}");
}
}
#[test]
fn random_names() {
assert!(looks_random("prvjr7hapd0i1ny14pz4"));
@@ -1486,8 +1945,8 @@ mod poster_rule_tests {
fn verdict(poster: &str, rules: &Rules) -> Option<(&'static str, String)> {
let groups = vec!["alt.binaries.erotica".to_string()];
let files = [JudgedFile { name: "!index0001.jpg", total_parts: 1, groups: &groups, first_message_id: "<a@b>", poster }];
match judge("k", &files, rules) {
let files = [JudgedFile { name: "!index0001.jpg", total_parts: 1, groups: &groups, first_message_id: "<a@b>", poster, segments: 1, bytes: 200_000 }];
match judge("k", &files, true, rules) {
Verdict::Keep => None,
Verdict::Drop { rule, key, .. } => Some((rule, key)),
}
@@ -1507,6 +1966,111 @@ mod poster_rule_tests {
}
}
#[cfg(test)]
mod single_archive_rule_tests {
use super::{judge, JudgedFile, Rules, Verdict};
const NYUU: &str = "FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu";
fn verdict(name: &str, poster: &str, groups: usize, segments: u32, bytes: u64, settled: bool, rules: &Rules) -> Option<(&'static str, String)> {
let groups: Vec<String> = (0..groups).map(|i| format!("alt.binaries.g{i}")).collect();
let files = [JudgedFile { name, total_parts: segments, groups: &groups, first_message_id: NYUU, poster, segments, bytes }];
match judge("k", &files, settled, rules) {
Verdict::Keep => None,
Verdict::Drop { rule, key, .. } => Some((rule, key)),
}
}
#[test]
fn stub_archives_by_article_size_groups_or_webmail() {
let rules = Rules::defaults(Default::default());
// Power-Post's 250 kB articles, two groups, 12 MB: the 2016 wave.
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com (Jahnunsinos007)", 2, 48, 12_000_000, true, &rules), Some(("stub-archive", "AW".to_string())));
assert_eq!(verdict("Some.Movie.2014.720p.BluRay-GRP.rar", "Yenc@power-post.org (Yenc-PP-A&A)", 2, 48, 12_000_000, true, &rules), Some(("stub-archive", "A".to_string())));
// Five groups, 319 kB in two articles: the 2015 a.b.cracks wave (under 1 MB, so the groups leg alone).
assert_eq!(verdict("Big Hero 6 (2014) 720p BDRip [COMPRESSED].rar", "Yenc@power-post.org (Yenc-PP-A&A)", 5, 2, 319_000, true, &rules), Some(("stub-archive", "X".to_string())));
// A webmail poster with 650 kB articles: the 2014 "leaked Sex Video" wave.
assert_eq!(verdict("Leaked Sex Video.rar", "first.last88@gmail.com", 2, 400, 260_000_000, true, &rules), None, "over 100 MB without the article-size leg");
assert_eq!(verdict("Leaked Sex Video.rar", "first.last88@gmail.com", 2, 100, 65_000_000, true, &rules), Some(("stub-archive", "W".to_string())));
// Settled only: a par2 can still join an open release.
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com", 2, 48, 12_000_000, false, &rules), None);
}
#[test]
fn small_and_single_group_and_split_archives_are_kept() {
let rules = Rules::defaults(Default::default());
// An e-book: one or two small articles always have small articles (LOG.md (126)).
assert_eq!(verdict("Maeve Binchy - Tara Road (epub).rar", "kie@home.now (KiE)", 2, 2, 521_000, true, &rules), None);
// One group: most real single rars.
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com", 1, 48, 12_000_000, true, &rules), None);
// Modern 700 kB articles, no webmail, two groups.
assert_eq!(verdict("Some.Movie.2014.720p.BluRay-GRP.rar", "poster@example.org", 2, 100, 70_000_000, true, &rules), None);
// A volume, not a single archive; a hash-named 2 GB upload from a gmail-looking poster.
assert_eq!(verdict("Some.Movie.2014.part01.rar", "a@gmail.com", 2, 48, 12_000_000, true, &rules), None);
assert_eq!(verdict("cbcd32359460df1fa9b7d01d8720.rar", "b648de0d1c0f7a9a77be273d@gmail.com", 2, 3163, 2_340_000_000, true, &rules), None);
// ROT13-named volumes of passworded videos in a.b.erotica, 258 kB articles, two groups, 1-270 MB; and the
// 100 MB cap on every leg (LOG.md (128)).
assert_eq!(verdict("Ghful.17.12.07.Qnvvfl.Fgbar.cneg3.rar", "nospam@nospam.nl (EvH)", 2, 5, 1_147_000, true, &rules), None);
assert_eq!(verdict("Ghful.16.07.05.Wbwb.Xvff.cneg1.rar", "nospam@nospam.nl (EvH)", 2, 1051, 271_000_000, true, &rules), None);
assert_eq!(verdict("Tinasoft EasyCafe 2.2.14 by OmerSalaj.rar", "Seven7i@mail.com ({Seven7i})", 4, 463, 117_000_000, true, &rules), None);
assert_eq!(verdict("Tipard DVD Ripper - 10 0 12 x86 x64 incl Patch.rar", "Seven7i@mail.com ({Seven7i})", 4, 244, 62_000_000, true, &rules), Some(("stub-archive", "AXW".to_string())));
let off = Rules { drop_stub_archives: false, ..Rules::defaults(Default::default()) };
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com", 2, 48, 12_000_000, true, &off), None);
// A karaoke zip crossposted to a.b.test with Power-Post's articles: its group exempts it (LOG.md (127)).
let groups = vec!["alt.binaries.sounds.karaoke".to_string(), "alt.binaries.test".to_string()];
let files = [JudgedFile { name: "KV-1055806 - Walker Hayes - U Gurl.zip", total_parts: 13, groups: &groups, first_message_id: NYUU, poster: "home@home.com (TexasDj)", segments: 13, bytes: 3_300_000 }];
assert!(matches!(judge("k", &files, true, &rules), Verdict::Keep));
}
#[test]
fn nonsense_pieces() {
let rules = Rules::defaults(Default::default());
// One 790 kB article under a release-like name: the newznzb.* pieces.
assert_eq!(verdict("Some.Show.S01E01.1080p.WEB-DL.rar", "poster@example.org", 1, 1, 790_000, true, &rules), Some(("nonsense-piece", "nyuu".to_string())));
// A 3 kB cue/log sidecar rar with a year in its name: under this rule's floor (the tiny-archive rule's outside the music groups).
assert_eq!(verdict("The Smiths-the queen is dead(Remaster)(2011).rar", "Yenc@power-post.org (Sir Cedrik)", 1, 1, 3_000, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
// A book (a year or a format in its name, written with spaces), a dotted one too: not a piece.
assert_eq!(verdict("Kevin Ashman - [Roman 02] - The Rise of Caratacus (2020) (epub).rar", "fake@address.com (Psychopath)", 2, 1, 303_000, true, &rules), None);
assert_eq!(verdict("One.Wrong.Turn.Deanna.Lynn.Sletten.epub.rar", "fake@address.com (Psychopath)", 1, 1, 382_000, true, &rules), None);
assert_eq!(verdict("Vieweg.Teubner.Verlag.-.Numerische.Mathematik.2011.German.RETAIL.eBook-BitBook.rar", "saggir@domain.com", 1, 1, 113_000, true, &rules), None);
// Two articles, or no release-like name, or scrambled: not this rule.
assert_eq!(verdict("Some.Show.S01E01.1080p.WEB-DL.rar", "poster@example.org", 1, 2, 790_000, true, &rules), None);
assert_eq!(verdict("holiday snaps.rar", "poster@example.org", 1, 1, 790_000, true, &rules), None);
assert_eq!(verdict("nYDeXeF49dieZ1J7flK-JFzZ3j.rar", "poster@example.org", 1, 1, 790_000, true, &rules), None);
let off = Rules { drop_nonsense_pieces: false, ..Rules::defaults(Default::default()) };
assert_eq!(verdict("Some.Show.S01E01.1080p.WEB-DL.rar", "poster@example.org", 1, 1, 790_000, true, &off), None);
}
#[test]
fn tiny_archives() {
let rules = Rules::defaults(Default::default());
// One article of 2-100 kB under a release name, dotted or with spaces: a RAR holding "<title>.exe" (LOG.md (131)).
assert_eq!(verdict("Humans.S01E04.German.DD20.Dubbed.DL.1080p.BD.x264-TVS.rar", "Yanc@power-post.org <Yanc@power-post.org>", 1, 1, 15_084, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
assert_eq!(verdict("Cicero Magazin fuer politische Kultur Mai 05 2016.rar", "WarezGeek@WarezGeek.org (WarezGeek)", 1, 1, 17_478, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
// A scene name without a year, a tag or a group suffix: not release-like, so kept (the flood's porn names mostly carry one).
assert_eq!(verdict("[BigTitsAtWork Brazzers] Eva Angelina (Camera Cums In Handy).rar", "Yanc@power-post.org", 2, 1, 23_449, true, &rules), None);
assert_eq!(verdict("Graphisoft.Archicad.v14.0.GERMAN.iSO.X86-PillePalle.rar", "WarezGeek@WarezGeek.org (WarezGeek)", 1, 1, 7_869, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
// Subtitles, a crack, a trainer, a cover, an e-book: the real tiny archives.
assert_eq!(verdict("The.Big.Bang.Theory.S04E13.720p.HDTV.subs.rar", "henk@example.org", 1, 1, 38_000, true, &rules), None);
assert_eq!(verdict("Tipard.DVD.Ripper.v10.0.12.Crack-XXX.rar", "poster@example.org", 1, 1, 60_000, true, &rules), None);
assert_eq!(verdict("Far.Cry.4.v1.7.0.Plus.12.Trainer-FLiNG.rar", "poster@example.org", 1, 1, 45_000, true, &rules), None);
assert_eq!(verdict("Some Band - Album (2011) covers.rar", "poster@example.org", 1, 1, 45_000, true, &rules), None);
assert_eq!(verdict("One.Wrong.Turn.Deanna.Lynn.Sletten.epub.rar", "fake@address.com (Psychopath)", 1, 1, 38_000, true, &rules), None);
// A karaoke zip in its group; a plain name; a scrambled one; two articles; under the floor; above 100 kB is the nonsense-piece rule's.
let groups = vec!["alt.binaries.sounds.karaoke".to_string()];
let files = [JudgedFile { name: "KV-1055806 - Walker Hayes - U Gurl (2017).zip", total_parts: 1, groups: &groups, first_message_id: NYUU, poster: "home@home.com", segments: 1, bytes: 60_000 }];
assert!(matches!(judge("k", &files, true, &rules), Verdict::Keep));
assert_eq!(verdict("holiday snaps.rar", "poster@example.org", 1, 1, 20_000, true, &rules), None);
assert_eq!(verdict("nYDeXeF49dieZ1J7flK-JFzZ3j.rar", "poster@example.org", 1, 1, 20_000, true, &rules), None);
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 2, 20_000, true, &rules), None);
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 1_500, true, &rules), None);
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 150_000, true, &rules), Some(("nonsense-piece", "nyuu".to_string())));
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 20_000, false, &rules), None);
let off = Rules { drop_tiny_archives: false, ..Rules::defaults(Default::default()) };
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 20_000, true, &off), None);
}
}
#[cfg(test)]
mod image_set_tests {
use super::{release_identity, sized_name, subject_remainder, subject_remainder_re};
+12 -43
View File
@@ -1,7 +1,7 @@
//! A batch as the coordinator takes it (lib/edge/wire.ts toWire): JSON with
//! the TypeScript names, chunk payloads in base64.
use crate::indexer::{Batch, Candidate, Fragment, ReleaseChunk};
use crate::indexer::{Batch, Candidate, ReleaseChunk};
use base64::Engine;
use serde::Serialize;
@@ -24,6 +24,11 @@ struct WireRelease<'a> {
last_article: i64,
readable: bool,
has_par2: bool,
finished: bool,
edge: bool,
partial: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tool: Option<&'a str>,
payload: String,
#[serde(skip_serializing_if = "Option::is_none")]
rule: Option<&'a str>,
@@ -39,6 +44,7 @@ struct WireCandidate<'a> {
kind: &'a str,
message_id: &'a str,
file: &'a str,
bytes: i64,
}
#[derive(Serialize)]
@@ -49,30 +55,6 @@ struct WireCount<'a> {
files: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireFragmentRelease<'a> {
key: &'a str,
name: &'a str,
source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
file_total: Option<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireFragment<'a> {
file_key: &'a str,
name: &'a str,
poster: &'a str,
subject: &'a str,
total_parts: u32,
groups: &'a [String],
release: Option<WireFragmentRelease<'a>>,
posted_at: Option<i64>,
segments: Vec<(u32, &'a str, i64, i64)>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireBatch<'a> {
@@ -85,7 +67,6 @@ struct WireBatch<'a> {
samples: Vec<WireRelease<'a>>,
candidates: Vec<WireCandidate<'a>>,
counts: Vec<WireCount<'a>>,
fragments: Vec<WireFragment<'a>>,
}
fn release<'a>(r: &'a ReleaseChunk, rule: Option<(&'a str, &'a str)>) -> WireRelease<'a> {
@@ -106,6 +87,10 @@ fn release<'a>(r: &'a ReleaseChunk, rule: Option<(&'a str, &'a str)>) -> WireRel
last_article: r.last_article,
readable: r.readable,
has_par2: r.has_par2,
finished: r.finished,
edge: r.edge,
partial: r.partial,
tool: r.tool,
payload: base64::engine::general_purpose::STANDARD.encode(&r.payload),
rule: rule.map(|(rule, _)| rule),
rule_key: rule.map(|(_, key)| key),
@@ -113,22 +98,7 @@ fn release<'a>(r: &'a ReleaseChunk, rule: Option<(&'a str, &'a str)>) -> WireRel
}
fn candidate(c: &Candidate) -> WireCandidate<'_> {
WireCandidate { release_key: &c.release_key, upload: c.upload, kind: c.kind, message_id: &c.message_id, file: &c.file }
}
fn fragment(f: &Fragment) -> WireFragment<'_> {
WireFragment {
file_key: &f.file_key,
name: &f.name,
poster: &f.poster,
subject: &f.subject,
total_parts: f.total_parts,
groups: &f.groups,
// A named file always has a release; fileTotal only when truthy, as fragmentOf.
release: Some(WireFragmentRelease { key: &f.release.key, name: &f.release.name, source: f.release.source, file_total: f.release.file_total_truthy() }),
posted_at: f.posted_at,
segments: f.segments.iter().map(|(n, id, bytes, article)| (*n, id.as_str(), *bytes, *article)).collect(),
}
WireCandidate { release_key: &c.release_key, upload: c.upload, kind: c.kind, message_id: &c.message_id, file: &c.file, bytes: c.bytes }
}
/// toWire(batch) as JSON bytes.
@@ -143,7 +113,6 @@ pub fn to_json(batch: &Batch) -> Vec<u8> {
samples: batch.samples.iter().map(|(r, rule, key)| release(r, Some((rule, key.as_str())))).collect(),
candidates: batch.candidates.iter().map(candidate).collect(),
counts: batch.counts.iter().map(|(rule, key, articles, files)| WireCount { rule, key, articles: *articles, files: *files }).collect(),
fragments: batch.fragments.iter().map(fragment).collect(),
};
serde_json::to_vec(&wire).unwrap()
}
+1 -2
View File
@@ -1,8 +1,7 @@
export { RangeIndexer, postedSpan, chunkOf, candidatesOf, releaseKeyOf, uploadsOf, UPLOAD_GAP_MS, type EdgeBatch, type ReleaseChunk, type DroppedChunk, type ProbeCandidate, type CandidateKind, type RuleCount, type Fragment, type RangeIndexerOptions } from './range_indexer.js'
export { encodeFrame, decodeFrames, isKeepalive, KEEPALIVE, type Frame } from './stream.js'
export { toWire, fromWire, type WireBatch } from './wire.js'
export type { Lease } from './lease.js'
export { mergeFragments } from './merge.js'
export type { Lease, GroupLease, Lane, NextRange } from './lease.js'
export type { EdgeSegment, FileMeta } from './scan.js'
export { throttle } from './throttle.js'
export { filterOverviews, undelta, type ChunkDrops } from './corpus_filter.js'
+24
View File
@@ -5,3 +5,27 @@ export interface Lease {
range: { id: number; group: string; start: number; end: number; next: number }
rules: Rules
}
/**
* A group's two cursors (REBUILD.md, "Voyager 3: window indexing", 1): `history` works the ranges the lease bounds
* hand out (the windows, the history), `tail` the tail planner's ranges. Each lane has its own holder, window and
* snapshot; the group routes take `?lane=` (history by default).
*/
export type Lane = 'history' | 'tail'
/**
* A group lease (REBUILD.md, "Voyager 3: window indexing", 1): the group a worker thread holds, the lane, the rules,
* and the snapshot of the lane's window when there is one (its next article; the body comes from GET
* groups/:id/snapshot).
*/
export interface GroupLease {
group: { id: number; name: string; priority: number }
lane: Lane
rules: Rules
snapshot: { next: number } | null
}
/** The group's next range in article order, leased to the holder (POST groups/:id/next; 204 when caught up). */
export interface NextRange {
range: { id: number; start: number; end: number; next: number }
}
-35
View File
@@ -1,35 +0,0 @@
import type { AggregatedFile } from '../files/index.js'
import type { Fragment } from './range_indexer.js'
import type { EdgeSegment, FileMeta } from './scan.js'
/**
* The fragments of one file from neighbouring ranges, put together
* (REBUILD.md, fragments): the complete file when every part from 1 to its
* total is there (the first article seen of a part wins), else null.
*/
export function mergeFragments(fragments: Fragment[]): AggregatedFile<EdgeSegment> | null {
if (fragments.length === 0) return null
const first = fragments[0]
const meta: FileMeta = {
fileKey: first.fileKey,
name: first.name,
poster: first.poster,
subject: first.subject,
totalParts: first.totalParts,
groups: first.groups,
release: first.release ? { ...first.release, source: first.release.source as any } : null,
}
const parts = new Map<number, EdgeSegment>()
for (const fragment of fragments) {
const postedAt = fragment.postedAt ?? Number.NaN
for (const [number, messageId, bytes, articleNumber] of fragment.segments) {
const known = parts.get(number)
if (!known || articleNumber < known.articleNumber) {
parts.set(number, { fileKey: meta.fileKey, number, totalParts: meta.totalParts, messageId, bytes, articleNumber, postedAt, file: meta })
}
}
}
if (parts.size < meta.totalParts) return null
for (let n = 1; n <= meta.totalParts; n++) if (!parts.has(n)) return null
return { fileKey: meta.fileKey, segments: [...parts.values()].sort((a, b) => a.number - b.number) }
}
+73 -12
View File
@@ -1,7 +1,9 @@
import { hash } from 'node:crypto'
import type { Overview } from '../nntp/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, isScrambledFile, uploadTool, type Rules } from '../rules/index.js'
import { classKey, isUnreadableName, pendingClass } from '../release/index.js'
import { explored } from '../rules/upload_tools.js'
import { SegmentScanner, scanOverviewText, type EdgeSegment } from './scan.js'
import { encodeChunk, type ChunkFile } from '../chunks/index.js'
@@ -26,6 +28,20 @@ export interface ReleaseChunk {
/** A file with a readable name; a par2 file. */
readable: boolean
hasPar2: boolean
/**
* Voyager 3: the window closed on the release (default), or it is a tail release sent provisionally, finished by a
* later chunk of the same release key (the totals then are the release's; the payload only the files not sent yet).
*/
finished?: boolean
/** Still open at its block's end, or posted within the window of its start: the block merger joins it with the neighbour block's part. */
edge?: boolean
/** A file of the release misses parts (the window closed on it): not complete, whatever the counter says. */
partial?: boolean
/**
* Voyager 3, an unnamed release (readable false): the upload tool from its first article's message-id, its
* signature with the group; the payload is empty (no chunk until a probe names it: pending_releases).
*/
tool?: string
payload: Buffer
}
@@ -44,11 +60,13 @@ export interface ProbeCandidate {
kind: CandidateKind
messageId: string
file: string
/** The target article's bytes: the cheapest probe first within a class (LOG.md (130)). */
bytes?: number
}
/** What a rule dropped in a batch (REBUILD.md, rules and recovery). */
export interface RuleCount {
rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'incomplete'
rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'stub-archive' | 'nonsense-piece' | 'tiny-archive' | 'incomplete'
key: string
articles: number
files: number
@@ -88,7 +106,8 @@ export interface EdgeBatch {
samples: DroppedChunk[]
candidates: ProbeCandidate[]
counts: RuleCount[]
fragments: Fragment[]
/** The range path's cut-off files (the TypeScript indexer); the Rust edge's window has none, the store ignores them. */
fragments?: Fragment[]
}
export interface RangeIndexerOptions {
@@ -108,6 +127,11 @@ export interface RangeIndexerOptions {
* chunks seen so far), as uploads in busy groups spread over minutes.
*/
edgeArticles?: number
/**
* A file given up short of parts is a file of its release all the same (partial), as the Rust edge's window writes
* it, not a fragment: the pending namer reads such a release again (LOG.md 2026-10-29 (121)).
*/
partialFiles?: boolean
}
const MAX_CANDIDATES = 4
@@ -203,7 +227,7 @@ export class RangeIndexer {
/** Files completed and fragments cut since the last take: how big the next batch is. */
get pending(): number {
let files = this.batch.fragments.length
let files = this.batch.fragments!.length
for (const list of this.completed.values()) files += list.length
return files
}
@@ -226,12 +250,34 @@ export class RangeIndexer {
this.completed = new Map()
for (const [key, all] of byRelease) {
uploadsOf(all).forEach((list, upload) => {
// Settled as far as this indexer knows: it judges a release's files as each batch completes them (the Rust
// window passes whether the window closed on the release and no block edge cut it).
const verdict = this.rules.judge(
key,
list.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId, poster: f.segments[0].file.poster }))
list.map((f) => ({
name: f.segments[0].file.name,
totalParts: f.segments[0].totalParts,
groups: f.segments[0].file.groups,
firstMessageId: f.segments[0].messageId,
poster: f.segments[0].file.poster,
segments: f.segments.length,
bytes: f.segments.reduce((n, s) => n + s.bytes, 0),
})),
true
)
if (verdict.rule === null) {
this.batch.releases.push(chunkOf(key, list, upload))
const chunk = chunkOf(key, list, upload)
if (!chunk.readable) {
// Unnamed: a class the ladder found never names anything stays out of the pending store, its explored
// sample excepted (so the rate keeps being measured); counted as garbage (LOG.md (130)).
const tool = uploadTool(list[0].segments[0].messageId)
const klass = pendingClass(chunk.name, chunk.bytes, chunk.partial === true)
if (this.rules.classes.has(classKey(tool, this.options.group, klass)) && !explored(key, this.options.rules.samplePercent)) {
this.count('garbage', `${tool} ${this.options.group} #${klass}`, list.reduce((n, f) => n + f.segments.length, 0), list.length)
return
}
}
this.batch.releases.push(chunk)
this.batch.candidates.push(...candidatesOf(key, list, upload))
return
}
@@ -259,6 +305,20 @@ export class RangeIndexer {
else byFile.set(s.fileKey, [s])
}
for (const list of byFile.values()) {
if (this.options.partialFiles) {
// As the Rust window's files(): one article of a multi-part random file is no release; a scrambled name only
// before the end, where the rest of the file can still come.
const name = list[0].file.name
if (list.length === 1 && (list[0].number !== 1 || list[0].totalParts > 1) && (isRandomFile(name) || (!atEnd && isScrambledFile(name)))) {
this.count('per-article', uploadTool(list[0].messageId), 1, 0)
continue
}
// One segment per part (two copies of a file share its key here), the first article seen.
const parts = new Map<number, EdgeSegment>()
for (const s of list.sort((a, b) => a.articleNumber - b.articleNumber)) if (!parts.has(s.number)) parts.set(s.number, s)
this.files([{ fileKey: list[0].fileKey, segments: [...parts.values()].sort((a, b) => a.number - b.number) }])
continue
}
// One article of a "file" of many parts under a random name: a random subject per article, which
// never completes from headers (99.8% of the boneless fragments; LOG.md 2026-10-09).
if (list.length === 1 && isRandomFile(list[0].file.name)) {
@@ -287,7 +347,7 @@ export class RangeIndexer {
fragment.segments.push([s.number, s.messageId, s.bytes, s.articleNumber])
if (Number.isFinite(s.postedAt) && (fragment.postedAt === null || s.postedAt < fragment.postedAt)) fragment.postedAt = s.postedAt
}
this.batch.fragments.push(fragment)
this.batch.fragments!.push(fragment)
}
}
@@ -413,8 +473,9 @@ export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[], uploa
bytes,
firstArticle,
lastArticle,
readable: chunkFiles.some((f) => !isRandomFile(f.name)),
readable: chunkFiles.some((f) => !isUnreadableName(f.name)),
hasPar2: chunkFiles.some((f) => /\.par2$/i.test(f.name)),
partial: files.some((f) => f.segments.length < f.segments[0].totalParts),
payload: encodeChunk(chunkFiles),
}
}
@@ -436,12 +497,12 @@ export function candidatesOf(key: string, files: AggregatedFile<EdgeSegment>[],
}
// An unsplit 7z of more than one segment: its listing is in its last one.
if (volume === 0 && f.segments.length > 1 && !tail) tail = { volume, file: f }
if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name })
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name })
if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name, bytes: f.segments[0].bytes })
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name, bytes: f.segments[0].bytes })
}
if (tail) out.push({ releaseKey: key, upload, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name })
if (tail) out.push({ releaseKey: key, upload, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name, bytes: tail.file.segments.at(-1)!.bytes })
if (out.length === 0 && files.length === 1 && isRandomFile(files[0].segments[0].file.name)) {
out.push({ releaseKey: key, upload, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name })
out.push({ releaseKey: key, upload, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name, bytes: files[0].segments[0].bytes })
}
return out.slice(0, MAX_CANDIDATES)
}
+8 -1
View File
@@ -1,4 +1,11 @@
export { XoverClient, MissingArticle, parseOverviews, type Overview, type XoverClientOptions } from './xover_client.js'
export {
XoverClient,
MissingArticle,
TruncatedReply,
parseOverviews,
type Overview,
type XoverClientOptions,
} from './xover_client.js'
export { parseXrefGroups } from './xref.js'
export { parseGroupResponse, type GroupInfo } from './group.js'
export { hedge, type Request, type Hedged } from './hedge.js'
+71 -11
View File
@@ -36,6 +36,14 @@ const DOT_LINE = Buffer.from('.\r\n')
*/
/** The server doesn't have the article (430): taken down or expired. */
export class MissingArticle extends Error {}
/**
* A compressed reply the server cut short: it ends its compressed output at the first ".\r\n" in it, as if that
* were the terminator, and sends nothing more (Eweka; "78 01 2e 0d 0a" for 16 of 14k small XOVERs, LOG.md
* 2026-10-25 (105)). The same request without compression answers.
*/
export class TruncatedReply extends Error {}
/** How long a compressed reply that doesn't inflate may stay silent before it counts as cut short. */
const TRUNCATED_MS = 2_000
export class XoverClient {
private chunks: Buffer[] = []
@@ -54,6 +62,10 @@ export class XoverClient {
get buffered() {
return this.length
}
/** The first `n` bytes received and not consumed yet (what a reply that stalled left; nntp:probe). */
bufferedHead(n = 32): Buffer {
return this.buffer().subarray(0, n)
}
private lastDataAt = 0
private longestGap = 0
@@ -85,7 +97,11 @@ export class XoverClient {
static async connect(options: XoverClientOptions): Promise<XoverClient> {
const sock = options.secure
? 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 })
const client = new XoverClient(sock, options.timeoutMs ?? 120_000)
// The greeting and login are short: a server holding a connection back (too many open) fails it in 30 s, not timeoutMs.
@@ -94,10 +110,14 @@ export class XoverClient {
await client.expect(await client.line(), ['200', '201'])
if (options.user) {
const reply = await client.command(`AUTHINFO USER ${options.user}`)
if (reply.startsWith('381')) await client.expect(await client.command(`AUTHINFO PASS ${options.password ?? ''}`), ['281'])
if (reply.startsWith('381'))
await client.expect(await client.command(`AUTHINFO PASS ${options.password ?? ''}`), [
'281',
])
else await client.expect(reply, ['281'])
}
if (options.compress) client.compressed = (await client.command('XFEATURE COMPRESS GZIP')).startsWith('290')
if (options.compress)
client.compressed = (await client.command('XFEATURE COMPRESS GZIP')).startsWith('290')
} catch (error) {
client.close()
throw error
@@ -127,7 +147,9 @@ export class XoverClient {
const reply = await this.command(`LIST ACTIVE ${pattern}`)
await this.expect(reply, ['215'])
// With XFEATURE COMPRESS GZIP on, Eweka compresses this list too ("215 NewsGroups Follow [COMPRESS=GZIP]").
const body = reply.includes('COMPRESS=GZIP') ? (await this.compressedRaw(true)).text : await this.plainBody()
const body = reply.includes('COMPRESS=GZIP')
? (await this.compressedRaw(true)).text
: await this.plainBody()
return body
.split('\r\n')
.filter(Boolean)
@@ -154,7 +176,11 @@ export class XoverClient {
return deflateAsync(Buffer.from(await this.plainBody()), { level: 1 })
} finally {
this.measuring = false
this.lastTiming = { replyMs: repliedAt - sentAt, bodyMs: performance.now() - repliedAt, longestGapMs: this.longestGap }
this.lastTiming = {
replyMs: repliedAt - sentAt,
bodyMs: performance.now() - repliedAt,
longestGapMs: this.longestGap,
}
}
})
}
@@ -180,7 +206,9 @@ export class XoverClient {
const reply = await this.command(`XOVER ${from}-${to}`)
if (reply.startsWith('420') || reply.startsWith('423')) return []
await this.expect(reply, ['224'])
const data = reply.includes('COMPRESS=GZIP') ? await this.compressedBody() : await this.plainBody()
const data = reply.includes('COMPRESS=GZIP')
? await this.compressedBody()
: await this.plainBody()
return parseOverviews(data)
}
@@ -199,7 +227,8 @@ export class XoverClient {
}
private async expect(reply: string, codes: string[]) {
if (!codes.some((code) => reply.startsWith(code))) throw new Error(`NNTP: unexpected reply "${reply}"`)
if (!codes.some((code) => reply.startsWith(code)))
throw new Error(`NNTP: unexpected reply "${reply}"`)
}
private buffer(): Buffer {
@@ -223,10 +252,28 @@ export class XoverClient {
if (this.failure && this.length === 0) throw this.failure
}
/** As more(), but false when nothing came within `ms`. */
private async moreWithin(ms: number): Promise<boolean> {
if (this.failure) throw this.failure
const came = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), ms)
this.wake = () => {
clearTimeout(timer)
resolve(true)
}
})
this.wake = undefined
if (this.failure && this.length === 0) throw this.failure
return came
}
private async line(): Promise<string> {
while (true) {
const at = this.buffer().indexOf('\r\n')
if (at !== -1) return this.take(at + 2).subarray(0, at).toString('latin1')
if (at !== -1)
return this.take(at + 2)
.subarray(0, at)
.toString('latin1')
await this.more()
}
}
@@ -267,7 +314,9 @@ export class XoverClient {
*/
async bodies(messageIds: string[]): Promise<(Buffer | null)[]> {
return this.timed(async () => {
this.sock.write(messageIds.map((m) => `BODY ${m.startsWith('<') ? m : `<${m}>`}\r\n`).join(''))
this.sock.write(
messageIds.map((m) => `BODY ${m.startsWith('<') ? m : `<${m}>`}\r\n`).join('')
)
const out: (Buffer | null)[] = []
for (let i = 0; i < messageIds.length; i++) {
const reply = await this.line()
@@ -309,7 +358,11 @@ export class XoverClient {
return ''
}
const at = all.indexOf(TERMINATOR, Math.max(0, searched - TERMINATOR.length))
if (at !== -1) return this.take(at + TERMINATOR.length).subarray(0, at + 2).toString('utf8').replace(/^\.\./gm, '.')
if (at !== -1)
return this.take(at + TERMINATOR.length)
.subarray(0, at + 2)
.toString('utf8')
.replace(/^\.\./gm, '.')
searched = all.length
await this.more()
}
@@ -359,7 +412,14 @@ export class XoverClient {
this.take(all.length)
return { raw, text: inflated.toString('utf8') }
} catch {
// ".\r\n" inside the compressed data: wait for more.
// ".\r\n" inside the compressed data: wait for more, but not for the reply timeout: the server may have
// cut its output there (TruncatedReply), and then nothing comes. Raw, the chunk is returned as it is
// and reported by the worker; the text is wanted here.
if (!(await this.moreWithin(TRUNCATED_MS))) {
this.take(all.length)
throw new TruncatedReply(`NNTP: compressed reply cut short after ${all.length} bytes`)
}
continue
}
}
await this.more()
+1 -1
View File
@@ -1 +1 @@
export { inspect, innerName, MAX_NAMES, PROBE_VERSION, type Inspection } from './inspect.js'
export { inspect, innerName, usableName, MAX_NAMES, PROBE_VERSION, type Inspection } from './inspect.js'
+54 -8
View File
@@ -5,6 +5,7 @@ import { parseRarHeaders } from '#lib/rar/index'
import { parseSevenZipTail } from '#lib/sevenzip/index'
import { parseZipStart } from '#lib/zip/index'
import { sniffContentType, type ContentType } from '#lib/content/index'
import { isUnreadableName } from '#lib/release/index'
/** What one probed article showed (REBUILD.md, part 6; stored per release and kind). */
export interface Inspection {
@@ -25,7 +26,10 @@ export interface Inspection {
*/
hash16k?: string
/** A par2 body's recovery set: every file it describes (not kept in release_inspections; par2_files). */
par2?: { setId: string; files: { fileId: string; hash16k: string; length: number; name: string }[] }
par2?: {
setId: string
files: { fileId: string; hash16k: string; length: number; name: string }[]
}
}
const HASH_BYTES = 16 * 1024
@@ -63,9 +67,16 @@ function largestFirst(items: { name: string; size: number }[]): string[] {
*/
export function inspect(body: Buffer, kind?: string): Inspection {
const segment = decodeBody(body)
const result: Inspection = { yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH), size: segment.meta.begin.size, type: sniffContentType(segment.data) }
const result: Inspection = {
yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH),
size: segment.meta.begin.size,
type: sniffContentType(segment.data),
}
// The file's first 16 KiB, or all of a smaller file.
if (segment.offset === 0 && (segment.data.length >= HASH_BYTES || segment.data.length === result.size)) {
if (
segment.offset === 0 &&
(segment.data.length >= HASH_BYTES || segment.data.length === result.size)
) {
result.hash16k = createHash('md5').update(segment.data.subarray(0, HASH_BYTES)).digest('hex')
}
if (isPar2(segment.data)) {
@@ -76,7 +87,12 @@ export function inspect(body: Buffer, kind?: string): Inspection {
result.names = largestFirst(set.files.map((f) => ({ name: f.name, size: f.length })))
result.par2 = {
setId: set.recoverySetId,
files: set.files.map((f) => ({ fileId: f.fileId, hash16k: f.md5_16k, length: f.length, name: f.name.slice(0, MAX_NAME_LENGTH) })),
files: set.files.map((f) => ({
fileId: f.fileId,
hash16k: f.md5_16k,
length: f.length,
name: f.name.slice(0, MAX_NAME_LENGTH),
})),
}
}
return result
@@ -99,7 +115,10 @@ export function inspect(body: Buffer, kind?: string): Inspection {
}
}
// 7z keeps its listing at the end: the last segment of the last volume (a 7z-tail), or a small archive whole.
if ((kind === '7z-tail' || result.type === '7z') && segment.offset + segment.data.length === result.size) {
if (
(kind === '7z-tail' || result.type === '7z') &&
segment.offset + segment.data.length === result.size
) {
const listing = parseSevenZipTail(segment.data)
if (listing) {
result.entries = listing.files.length
@@ -110,16 +129,43 @@ export function inspect(body: Buffer, kind?: string): Inspection {
return result
}
/**
* The usable name one probe of an unnamed release found (the pending ladder, REBUILD.md decision f): the largest
* named file of a par2 set or an archive's listing, or a lone file's yEnc name, when it isn't packaging and isn't
* random itself (an obfuscated upload's par2 names its random volumes; its yEnc name is mostly the random name
* again). Null: this probe named nothing, the next target is tried.
*/
export function usableName(inspection: Inspection, kind: string): string | null {
const usable = (n: string | undefined) => {
if (!n) return null
const name = n.replace(/^.*[\\/]/, '').slice(0, 255)
return name && !PACKAGING.test(name) && !isUnreadableName(name) ? name : null
}
const named = (inspection.names ?? []).map(usable).find((n) => n)
if (named) return named
return kind === 'lone' || !inspection.names?.length ? usable(inspection.yencName) : null
}
/**
* A release's best inner name from its inspections (by probe kind): the
* largest named entry of an archive, else the largest file of a par2 set
* that isn't packaging, else a lone file's yEnc name. Null when none says
* more than the release's own name.
*/
export function innerName(inspections: { kind: string; result: Inspection }[], releaseName = ''): string | null {
export function innerName(
inspections: { kind: string; result: Inspection }[],
releaseName = ''
): string | null {
const of = (kind: string) => inspections.filter((i) => i.kind === kind).map((i) => i.result)
const content = (kind: string) => of(kind).flatMap((i) => i.names ?? []).find((n) => !PACKAGING.test(n))
const pick = content('archive') ?? content('7z-tail') ?? content('par2') ?? of('lone').find((i) => i.yencName && !PACKAGING.test(i.yencName))?.yencName
const content = (kind: string) =>
of(kind)
.flatMap((i) => i.names ?? [])
.find((n) => !PACKAGING.test(n))
const pick =
content('archive') ??
content('7z-tail') ??
content('par2') ??
of('lone').find((i) => i.yencName && !PACKAGING.test(i.yencName))?.yencName
const name = pick?.replace(/^.*[\\/]/, '').slice(0, 255)
// The release's own name with an extension says nothing new (a random-named file's yEnc name is mostly its random name).
return name && !name.toLowerCase().startsWith(releaseName.toLowerCase() || '\0') ? name : null
+55
View File
@@ -0,0 +1,55 @@
/**
* The class of an unnamed release (REBUILD.md decision f; LOG.md (130)): what the headers say about its chance of a
* name, from the pending ladder's own record. Size bucket × name shape × partial, 1..70, kept in step with
* names.rs pending_class and the SQL pending_class() (migration 6800): the batch store sets it with the SQL, the
* edge skips the classes a lease marks dropped.
*/
/** Size buckets: under 1 MB, 10 MB, 100 MB, 1 GB, and from 1 GB. */
export const SIZE_BUCKETS = [1_000_000, 10_000_000, 100_000_000, 1_000_000_000]
/** Name shapes, 1..7: hex, digits, base32, letters only, mixed letters and digits, with separators, other. */
export const SHAPES = [
'hex',
'digits',
'base32',
'letters',
'alnum',
'separators',
'other',
] as const
export function sizeBucket(bytes: number): number {
let i = 0
while (i < SIZE_BUCKETS.length && bytes >= SIZE_BUCKETS[i]) i++
return i
}
export function nameShape(name: string): number {
const s = name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
if (/^[0-9a-fA-F]{16,}$/.test(s)) return 1
if (/^[0-9]{6,}$/.test(s)) return 2
if (/^[a-z2-7]{20,}$/.test(s) && /[2-7]/.test(s)) return 3
if (/^[A-Za-z]+$/.test(s)) return 4
if (/^[A-Za-z0-9]+$/.test(s)) return 5
if (/^[A-Za-z0-9._-]+$/.test(s)) return 6
return 7
}
/** 1..70: (size bucket × 7 + shape − 1) × 2 + partial + 1. */
export function pendingClass(name: string, bytes: number, partial: boolean): number {
return sizeBucket(bytes) * 14 + (nameShape(name) - 1) * 2 + (partial ? 1 : 0) + 1
}
/** A class as droppedClasses lists it: "tool\tgroup\tklass". */
export function classKey(tool: string, group: string, klass: number): string {
return `${tool}\t${group}\t${klass}`
}
/** A class in words ("10-100M hex partial"). */
export function describeClass(klass: number): string {
const k = klass - 1
const size = Math.floor(k / 14)
const shape = SHAPES[Math.floor((k % 14) / 2)]
const sizes = ['<1M', '1-10M', '10-100M', '100M-1G', '1G+']
return `${sizes[size]} ${shape}${k % 2 ? ' partial' : ''}`
}
+25 -2
View File
@@ -105,6 +105,29 @@ export function subjectRemainder(subject: string): string {
.trim()
}
/**
* A subject name as shown: brackets the counters left unmatched removed ("… Facialized 002/477] - 0177.jpg", the
* counter's "[" missing, gave "… Facialized ]"; LOG.md 2026-10-29 (121)). Only the name: the key keeps the remainder.
*/
export function displayName(remainder: string): string {
if (!/[[\]()]/.test(remainder)) return remainder
const drop = new Set<number>()
for (const [open, close] of [['[', ']'], ['(', ')']]) {
const opens: number[] = []
for (let i = 0; i < remainder.length; i++) {
if (remainder[i] === open) opens.push(i)
else if (remainder[i] === close) {
if (opens.length > 0) opens.pop()
else drop.add(i)
}
}
for (const i of opens) drop.add(i)
}
if (drop.size === 0) return remainder
const name = remainder.split('').filter((_, i) => !drop.has(i)).join('').replace(/ {2,}/g, ' ').trim()
return name || remainder
}
/**
* Posting tools put random per-file tokens and poster IDs in subjects (e.g.
* `elys-de81bcabef5faf6a - [Nyh5Q07Y]`); only text that still reads like a
@@ -448,7 +471,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
if (!image && RANDOM_VOLUME.test(input.name) && /^[A-Za-z0-9_-]+$/.test(stem) && looksRandom(stem.replace(/[-_]/g, ''))) parts.push(stem)
return {
key: hash(...parts),
name: remainder,
name: displayName(remainder),
fileTotal,
source: 'subject',
}
@@ -456,7 +479,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
// Not a name, but a post id (e.g. `83110629-n [10/18] - "random"`): with the
// [i/M] counter it groups the files of one post whose names are random.
if (!tag && postTotal && isPostToken(remainder)) {
return { key: hash('post', poster, remainder, postTotal), name: remainder, fileTotal, source: 'post' }
return { key: hash('post', poster, remainder, postTotal), name: displayName(remainder), fileTotal, source: 'post' }
}
// A scene music post's index files under a bare counter go with its tracks: the artist and the total ("[01/13] -
// "00-ammar-turk_uit_de_kroeg-single-web-nl-2022-cover.jpg"" with "[05/13] - "01-ammar-turk_uit_de_kroeg.mp3"": the
+3 -1
View File
@@ -1,5 +1,7 @@
/**
* Grouping of files into releases (downloads), independent of AdonisJS.
*/
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, sizeAfterName, runOn, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js'
export { releaseIdentity, displayName, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, sizeAfterName, runOn, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js'
export { looksRandom } from './random.js'
export { isUnreadableName, tokenKind } from './readable.js'
export { pendingClass, classKey, describeClass, nameShape, sizeBucket } from './classes.js'
+34
View File
@@ -0,0 +1,34 @@
import { createHash } from 'node:crypto'
/**
* The catalog's keys as voyager 3 stores them (migration 6000): 20-byte SHA-1s in bytea columns.
* - identity_key: the identity's hash (lib/release/identity.ts gives it as 40 hex characters).
* - release_key: one upload of an identity, unique: the identity, the owner group and the first article the window
* saw of it. The same release sent twice (a tail release provisionally, then finished) hits the same row; a repost
* starts at another article and is a release of its own (decision d). Deterministic, so no numbering under a lock.
*/
const HEX40 = /^[0-9a-f]{40}$/i
function sha1(text: string): Buffer {
return createHash('sha1').update(text).digest()
}
/** The identity key's bytes: 40 hex characters decoded, any other text (a spot's, a test's) hashed. */
export function identityKey(key: string): Buffer {
return HEX40.test(key) ? Buffer.from(key, 'hex') : sha1(key)
}
/** The release key's bytes for an identity in a group, first seen at an article (null for releases without one: a spot's). */
export function releaseKey(
identity: string,
groupId: number | null,
firstArticle: number | null
): Buffer {
return sha1(`release\n${identity}\n${groupId ?? ''}\n${firstArticle ?? ''}`)
}
/** A stored key as hex, for logs, the API and release_merges.from_key. */
export function keyHex(key: Buffer | Uint8Array | string | null | undefined): string | null {
if (key === null || key === undefined) return null
return typeof key === 'string' ? key : Buffer.from(key).toString('hex')
}
+148
View File
@@ -0,0 +1,148 @@
import { fileStem } from './identity.js'
import { looksRandom } from './random.js'
/**
* Readable or not: whether a name says what a release is (the catalog's readable flag, the prober's inner names).
* looksRandom (random.ts) knows the single-token shapes; this reads a name token by token, so separators, spaces
* and random "extensions" don't hide a scramble (LOG.md (129)).
*/
/**
* Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed
* words ("Description - Lt5nHjlo3RiuBb3C", "bd5b4a59f3aa87114dcf75c0-addon").
*/
const NOISE =
/^(addon|samples?|proof|subs?|nfo|part\d+|vol\d+|description|repost|rep|extras?|bonus|cd\d*|dvd\d*|dis[ck]\d*|par2|rar|zip|7z|mkv|mp4|avi|vol)$/i
/** Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th. */
const CODE =
/^([sS]\d{1,2}[eE]\d{1,3}|[xh]26[45]|[A-Za-z]+\d+([a-z]{1,3}|x(64|86))?|\d+[A-Za-z]{1,3}|\d+[A-Za-z]+\d+)$/
/** The NZBSPLIT tools' hash between their markers is no part of the name. */
const NZBSPLIT = /__NZBSPLIT__[0-9a-f]{32}__NZBSPLIT__/i
/** Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there. */
function sceneCase(t: string): string {
let out = ''
for (let i = 0; i < t.length; i++) {
out +=
t[i] === 'i' && i > 0 && i < t.length - 1 && /[A-Z]/.test(t[i - 1]) && /[A-Z]/.test(t[i + 1])
? 'I'
: t[i]
}
return out
}
/**
* The signals of a scrambled token: letter/digit and lower-to-upper switches; case switches in both directions; lone
* lowercase letters between capitals ("NtpUgCmS"); an inner run of 2+ capitals with lowercase on both sides
* ("tBWTuz", "YDoaROv"); the longest consonant run; vowels (a lowercase y too: "McCartney"); runs of one character class.
*/
function signals(t: string) {
let switches = 0
let caseSwitches = 0
let lone = 0
let consonants = 0
let maxConsonants = 0
for (let i = 0; i < t.length; i++) {
const c = t[i]
if (/[b-df-hj-np-tv-xz]/i.test(c)) maxConsonants = Math.max(maxConsonants, ++consonants)
else consonants = 0
if (i === 0) continue
const a = t[i - 1]
if (/\d/.test(a) !== /\d/.test(c)) switches++
else if (/[a-z]/.test(a) && /[A-Z]/.test(c)) switches++
if ((/[a-z]/.test(a) && /[A-Z]/.test(c)) || (/[A-Z]/.test(a) && /[a-z]/.test(c))) caseSwitches++
if (
i < t.length - 1 &&
/[a-z]/.test(c) &&
c !== 'i' &&
/[A-Z]/.test(a) &&
/[A-Z]/.test(t[i + 1])
)
lone++
}
const innerUpper = /[a-z][A-Z]{2,}[a-z]/.test(t)
const vowels = (t.match(/[aeiouAEIOU]|y/g) ?? []).length
const runs = (t.match(/[a-z]+|[A-Z]+|\d+/g) ?? []).length
const letters = (t.match(/[A-Za-z]/g) ?? []).length
const maxLower = Math.max(0, ...(t.match(/[a-z]+/g) ?? []).map((r) => r.length))
return {
switches,
caseSwitches,
lone,
innerUpper,
maxConsonants,
vowels,
runs,
letters,
maxLower,
}
}
/**
* What one alphanumeric token of a name is: a word (letters only, in one case or Capitalized, 3+ long, not a packaging
* word), a scramble, or neither (digits, codes, CamelCase, short tokens). Kept in step with names.rs token_kind.
*/
export function tokenKind(t: string): 'word' | 'random' | null {
if (/^\d+$/.test(t) || NOISE.test(t) || CODE.test(t)) return null
if (/^[A-Za-z]+$/.test(t)) {
const s = signals(sceneCase(t))
if (s.caseSwitches === 0 || (s.caseSwitches === 1 && /^[A-Z][a-z]+$/.test(t))) {
// A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word.
if (t.length >= 10 && s.vowels * 6 < t.length) return 'random'
return t.length >= 3 ? 'word' : null
}
// Mixed case beyond Capitalization: CamelCase (neutral) or a scramble. Two CamelCase words with few vowels
// (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone letter; a scramble has more runs, a lone
// letter or an inner capital run.
if (
t.length >= 6 &&
s.caseSwitches >= 2 &&
s.vowels * 4 < t.length &&
(s.runs >= 5 || s.lone >= 1 || s.innerUpper)
)
return 'random'
return null
}
if (t.length >= 8 && /^[0-9a-f]+$/i.test(t) && (t.match(/\d/g)?.length ?? 0) >= 2) return 'random'
if (/^[A-Z0-9]+$/.test(t)) {
// 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay.
return t.length >= 12 && signals(t).switches >= 6 ? 'random' : null
}
// Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym.
if (
(t.match(/[A-Za-z]+/g) ?? []).every(
(w) =>
/^[A-Z]{2,}$/.test(w) ||
(/^[A-Z]?[a-z]{2,}$/.test(w) && (w.match(/[aeiouy]/g) ?? []).length * 5 >= w.length)
)
)
return null
const s = signals(sceneCase(t))
if (t.length >= 5 && s.switches >= 3) return 'random'
if (t.length >= 6 && s.switches >= 2 && (s.lone >= 1 || s.innerUpper || s.maxConsonants >= 4))
return 'random'
// Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus").
if (t.length >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.maxLower <= 2)
return 'random'
return null
}
/**
* A file or release name no one can read: looksRandom's shapes, or more scrambled tokens than words by letter count
* ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ"; not "Paul McCartney - Band on the Run",
* "ShesSoWild - Ester Ana 1080p"). Separators and a random "extension" of any length don't hide a scramble, which
* looksRandom's single-token shapes miss (15% of the readable catalog on 2026-11-02; LOG.md (129)). The readable
* flag; the drop rules keep looksRandom, which is narrower.
*/
export function isUnreadableName(name: string): boolean {
const stem = fileStem(name).replace(NZBSPLIT, ' ')
if (looksRandom(stem)) return true
let words = 0
let random = 0
for (const t of stem.split(/[^A-Za-z0-9]+/)) {
if (!t) continue
const kind = tokenKind(t)
if (kind === 'word') words += t.length
else if (kind === 'random') random += t.length
}
return random > 0 && random > words
}
+38
View File
@@ -0,0 +1,38 @@
/**
* How a release got its name, as releases.source stores it (a smallint; voyager 3, migration 6000). The names are
* what the edge, the spot inspector and the API use; the numbers never change meaning once written.
*/
export const SOURCES = {
/** A name the subject announces ("name" yEnc (k/N)). */
subject: 1,
/** The post's files, named after the first file (no release name in the subject). */
post: 2,
/** The files' shared stem. */
stem: 3,
/** A Spotnet spot's files that matched no release. */
spot: 4,
/** An NZB's files. */
nzb: 5,
/** One poster's files that differ only by a number sequence, or share a prefix (voyager 3). */
album: 6,
/** An obfuscated post's stem, read from the yEnc body (identity.ts: weak). */
'weak-stem': 7,
} as const
export type Source = keyof typeof SOURCES
const NAMES = Object.fromEntries(
Object.entries(SOURCES).map(([name, code]) => [code, name])
) as Record<number, Source>
/** The code of a source name; throws for an unknown one (a typo would write a wrong number for good). */
export function sourceCode(name: string): number {
const code = SOURCES[name as Source]
if (code === undefined) throw new Error(`Unknown release source "${name}"`)
return code
}
/** The name of a stored code; "unknown" for one not listed (never written, but a row might be read by an older build). */
export function sourceName(code: number | string | null | undefined): Source | 'unknown' {
return NAMES[Number(code)] ?? 'unknown'
}
+60
View File
@@ -0,0 +1,60 @@
/**
* What a searchable string of a release is, as release_texts.kind stores it (a smallint; voyager 3, migration 6200).
* The table holds every string search matches against (the release name too), appended by whoever learns one: the
* batch store the name, the prober the file names inside an archive, later passes NFO titles, par2 set names,
* media titles. The numbers never change meaning once written.
*/
export const TEXT_KINDS = {
/** The release name (releases.name, once more for the one trigram index). */
name: 1,
/** A file name inside an archive (the prober's inner names). */
file: 2,
/** A title read from an NFO. */
nfo: 3,
/** A par2 set's name when it differs from the release name. */
par2: 4,
/** A title from a media container's metadata. */
media: 5,
/** An album's member, by its own file name (the edge's albums: one poster's files differing by a number sequence). */
member: 6,
} as const
export type TextKind = keyof typeof TEXT_KINDS
/** The longest string stored (release_texts.text is a varchar(255)); longer ones are cut, not refused. */
export const TEXT_MAX = 255
/** The most strings one writer appends per release and kind (an archive of two thousand files is not two thousand rows). */
export const TEXTS_PER_KIND = 20
const NAMES = Object.fromEntries(
Object.entries(TEXT_KINDS).map(([name, code]) => [code, name])
) as Record<number, TextKind>
/** The code of a kind; throws for an unknown one (a typo would write a wrong number for good). */
export function textKindCode(name: string): number {
const code = TEXT_KINDS[name as TextKind]
if (code === undefined) throw new Error(`Unknown release text kind "${name}"`)
return code
}
/** The name of a stored code; "unknown" for one not listed. */
export function textKindName(code: number | string | null | undefined): TextKind | 'unknown' {
return NAMES[Number(code)] ?? 'unknown'
}
/**
* The strings a writer appends for one kind: trimmed, cut to TEXT_MAX, empty ones and repeats dropped, the first
* TEXTS_PER_KIND kept in the order given (the writer puts the most telling first).
*/
export function textsToStore(texts: Iterable<string | null | undefined>): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of texts) {
const text = (raw ?? '').trim().slice(0, TEXT_MAX)
if (!text || seen.has(text)) continue
seen.add(text)
out.push(text)
if (out.length === TEXTS_PER_KIND) break
}
return out
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { uploadTool, signatureGroup, signatureKey, explored } from './upload_tools.js'
export { RuleSet, DEFAULT_RULES, isRandomFile, ownerOf, posterWords, type Rules, type Verdict } from './rule_set.js'
export { RuleSet, DEFAULT_RULES, isRandomFile, isScrambledFile, ownerOf, posterWords, stubArchiveLegs, isNonsensePiece, isTinyArchive, STUB_ARTICLE_BYTES, STUB_MIN_BYTES, STUB_MAX_BYTES, STUB_WIDE_GROUPS, NONSENSE_MIN_BYTES, NONSENSE_MAX_BYTES, TINY_MIN_BYTES, type Rules, type Verdict, type JudgedFile } from './rule_set.js'
+174 -4
View File
@@ -12,6 +12,11 @@ export interface Rules {
priorities: Record<string, number>
/** Dropped signatures, as "tool\tgroup" (GarbageFilter's table). */
droppedSignatures: string[]
/**
* Dropped classes of unnamed releases, as "tool\tgroup\tklass" (lib/release/classes.ts; the pending ladder's record,
* LOG.md (130)): the edge keeps them out of the pending store, the explored sample excepted.
*/
droppedClasses: string[]
/** Tools whose random-named uploads are dropped in every group (named < 1%; LOG.md 2026-10-08 (5)). */
droppedTools: string[]
/** A random-named file of at most this many parts: a random subject per article, needs bodies. */
@@ -30,6 +35,24 @@ export interface Rules {
* again under other titles (119k releases on 2026-10-16; LOG.md 2026-10-16 (65)).
*/
droppedPosters: string[]
/**
* Drop settled releases of one unsplit archive without a par2, posted to 2+ groups, under STUB_MAX_BYTES, whose
* articles are at most STUB_ARTICLE_BYTES with the release at least STUB_MIN_BYTES, or posted to 4+ groups, or by
* a webmail poster: the exe-stub and recycled-installer floods under film and software names (55% of the catalog
* on 2026-10-30; 99.3% of the known fakes, 0 known content; LOG.md (123)-(126), (128)).
*/
dropStubArchives: boolean
/**
* Drop one-article archives of NONSENSE_MIN_BYTES to NONSENSE_MAX_BYTES under a release-like, unscrambled name:
* the newznzb.* pieces (9% of the catalog; LOG.md (123), (125)).
*/
dropNonsensePieces: boolean
/**
* Drop one-article archives of TINY_MIN_BYTES to NONSENSE_MIN_BYTES under a release-like name (spaces allowed),
* outside the exempt groups, without an e-book, subtitle, crack or sidecar word: the 2015-17 exe-stub flood in
* miniature (a RAR holding "<title>.exe" at 2-60 kB; 1.65M catalog rows; LOG.md (131), (133)).
*/
dropTinyArchives: boolean
}
export const DEFAULT_RULES: Omit<Rules, 'priorities' | 'droppedSignatures'> = {
@@ -39,9 +62,37 @@ export const DEFAULT_RULES: Omit<Rules, 'priorities' | 'droppedSignatures'> = {
samplePercent: 1,
dropExecutables: true,
droppedPosters: ['celebmovie', 'celebwow', 'celebfx', 'iluvcelebs', 'nudecelebs', 'celebsextapes'],
dropStubArchives: true,
dropNonsensePieces: true,
dropTinyArchives: true,
droppedClasses: [],
}
export type Verdict = { rule: null } | { rule: 'poster' | 'executable' | 'garbage' | 'per-article'; key: string; sample: boolean }
/** The stub-archive rule's bounds (edge/src/names.rs): Power-Post's 250 kB default article, the fakes' size span. */
export const STUB_ARTICLE_BYTES = 262144
export const STUB_MIN_BYTES = 1_000_000
export const STUB_MAX_BYTES = 100_000_000
export const STUB_WIDE_GROUPS = 4
/** The nonsense-piece rule's bounds: under 100 kB are cue/log sidecars and adverts, 1 MB and up is rule 1's. */
export const NONSENSE_MIN_BYTES = 100_000
export const NONSENSE_MAX_BYTES = 1_000_000
/** The tiny-archive rule's floor: a RAR holding one exe stub is 2 kB and up; below are empty archives and nfo rars. */
export const TINY_MIN_BYTES = 2_000
export type Verdict =
| { rule: null }
| { rule: 'poster' | 'executable' | 'garbage' | 'per-article' | 'stub-archive' | 'nonsense-piece' | 'tiny-archive'; key: string; sample: boolean }
/** A file as the judge sees it: its name, parts, groups, poster, and the articles seen with their bytes. */
export interface JudgedFile {
name: string
totalParts: number
groups: string[]
firstMessageId: string
poster: string
segments: number
bytes: number
}
/** A poster as droppedPosters matches it: lowercased, letters and digits only. */
export function posterWords(poster: string): string {
@@ -51,11 +102,117 @@ export function posterWords(poster: string): string {
/** Files that come along with a release's content: they don't make it one of executables or not. */
const SIDECAR = /\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$/i
const EXECUTABLE = /\.(exe|scr|com|pif|bat|cmd|vbs|lnk)\s*$/i
/** One zip, rar or 7z that is not a volume of a set. */
const ARCHIVE = /\.(zip|rar|7z)$/i
/** "cneg" is "part" in ROT13: the a.b.erotica posts named that way are volumes of passworded videos (LOG.md (128)). */
const VOLUME = /\.((part|cneg)\d+\.rar|r\d\d|z\d\d|7z\.\d\d\d|\d\d\d)$/i
/** A poster at a free mail provider: the stub floods' throwaway accounts. */
const WEBMAIL = /@(gmail|googlemail|yahoo|hotmail|outlook|live|msn|aol|gmx|web|t-online|freenet|mail|yandex|icloud|me|hushmail|protonmail|libero|orange|free|arcor|wanadoo|ymail|rocketmail|inbox|mail\.ru|bk|list)\./i
/** A name that reads as a release: a year, a scene tag, a group suffix or dotted words. */
const RELEASE_LIKE = /(\b(19|20)\d\d\b|[._-](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s\d\de\d\d|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v\d+(\.\d+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)/i
/**
* Groups whose communities post real single small archives (karaoke zips, e-books, comics, tabs, fonts) the way the
* stub floods do (Power-Post defaults, a crosspost or two): a release touching one is exempt from the stub-archive
* rule. Costs 16 of 32,308 known fakes in the 217k sample (LOG.md (127)).
*/
const STUB_EXEMPT_GROUPS = /(sound|mp3|karaoke|music|audio|flac|lossless|e-?book|epub|comic|guitar|font|magazine)/i
/** The single unsplit archive a release consists of, or null. */
function singleArchive(files: JudgedFile[]): JudgedFile | null {
return files.length === 1 && ARCHIVE.test(files[0].name) && !VOLUME.test(files[0].name) ? files[0] : null
}
/**
* The legs of the stub-archive rule a settled single archive under STUB_MAX_BYTES (the fakes stop at ~90 MB;
* hash-named uploads, real albums and passworded videos above it don't) trips: "A" (articles <= STUB_ARTICLE_BYTES,
* release >= STUB_MIN_BYTES: a one-article release always has small articles, so e-books and sidecars are exempt),
* "X" (4+ groups) and "W" (webmail poster). Empty when none.
*/
export function stubArchiveLegs(f: JudgedFile): string {
if (f.groups.length < 2 || f.bytes >= STUB_MAX_BYTES || f.groups.some((g) => STUB_EXEMPT_GROUPS.test(g))) return ''
let legs = ''
if (f.bytes / Math.max(f.segments, 1) <= STUB_ARTICLE_BYTES && f.bytes >= STUB_MIN_BYTES) legs += 'A'
if (f.groups.length >= STUB_WIDE_GROUPS) legs += 'X'
if (WEBMAIL.test(f.poster)) legs += 'W'
return legs
}
/** An e-book by its name ("Author - Title (2020) (epub).rar"): one small article is a book, not a nonsense piece. */
const EBOOK = /\b(epub|mobi|azw3?|pdf|djvu|cbr|cbz|e-?books?)\b/i
/**
* A nonsense piece: one article, one small archive under a release-like, unscrambled name without spaces (the
* newznzb.* pieces are scene-styled or random with a "-XX" suffix; books and albums are written with spaces).
*/
export function isNonsensePiece(f: JudgedFile): boolean {
return (
f.totalParts === 1 &&
f.segments === 1 &&
f.bytes >= NONSENSE_MIN_BYTES &&
f.bytes < NONSENSE_MAX_BYTES &&
!f.name.includes(' ') &&
RELEASE_LIKE.test(fileStem(f.name)) &&
!EBOOK.test(f.name) &&
!isScrambledFile(f.name) &&
!isRandomFile(f.name)
)
}
/** RELEASE_LIKE with spaces as separators too: the tiny fakes are posted under names written either way. */
const RELEASE_LIKE_SPACED = new RegExp(RELEASE_LIKE.source.replace('[._-]', '[._ -]'), 'i')
/**
* Words of the real tiny archives under release-like names: subtitles, cracks, trainers and keygens (a few kB of
* exe is the content), covers and sidecars.
*/
const TINY_CONTENT =
/\b(subs?|subtitles?|subpack|srt|untertitel|ondertitels?|ondertiteling|legendas?|napisy|sottotitoli|cracks?|cracked|keygen|keymaker|trainer|patch|patched|serials?|activator|loader|nocd|no-cd|fix|covers?|artwork|booklet|scans?|proof|sample|info|readme)\b/i
/**
* A tiny archive: one article of TINY_MIN_BYTES to NONSENSE_MIN_BYTES holding one archive under a release-like,
* unscrambled name (spaces allowed), outside the groups that post real small archives, and without an e-book,
* subtitle, crack or sidecar word: the 2015-17 exe-stub flood in miniature (LOG.md (131)).
*/
export function isTinyArchive(f: JudgedFile): boolean {
return (
f.totalParts === 1 &&
f.segments === 1 &&
f.bytes >= TINY_MIN_BYTES &&
f.bytes < NONSENSE_MIN_BYTES &&
!f.groups.some((g) => STUB_EXEMPT_GROUPS.test(g)) &&
RELEASE_LIKE_SPACED.test(fileStem(f.name)) &&
!EBOOK.test(f.name) &&
!TINY_CONTENT.test(f.name) &&
!isScrambledFile(f.name) &&
!isRandomFile(f.name)
)
}
export function isRandomFile(name: string) {
return looksRandom(fileStem(name))
}
/**
* At least half the letters and digits of a file's stem in tokens of 5 or more that switch case (lower to upper) or
* between letters and digits 3 times or more ("nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa"): the names a
* random-subject-per-article upload gives each article that looksRandom misses for their separators.
*/
export function isScrambledFile(name: string) {
let total = 0
let scrambled = 0
for (const token of fileStem(name).split(/[^A-Za-z0-9]+/)) {
total += token.length
let switches = 0
for (let i = 1; i < token.length; i++) {
const [a, b] = [token[i - 1], token[i]]
if ((/[a-z]/.test(a) && /[A-Z]/.test(b)) || /\d/.test(a) !== /\d/.test(b)) switches++
}
if (token.length >= 5 && switches >= 3) scrambled += token.length
}
return total >= 8 && scrambled * 2 >= total
}
/** The owner of an article posted to `groups` (its Xref), or null when none of them is indexed. */
export function ownerOf(groups: string[], priorities: Record<string, number>): string | null {
let owner: string | null = null
@@ -68,20 +225,24 @@ export function ownerOf(groups: string[], priorities: Record<string, number>): s
export class RuleSet {
private signatures: Set<string>
readonly classes: Set<string>
private tools: Set<string>
private posters: string[]
constructor(readonly rules: Rules) {
this.posters = rules.droppedPosters ?? []
this.signatures = new Set(rules.droppedSignatures)
this.classes = new Set(rules.droppedClasses ?? [])
this.tools = new Set(rules.droppedTools)
}
/**
* A release's verdict from its files: dropped when its poster is; when its content is bare
* executables; else kept as a whole when any file has a readable name; else dropped as garbage when its signature or tool is
* dropped, as per-article random subjects when every file has few parts.
* executables; when it is a settled single archive that is a stub or a nonsense piece; else kept as a whole when
* any file has a readable name; else dropped as garbage when its signature or tool is dropped, as per-article
* random subjects when every file has few parts. `settled`: the window closed on the release (a par2 can still
* join an open one), which the single-archive rules need.
*/
judge(releaseKey: string, files: { name: string; totalParts: number; groups: string[]; firstMessageId: string; poster: string }[]): Verdict {
judge(releaseKey: string, files: JudgedFile[], settled = true): Verdict {
const sample = explored(releaseKey, this.rules.samplePercent)
if (this.posters.length > 0 && files.length > 0) {
const words = posterWords(files[0].poster)
@@ -94,6 +255,15 @@ export class RuleSet {
return { rule: 'executable', key: EXECUTABLE.exec(content[0].name)![1].toLowerCase(), sample }
}
}
const archive = settled ? singleArchive(files) : null
if (archive) {
if (this.rules.dropStubArchives) {
const legs = stubArchiveLegs(archive)
if (legs !== '') return { rule: 'stub-archive', key: legs, sample }
}
if (this.rules.dropNonsensePieces && isNonsensePiece(archive)) return { rule: 'nonsense-piece', key: uploadTool(archive.firstMessageId), sample }
if (this.rules.dropTinyArchives && isTinyArchive(archive)) return { rule: 'tiny-archive', key: uploadTool(archive.firstMessageId), sample }
}
if (files.some((f) => !isRandomFile(f.name))) return { rule: null }
const first = files[0]
const tool = uploadTool(first.firstMessageId)
+1 -1
View File
@@ -22,7 +22,7 @@
<label class="search-box">
<svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false"
placeholder="Search release names…" aria-label="Search" />
placeholder="Search release names… (^word: at a word's start)" aria-label="Search" />
<kbd aria-hidden="true">/</kbd>
</label>
<label class="select">
+8
View File
@@ -74,6 +74,14 @@ export default await Env.create(new URL('../', import.meta.url), {
SPOT_STANDALONE_AFTER_HOURS: Env.schema.number.optional(),
CORPUS_AHEAD: Env.schema.number.optional(),
PROBE_PIPELINE: Env.schema.number.optional(),
SNAPSHOT_DIR: Env.schema.string.optional(),
LEASE_MIN_PRIORITY: Env.schema.number.optional(),
LEASE_MAX_PRIORITY: Env.schema.number.optional(),
LEASE_CAPTURE_ONLY: Env.schema.boolean.optional(),
STREAM_CHUNK_WAIT_MINUTES: Env.schema.number.optional(),
PENDING_SHARE: Env.schema.number.optional(),
PENDING_MIN_PROBES: Env.schema.number.optional(),
PENDING_MAX_NAMED: Env.schema.number.optional(),
SPOOL_AHEAD_PER_LEASE: Env.schema.number.optional(),
SPOOL_MIN_AHEAD: Env.schema.number.optional(),
SPOOL_MAX_GB: Env.schema.number.optional(),
+7
View File
@@ -63,6 +63,13 @@ router
router
.group(() => {
router.post('lease', [controllers.Node, 'lease'])
// Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): the window worker's.
router.post('groups/lease', [controllers.Node, 'leaseGroup'])
router.post('groups/:id/next', [controllers.Node, 'nextRange'])
router.post('groups/:id/heartbeat', [controllers.Node, 'groupHeartbeat'])
router.post('groups/:id/release', [controllers.Node, 'releaseGroup'])
router.put('groups/:id/snapshot', [controllers.Node, 'putSnapshot'])
router.get('groups/:id/snapshot', [controllers.Node, 'getSnapshot'])
router.get('ranges/:id/stream', [controllers.Node, 'stream'])
router.post('ranges/:id/batches', [controllers.Node, 'batch'])
router.post('ranges/:id/heartbeat', [controllers.Node, 'heartbeat'])
+3
View File
@@ -8,6 +8,7 @@ import testUtils from '@adonisjs/core/services/test_utils'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import type { Registry } from '../.adonisjs/client/registry/schema.d.ts'
import CatalogRefs from '#services/catalog_refs'
/**
* This file is imported by the "bin/test.ts" entrypoint file
@@ -51,6 +52,8 @@ export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
// Each test's transaction is rolled back: the poster and group-set ids cached meanwhile are gone with it.
suite.onGroup((group) => group.each.setup(() => CatalogRefs.forget()))
return suite.setup(() => testUtils.httpServer().start())
}
}
+238
View File
@@ -0,0 +1,238 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Node from '#models/node'
import Spool from '#services/spool'
import Corpus from '#services/corpus'
import GroupLeases from '#services/group_leases'
import coordinatorConfig from '#config/coordinator'
import { GROUP, overviewChunk, runEdge, startEdge, waitFor } from '#tests/e2e/helpers/edge'
/**
* The Rust edge worker against the real node API, database, spool and corpus (REBUILD.md, "Voyager 3: window
* indexing", 1 and 5): what broke in production on 2026-10-27 (LOG.md (118)) — a stream waiting on a chunk the
* corpus fill stored, a failed stream, a restart with and without the snapshot, a takeover.
*/
test.group('Edge worker end to end', (group) => {
const saved = {
spool: coordinatorConfig.spool.dir,
corpus: coordinatorConfig.corpus.dir,
snapshots: coordinatorConfig.snapshotDir,
chunk: coordinatorConfig.chunkArticles,
wait: coordinatorConfig.streamChunkWaitMs,
}
let dir = ''
group.each.setup(async () => {
dir = await mkdtemp(join(tmpdir(), 'e2e-'))
coordinatorConfig.spool.dir = join(dir, 'spool')
coordinatorConfig.corpus.dir = join(dir, 'corpus')
coordinatorConfig.snapshotDir = join(dir, 'snapshots')
coordinatorConfig.chunkArticles = 100
return testUtils.db().truncate()
})
group.each.teardown(async () => {
Object.assign(coordinatorConfig.spool, { dir: saved.spool })
coordinatorConfig.corpus.dir = saved.corpus
coordinatorConfig.snapshotDir = saved.snapshots
coordinatorConfig.chunkArticles = saved.chunk
coordinatorConfig.streamChunkWaitMs = saved.wait
await rm(dir, { recursive: true, force: true })
})
/** A group with one range of three chunks and a node with a token. */
async function setup(opts: { capture?: boolean } = {}) {
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
const [r] = await db
.table('ranges')
.insert({
group_id: g.id,
start_id: 1,
end_id: 300,
next_id: 1,
priority: 0,
capture: opts.capture ?? false,
})
.returning('id')
const node = await Node.create({ name: 'e2e-node' })
const token = (await Node.accessTokens.create(node)).value!.release()
return { groupId: Number(g.id), rangeId: Number(r.id), nodeId: Number(node.id), token }
}
const range = async (id: number) => (await db.from('ranges').where('id', id).firstOrFail()) as any
const lease = async (groupId: number) =>
(await db
.from('group_leases')
.where('group_id', groupId)
.where('lane', 'history')
.first()) as any
/** The release whose first article is `at` (a release's name isn't the file's: the identity strips it). */
const releaseAt = async (at: number) =>
(await db.from('releases').where('first_article', at).first()) as any
const names = async () =>
JSON.stringify(
await db
.from('releases')
.select('name', 'first_article', 'last_article', 'segments', 'complete')
.orderBy('first_article')
)
const has = (lines: string[], text: string) => lines.some((l) => l.includes(text))
test('a range streamed from the spool and the corpus, with a chunk the corpus fill stores while the stream waits', async ({
assert,
}) => {
const { groupId, rangeId, token } = await setup({ capture: true })
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
const run = await startEdge(token)
// The worker holds the group and streams: chunk 1 goes out, chunk 2 is waited for — the corpus fill stores it.
await waitFor(
'the group to be held',
async () =>
(await lease(groupId))?.range_id !== null && (await lease(groupId))?.range_id !== undefined
)
await new Promise((r) => setTimeout(r, 700))
await Corpus.write(
{ group: GROUP, start: 1, end: 300 },
{ from: 101, to: 200 },
overviewChunk(101, 200),
3
)
const { code, lines } = { ...(await run.exit), lines: run.lines }
assert.equal(code, 0, lines.join('\n'))
assert.isTrue(has(lines, 'caught up after 1 ranges'), lines.join('\n'))
assert.isTrue(has(lines, 'snapshot at 301'), lines.join('\n'))
assert.include(await range(rangeId), { status: 'done' })
assert.equal(Number((await range(rangeId)).headers), 300)
const l = await lease(groupId)
assert.isNull(l.node_id)
assert.equal(Number(l.snapshot_next), 301)
assert.equal(Number(l.ranges_done), 1)
// The window closed on the early files (the frontier is a day past them); every closed release is settled.
const releases = await db
.from('releases')
.select('name', 'settled_at', 'first_article', 'last_article', 'segments')
assert.isAtLeast(releases.length, 10)
assert.isTrue(releases.every((r) => r.settled_at !== null))
const spanning = releases.find((r) => Number(r.first_article) === 100)
assert.isDefined(spanning, await names())
assert.deepEqual(
[Number(spanning.first_article), Number(spanning.last_article), Number(spanning.segments)],
[100, 101, 2],
'the file over the chunk boundary came through the carried window'
)
})
test('a stream that fails: the batch so far is posted, the window snapshotted, the group given back; the next run restores it', async ({
assert,
}) => {
coordinatorConfig.streamChunkWaitMs = 1500
const { groupId, rangeId, token } = await setup()
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
const first = await runEdge(token)
assert.equal(first.code, 0, first.lines.join('\n'))
assert.isTrue(has(first.lines, 'the group goes back'), first.lines.join('\n'))
assert.isTrue(has(first.lines, 'snapshot at 101'), first.lines.join('\n'))
let r = await range(rangeId)
assert.include(r, { status: 'planned', node_id: null })
assert.equal(
Number(r.next_id),
101,
'the batch for chunk 1 was stored before the group went back'
)
let l = await lease(groupId)
assert.isNull(l.node_id)
assert.equal(Number(l.snapshot_next), 101)
const before = await db.from('releases').count('* as n').first()
assert.isAtLeast(Number(before!.n), 10)
assert.isNull(await releaseAt(100), 'the file over the boundary is still open in the window')
// The next run: the window restored, the rest of the range streamed, the open file completed.
coordinatorConfig.streamChunkWaitMs = saved.wait
await Spool.write(rangeId, { from: 101, to: 200 }, overviewChunk(101, 200))
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
const second = await runEdge(token)
assert.equal(second.code, 0, second.lines.join('\n'))
assert.isTrue(
has(second.lines, 'window restored from the snapshot at 101'),
second.lines.join('\n')
)
r = await range(rangeId)
assert.equal(r.status, 'done')
assert.equal(Number(r.headers), 300)
l = await lease(groupId)
assert.equal(Number(l.snapshot_next), 301)
const spanning = await releaseAt(100)
assert.isNotNull(spanning, await names())
assert.deepEqual(
[Number(spanning.first_article), Number(spanning.last_article), Number(spanning.segments)],
[100, 101, 2]
)
})
test('a restart whose snapshot file is gone: the window starts empty and the file over the seam ends incomplete', async ({
assert,
}) => {
coordinatorConfig.streamChunkWaitMs = 1500
const { groupId, rangeId, token } = await setup()
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
const first = await runEdge(token)
assert.isTrue(has(first.lines, 'snapshot at 101'), first.lines.join('\n'))
await rm(GroupLeases.snapshotPath(groupId), { force: true })
coordinatorConfig.streamChunkWaitMs = saved.wait
await Spool.write(rangeId, { from: 101, to: 200 }, overviewChunk(101, 200))
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
const second = await runEdge(token)
assert.equal(second.code, 0, second.lines.join('\n'))
assert.isTrue(
has(second.lines, 'but there is none; the window starts empty'),
second.lines.join('\n')
)
assert.equal((await range(rangeId)).status, 'done')
// Part 1 was in the lost window: one seam. Part 2 alone is a file short of a part.
assert.isNull(await releaseAt(100), await names())
const half = await releaseAt(101)
assert.isNotNull(half, await names())
assert.deepEqual([Number(half.segments), half.complete], [1, false], await names())
})
test("a takeover: the stale holder's next batch is refused, its snapshot too, and the group stays the new holder's", async ({
assert,
}) => {
const { groupId, rangeId, token, nodeId } = await setup()
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
const run = await startEdge(token)
// Chunk 1 posted; the stream now waits for chunk 2.
await waitFor('the first batch', async () => Number((await range(rangeId)).next_id) === 101)
// The holder looks dead (its group and range heartbeats stale): another node takes the group over (no snapshot
// yet: the range goes back to its resume point).
const stale = new Date(Date.now() - 24 * 3600_000)
await db.from('group_leases').where('group_id', groupId).update({ heartbeat_at: stale })
await db.from('ranges').where('id', rangeId).update({ heartbeat_at: stale })
const other = await Node.create({ name: 'e2e-other' })
const taken = await GroupLeases.lease(Number(other.id))
assert.equal(taken?.group.id, groupId)
assert.isNull(taken?.snapshot)
assert.include(await range(rangeId), { status: 'planned', node_id: null })
// The old holder gets chunk 2 all the same, posts, and is refused.
await Spool.write(rangeId, { from: 101, to: 200 }, overviewChunk(101, 200))
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
const { code } = await run.exit
const lines = run.lines
assert.equal(code, 0, lines.join('\n'))
assert.isTrue(has(lines, 'snapshot failed'), lines.join('\n'))
assert.isTrue(
has(lines, 'not leased to this node') || has(lines, 'no longer this node'),
lines.join('\n')
)
const l = await lease(groupId)
assert.equal(Number(l.node_id), Number(other.id))
assert.isNull(l.snapshot_next)
const r = await range(rangeId)
assert.equal(r.status, 'planned')
assert.equal(Number(r.next_id), 101, 'nothing past the first batch was stored')
assert.notEqual(Number(r.node_id), nodeId)
})
})
+149
View File
@@ -0,0 +1,149 @@
import { spawn } from 'node:child_process'
import { access } from 'node:fs/promises'
import { resolve } from 'node:path'
import { deflateSync } from 'node:zlib'
import env from '#start/env'
export const GROUP = 'alt.binaries.e2e'
/** The edge worker binary: EDGE_NODE_BIN, else the release or debug build under edge/target. */
export async function edgeBinary(): Promise<string> {
const candidates = [
process.env.EDGE_NODE_BIN,
'edge/target/release/edge-node',
'edge/target/debug/edge-node',
]
.filter((c): c is string => !!c)
.map((c) => resolve(c))
for (const path of candidates) {
try {
await access(path)
return path
} catch {}
}
throw new Error(
`No edge-node binary (looked at ${candidates.join(', ')}): build it with "cargo build --release --manifest-path edge/Cargo.toml --bin edge-node", or set EDGE_NODE_BIN`
)
}
/** The test server's address for the worker (tests/bootstrap.ts starts it on HOST:PORT). */
export function coordinatorUrl(): string {
const host = env.get('HOST', 'localhost')
return `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${env.get('PORT', 3333)}`
}
export interface EdgeRun {
exit: Promise<{ code: number | null; signal: string | null }>
lines: string[]
kill: () => void
}
/**
* The real edge worker against the test server: one thread on the history lane, one group, a batch per chunk
* (100 articles in these tests), a snapshot after every range.
*/
export async function startEdge(token: string, args: string[] = []): Promise<EdgeRun> {
const bin = await edgeBinary()
// The test server first (tests/bootstrap.ts starts it for this suite): a worker that can't connect retries for
// minutes before it says so.
const probe = await fetch(`${coordinatorUrl()}/api/v1/node/lease`, { method: 'POST' }).catch(
(e) => e as Error
)
if (probe instanceof Error || probe.status !== 401)
throw new Error(
`The test server doesn't answer at ${coordinatorUrl()}: ${probe instanceof Error ? probe.message : `HTTP ${probe.status}`}`
)
const child = spawn(
bin,
[
'--threads',
'1',
'--tail-threads',
'0',
'--groups',
'1',
'--batch-articles',
'100',
'--snapshot-minutes',
'0',
...args,
],
{
env: { ...process.env, COORDINATOR_URL: coordinatorUrl(), NODE_TOKEN: token },
stdio: ['ignore', 'pipe', 'pipe'],
}
)
const lines: string[] = []
let rest = ''
const collect = (chunk: Buffer) => {
rest += chunk.toString('utf8')
const parts = rest.split('\n')
rest = parts.pop() ?? ''
lines.push(...parts)
}
child.stdout.on('data', collect)
child.stderr.on('data', collect)
const exit = new Promise<{ code: number | null; signal: string | null }>((done) => {
child.on('exit', (code, signal) => {
if (rest) lines.push(rest)
done({ code, signal })
})
})
return { exit, lines, kill: () => child.kill('SIGKILL') }
}
/** Runs the worker to its end (at most `timeoutMs`), its log lines and exit code. */
export async function runEdge(token: string, args: string[] = [], timeoutMs = 60_000) {
const run = await startEdge(token, args)
const timer = setTimeout(() => run.kill(), timeoutMs)
const result = await run.exit
clearTimeout(timer)
return { ...result, lines: run.lines }
}
/** Polls `condition` every 100 ms until it holds or `timeoutMs` passed (then throws). */
export async function waitFor(what: string, condition: () => Promise<boolean>, timeoutMs = 30_000) {
const until = Date.now() + timeoutMs
while (Date.now() < until) {
if (await condition()) return
await new Promise((r) => setTimeout(r, 100))
}
throw new Error(`Timed out waiting for ${what}`)
}
const BASE_MS = Date.UTC(2026, 9, 5, 10)
/** A file name without digits (a number sequence would make an album of a poster's files): letters from `k`. */
export function nameOf(k: number): string {
let s = ''
let n = k
do {
s = String.fromCharCode(97 + (n % 26)) + s
n = Math.floor(n / 26)
} while (n > 0)
return `Set.${s}.mkv`
}
/**
* The overview lines of articles `from`..`to`, one an hour from 2026-10-05: article 1 is a one-part file, and from 2
* on each even article is part 1 of a two-part file whose part 2 is the next (odd) article — so a file spans every
* chunk boundary at an even `to` (100 and 101, 200 and 201: the window has to carry it). Dates an hour apart make
* the posting frontier move a day every 24 articles, so the window closes on files as it goes.
*/
export function overviewLines(from: number, to: number): string[] {
const lines: string[] = []
for (let n = from; n <= to; n++) {
const date = new Date(BASE_MS + n * 3600_000).toUTCString().replace(' GMT', ' +0000')
const [name, part] =
n === 1 ? [nameOf(1), '(1/1)'] : n % 2 === 0 ? [nameOf(n), '(1/2)'] : [nameOf(n - 1), '(2/2)']
lines.push(
`${n}\t"${name}" yEnc ${part}\tposter <p@x>\t${date}\t<a${n}@e2e>\t\t700000\t5000\tXref: news.example ${GROUP}:${n}`
)
}
return lines
}
/** One XOVER chunk as the news server sends it (deflated). */
export function overviewChunk(from: number, to: number): Buffer {
return deflateSync(Buffer.from(overviewLines(from, to).join('\r\n') + '\r\n'))
}
+489 -100
View File
@@ -2,19 +2,35 @@ import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import BatchStore, { StaleBatch } from '#services/batch_store'
import { RangeIndexer } from '#lib/edge/index'
import { decodeChunk } from '#lib/chunks/index'
import { RangeIndexer, type EdgeBatch, type ReleaseChunk } from '#lib/edge/index'
import { decodeChunk, encodeChunk } from '#lib/chunks/index'
import { DEFAULT_RULES } from '#lib/rules/index'
import type { Overview } from '#lib/nntp/index'
const GROUP = 'alt.binaries.test'
let id = 0
function post(at: number, name: string, parts: number, counter: string, tool: 'ngPost' | 'plain' = 'plain'): Overview[] {
function post(
at: number,
name: string,
parts: number,
counter: string,
tool: 'ngPost' | 'plain' = 'plain'
): Overview[] {
return Array.from({ length: parts }, (_, i) => {
id++
const mid = tool === 'ngPost' ? `${id.toString(16).padStart(32, '0')}@ngPost` : `art${id}@test`
return [at + i, { 'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000', 'message-id': `<${mid}>`, ':bytes': '700000', 'xref': `news.example ${GROUP}:${at + i}` }] as Overview
return [
at + i,
{
'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`,
'from': 'p <p@x>',
'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
'message-id': `<${mid}>`,
':bytes': '700000',
'xref': `news.example ${GROUP}:${at + i}`,
},
] as Overview
})
}
@@ -23,73 +39,224 @@ async function leasedRange() {
const [node] = await db.table('nodes').insert({ name: 'test-node' }).returning('id')
const [range] = await db
.table('ranges')
.insert({ group_id: group.id, start_id: 1, end_id: 1_000_000, next_id: 1, status: 'leased', node_id: node.id, rule_version: 1 })
.insert({
group_id: group.id,
start_id: 1,
end_id: 1_000_000,
next_id: 1,
status: 'leased',
node_id: node.id,
rule_version: 1,
})
.returning('id')
return { rangeId: Number(range.id), nodeId: Number(node.id) }
}
function indexer() {
return new RangeIndexer({ group: GROUP, start: 1, end: 1_000_000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } }, edgeArticles: 1000 })
return new RangeIndexer({
group: GROUP,
start: 1,
end: 1_000_000,
rules: {
...DEFAULT_RULES,
samplePercent: 0,
droppedSignatures: [],
priorities: { [GROUP]: 1 },
},
edgeArticles: 1000,
})
}
/** One file's articles, posted `minutes` apart from `start` (an ISO time), in `group`. */
function upload(at: number, name: string, parts: number, counter: string, start: string, group = GROUP, minutes = 1): Overview[] {
function upload(
at: number,
name: string,
parts: number,
counter: string,
start: string,
group = GROUP,
minutes = 1
): Overview[] {
return Array.from({ length: parts }, (_, i) => {
id++
const date = new Date(Date.parse(start) + i * minutes * 60_000).toUTCString().replace('GMT', '+0000')
return [at + i, { 'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`, 'from': 'p <p@x>', 'date': date, 'message-id': `<up${id}@test>`, ':bytes': '700000', 'xref': `news.example ${group}:${at + i}` }] as Overview
const date = new Date(Date.parse(start) + i * minutes * 60_000)
.toUTCString()
.replace('GMT', '+0000')
return [
at + i,
{
'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`,
'from': 'p <p@x>',
'date': date,
'message-id': `<up${id}@test>`,
':bytes': '700000',
'xref': `news.example ${group}:${at + i}`,
},
] as Overview
})
}
async function rangeIn(groupName: string, start: number, nodeId?: number) {
const g = (await db.from('groups').where('name', groupName).first()) ?? (await db.table('groups').insert({ name: groupName, priority: groupName === GROUP ? 1 : 2 }).returning('*'))[0]
const node = nodeId ?? Number((await db.table('nodes').insert({ name: `n${groupName}${start}` }).returning('id'))[0].id)
const [range] = await db.table('ranges').insert({ group_id: g.id, start_id: start, end_id: start + 99_999, next_id: start, status: 'leased', node_id: node, rule_version: 1 }).returning('id')
const g =
(await db.from('groups').where('name', groupName).first()) ??
(
await db
.table('groups')
.insert({ name: groupName, priority: groupName === GROUP ? 1 : 2 })
.returning('*')
)[0]
const node =
nodeId ??
Number(
(
await db
.table('nodes')
.insert({ name: `n${groupName}${start}` })
.returning('id')
)[0].id
)
const [range] = await db
.table('ranges')
.insert({
group_id: g.id,
start_id: start,
end_id: start + 99_999,
next_id: start,
status: 'leased',
node_id: node,
rule_version: 1,
})
.returning('id')
return { rangeId: Number(range.id), nodeId: node }
}
function batchOf(groupName: string, start: number, overviews: Overview[]): EdgeBatch {
const ix = new RangeIndexer({
group: groupName,
start,
end: start + 99_999,
rules: {
...DEFAULT_RULES,
samplePercent: 0,
droppedSignatures: [],
priorities: { [GROUP]: 1, 'alt.binaries.other': 2 },
},
edgeArticles: 1,
})
ix.add(overviews, start + 100_000)
ix.finish()
return ix.take()
}
async function storeIn(groupName: string, start: number, overviews: Overview[]) {
const { rangeId, nodeId } = await rangeIn(groupName, start)
const ix = new RangeIndexer({ group: groupName, start, end: start + 99_999, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1, 'alt.binaries.other': 2 } }, edgeArticles: 1 })
ix.add(overviews, start + 100_000)
ix.finish()
await BatchStore.store(rangeId, nodeId, ix.take())
return BatchStore.store(rangeId, nodeId, batchOf(groupName, start, overviews))
}
const releasesOf = () => db.from('releases').orderBy('id').select('release_key', 'identity_key', 'files', 'posted_at', 'last_posted_at')
const releasesOf = () =>
db
.from('releases')
.orderBy('id')
.select(
db.raw(
`encode(release_key, 'hex') AS release_key, encode(identity_key, 'hex') AS identity_key, files, chunks, posted_at, last_posted_at, settled_at`
)
)
test.group('BatchStore', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('stores releases, their chunks, probe candidates, rule counts and fragments, and moves the resume point', async ({ assert }) => {
test('stores releases by id references, their texts, chunks, probe candidates and rule counts, and moves the resume point', async ({
assert,
}) => {
const { rangeId, nodeId } = await leasedRange()
const ix = indexer()
ix.add([
...post(100_000, 'Some.Show.S01E01.1080p.WEB.part1.rar', 2, 'Some.Show.S01E01 [1/2]'),
...post(100_010, 'Some.Show.S01E01.1080p.WEB.par2', 1, 'Some.Show.S01E01 [2/2]'),
...post(100_020, 'Hs7dKq2LmZ9xWv3RtYp1Qa.part01.rar', 3, '[1/1]', 'ngPost'),
...post(10, 'Edge.Cut.2020.part1.rar', 3, 'Edge.Cut [1/1]').slice(1),
], 500_000)
ix.add(
[
...post(100_000, 'Some.Show.S01E01.1080p.WEB.part1.rar', 2, 'Some.Show.S01E01 [1/2]'),
...post(100_010, 'Some.Show.S01E01.1080p.WEB.par2', 1, 'Some.Show.S01E01 [2/2]'),
...post(100_020, 'Hs7dKq2LmZ9xWv3RtYp1Qa.part01.rar', 3, '[1/1]', 'ngPost'),
],
500_000
)
const first = ix.take()
const stored = await BatchStore.store(rangeId, nodeId, first)
assert.include(stored, { releases: 1, newReleases: 1, candidates: 2, fragments: 0 })
assert.deepEqual(stored, {
releases: 1,
newReleases: 1,
finalized: 0,
candidates: 2,
pending: 0,
})
const release = await db.from('releases').first()
assert.include(release, { files: 2, segments: '3', chunks: 1, readable: true, has_par2: true, file_total: 2 })
assert.include(release, {
files: 2,
segments: '3',
chunks: 1,
readable: true,
has_par2: true,
file_total: 2,
complete: true,
edge: false,
source: 1,
})
assert.isNotNull(release.settled_at)
// The poster and the groups by id, the keys 20 bytes, the name searchable.
assert.equal(
(await db.from('posters').where('id', release.poster_id).firstOrFail()).poster,
'p <p@x>'
)
assert.deepEqual(
(await db.from('group_sets').where('id', release.group_set_id).firstOrFail()).groups,
[GROUP]
)
assert.lengthOf(release.release_key, 20)
assert.lengthOf(release.identity_key, 20)
assert.deepEqual(await db.from('release_texts').select('release_id', 'kind', 'text'), [
{ release_id: release.id, kind: 1, text: 'Some Show S01E01' },
])
const chunk = await db.from('release_chunks').where('release_id', release.id).first()
assert.deepEqual(decodeChunk(chunk.payload).map((f) => f.name).sort(), ['Some.Show.S01E01.1080p.WEB.par2', 'Some.Show.S01E01.1080p.WEB.part1.rar'])
assert.deepEqual((await db.from('probe_candidates').orderBy('kind')).map((c) => c.kind), ['archive', 'par2'])
assert.deepEqual((await db.from('rule_counts')).map((c) => [c.rule, c.key, Number(c.articles)]), [['garbage', 'ngPost alt.binaries.test', 3]])
assert.equal(Number((await db.from('ranges').where('id', rangeId).first()).next_id), 500_000)
assert.deepEqual(
decodeChunk(chunk.payload)
.map((f) => f.name)
.sort(),
['Some.Show.S01E01.1080p.WEB.par2', 'Some.Show.S01E01.1080p.WEB.part1.rar']
)
// Claimable at once: the release is finished.
assert.deepEqual(
(await db.from('probe_candidates').orderBy('kind')).map((c) => [
c.kind,
c.ready,
c.unreadable,
]),
[
['archive', true, false],
['par2', true, false],
]
)
// Keyed by the range's block (a range without one is a block of its own, until blocks are leased).
const range = await db.from('ranges').where('id', rangeId).first()
assert.isNotNull(range.block_id)
assert.equal(Number(chunk.block_id), Number(range.block_id))
assert.deepEqual(
(await db.from('rule_counts')).map((c) => [
Number(c.block_id),
c.rule,
c.key,
Number(c.articles),
]),
[[Number(range.block_id), 'garbage', 'ngPost alt.binaries.test', 3]]
)
assert.equal(Number(range.next_id), 500_000)
// The same batch again (a retry after a lost reply) is refused, and changes nothing.
await assert.rejects(() => BatchStore.store(rangeId, nodeId, first), StaleBatch)
assert.equal((await db.from('releases').first()).files, 2)
// The end of the range: the cut-off file near the start edge is kept as a fragment.
// The end of the range.
ix.finish()
const last = ix.take()
assert.include(await BatchStore.store(rangeId, nodeId, last), { fragments: 1 })
await BatchStore.store(rangeId, nodeId, ix.take())
assert.equal(Number((await db.from('ranges').where('id', rangeId).first()).next_id), 1_000_001)
})
@@ -97,100 +264,317 @@ test.group('BatchStore', (group) => {
const { rangeId, nodeId } = await leasedRange()
const ix = indexer()
ix.add(post(100_000, 'A.Release.2020.mkv', 1, 'A [1/1]'), 200_000)
await assert.rejects(() => BatchStore.store(rangeId, nodeId + 1, ix.take()), /not leased to this node/)
await assert.rejects(
() => BatchStore.store(rangeId, nodeId + 1, ix.take()),
/not leased to this node/
)
})
test('a re-upload is a release of its own: in one range, or a later range; a continuation joins', async ({ assert }) => {
// One range: the same file posted Monday and again Thursday.
test('every chunk is a release of its own: a re-upload, and a part read by another range (the block merger joins those)', async ({
assert,
}) => {
// One range: the same file posted Monday and again Thursday are two uploads.
await storeIn(GROUP, 1, [
...upload(10, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/2]', '2026-10-05T10:00:00Z'),
...upload(20, 'Some.Show.S01E01.1080p.par2', 1, 'Some.Show.S01E01.1080p [2/2]', '2026-10-05T10:05:00Z'),
...upload(500, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/2]', '2026-10-08T10:00:00Z'),
...upload(
10,
'Some.Show.S01E01.1080p.mkv',
2,
'Some.Show.S01E01.1080p [1/2]',
'2026-10-05T10:00:00Z'
),
...upload(
20,
'Some.Show.S01E01.1080p.par2',
1,
'Some.Show.S01E01.1080p [2/2]',
'2026-10-05T10:05:00Z'
),
...upload(
500,
'Some.Show.S01E01.1080p.mkv',
2,
'Some.Show.S01E01.1080p [1/2]',
'2026-10-08T10:00:00Z'
),
])
let rows = await releasesOf()
assert.lengthOf(rows, 2)
assert.equal(rows[1].release_key, `${rows[0].release_key}:2`)
assert.deepEqual(rows.map((r) => [r.identity_key, r.files]), [[rows[0].release_key, 2], [rows[0].release_key, 1]])
assert.deepEqual(
rows.map((r) => r.files),
[2, 1]
)
assert.equal(rows[0].identity_key, rows[1].identity_key)
assert.notEqual(rows[0].release_key, rows[1].release_key)
// The next range: Thursday's par2 an hour later joins Thursday's release; a Saturday copy is a third.
await storeIn(GROUP, 100_001, upload(100_010, 'Some.Show.S01E01.1080p.par2', 1, 'Some.Show.S01E01.1080p [2/2]', '2026-10-08T11:00:00Z'))
await storeIn(GROUP, 200_001, upload(200_010, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/2]', '2026-10-10T10:00:00Z'))
// The next range: Thursday's par2 an hour later is written as a release of its own (its key: the identity, the
// group and its first article); the window keeps such parts together, and the block merger joins the edges.
await storeIn(
GROUP,
100_001,
upload(
100_010,
'Some.Show.S01E01.1080p.par2',
1,
'Some.Show.S01E01.1080p [2/2]',
'2026-10-08T11:00:00Z'
)
)
rows = await releasesOf()
assert.deepEqual(rows.map((r) => [r.release_key.split(':')[1] ?? '1', r.files]), [['1', 2], ['2', 2], ['3', 1]])
const chunks = await db.from('release_chunks').count('* as n').first()
assert.equal(Number(chunks!.n), 4)
assert.deepEqual(
rows.map((r) => r.files),
[2, 1, 1]
)
assert.lengthOf(new Set(rows.map((r) => r.release_key)), 3)
assert.lengthOf(new Set(rows.map((r) => r.identity_key)), 1)
assert.equal(Number((await db.from('release_chunks').count('* as n').first())!.n), 3)
})
test('candidates added to a settled release (a later range of its upload) are claimable at once', async ({ assert }) => {
await storeIn(GROUP, 1, upload(10, 'Some.Show.S01E02.1080p.mkv', 2, 'Some.Show.S01E02.1080p [1/2]', '2026-10-05T10:00:00Z'))
// Settled before the next range came (the settler marks only what it settles).
await db.from('releases').update({ settled_at: new Date() })
await db.from('probe_candidates').update({ ready: true })
await storeIn(GROUP, 100_001, upload(100_010, 'Some.Show.S01E02.1080p.par2', 1, 'Some.Show.S01E02.1080p [2/2]', '2026-10-05T10:20:00Z'))
const candidates = await db.from('probe_candidates').orderBy('id').select('kind', 'ready')
assert.deepEqual(candidates.map((c) => c.kind), ['par2'])
assert.isTrue(candidates.every((c) => c.ready))
assert.lengthOf(await releasesOf(), 1)
})
test('a provisional tail release is finished once by a later chunk of its key, which adds the files not sent before', async ({
assert,
}) => {
const { rangeId, nodeId } = await rangeIn(GROUP, 1)
const batch = batchOf(
GROUP,
1,
upload(
10,
'Some.Show.S01E02.1080p.mkv',
2,
'Some.Show.S01E02.1080p [1/2]',
'2026-10-05T10:00:00Z'
)
)
assert.lengthOf(batch.releases, 1)
const provisional: ReleaseChunk = { ...batch.releases[0], finished: false }
const stored = await BatchStore.store(rangeId, nodeId, {
...batch,
next: 50_000,
releases: [provisional],
})
assert.include(stored, { newReleases: 1, finalized: 0 })
let [row] = await releasesOf()
assert.isNull(row.settled_at)
assert.include(row, { files: 1, chunks: 1 })
test('a split 7z read in pieces keeps the tail of its highest volume only; RAR gets none', async ({ assert }) => {
// As the live tail reads it while posted: each piece queues a tail for the last volume it saw so far.
// The finish: the par2 that came within the window, under the same key (the identity, the group, the first
// article), with its probe candidate: claimable at once, the release being finished.
const second = batchOf(
GROUP,
1,
upload(
30,
'Some.Show.S01E02.1080p.par2',
1,
'Some.Show.S01E02.1080p [2/2]',
'2026-10-05T10:20:00Z'
)
)
const [par2] = second.releases
const finish: ReleaseChunk = {
...par2,
key: provisional.key,
upload: provisional.upload,
firstArticle: provisional.firstArticle,
finished: true,
}
const candidates = second.candidates.map((c) => ({
...c,
releaseKey: provisional.key,
upload: provisional.upload,
}))
assert.deepEqual(
candidates.map((c) => c.kind),
['par2']
)
const finished = await BatchStore.store(rangeId, nodeId, {
...batch,
from: 50_000,
next: 60_000,
releases: [finish],
candidates,
counts: [],
samples: [],
fragments: [],
})
assert.include(finished, { releases: 1, newReleases: 0, finalized: 1, candidates: 1 })
;[row] = await releasesOf()
assert.isNotNull(row.settled_at)
assert.include(row, { files: 2, chunks: 2 })
const release = await db.from('releases').firstOrFail()
assert.include(release, { complete: true, has_par2: true, last_article: '30' })
assert.deepEqual(await db.from('probe_candidates').select('kind', 'ready'), [
{ kind: 'par2', ready: true },
])
assert.deepEqual(
(await db.from('release_chunks').orderBy('id')).flatMap((c) =>
decodeChunk(c.payload).map((f) => f.name)
),
['Some.Show.S01E02.1080p.mkv', 'Some.Show.S01E02.1080p.par2']
)
// Sent once more, finished already: left as it is.
const again = await BatchStore.store(rangeId, nodeId, {
...batch,
from: 60_000,
next: 70_000,
releases: [{ ...finish, payload: encodeChunk([]), files: 0, segments: 0, bytes: 0 }],
candidates: [],
counts: [],
samples: [],
fragments: [],
})
assert.include(again, { releases: 1, newReleases: 0, finalized: 0 })
assert.include((await releasesOf())[0], { files: 2, chunks: 2 })
assert.equal(Number((await db.from('release_chunks').count('* as n').first())!.n), 2)
})
test('a split 7z keeps the tail of its highest volume only; RAR gets none', async ({
assert,
}) => {
await storeIn(GROUP, 1, [
...upload(10, 'TsIgNBewuFbuwWy.7z.001', 2, '[1/4]', '2026-10-23T08:24:00Z'),
...upload(20, 'TsIgNBewuFbuwWy.7z.002', 2, '[2/4]', '2026-10-23T08:26:00Z'),
...upload(10, 'Some.Show.S01E04.7z.001', 2, '[1/4]', '2026-10-23T08:24:00Z'),
...upload(20, 'Some.Show.S01E04.7z.002', 2, '[2/4]', '2026-10-23T08:26:00Z'),
...upload(30, 'Some.Show.S01E04.7z.003', 2, '[3/4]', '2026-10-23T08:28:00Z'),
...upload(40, 'Some.Show.S01E04.7z.004', 2, '[4/4]', '2026-10-23T08:30:00Z'),
...upload(
50,
'Some.Show.S01E03.part01.rar',
2,
'Some.Show.S01E03 [1/2]',
'2026-10-23T09:00:00Z'
),
...upload(
60,
'Some.Show.S01E03.part02.rar',
2,
'Some.Show.S01E03 [2/2]',
'2026-10-23T09:02:00Z'
),
])
await storeIn(GROUP, 100_001, upload(100_010, 'TsIgNBewuFbuwWy.7z.004', 2, '[4/4]', '2026-10-23T08:30:00Z'))
// A lower volume posted late: its tail goes at once.
await storeIn(GROUP, 200_001, upload(200_010, 'TsIgNBewuFbuwWy.7z.003', 2, '[3/4]', '2026-10-23T08:28:00Z'))
await storeIn(GROUP, 300_001, upload(300_010, 'Some.Show.S01E03.part01.rar', 2, 'Some.Show.S01E03 [1/2]', '2026-10-23T09:00:00Z'))
await storeIn(GROUP, 400_001, upload(400_010, 'Some.Show.S01E03.part02.rar', 2, 'Some.Show.S01E03 [2/2]', '2026-10-23T09:02:00Z'))
const candidates = await db.from('probe_candidates').orderBy('file').select('kind', 'file')
assert.deepEqual(
candidates.map((c) => [c.kind, c.file]),
[
['archive', 'Some.Show.S01E03.part01.rar'],
['archive', 'TsIgNBewuFbuwWy.7z.001'],
['7z-tail', 'TsIgNBewuFbuwWy.7z.004'],
['archive', 'Some.Show.S01E04.7z.001'],
['7z-tail', 'Some.Show.S01E04.7z.004'],
]
)
})
test('the same upload in another group (multi-posted) is a release of its own', async ({ assert }) => {
await storeIn(GROUP, 1, upload(10, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/1]', '2026-10-05T10:00:00Z'))
await storeIn('alt.binaries.other', 1, upload(10, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/1]', '2026-10-05T10:01:00Z', 'alt.binaries.other'))
test('the same upload in another group (multi-posted) is a release of its own', async ({
assert,
}) => {
await storeIn(
GROUP,
1,
upload(
10,
'Some.Show.S01E01.1080p.mkv',
2,
'Some.Show.S01E01.1080p [1/1]',
'2026-10-05T10:00:00Z'
)
)
await storeIn(
'alt.binaries.other',
1,
upload(
10,
'Some.Show.S01E01.1080p.mkv',
2,
'Some.Show.S01E01.1080p [1/1]',
'2026-10-05T10:01:00Z',
'alt.binaries.other'
)
)
const rows = await releasesOf()
assert.lengthOf(rows, 2)
assert.equal(rows[0].identity_key, rows[1].identity_key)
assert.notEqual(rows[0].release_key, rows[1].release_key)
})
test('an upload spread over groups (other files in another group) is one release', async ({ assert }) => {
await storeIn(GROUP, 1, upload(10, 'Some.Album-WEB-2026-FTD.part1.rar', 2, 'Some.Album-WEB-2026-FTD [2/3]', '2026-10-05T10:00:00Z'))
await storeIn('alt.binaries.other', 1, upload(10, 'Some.Album-WEB-2026-FTD.part2.rar', 2, 'Some.Album-WEB-2026-FTD [3/3]', '2026-10-05T10:00:30Z', 'alt.binaries.other'))
const rows = await releasesOf()
assert.lengthOf(rows, 1)
assert.equal(rows[0].files, 2)
test('an unnamed release goes to the pending store with its probe targets, not into the catalog', async ({
assert,
}) => {
const { rangeId, nodeId } = await rangeIn(GROUP, 1)
const batch = batchOf(GROUP, 1, [
...upload(10, 'q3B9OrpKiCxUnzgtUGnp.part01.rar', 4, '[1/2]', '2026-10-05T10:00:00Z'),
...upload(20, 'q3B9OrpKiCxUnzgtUGnp.par2', 1, '[2/2]', '2026-10-05T10:01:00Z'),
])
assert.lengthOf(batch.releases, 1)
assert.isFalse(batch.releases[0].readable)
const stored = await BatchStore.store(rangeId, nodeId, batch)
assert.include(stored, { releases: 1, newReleases: 0, candidates: 0, pending: 1 })
assert.equal(Number((await db.from('releases').count('* as n').first())!.n), 0)
assert.equal(Number((await db.from('probe_candidates').count('* as n').first())!.n), 0)
assert.equal(Number((await db.from('release_chunks').count('* as n').first())!.n), 0)
const pending = await db.from('pending_releases').firstOrFail()
assert.include(pending, {
files: 2,
segments: '5',
partial: false,
finished: true,
tool: 'other',
status: 0,
poster: 'p <p@x>',
})
assert.lengthOf(pending.release_key, 20)
assert.deepEqual(
pending.candidates.map((c: { kind: string; file: string }) => [c.kind, c.file]).sort(),
[
['archive', 'q3B9OrpKiCxUnzgtUGnp.part01.rar'],
['par2', 'q3B9OrpKiCxUnzgtUGnp.par2'],
]
)
// Sent again as a later chunk of a tail release: its counts add up while it is open, not once finished.
const again = await BatchStore.store(rangeId, nodeId, {
...batch,
from: batch.next,
next: batch.next + 1000,
releases: [{ ...batch.releases[0], files: 1, segments: 5, bytes: 100 }],
candidates: [],
})
assert.include(again, { pending: 0 })
assert.include(await db.from('pending_releases').firstOrFail(), { files: 2, segments: '5' })
})
test('a release key taken meanwhile by another group\'s writer is skipped for the next number', async ({ assert }) => {
await storeIn(GROUP, 1, upload(10, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/1]', '2026-10-05T10:00:00Z'))
const [first] = await releasesOf()
// As if another group's batch numbered the key at the same moment: key:2 exists, not counted for this key.
const [g] = await db.table('groups').insert({ name: 'alt.binaries.third', priority: 3 }).returning('id')
await db.table('releases').insert({ release_key: `${first.identity_key}:2`, identity_key: 'someone-else', group_id: g.id, name: 'x', source: 'subject', poster: 'p', groups: JSON.stringify([]), files: 0, segments: 0, bytes: 0, chunks: 0 })
await storeIn('alt.binaries.other', 1, upload(10, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/1]', '2026-10-05T10:01:00Z', 'alt.binaries.other'))
const keys = (await releasesOf()).filter((r) => r.identity_key === first.identity_key).map((r) => r.release_key)
assert.deepEqual(keys, [first.identity_key, `${first.identity_key}:3`])
})
test('a re-upload is numbered after the highest number, not into the holes merges left', async ({ assert }) => {
for (let day = 0; day < 4; day++) {
await storeIn(GROUP, 1 + day * 100_000, upload(10 + day * 100_000, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/1]', `2026-10-0${day * 2 + 1}T10:00:00Z`))
test("an album's members stay findable by their own names (release_texts kind member)", async ({
assert,
}) => {
const { rangeId, nodeId } = await rangeIn(GROUP, 1)
// Two scans the TypeScript indexer puts under their subject; the Rust edge's album of them is the same chunk
// with its name and source.
const batch = batchOf(GROUP, 1, [
...upload(10, 'Scan_001.dat', 1, 'Some Scans [1/2]', '2026-10-05T10:00:00Z'),
...upload(20, 'Scan_002.dat', 1, 'Some Scans [2/2]', '2026-10-05T10:01:00Z'),
])
assert.lengthOf(batch.releases, 1)
const album: ReleaseChunk = {
...batch.releases[0],
name: 'Scan_#.dat',
source: 'album',
fileTotal: null,
}
const [first] = await releasesOf()
// Merged away: key:2 and key:3 gone, key and key:4 left.
await db.from('releases').whereIn('release_key', [`${first.identity_key}:2`, `${first.identity_key}:3`]).delete()
await storeIn(GROUP, 400_001, upload(400_010, 'Some.Show.S01E01.1080p.mkv', 2, 'Some.Show.S01E01.1080p [1/1]', '2026-10-09T10:00:00Z'))
const keys = (await releasesOf()).map((r) => r.release_key.split(':')[1] ?? '1')
assert.deepEqual(keys, ['1', '4', '5'])
const stored = await BatchStore.store(rangeId, nodeId, { ...batch, releases: [album] })
assert.include(stored, { releases: 1, newReleases: 1 })
const release = await db.from('releases').firstOrFail()
assert.include(release, {
name: 'Scan_#.dat',
source: 6,
files: 2,
file_total: null,
complete: null,
})
assert.deepEqual(
(
await db.from('release_texts').where('release_id', release.id).orderBy(['kind', 'text'])
).map((t) => [t.kind, t.text]),
[
[1, 'Scan_#.dat'],
[6, 'Scan_001.dat'],
[6, 'Scan_002.dat'],
]
)
})
test('a small release of sidecars only is marked for the sidecar fold', async ({ assert }) => {
@@ -199,7 +583,12 @@ test.group('BatchStore', (group) => {
...upload(1_000_010, 'Some.Tool.v1.zip', 1, '[01/01]', '2026-10-05T10:00:00Z'),
...upload(1_000_020, 'Some.Show.S01E01.part01.rar', 2, '[02/30]', '2026-10-05T10:00:00Z'),
])
const flags = new Map((await db.from('releases').select('name', 'sidecar_only')).map((r) => [r.name, r.sidecar_only]))
const flags = new Map(
(await db.from('releases').select('name', 'sidecar_only')).map((r) => [
r.name,
r.sidecar_only,
])
)
assert.isTrue(flags.get('Murdoch Mysteries S8D1'))
assert.isFalse(flags.get('Some.Tool.v1'))
// Over 1 MB: not looked at.
@@ -1,95 +0,0 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import BatchStore from '#services/batch_store'
import RangeBlocks from '#services/range_blocks'
import SearchService from '#services/search_service'
import { RangeIndexer, postedSpan } from '#lib/edge/index'
import { DEFAULT_RULES } from '#lib/rules/index'
import type { Overview } from '#lib/nntp/index'
const GROUP = 'alt.binaries.final'
const at = (iso: string) => new Date(iso)
test.group('Final completeness', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test("a batch's posting times, without the earliest and latest 1%, become the range's span", async ({ assert }) => {
assert.deepEqual(postedSpan([]), [null, null])
const dates = Array.from({ length: 200 }, (_, i) => 1_000_000 + i)
dates.push(5, 9_999_999_999)
// 202 dates: 2 trimmed at each end, so the two wild ones go.
assert.deepEqual(postedSpan(dates), [1_000_001, 1_000_198])
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
const [node] = await db.table('nodes').insert({ name: 'final-node' }).returning('id')
const [range] = await db
.table('ranges')
.insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: node.id, rule_version: 1 })
.returning('id')
const overviews = Array.from({ length: 10 }, (_, i) => {
const date = new Date(Date.parse('2026-01-01T10:00:00Z') + i * 60_000).toUTCString().replace('GMT', '+0000')
return [1 + i, { 'subject': `[1/1] - "Some.Thing.2026.rar" yEnc (${i + 1}/10)`, 'from': 'p <p@x>', 'date': date, 'message-id': `<f${i}@x>`, ':bytes': '1000', 'xref': `x ${GROUP}:${1 + i}` }] as Overview
})
const ix = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
ix.add(overviews, 11)
await BatchStore.store(Number(range.id), Number(node.id), ix.take())
const stored = await db.from('ranges').where('id', range.id).firstOrFail()
assert.equal(new Date(stored.posted_low).toISOString(), '2026-01-01T10:00:00.000Z')
assert.equal(new Date(stored.posted_high).toISOString(), '2026-01-01T10:09:00.000Z')
})
test('a release is final once one block of done ranges covers its posting times and 6 hours either side', async ({ assert }) => {
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
const range = (start: number, status: string, low: string | null, high: string | null) =>
db.table('ranges').insert({ group_id: g.id, start_id: start, end_id: start + 99_999, next_id: start, status, posted_low: low && at(low), posted_high: high && at(high) })
await range(1, 'done', '2026-01-01T00:00:00Z', '2026-01-01T12:00:00Z')
await range(100_001, 'done', '2026-01-01T12:00:00Z', '2026-01-01T23:00:00Z')
await range(200_001, 'planned', null, null)
await range(300_001, 'done', '2026-01-02T10:00:00Z', '2026-01-02T20:00:00Z')
assert.equal(await RangeBlocks.rebuild(), 2)
let n = 0
const release = async (first: number, posted: string, extra: Record<string, unknown> = {}) => {
n++
const key = `fc${n}`.padEnd(40, '0')
const [row] = await db
.table('releases')
.insert({ release_key: key, identity_key: key, name: `Final.Test.${n}`, source: 'stem', groups: '[]', group_id: g.id, files: 3, file_total: 5, complete: false, first_article: first, last_article: first + 10, posted_at: at(posted), last_posted_at: at(posted), readable: true, settled_at: new Date(), ...extra })
.returning('id')
return Number(row.id)
}
const final = async (id: number) => {
const r = await db.from('releases').where('id', id).firstOrFail()
const { rows } = await db.rawQuery(`SELECT release_final(?, ?, ?, ?, ?, ?) AS final`, [r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete])
return rows[0].final as boolean
}
// Mid-block: final either way.
const middle = await release(90_000, '2026-01-01T11:00:00Z')
// In the first range: nothing older to wait for, although 6 hours before it isn't covered.
const first = await release(1_000, '2026-01-01T02:00:00Z')
// Near the block's end, the next range unread: its missing files may be there.
const edge = await release(195_000, '2026-01-01T22:00:00Z')
// In the island after the gap: the gap is before it.
const island = await release(310_000, '2026-01-02T12:00:00Z')
assert.isTrue(await final(middle))
assert.isTrue(await final(first))
assert.isFalse(await final(edge))
// Complete there: every file found, nothing to wait for.
assert.isTrue(await final(await release(196_000, '2026-01-01T22:30:00Z', { files: 5, complete: true, name: 'Complete.Edge' })))
assert.isFalse(await final(island))
// Search: an incomplete release that isn't final isn't left out as incomplete; the API says which is final.
const shown = await SearchService.search({ q: 'Final.Test', notIncomplete: true })
assert.sameMembers(shown.releases.map((r) => r.id), [edge, island])
assert.isTrue(shown.releases.every((r) => r.final === false))
// The gap read: one block, the edge release final (the island's 12:00 + 6 h is inside 20:00).
await db.from('ranges').where('start_id', 200_001).update({ status: 'done', posted_low: at('2026-01-01T23:00:00Z'), posted_high: at('2026-01-02T10:00:00Z') })
assert.equal(await RangeBlocks.rebuild(), 1)
assert.isTrue(await final(edge))
assert.isTrue(await final(island))
assert.equal((await SearchService.search({ q: 'Final.Test', notIncomplete: true })).total, 0)
})
})
-65
View File
@@ -1,65 +0,0 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import BatchStore from '#services/batch_store'
import FragmentMerger from '#services/fragment_merger'
import { RangeIndexer } from '#lib/edge/index'
import { decodeChunk } from '#lib/chunks/index'
import { DEFAULT_RULES } from '#lib/rules/index'
import type { Overview } from '#lib/nntp/index'
const GROUP = 'alt.binaries.test'
const rules = { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } }
function parts(name: string, total: number, numbers: number[], at: number): Overview[] {
return numbers.map((n, i) => [at + i, {
'subject': `Big.Upload.2020 [1/1] - "${name}" yEnc (${n}/${total})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
'message-id': `<${name}-${n}@test>`, ':bytes': '700000', 'xref': `news.example ${GROUP}:${at + i}`,
}] as Overview)
}
/** Indexes one range's overviews and stores them as the range's only batch; the range ends done. */
async function indexRange(groupId: number, nodeId: number, start: number, end: number, overviews: Overview[]) {
const [r] = await db.table('ranges').insert({ group_id: groupId, start_id: start, end_id: end, next_id: start, status: 'leased', node_id: nodeId }).returning('id')
const indexer = new RangeIndexer({ group: GROUP, start, end, rules, edgeArticles: 100 })
indexer.add(overviews, end + 1)
indexer.finish()
await BatchStore.store(Number(r.id), nodeId, indexer.take())
await db.from('ranges').where('id', r.id).update({ status: 'done' })
return Number(r.id)
}
test.group('FragmentMerger', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('a file cut by a range edge is completed from both sides once both ranges are done', async ({ assert }) => {
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1, low: 1 }).returning('id')
const [n] = await db.table('nodes').insert({ name: 'merge-node' }).returning('id')
// Parts 1–2 at the end of the first range (still open there), 3–4 at the start of the next.
await indexRange(g.id, n.id, 1, 1000, parts('big.part1.rar', 4, [1, 2], 990))
assert.equal((await db.from('fragments').count('* as c'))[0].c, '1')
assert.include(await FragmentMerger.run(), { completed: 0, incomplete: 0, waiting: 1 })
await indexRange(g.id, n.id, 1001, 2000, parts('big.part1.rar', 4, [3, 4], 1001))
assert.include(await FragmentMerger.run(), { completed: 1, incomplete: 0 })
assert.equal((await db.from('fragments').count('* as c'))[0].c, '0')
const release = await db.from('releases').first()
assert.include(release, { files: 1, chunks: 1 })
const chunk = await db.from('release_chunks').where('release_id', release.id).first()
assert.deepEqual(decodeChunk(chunk.payload)[0].segments.map((s) => s[0]), [1, 2, 3, 4])
assert.deepEqual((await db.from('probe_candidates')).map((c) => c.kind), ['archive'])
})
test('a file still missing parts when all its neighbours are done is dropped', async ({ assert }) => {
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1, low: 1 }).returning('id')
const [n] = await db.table('nodes').insert({ name: 'merge-node' }).returning('id')
await indexRange(g.id, n.id, 1, 1000, parts('gap.part1.rar', 5, [1, 2], 990))
await indexRange(g.id, n.id, 1001, 2000, parts('gap.part1.rar', 5, [4, 5], 1001))
// Its later neighbour isn't there yet: the missing part could still come.
assert.include(await FragmentMerger.run(), { completed: 0, incomplete: 0, waiting: 1 })
await indexRange(g.id, n.id, 2001, 3000, [])
assert.include(await FragmentMerger.run(), { completed: 0, incomplete: 1 })
assert.equal((await db.from('fragments').count('* as c'))[0].c, '0')
assert.lengthOf(await db.from('releases'), 0)
})
})
+223
View File
@@ -0,0 +1,223 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import coordinatorConfig from '#config/coordinator'
import GroupLeases from '#services/group_leases'
import Leases, { LeaseLost } from '#services/leases'
import { Readable } from 'node:stream'
async function node(name: string) {
return Number((await db.table('nodes').insert({ name }).returning('id'))[0].id)
}
async function group(name: string, priority: number) {
return Number((await db.table('groups').insert({ name, priority }).returning('id'))[0].id)
}
async function range(groupId: number, start: number, priority: number, extra: Record<string, unknown> = {}) {
const [row] = await db
.table('ranges')
.insert({ group_id: groupId, start_id: start, end_id: start + 99, next_id: start, priority, ...extra })
.returning('id')
return Number(row.id)
}
const rangeRow = async (id: number) => (await db.from('ranges').where('id', id).firstOrFail()) as any
test.group('Group leases', (g) => {
let dir = ''
let snapshotDir = ''
g.each.setup(async () => {
const rollback = await testUtils.db().withGlobalTransaction()
dir = await mkdtemp(join(tmpdir(), 'snapshots-'))
snapshotDir = coordinatorConfig.snapshotDir
coordinatorConfig.snapshotDir = dir
return rollback
})
g.each.teardown(async () => {
coordinatorConfig.snapshotDir = snapshotDir
await rm(dir, { recursive: true, force: true })
})
test('a lease takes the group with the most urgent planned range of its lane; its ranges come in article order; caught up it gives nothing', async ({
assert,
}) => {
const a = await group('alt.binaries.gl.a', 1)
const b = await group('alt.binaries.gl.b', 2)
// Group a: history at priority 0 and 1; group b: a tail range (priority -3) and history.
const a1 = await range(a, 1, 0)
const a2 = await range(a, 101, 0)
const a3 = await range(a, 201, 1)
const b2 = await range(b, 101, 0)
const b1 = await range(b, 1, -3)
const n1 = await node('gl-1')
const n2 = await node('gl-2')
// The history lane: both groups have priority 0 work; a has the most planned articles (300 to 100).
const first = await GroupLeases.lease(n1)
assert.equal(first?.group.id, a, 'the most planned articles first')
assert.equal(first?.lane, 'history')
assert.isNull(first?.snapshot)
// Held: the next node gets the other group; a third gets nothing.
const second = await GroupLeases.lease(n2)
assert.equal(second?.group.id, b)
assert.isNull(await GroupLeases.lease(await node('gl-3')))
// Ranges in article order, whatever their priority; the tail range isn't the history lane's.
assert.deepEqual((await GroupLeases.next(b, n2))?.range, { id: b2, start: 101, end: 200, next: 101 })
assert.include(await rangeRow(b2), { status: 'leased', node_id: n2 })
// Asked again before finishing: the same range.
assert.equal((await GroupLeases.next(b, n2))?.range.id, b2)
// A chunk of b2 that doesn't inflate, reported after the batch before it: the range is asked for again and
// comes back at its resume point (the chunk fetched plain next time).
await db.from('ranges').where('id', b2).update({ next_id: 151 })
await Leases.corrupt(b2, n2, 151)
assert.include(await rangeRow(b2), { status: 'planned', node_id: null })
assert.deepEqual((await GroupLeases.next(b, n2))?.range, { id: b2, start: 101, end: 200, next: 151 })
assert.deepEqual((await rangeRow(b2)).plain_chunks.map(Number), [151])
await db.from('ranges').where('id', b2).update({ status: 'done', next_id: 201 })
assert.isNull(await GroupLeases.next(b, n2), 'caught up: the tail range is the other lane\'s')
assert.include(await rangeRow(b1), { status: 'planned' })
// The tail lane: group b's tail, held by a third node while n2 holds b's history.
const n3 = await node('gl-3b')
const tail = await GroupLeases.lease(n3, 'tail')
assert.equal(tail?.group.id, b)
assert.equal(tail?.lane, 'tail')
assert.isNull(await GroupLeases.lease(await node('gl-3c'), 'tail'), 'no other group has tail work')
assert.deepEqual((await GroupLeases.next(b, n3, 'tail'))?.range, { id: b1, start: 1, end: 100, next: 1 })
// The history lane is n2's.
await assert.rejects(() => GroupLeases.next(b, n3), LeaseLost)
await db.from('ranges').where('id', b1).update({ status: 'done', next_id: 101 })
assert.isNull(await GroupLeases.next(b, n3, 'tail'), 'the tail lane is caught up')
// Each lane its own snapshot.
await GroupLeases.putSnapshot(b, n3, 101, Readable.from([Buffer.from('tail at 101')]), 'tail')
await GroupLeases.putSnapshot(b, n2, 201, Readable.from([Buffer.from('history at 201')]))
assert.equal((await GroupLeases.getSnapshot(b, n3, 'tail'))?.next, 101)
assert.equal((await GroupLeases.getSnapshot(b, n2))?.next, 201)
await GroupLeases.release(b, n3, 'tail')
assert.include(await GroupLeases.held(b, n2), { node_id: n2 })
await assert.rejects(() => GroupLeases.held(b, n3, 'tail'), LeaseLost)
assert.deepEqual([a1, a2, a3].length, 3)
await assert.rejects(() => GroupLeases.next(a, n2), LeaseLost)
})
test('the order mixes the group with the most planned articles and the longest unheld one', async ({ assert }) => {
// Four groups with priority 0 work: c was held before (5000 articles left), a, b and d never (100, 1000, 50).
const a = await group('alt.binaries.gl.o.a', 1)
const b = await group('alt.binaries.gl.o.b', 2)
const c = await group('alt.binaries.gl.o.c', 3)
const d = await group('alt.binaries.gl.o.d', 4)
await range(a, 1, 0)
for (let i = 0; i < 10; i++) await range(b, 1 + 100 * i, 0)
for (let i = 0; i < 50; i++) await range(c, 1 + 100 * i, 0)
await db.table('ranges').insert({ group_id: d, start_id: 1, end_id: 50, next_id: 1, priority: 0 })
await db.table('group_leases').insert({ group_id: c, lane: 'history', node_id: null, heartbeat_at: new Date(Date.now() - 3600_000) })
// By articles c, b, a, d; by age a, b, d (never held, by group priority), c. The leases alternate which
// ranking goes first: c, a, b, d (or a, c, b, d when the sequence stands on the other parity).
await db.rawQuery(`SELECT setval('group_lease_turns', 2)`)
const order = []
for (const name of ['o1', 'o2', 'o3', 'o4']) order.push((await GroupLeases.lease(await node(name)))?.group.id)
assert.deepEqual(order, [c, a, b, d])
})
test('a stale holder is taken over and the ranges after the snapshot are read again; a release keeps the resume point', async ({
assert,
}) => {
const a = await group('alt.binaries.gl.c', 1)
const r1 = await range(a, 1, 0)
const r2 = await range(a, 101, 0)
const r3 = await range(a, 201, 0)
const r4 = await range(a, 301, 0)
const n1 = await node('gl-4')
const n2 = await node('gl-5')
assert.equal((await GroupLeases.lease(n1))?.group.id, a)
// The worker did r1 and r2, snapshotted at 201 (r3's start), then got halfway through r3 and went quiet.
await db.from('ranges').whereIn('id', [r1, r2]).update({ status: 'done', next_id: db.raw('end_id + 1') })
await GroupLeases.putSnapshot(a, n1, 201, Readable.from([Buffer.from('window at 201')]))
await GroupLeases.next(a, n1)
await db.from('ranges').where('id', r3).update({ next_id: 251 })
await db.from('group_leases').where('group_id', a).update({ heartbeat_at: new Date(Date.now() - 24 * 3600_000) })
const lease = await GroupLeases.lease(n2)
assert.equal(lease?.group.id, a)
assert.deepEqual(lease?.snapshot, { next: 201 })
// r1 and r2 stay done (before the snapshot); r3 is read again from the snapshot's article, r4 untouched.
assert.include(await rangeRow(r1), { status: 'done' })
assert.include(await rangeRow(r2), { status: 'done' })
assert.include(await rangeRow(r3), { status: 'planned', node_id: null })
assert.equal(Number((await rangeRow(r3)).next_id), 201)
assert.include(await rangeRow(r4), { status: 'planned' })
const snapshot = await GroupLeases.getSnapshot(a, n2)
assert.equal(snapshot?.next, 201)
assert.equal(snapshot?.size, 'window at 201'.length)
await assert.rejects(() => GroupLeases.next(a, n1), LeaseLost)
// The new holder works r3, snapshots at 301 and gives the group back: r3 done, r4 waits at its start.
assert.equal((await GroupLeases.next(a, n2))?.range.id, r3)
await db.from('ranges').where('id', r3).update({ status: 'done', next_id: 301 })
await GroupLeases.putSnapshot(a, n2, 301, Readable.from([Buffer.from('window at 301')]))
assert.equal((await GroupLeases.next(a, n2))?.range.id, r4)
await db.from('ranges').where('id', r4).update({ next_id: 301 })
await GroupLeases.release(a, n2)
assert.include(await rangeRow(r4), { status: 'planned', node_id: null })
assert.isNull((await db.from('group_leases').where('group_id', a).firstOrFail()).node_id)
// Leased again at once (free, not stale): the snapshot at 301 covers everything done.
const again = await GroupLeases.lease(n1)
assert.deepEqual(again?.snapshot, { next: 301 })
assert.include(await rangeRow(r3), { status: 'done' })
assert.equal((await GroupLeases.next(a, n1))?.range.id, r4)
})
test('capture only: the history ranges sharing a window priority are neither leased nor handed out', async ({
assert,
}) => {
// Group e: history at priority 0 only (the planner's oldest bucket); group f: a 2016 window range (capture)
// between two history ranges at 0.
const e = await group('alt.binaries.gl.e', 1)
await range(e, 1, 0)
const f = await group('alt.binaries.gl.f', 2)
await range(f, 1, 0)
const f2 = await range(f, 101, 0, { capture: true })
await range(f, 201, 0)
const n1 = await node('gl-8')
const was = coordinatorConfig.leaseCaptureOnly
coordinatorConfig.leaseCaptureOnly = true
try {
assert.equal((await GroupLeases.lease(n1))?.group.id, f, 'the group with a capture range, not the older one')
assert.equal((await GroupLeases.next(f, n1))?.range.id, f2)
await db.from('ranges').where('id', f2).update({ status: 'done', next_id: 201 })
assert.isNull(await GroupLeases.next(f, n1), 'the history range after the window is not handed out')
assert.isNull(await GroupLeases.lease(await node('gl-9')))
} finally {
coordinatorConfig.leaseCaptureOnly = was
}
assert.equal((await GroupLeases.lease(await node('gl-10')))?.group.id, e, 'any range again without the flag')
})
test('a snapshot older than a done range makes that range be read again from its start', async ({ assert }) => {
const a = await group('alt.binaries.gl.d', 1)
const r1 = await range(a, 1, 0)
const r2 = await range(a, 101, 0)
const n1 = await node('gl-6')
await GroupLeases.lease(n1)
// A range after the snapshot's article done long before the snapshot was taken (the old range leases did it):
// not the window's, it stays done.
const r3 = await range(a, 201, 0)
await db.from('ranges').where('id', r3).update({ status: 'done', next_id: 301, done_at: new Date(Date.now() - 48 * 3600_000) })
// The worker snapshotted at the start, finished r1, and crashed before its next snapshot: r1 is read again.
await GroupLeases.putSnapshot(a, n1, 1, Readable.from([Buffer.from('window at 1')]))
await db.from('ranges').where('id', r1).update({ status: 'done', next_id: 101, done_at: new Date() })
await db.from('group_leases').where('group_id', a).update({ heartbeat_at: new Date(Date.now() - 24 * 3600_000) })
await GroupLeases.lease(await node('gl-7'))
assert.equal((await rangeRow(r1)).status, 'planned')
assert.equal(Number((await rangeRow(r1)).next_id), 1)
assert.equal((await rangeRow(r2)).status, 'planned')
assert.equal(Number((await rangeRow(r2)).next_id), 101)
assert.equal((await rangeRow(r3)).status, 'done')
})
})
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more