146 Commits
Author SHA1 Message Date
Daan MeijerandClaude Opus 5.5 1322e42fb7 Accounts with limits: a Newznab key and a tier per user, daily API and NZB counts, invites
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m44s
Test and deploy / deploy (push) Skipped
ACCESS=accounts (config/access.ts; open stays the default and behaves as
before): Newznab and the catalog's releases routes name a user (apikey or
the web interface's token), whose tier sets searches and NZBs per UTC day
(Newznab 500/501, the catalog 429) and how far back they see (102/403).
Counted in one upsert per use, so the last one can't be taken twice;
caps, stats and refused requests aren't counted. Signup takes an invite
(SIGNUP), which may give a tier; ace access:invite, access:tier (extends a
running tier), access:suspend. The web interface signs in and shows the
day's usage and the Newznab key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:16:45 +02:00
Daan MeijerandClaude Opus 5.5 c55918350e Poster search per poster found (LATERAL on releases_poster_newest_idx), at most 200 posters
Test and deploy / test (push) Successful in 52s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Successful in 2m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:58:26 +02:00
Daan MeijerandClaude Opus 5.5 c0e00f1707 Poster search through a posters table and releases_poster_newest_idx; complete releases are final; the tail before everything (-3)
Test and deploy / test (push) Successful in 56s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 5m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:25:30 +02:00
Daan MeijerandClaude Opus 5.5 c9a9c0155e Postgres memory for the freed RAM: shared_buffers 8GB, effective_cache_size 32GB, random_page_cost 1.1, work_mem 16MB
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 2m6s
Test and deploy / deploy (push) Successful in 2m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:57:12 +02:00
Daan MeijerandClaude Opus 5.5 aa3bd82445 At most two leases per group: a group's batches wait for each other, more workers only queue
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 2m4s
Test and deploy / deploy (push) Successful in 3m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:28:42 +02:00
Daan MeijerandClaude Opus 5.5 b73e91219e The judge's unusable waits for final: next to an unread range a release's par2 or files may still come
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 2m13s
Test and deploy / deploy (push) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:06:13 +02:00
Daan MeijerandClaude Opus 5.5 ec2e5a60be Settle every 5 min in rounds of 200k, not ~3M at once hourly; range blocks in a loop of their own
Test and deploy / test (push) Successful in 1m0s
Test and deploy / edge (push) Successful in 2m1s
Test and deploy / deploy (push) Successful in 3m48s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:56:27 +02:00
Daan MeijerandClaude Opus 5.5 ce43099188 Final completeness by time window: ranges keep their posting span, release_final() over blocks of done ranges
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 2m4s
Test and deploy / deploy (push) Successful in 8m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:19:03 +02:00
Daan MeijerandClaude Opus 5.5 3d24db5565 Rules drop posters at the header pass: the CelebMovie family (rule set version 3)
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 1m55s
Test and deploy / deploy (push) Successful in 1m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:36:46 +02:00
Daan MeijerandClaude Opus 5.5 093d0a71ad The judge hides scraped celebrity pictures; no ? in its patterns (a raw query takes it for a binding)
Test and deploy / test (push) Successful in 52s
Test and deploy / edge (push) Successful in 2m0s
Test and deploy / deploy (push) Successful in 2m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:01:07 +02:00
Daan MeijerandClaude Opus 5.5 7d9a81784e A folder's images posted with their sizes are one release: no image set under the subject
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:13:20 +02:00
Daan MeijerandClaude Opus 5.5 e154b2896f par2_files back on the NVMe: the merger reads it per set, a random read per row on the HDD
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 1m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:22:37 +02:00
Daan MeijerandClaude Opus 5.5 1a20fcd2c3 Prune par2_files of complete and judged sets (hourly, from the merger)
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 1m55s
Test and deploy / deploy (push) Successful in 3m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:59:40 +02:00
Daan MeijerandClaude Opus 5.5 dd35b49a3f Nightly pause (23:45) and resume (07:45); a deploy while paused leaves the workers stopped
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:13:21 +02:00
Daan MeijerandClaude Opus 5.5 59c93071c7 The sidecar fold every 5 min inside a long par2 pass too
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m50s
Test and deploy / deploy (push) Successful in 2m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:48:30 +02:00
Daan MeijerandClaude Opus 5.5 2e25b7c56e The sidecar fold first in a merger round: a queue, it needn't wait for the par2 pass
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 2m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:37:53 +02:00
Daan MeijerandClaude Opus 5.5 e627862a8d The merger keeps its place over a restart (worker_cursors), not from 1970 each deploy
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 2m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:45:25 +02:00
Daan MeijerandClaude Opus 5.5 46525f4595 fragments back on the NVMe: the fragment merger reads every row, not by key
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 2m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:46:14 +02:00
Daan MeijerandClaude Opus 5.5 329c7a1373 Fold sidecars (.nfo, .sfv, .nzb) apart from their upload into it
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 2m14s
"Murdoch Mysteries S8D1 [00/80] - \"….nzb\"" beside its "[01/94]" upload
was a release of its own (23k sidecar-only releases on the corpus, 15k with
the poster's bigger release within 10 minutes). The settler queues small
settled releases (fold_candidates); the merger's third pass folds one whose
files are all sidecars into the poster's bigger release in the same group
within 10 minutes: the one sharing a 4+ letter word with its subject, else
the one under the same counter total, else none (via 'sidecar').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:10:48 +02:00
Daan MeijerandClaude Opus 5.5 5897d595cf The junk judge: releases judged by what their probes showed; verdicts hide them
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m53s
Test and deploy / deploy (push) Successful in 3m29s
Voyager 1's second filter (D42) for voyager 2. Hourly, in order (the first
rule wins): dead (every probed first article gone), fake (a film, episode or
music name whose content is a program: archive entries all executables, or
the file itself by name or sniffed exe), repeated (the same first 16 KiB and
size in 20+ releases), locked (passworded, no password known), unusable
(random names, no inner name, no merge, no spot, a day after settling).
Verdicts in release_verdicts (rule, version) hide releases from search
(judged shows them); a verdict whose rule no longer matches is taken back.
Nothing dropped yet. Service judge; releases:judge --once --dry counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:45:08 +02:00
Daan MeijerandClaude Opus 5.5 99b72ddd8f Sniff Windows executables (MZ with its PE header)
For the junk judge: a "movie" whose first bytes are a program is fake.
Two bytes alone match random data too often, so the PE signature where the
DOS header points must be there too. No PROBE_VERSION bump: new probes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:42:29 +02:00
Daan MeijerandClaude Opus 5.5 c65282ec5d par2_files, fragments and probe_cache with the chunks (CHUNK_TABLESPACE)
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 1m48s
Test and deploy / deploy (push) Successful in 2m49s
~20 GB of the NVMe, read by key only; the catalog was at its cap and history
paused. Moved on homelab by hand (3 min, writers stopped; the cap raised to
100); the migration does the same on a new deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:11:36 +02:00
Daan MeijerandClaude Opus 5.5 377decaf3d release_chunks' indexes with its chunks (CHUNK_TABLESPACE)
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 2m8s
Test and deploy / deploy (push) Successful in 2m7s
The October partition's three indexes took 2.9 GB of the NVMe, its pkey
never scanned; chunks are read from the HDD anyway. Moved on homelab by hand
(12 s); the migration does the same on a new deploy, parent indexes too so new
partitions follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:27:58 +02:00
Daan MeijerandClaude Opus 5.5 256fb24c46 A cap on the catalog's NVMe size: above it only the tail and the stored ranges
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 1m20s
After reset 24 the catalog grew ~3.7 GB an hour (77 GB, root 31 GB free, 600k
history ranges to go). CATALOG_MAX_NVME_GB (as voyager 1's
BACKFILL_MAX_NVME_GB): once the default tablespace is over it, leases and the
prefetcher take priority < 0 only (the tail, the stored ranges); measured once
a minute. Off when unset; 90 on homelab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:10:11 +02:00
Daan MeijerandClaude Opus 5.5 eddbe2863f Take the padded volume back out: it merged a dump's versions
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Successful in 2m34s
208ca1d's volume of 3-5 digits after a digit (YME's "CHT PCK 2 9.26.001")
also took versions for volumes: "K-ML 3.8.233", "3.8.239", "3.8.241" and
Kaizen's "v2.0.1021" ... "v2.0.1057" became one release (505 names on the
corpus). The random name's volumes with their own extensions stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:11:03 +02:00
Daan MeijerandClaude Opus 5.5 208ca1d535 A random name's volumes with extensions of their own; a padded volume after a date
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 1m31s
- "df2BkeRxIvHcWrsWwwz.part01.cer" … ".part69.bdmv", "jA0eSSUMat8tQtIKUyPMA.part01":
  obfuscators give each volume its own extension (or none); for a random name
  (no dots or spaces) .partNN and that extension go.
- "(????) [0001/1009] - "CHT PCK 2 9.26.001"": a volume of 3-5 digits after a
  dot counts with a digit before it too (5,419 single files of YME).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:10:16 +02:00
Daan MeijerandClaude Opus 5.5 c624a624df Take the image set head back out of the subject key
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 2m13s
f1c5af5's set head (an image's name up to its first number) split sets whose
images aren't named alike: "a1.jpg" beside "a1 (2).jpg", and "468_1000.jpg"
whose first number is the image's (1 release became 211 and 161 on the
corpus). The random volume stem stays. Tests keep both sets whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:14:17 +02:00
Daan MeijerandClaude Opus 5.5 f1c5af5f36 Under a constant subject: an upload's random volume stem, and an image's set head
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 2m27s
- "[ TOWN ] [ www town ag ] [ TV ] … "Z3mMTMPsoT4ikE_Di3fKnNF21.part01.rar"",
  "….vol4+4.par2": the subject and the total were the key, so every upload of
  a total was one release (2,338 files for 20; also partner-of-ssl-news,
  brothers-of-usenet). A random stem (no dots or spaces) on a volume file
  joins the subject key; a scene name doesn't, so its nfo stays.
- "Molly Set [i/50] "mo103x003.jpg"": every set of 50 was one. An image's
  name up to its first number, when another follows, joins the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:12:17 +02:00
Daan MeijerandClaude Opus 5.5 9f255e6ac0 Images and tracks named by a prefix alone are bounded by the counter's total
Test and deploy / test (push) Successful in 1m3s
Test and deploy / edge (push) Successful in 2m10s
Test and deploy / deploy (push) Successful in 3m17s
"[…/559] - "lop1001.jpg"", "pic004.jpg", "DSC_0002[1].jpg", and tracks
"r86_022 - Gwen Guthrie - ….mp3" ("r"): the stem route keyed them by the
letters before the number alone, so every post of a poster was one release
(5,731 images for 559; 6,433 tracks for 457). Under a counter its total is
part of the key; a cover named as its archive's stem keeps the stem's key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:54:02 +02:00
Daan MeijerandClaude Opus 5.5 3f6bb8b162 Spots: rematch the never-tried and newest spots first
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 1m27s
Oldest inspection first went through the 3-month backfill before today's
spots, whose releases the tail has: 3 h after reset 24, 2 of 16.7k matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:50:40 +02:00
Daan MeijerandClaude Opus 5.5 c174b3691e PROBE_CONNECTIONS 8: 2 connections to spare
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 52s
At 50 exactly the coordinator still got 502s now and then: a hedged request
closes its connection and opens another while the old one still counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:17 +02:00
Daan MeijerandClaude Opus 5.5 9104e9af18 PROBE_CONNECTIONS 9: with 10 the account was at 51
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 1m22s
Coordinator 38 + its plain connection + prober 10 + spotter + spot inspector:
the spot inspector got 502 Too many connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:35 +02:00
Daan MeijerandClaude Opus 5.5 0c5946eb06 Spots: 5,000 unmatched spots matched again an hour
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 2m21s
Test and deploy / deploy (push) Successful in 1m8s
After a reset every spot is unmatched: 16.5k at 500 an hour took 33 h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:55:17 +02:00
Daan MeijerandClaude Opus 5.5 1b64fed427 Spots: a matched spot's NZB isn't kept
Test and deploy / test (push) Successful in 1m6s
Test and deploy / edge (push) Successful in 2m47s
Test and deploy / deploy (push) Successful in 1m26s
Only a spot that becomes a release of its own needs its NZB (the header
pass's releases have their chunks); spots took 2.7 GB on the NVMe at 9k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:42:26 +02:00
Daan MeijerandClaude Opus 5.5 96553a5504 Spaces at a dash are no part of a stem key
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 2m9s
Test and deploy / deploy (push) Successful in 1m38s
"www.realmom.info - XVID- Farbiges Krabbeltier.r00" beside "… XVID-Farbiges
Krabbeltier.vol03+04.par2": 52 JBinUp uploads were two releases each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:51:47 +02:00
Daan MeijerandClaude Opus 5.5 550f63cd84 Spots: unmatched ones matched again in turns; a standalone release gives way
Test and deploy / test (push) Successful in 58s
Test and deploy / edge (push) Successful in 2m18s
Test and deploy / deploy (push) Successful in 1m52s
A spot older than what the catalog has indexed (the spotter's first backfill
reaches back ~3 months) matched nothing and would have become a release of
its own, a duplicate once the history pass builds the upload. Unmatched
spots are matched again in turns (rematched_at) for 30 days; the 24 h before
a standalone release count from the inspection; a later match deletes the
spot's own release and links the header pass's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:19:24 +02:00
Daan MeijerandClaude Opus 5.5 7df5a638ca Spots: the spotter and the spot inspector, passwords kept apart
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 2m1s
Test and deploy / deploy (push) Successful in 1m28s
Two processes, one NNTP connection each (the coordinator 40 -> 38):
- spots:read (service spotter): free.pt's overviews after the last article
  seen, each spot's full header verified (lib/spotnet from voyager 1, as
  Spotweb), its description, website and NZB articles; the password its
  title or text names; delete/dispose messages applied.
- spots:inspect (service spot-inspector): each spot's compressed NZB read,
  its files listed (spot_files), each matched to the header pass's release
  by the same release identity in one of its groups within 48 h
  (release_spots); unmatched spots matched again hourly, after 24 h a
  release of their own (source spot) with the spot's NZB.
- release_passwords: per release and password, its source (nzb, spot-title,
  spot-text) and spot; the catalog keeps only passworded.
XoverClient.head(); PROBE_CONNECTIONS default 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:10:10 +02:00
Daan MeijerandClaude Opus 5.5 3fd08b598a An image's post token needs a word in it, not only a size
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 2m1s
Test and deploy / deploy (push) Successful in 1m23s
"01of37 ## Alexi uses strange toys … ## alexis001.jpg (1/1) 57295b": with
the whole text as the name, the remainder is the file's size "57295b", which
229189f took for a post token: 16 sets of 36-37 images became 268 releases.
"MDG005b" has letters in a row; "57295b" doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:33:52 +02:00
Daan MeijerandClaude Opus 5.5 652f278474 Postgres WAL on the NVMe; wal_buffers 64 MB
Test and deploy / test (push) Successful in 57s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 1m6s
Every commit waited on the WAL in ./pg on the HDD (WALWrite; the HDD 100%
busy, 56–70 ms a write): prober flushes and claims took most of each minute.
A PG_WAL_DIR volume (default /var/lib/usenet-rebuild-wal) at
/var/lib/postgresql/wal, with data/pg_wal a symlink to it (set up by hand
once, with Postgres stopped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:17:24 +02:00
Daan MeijerandClaude Opus 5.5 8e4ed774c0 release_inspections in the catalog's tablespace (the NVMe)
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Successful in 2m33s
The prober writes it every flush and the merger looks inner stems up in it,
so it no longer belongs with the write-once payloads on the HDD. Moved on
homelab by hand (prober and merger stopped); the migration is a no-op there
and moves it on a new deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:09:20 +02:00
Daan MeijerandClaude Opus 5.5 229189f35c A date alone isn't an unquoted name; an image under a post token keys by the post
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 2m12s
Test and deploy / deploy (push) Successful in 3m28s
- "Ballers.2014.S01E01.HDTV.x264-KILLERS[ettv] 15_07_2015.rar", "Computer Bild
  - Ausgabe 152015 - 15_07_2015.rar": the cut after "] " or " - " left
  "15_07_2015.rar" for every item of a Razor1911 dump (1,335 files for 12);
  a date alone after the cut means the whole text is the name.
- "MDG005b - (01/90) "IMG_2307.JPG"": a counter in parentheses makes no post
  key, so every "IMG" set of the poster was one (609 for 90); an image under
  a post token is keyed by it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:03:23 +02:00
Daan MeijerandClaude Opus 5.5 590436d02a SCENE_RELEASE only for a track post's lower-case name
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Successful in 3m11s
7ad4b70 also keyed rar uploads by artist and total: "VA - Best Of Shamania
Pro 2K16-(SHB 006)-WEB-2016-ZzZz" became 140 files for 20, Ace_Of_Base 72
for 12. Those group by their full name; only a name without capitals or
spaces (Meow's "blaise_mc-in_hellstill_3-ep-web-2022-known.par2") joins
the tracks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:05:02 +02:00
Daan MeijerandClaude Opus 5.5 7ad4b70775 A scene music post's par2 under the release name; .par/.part before .par2
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 1m31s
"[07/14] - "blaise_mc-in_hellstill_3-ep-web-2022-known.par2"": the tracks and the
00- files are keyed by artist and total, the par2 by its stem, so ~1,900 Meow
and Cat posts were two releases. Under a counter a stem that reads as a scene
music release (artist-…-web|cd|ep|single|vinyl|…-…YYYY(-group)) takes the
artist key. And "goh1Phoz5ahXu.par.vol006+08.par2", "X.part.par2" beside
the volumes: .par/.part before a par2 suffix goes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:56:56 +02:00
Daan MeijerandClaude Opus 5.5 2bbabc08de corpus_sweep capture: budget for the marked ranges still filling
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m12s
Test and deploy / deploy (push) Successful in 2m18s
Only the bytes on disk were checked: at 296.6 GB with 2,300 ranges complete it
marked 100 more (~310 GB). The marked ranges not complete count at the stored
ranges' mean size, and no more are marked than fit under 300 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:55:26 +02:00
Daan MeijerandClaude Opus 5.5 6f7a1692d6 runOn: a space after the extension, not a bare dash
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 4m21s
0c5ede9 cut "Angry.Birds.Rio.v1.2.2.cracked.READ.NFO-THETA.rar" at .NFO: the
release lost its group, and two groups' releases of one game would merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:06:02 +02:00
Daan MeijerandClaude Opus 5.5 0c5ede99dd A quoted name that runs on into a title; .xNNN/.zNNN volumes
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 2m2s
Test and deploy / deploy (push) Successful in 1m32s
"lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy Joel - Extremes ",
"GrooveCoverage.part26.rarGrooveCoveragebestof", "Goga Sekulic.part134.rar -
Goga Sekulic - …": the stem kept the title, and each part was a release of its
own (lois, CPP, DieViper, Maveri, tigra: ~2,400 files). runOn cuts the name
after its archive extension when a space, a dash or the name's own start
follows. And three-digit .x/.z volumes ("Reply 987986177631730.x004"),
not .x264/.x265.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:59:24 +02:00
Daan MeijerandClaude Opus 5.5 1c8da1dd12 A long number is a per-post id only under a counter
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 1m55s
Test and deploy / deploy (push) Successful in 1m32s
"<kere.ws/illuminatenboard.org> - ID - 1855891249 - Along.The.Ridge.2006.Limited.PAL.DVDr-TWCiSO.rar":
no counter, the board's ID kept isPosterTag from taking the text for a tag,
and a dump of thousands of releases was one of 9,444 files. FTD's ids come
with a counter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:41:40 +02:00
Daan MeijerandClaude Opus 5.5 8d5385fdec Split zip volumes (.zNN) and .xNN as volumes, but not .x32/.x64/.x86
Test and deploy / test (push) Successful in 56s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 1m23s
87e15d8's .[s-v]NN let "Licence 700790900887990.z18" and "….x31" uploads
fall apart again (+1,740 releases on the corpus). .zNN is a split zip volume;
.xNN is a volume unless it's a build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:47:21 +02:00
Daan MeijerandClaude Opus 5.5 87e15d810d Old rar volumes: .s–.v only, not .x64/.x86
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 1m55s
Test and deploy / deploy (push) Successful in 1m36s
d4e53ca's .[s-z]NN took "Electric.Rain.Swift.3D.PS.v1.0.142.x64" for a
volume, so x86 and x64 builds would be one release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:53:38 +02:00
Daan MeijerandClaude Opus 5.5 d4e53ca0d8 More per-file suffixes: old rar volumes, .rev, a part's par2, .bc; a size after an unquoted name
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Successful in 1m22s
Each file of these uploads was a release of its own:
- "BD_NoTHing018.s41", "…CyjI.s00": rar volumes after .r99 (as the file's
  own lower-case extension only, so "show.s01.rar" keeps its season);
- "chappelle1.part02.rev": rar recovery volumes;
- "X.part1.par2", "X.part1.vol00+34.par2" beside "X.part1.rar";
- "NZPEC….180.bc": BCArchive volumes;
- "[27/70] - 2407.jpg - 28": the size in kB after an unquoted name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:59:09 +02:00
Daan MeijerandClaude Opus 5.5 2823d42bfa Merge obfuscated uploads by the random stem of their inner names
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 1m53s
Test and deploy / deploy (push) Successful in 2m32s
hep, hephaestus and gyges post each file under a subject name of its own
(31,000 one-file releases), but the yEnc name in each file's first article
keeps the upload's random stem ("bX147zx8I0oEgj7nzFCiorCT0ps2jEcb.7z.001"),
one stem per counter total. The merger's second pass joins releases whose
lone inspections share a random stem: one group and poster, the same total,
within 48 h, no more files than the total plus 10. An expression index over
the stem; release_merges records the kind (via) and takes a stem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:38:31 +02:00
Daan MeijerandClaude Opus 5.5 942c4901fa Par2 merge: a set held by many releases, or a merge far larger than its set, isn't one upload
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 1m34s
Every kantodoNNN.bin test upload carries the same par2 set: 64 releases held
it nearby and became one release of 631 files for a set of 10. A set held by
more than 4 releases nearby isn't merged, nor members with more than 3x the
set's files plus 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:48 +02:00
Daan MeijerandClaude Opus 5.5 c72bf68fd2 Par2 merge: a set held by two merged releases; one failed set doesn't stop a round
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 1m59s
A par2 index and its repost both hold a set: moving both rows to the target
broke par2_sets' primary key, and the round failed on it every minute from
20:28 UTC (no merges at all). Duplicates among the others go first; a set that
fails is logged and the round goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:28:50 +02:00
Daan MeijerandClaude Opus 5.5 7b99cfdb0e A scene music post's 00- files go with its tracks
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m58s
Test and deploy / deploy (push) Successful in 1m22s
"[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 tracks are keyed by
artist and total (SCENE_TRACK), but the cover, nfo, sfv and m3u were releases
of their own (2,630 single files of Meow in a.b.ftn). Under a bare counter a
00- stem takes the same key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:01:12 +02:00
Daan MeijerandClaude Opus 5.5 f6e54b6c58 CORPUS_AHEAD 16: 8 ranges left 20 of 40 connections idle
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:17:12 +02:00
Daan MeijerandClaude Opus 5.5 7b436f3f77 Idle connections fetch capture ranges into the corpus
Test and deploy / test (push) Successful in 42s
Test and deploy / edge (push) Successful in 1m48s
Test and deploy / deploy (push) Successful in 1m6s
The spool only fetches what workers lease soon, in priority order, and the
tail never ends: after a reset the 1,400 capture ranges waited behind it
while 34 of 40 connections idled. Up to CORPUS_AHEAD (8) planned capture
ranges whose chunks aren't all stored are now targets after everything else,
fetched into the corpus only (no spool); a later lease reads them from disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:57 +02:00
Daan MeijerandClaude Opus 5.5 0fafe71b21 The poster-tag tolerance only without a counter
Test and deploy / test (push) Successful in 42s
Test and deploy / edge (push) Successful in 1m43s
Test and deploy / deploy (push) Successful in 1m24s
"[ MacGyver.S01E03.DVDRip.XviD-MEDiEVAL ]-[04/38] - "sample.macgy.s01e03-med.avi""
shares one word with the sample's stem: 6225008 took the name for a tag and
split the episode 17 + 21. A counted subject names its upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:52:26 +02:00
Daan MeijerandClaude Opus 5.5 62250081ef A poster tag of 4+ words may share one with the stem
Test and deploy / test (push) Successful in 42s
Test and deploy / edge (push) Successful in 1m39s
Test and deploy / deploy (push) Successful in 1m10s
"German Board - [Usenet4ever.info] und [SecretUsenet.com] - "21.Jump.Street.2012.BDRip.AC3.German.XviD-POE.part01.rar"":
"German" in the stem made the tag a name, and the poster's readable uploads
were releases of 1,422 and 1,535 files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:24:42 +02:00
Daan MeijerandClaude Opus 5.5 cfd29c8de3 A long hex stem without a counter is the upload
Test and deploy / test (push) Successful in 45s
Test and deploy / edge (push) Successful in 1m48s
Test and deploy / deploy (push) Successful in 1m22s
"German Board - [Usenet4ever.info] und [SecretUsenet.com] - "<md5>-u4e.part6.rar"":
no counter, so the subject route lumped 11 uploads into one release of 14,450
files. HASHED also takes one 24+ hex run, so the stem keys each upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:53 +02:00
Daan MeijerandClaude Opus 5.5 6d301fc668 Hosts and sponsors without a counter name no upload
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 2m10s
"(www.Thunder-News.org) >XXX< <Sponsored by Secretusenet> - "ps-itb10b.r00""
has no counter to bound it, so a poster's uploads were one subject release
(1,135 files). Without a counter the remainder drops www hosts and
"Sponsored by X"; what's left names the release or the stem does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:58:25 +02:00
Daan MeijerandClaude Opus 5.5 cdc1cdc0cc Prober: claim only candidates of settled releases, without the join
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Failing after 4m2s
The settler marks a settled release's candidates ready (and unreadable when
it had no readable name). Claims took the candidates by joining releases
from random points of the queue; in its newer half nothing had settled, so
a slice scanned to the end (0.3 s each, 20 slices and 5 tiers a claim) and
the prober stood still: 2.4M waiting, 0 probed a minute. Now an index over
the claimable rows only, the slices drawn within them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:12:59 +02:00
Daan MeijerandClaude Opus 5.5 1866e38c25 Identity: per-file hex ids, dated stems, season packs under a bare counter
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 1m51s
Test and deploy / deploy (push) Successful in 1m26s
From the corpus sweep (695 ranges):
- A bracketed hex id per file ("[PRiVATE] Rosewood.S02E08… [1f1793f68d5321]
  [newzNZB] [01/22]") goes from the subject: nEwZ[NZB]'s 32,101 single files.
- A stem that starts with a date is specific enough for the poster-tag rule
  ("09-30-2008_CTC.r00" under "#sterntuary - Coast To Coast Radio Show":
  still one release of 25,188 files, the stem being too short to "describe").
- Episodes under a bare counter ("[01/36] "Pokemon - S02E01 - Pallet Party
  Panic.mkv"") are one release per show, season and counter total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:10:40 +02:00
Daan MeijerandClaude Opus 5.5 1cb6a4554f Stems: keyed without case and a scene index's 00-
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 2m15s
Test and deploy / deploy (push) Successful in 2m21s
"00-laura_van_dam_-_capture_radio_113-cable-10-10-2026-talion.m3u" and
.sfv beside "Laura_van_Dam_-_Capture_Radio_113-CABLE-10-10-2026-TALiON.par2"
were two releases of one upload (160731958, 160731584).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:04:17 +02:00
Daan MeijerandClaude Opus 5.5 123ed81782 Prober: where the lanes' time goes, in the minute report
Test and deploy / test (push) Successful in 45s
Test and deploy / edge (push) Successful in 1m55s
Test and deploy / deploy (push) Successful in 1m23s
Fetching, waiting for candidates, idle, inspecting; claims and flushes. The
link had room (222 of 1000 Mbit/s with the coordinator at 239k headers/s,
one test connection ~84 Mbit/s meanwhile) while the prober did ~5/s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:58:42 +02:00
Daan MeijerandClaude Opus 5.5 358f5a8571 Names: split rar volumes; a file's size in brackets after its counter
Test and deploy / test (push) Successful in 43s
Test and deploy / edge (push) Successful in 1m45s
Test and deploy / deploy (push) Successful in 1m4s
- "rld-gtre.r13.002": a split volume after a rar volume is stripped like
  one after .rar (usenet-town's posts were 1,620 single files).
- "[26/32] - [4929] "…part24.rar"": the bracketed size after the counter
  goes (Kingdn's episodes, 2,850 single files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:52:35 +02:00
Daan MeijerandClaude Opus 5.5 3301216aa8 Probe candidates: an index on (kind, id) of the waiting ones for the prober's claims
Test and deploy / test (push) Successful in 56s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 3m27s
A slice of a claim filtered ~700k rows by kind through the id index (0.37 s
a slice, 20 slices and up to 5 tiers a claim, the lanes waiting meanwhile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:33:27 +02:00
Daan MeijerandClaude Opus 5.5 0727afe968 Prober: pipelined bodies, merge-first order, a probe cache across resets
Test and deploy / test (push) Successful in 56s
Test and deploy / edge (push) Successful in 2m33s
Test and deploy / deploy (push) Successful in 2m26s
- XoverClient.bodies: PROBE_PIPELINE (8) BODY commands per connection at
  once, replies read in order (one at a time it ran 3-10 articles/s on 6
  connections against a queue of 1M).
- Claims in tiers: lone files first, then par2 sets and archives of
  releases without a readable name (what the par2 merge needs), then those
  of readable releases.
- probe_cache: final answers by message-id (status, inspection with the
  16 KiB hash and par2 file list), PROBE_VERSION 2. A reset empties the
  probe queue, not the cache; recreated candidates of probed articles are
  answered from it without the news server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:23:01 +02:00
Daan MeijerandClaude Opus 5.5 6d1f2cc2a1 Par2 merge: a set file matching several releases nearby is ambiguous
Test and deploy / test (push) Successful in 1m1s
Test and deploy / edge (push) Successful in 2m15s
Test and deploy / deploy (push) Successful in 2m4s
131 uploads of 1 GiB test files ("kantodoNNN.bin", a.b.test) share their
first 16 KiB and size, so all matched one set's single file and became one
release of 1,284 files. A par2 file that matches more than one release in
the anchor's group and time window is left out; a plain repost of the same
file stays apart too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:15:32 +02:00
Daan MeijerandClaude Opus 5.5 8e07478659 Counters: a total padded wider than its index is none; report diff on shared ranges
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 2m1s
Test and deploy / deploy (push) Successful in 1m25s
- "Very Best Of The Who [01/010]", "[02/020]", "[07/070]": a posting tool's
  total of index×10, so every track had a total of its own and was a
  release of its own. A total zero-padded wider than its index is no
  counter ("[001/010]" still is).
- corpus_sweep.py report compares builds over the ranges both replayed (the
  corpus grows between builds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:12:28 +02:00
Daan MeijerandClaude Opus 5.5 1a5035d988 Names: stray quotes, single quotes, hosts, bracketed names; dates, repeats
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 2m8s
Test and deploy / deploy (push) Successful in 2m6s
From the first corpus sweep (183 ranges):
- A stray quote took the last quote pair ('"x.mp3" "Album_BY_WaRLoRD',
  '"x.nfo" " yEnc', '"x.nzb" yEnc"'): the last quoted part with an
  extension is the name. Each file was a release of its own.
- A single-quoted name at the end ("… [001/106] 'You, Me And Dupree.nfo'"),
  not the host before it (www.x2l.nl).
- Text ending in a host ("… www.libranews.nl") is no file name; a name in
  brackets comes first ("[01/77][LIBRATEAM_M2219009.par2]",
  "-^-[Mnozil Brass - Seven.nzb]-^-").
- A leading date is no item number ("09-30-2008_BAK_JRS.r00": a daily
  show's 32,621 files were one release under the poster's tag).
- The file name repeated elsewhere in the subject goes too
  ("(NELET11.par2) [01/58] - "NELET11.par2"").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:58:04 +02:00
Daan MeijerandClaude Opus 5.5 885e4e6541 Settler: history releases settle after half an hour
Test and deploy / test (push) Successful in 45s
Test and deploy / edge (push) Successful in 1m56s
Test and deploy / deploy (push) Successful in 1m17s
A release whose last post is 6 hours old gets nothing from the tail, only
fragments from neighbouring ranges: settled once unchanged for 30 minutes.
Waiting 6 hours after it was read held every history release back from
phase 2 after each reset; with the hourly sweeps' resets the prober (and so
the par2 merge) never had work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:42:53 +02:00
Daan MeijerandClaude Opus 5.5 3d0dc6ca1f The par2 merge: releases whose files one par2 set describes become one
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m51s
Test and deploy / deploy (push) Successful in 1m27s
The prober stores, per probed article that starts a file, the MD5 of its
first 16 KiB (file_hashes), and per par2 body the set's whole file list
(par2_files, par2_sets): FileDesc holds the same hash per file, whatever
the file was renamed to. releases:merge (the merger service, no NNTP)
merges every minute the releases of one group, posted within 48 hours of
a release holding the set's par2, whose files the set describes, into the
one with the most files: chunks, probe candidates, inspections, hashes and
counts move; the par2's shared name when none was readable; release_merges
keeps what went where. Obfuscated uploads whose files were each a release
of their own ("[9/20] - "14f5999a…"", hep: 13,232) become one.

Also: corpus_sweep.py counts the marked ranges without psql's command tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:37:35 +02:00
Daan MeijerandClaude Opus 5.5 9ea2669b2a Corpus: stored XOVER chunks for the grouping sweeps
Test and deploy / test (push) Successful in 43s
Test and deploy / edge (push) Successful in 1m48s
Test and deploy / deploy (push) Successful in 1m23s
Ranges marked `capture` keep their XOVER chunks in CORPUS_DIR
(corpus/<group>/<start>-<end>/, edge-bench's layout, range.json once
complete; only chunks that inflate). The prefetcher reads a stored chunk
instead of fetching it, so a reset re-indexes them without the news
server; a chunk a worker reports corrupt goes from the corpus too.

edge-bench gets --releases (release chunks without payload, with their
first subjects) and ships in the edge image; deploy/corpus_sweep.py marks
more ranges (capture), replays the corpus with the deployed build (replay)
and reports suspects and the change since the previous build (report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:28:22 +02:00
Daan MeijerandClaude Opus 5.5 d43603a43f Identity: hashed stems under a tag, numbered volumes, scene tracks
Test and deploy / test (push) Successful in 45s
Test and deploy / edge (push) Successful in 1m53s
Test and deploy / deploy (push) Successful in 1m28s
From the top-100 groups:
- A hashed stem under text without a counter is the upload ("Seit 1753
  Tagen - usenet4ever.info - "055631a1_b6c820bc_…-u4e.part01.rar""): the
  day's uploads were one release of 3,747 files.
- A numbered volume as the extension, within the [i/M] total ("(????)
  [0659/2195] - "DN.VPL PCK.1508"", "TKY.ASN PCK.691."): keyed without it.
- Scene tracks under a bare counter ("[06/14] - "02-repiet_-_dont_think_
  twice_(extended_mix).mp3""): the artist's album bounded by the total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:46:10 +02:00
Daan MeijerandClaude Opus 5.5 3fc38edda8 Identity: other quoted text, text separators, ids in image names, more
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 2m0s
Test and deploy / deploy (push) Successful in 2m1s
- Quoted text other than the file name stays in the remainder: ">>> CREATIVE
  COMMONS NZB <<< "0190 alarm 5.0 " - File 1 of 9: "…rar"" made 12,605
  files of all uploads one release.
- namesRelease accepts words joined by , & : ; \ / + ("Posting & deleting,
  read 0 file "E:\VladModels\y038_Irina\185" …"): such image sets fell to
  the stem rule ("P8250001.JPG"), one release over all folders.
- imageSet: a 5+ digit number left in the set is a per-image id
  ("sss-70556-3474182-23-10825418-pc", "Alena p 26663_…"): no set. Copy
  markers may repeat ("diana-050-052[1][1]").
- unquotedName: an image after numbered text without a separator is the
  last word ("<abm> … AB: part2 0573-1002 043.jpg").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:51:21 +02:00
Daan MeijerandClaude Opus 5.5 e1d21d1e7c Identity: a set's number before yEnc is no size; names after an arrow
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 2m6s
- The unit-less size before the closing yEnc ('"x.mp3" - 8123 yEnc') took
  the set number in 'Newstar Destiny I set 1 [1 of 60] "destiny-001-001.jpg"
  yEnc' once the quotes were gone: 97 sets of 60 were one release. A number
  of fewer than 4 digits after a word stays.
- unquotedName also cuts after "-> " ("… -> [0001 of 0076] ->
  Exclusive-tgirls_000000-lg.jpg"): the arrow was part of the file name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:46:19 +02:00
Daan MeijerandClaude Opus 5.5 18a146e130 Image sets: keyed by set without words around them; px, ImageVenue, spaced repeats
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 2m4s
Test and deploy / deploy (push) Successful in 2m17s
- An image whose subject has no words ("##[ "diana-040-001.jpg" (01/68)")
  is keyed by its set, not its name without numbers: a poster's 263 diana
  sets were one release of 17,808 images.
- imageSet strips "-2048px" and ImageVenue's "_123_244lo", and reads a
  spaced suffix ("she has sensual legs 172 _ 172") as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:45:53 +02:00
Daan MeijerandClaude Opus 5.5 108d798d97 Image sets: a counter in the name ends the set; copy markers, resolutions, _00 go
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 2m59s
Each image was a release of its own in: "Set 008  10of63  new0464.jpg",
"202/430  princess-079-058.jpg", "( 098 /500)  002 148.jpg" (the counter
is part of the name: the set is the text before it),
"sexypattycake_0903040445[1].jpg" (a copy's marker), "092_081_2000x1500.jpg"
(a resolution) and "KP0027_197_00.jpg" (a constant suffix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:45:37 +02:00
Daan MeijerandClaude Opus 5.5 e72a9b1ef5 Identity: unquoted names are no counters; renumbered and timestamped image sets
Test and deploy / test (push) Successful in 53s
Test and deploy / edge (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 2m4s
- The file total is read from the subject without the file name, as with a
  quoted one: "shyla-shy-09-0195.jpg hardcore …" read as 9 of 195 made each
  of 866 images a release of its own.
- imageSet: "_N" right after a number is a renumbering ("kate108lg029_28"
  once an image was skipped), and a trailing camera timestamp
  ("kurozo-2006-7-26-15-21") numbers no set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:47:51 +02:00
Daan MeijerandClaude Opus 5.5 f094fb4593 Image sets: a suffix that ends the number before is a repeat too
Test and deploy / test (push) Successful in 48s
Test and deploy / edge (push) Successful in 1m53s
Test and deploy / deploy (push) Successful in 1m24s
"051 - White leather kate054001_1_1.jpg": the index is inside 054001
(set 054, image 001) and _1_1 repeats it, but only a suffix equal to the
whole number before counted as a repeat, so the set kept 054001 and every
image was a release (Duke's threads: 2,791 releases of 3,251 files). A
suffix that the number before ends with is a repeat as well. Both
implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:45:29 +02:00
Daan MeijerandClaude Opus 5.5 392ff09910 Image sets: suffixes that don't count the images go first
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 2m0s
Test and deploy / deploy (push) Successful in 2m1s
The set bound cut at an image name's last number, but in many threads
that's a suffix, so every image was a set ("It does my heart good to see
Red Dog dancing again": 4,683 releases of 4,730 files): a repeat of the
index (katelg010_10), a year (hgrscans1097_2009), a size
(pj02kristina_24of40_1600), an "of" total (24of40). imageSet drops
those, then cuts at the last number (cutie-071-032 stays cutie-071-).
Both implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:45:57 +02:00
Daan MeijerandClaude Opus 5.5 c4f48a6d4c Image sets by their numbered name, digits kept
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Successful in 2m1s
Without a file counter a subject-named image release was bounded by the
image's name with all numbers removed:
- STIRILING SILVER's sets stp250x…stp465x became one 44k-image release
  (all "stp x"); picture threads of many sets (keira_s04, keira_s05,
  perla_s01, apneatic-7, -8 …) one release each.
- Free-text names without numbers ("JimAndJoey Kindly Husband-to-Be.JPG")
  made each image a set: one thread was 1,177 releases.
The bound is now the name up to its last number with its digits
("stp250x", "keira_s05_l keira_s05_", "apneatic-7-"), and nothing for a
name without numbers. models fixture: 172 -> 754 releases, by set. Both
implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:45:52 +02:00
Daan MeijerandClaude Opus 5.5 0577b16e5e Dates are no file counters
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m42s
Test and deploy / deploy (push) Successful in 1m11s
"Avira AntiVir Rescue System 08-29-2011.rar", "Android Apps and Games
Pack 27-02-2011", "…-cable-18-08-2012-1king.rar": counterTotal read the
date as a counter (8 of 29, 2 of 2011), so the release showed a wrong
file total and counted as incomplete. Dates (2011-08-29, 08-29-2011,
29.08.2011, 02-2011) are taken out before the written-out counters are
read; [i/M] and (i/M) come first as before. Both implementations; tests;
fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:05:53 +02:00
Daan MeijerandClaude Opus 5.5 8dfccc1c8e Numbered items: also after a disc code (karaoke)
Test and deploy / test (push) Successful in 43s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Successful in 2m20s
"Spotlight - 83xx's [16/19] - \"SC8367-12 - Wynette, Tammy - Singing My
Song.zip\"": the disc-and-track code isn't a bare track number, so the
poster-tag rule keyed every track by its own name. A stem starting with
an upper-case code and digits before the track number (SC8367-12,
ZMP050-04) is a numbered item too. Both implementations; tests; fixtures
identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:59:03 +02:00
Daan MeijerandClaude Opus 5.5 2814c77225 A quoted word that isn't a file name; split parts with two digits
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 1m41s
From the re-index's single-file releases:
- A quoted text without an extension ("My \"Official\" remaining set's
  of Inna … 025.jpg [25/74]", "… lr140x16.jpg ( file 046 of 922)
  \"yEnc\"", "\"Dr. Lust\" - … - [12/21] - Ed_DRL_VDR_LM.part09.rar") was
  the file name, the real one stayed in the subject: every file a release.
  It gives way to a file name with an extension beside it
  (nameBesideQuotes); random quoted names with none beside keep theirs.
- Split parts numbered with two digits ("Admiral.EP05.2008.720p.Rus.
  HDCLUB.mkv.21", a.b.movies.divx.russian) share their stem, as .001.
Both implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:46:43 +02:00
Daan MeijerandClaude Opus 5.5 625ea23171 More single-file splits from the re-index
Test and deploy / test (push) Successful in 54s
Test and deploy / edge (push) Successful in 1m56s
Test and deploy / deploy (push) Successful in 1m49s
Releases of one file whose counter announces many (910k of 3M after the
sixth reset's first minutes), by cause:
- An unquoted name with double spaces ("lara_bath  15of19 larabath_2.5.jpg",
  "Luisa:     (068 of 160)"): the subject is collapsed, the name wasn't,
  so it stayed in the remainder; it's taken out collapsed too.
- Audiobook and album tracks keyed by their own name ("RP - File 268 of
  295 - \"… Seeing A Large Cat - D11.16-22.mp3\"", "CANDYD :: \"Candy
  Dulfer - Candy Store - 11 - everytime.mp3\""): by the name without its
  numbers, as image sets.
- A size without a unit before the closing yEnc ("…mp3\" - 8123 yEnc").
- Random posters with separators ("SOFZRw1-OTEd@…", "…_OOW@…").
Both implementations; tests; fixtures identical (models: 267 -> 172).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:15:31 +02:00
Daan MeijerandClaude Opus 5.5 8d400822bd Unquoted album tracks: the name starts at the track number
Test and deploy / test (push) Successful in 43s
Test and deploy / edge (push) Successful in 1m50s
Test and deploy / deploy (push) Successful in 1m21s
"A Strange Light from the East - Eastern Influenced Western Music
(1965-72) - CD2 - 07 - Rascals - Sattva.mp3 (1/43)": unquotedName cut at
the last " - " ("Sattva.mp3"), leaving track number and artist in the
subject, so every track was a release (85665085, 85665050). With a
" - NN - " track or part number the name starts there ("07 - Rascals -
Sattva.mp3"), the rest names the album and disc. Both implementations;
tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:41:54 +02:00
Daan MeijerandClaude Opus 5.5 e0a0d0d520 An upload spread over groups is one release
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m52s
Test and deploy / deploy (push) Successful in 1m55s
FTD posts one upload's files to different groups ("Knowsum-Loops_Are_My_
Religion-WEB-2026-FTD": par2 and part1 to a.b.music.misc, part2 to
a.b.sounds.music, each article in one group only; releases 81825221 and
81825412): the catalog matched a chunk only to releases of its own
group. A chunk without one there now joins a release of its key in
another group whose window is within 3 hours, when none of its file
names are in that release yet; a post of the same files to another group
(other message-ids, same names) stays a release of its own, or its NZB
would hold every file twice. Functional tests for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:28:57 +02:00
Daan MeijerandClaude Opus 5.5 df35f4678c A random poster per file is no part of a release key
Test and deploy / test (push) Successful in 44s
Test and deploy / edge (push) Successful in 1m47s
Test and deploy / deploy (push) Successful in 1m45s
Releases 78361820 and 78411563 were two files of one FTD upload
("Yusef_Lateef-Alight_Upon_the_Lake_…-WEB-2026-FTD", [1/6] and [3/6], 4 s
apart) under a new random poster each ("N9vplIaavBCz@aCTVoAsk.JSE",
"FVWWHku0fGAg@QfaTz0g0.RVH"): the stem key includes the poster. When the
poster's own name (before the @) looks random, it's left out of the key;
real posters keep theirs (two uploaders of one scene release at the same
time stay apart), and uploads hours apart stay apart by the re-upload
rule. Both implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:55:15 +02:00
Daan MeijerandClaude Opus 5.5 b1213fbea0 Image sets: sizes in brackets and one-word set names
Test and deploy / test (push) Successful in 45s
Test and deploy / edge (push) Successful in 1m51s
Test and deploy / deploy (push) Successful in 1m19s
Old picture groups split every image into a release (the reset's first
hour: 583k releases, 447k of one file; a.b.pictures.erotica.high-heels
262k): "Aletta Ocean - \"100628-035.jpg\" yEnc [347K]" kept its size in
the remainder, and "Corie - \"2877-041.jpg\" yEnc [290K]" named its set
with one word, which namesRelease (two words) refused, so the image's
own name keyed it. Sizes like [347K] (and a cut-off "336573 byt") are
removed; for images a one-word subject (3+ letters) names the set. On
100k high-heels articles: 4,509 releases -> 192. Both implementations;
tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:25:18 +02:00
Daan MeijerandClaude Opus 5.5 47b445ffb0 Numbered items of a set don't take the poster-tag rule
Test and deploy / test (push) Successful in 45s
Test and deploy / edge (push) Successful in 1m44s
Test and deploy / deploy (push) Successful in 1m28s
"ai-engineer-complete [401/465] - \"003 Defining Tools with JSON
Schema.mp4\"": the subject's text shares no word with the lesson's
descriptive name, so it was taken for a poster's constant tag and each of
the course's 465 files keyed a release of its own (releases 75941915,
75941916). A stem starting with a 1-3 digit index and a separator is an
item of a set the subject names, as audio tracks and images already
were; uploads hours apart stay apart by the re-upload rule. Both
implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:10:58 +02:00
Daan MeijerandClaude Opus 5.5 90f30f445d Subjects without a quoted file name: the name from the rest of the subject
Test and deploy / test (push) Successful in 51s
Test and deploy / edge (push) Successful in 1m48s
Test and deploy / deploy (push) Successful in 1m25s
Most "obfuscated" subjects of older and smaller groups name their file
without quotes ("DVDFab 8.2.0.5.rar (006/332)", fta's "q3B9…part023.rar
(104/137)", "New_Super_Mario_Bros._[Keygen].exe (1/2)") or with one
quote ("[keira_s04_0037_l.jpg\" yEnc] (2/2)"): 100% of ath 2013's and
fta's, 94% of models' (LOG.md 2026-10-11 (5)). With a segment counter and
no quoted name, unquotedName takes the name before a lone quote (after a
"[", else after the last space), else the text when it ends in a file
name (after "] " or " - "), else its first word that is one; a file
counter stuck in a bracket is dropped. The release identity takes such a
name out of the subject as it would quotes (else every image of a set
was a release: 9,130 instead of 267 on the models fixture).

Both implementations; a fourth fixture (fta, 25k lines) and tests; check
identical on all four. Truly random subjects (ngPost hex = message-id,
random tokens) stay obfuscated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:52:55 +02:00
Daan MeijerandClaude Opus 5.5 18b9ffdca3 tools: obf_runs and obf_kinds, measuring the obfuscated subjects
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m47s
Test and deploy / deploy (push) Successful in 1m24s
obf_runs: how obfuscated articles come in runs (strict, and per poster
and tool with a time gap). obf_kinds: what they are (unquoted subjects
with a counter, random tokens equal to the message-id, other random
tokens, ...), with examples. LOG.md 2026-10-11 (5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:03 +02:00
Daan MeijerandClaude Opus 5.5 5109481d67 The Rust worker is the product: edge/, built and checked in CI
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 2m56s
User: Rust is the product (option a). tools/edge-bench-rs moves to edge/
(crate edge; binaries edge-node, the worker, and edge-bench, the replay).
CI's new job edge runs cargo test and edge/check-fixtures.sh: both
RangeIndexers on three committed captures (edge/fixtures, 25k overview
lines each of a.b.models, a.b.ath and a.b.boneless in 5 chunks, 3.5 MB),
compared field by field with check.ts; deploy waits for it. The
TypeScript RangeIndexer stays as check.ts's reference. README: what it
is now; the benchmark findings as history.

Random names: looksRandom missed long tokens of capitals and digits
("51294P1CUKR2R35X", release 74539068, readable and a Newznab title).
12+ capitals and digits switching 6+ times between them are random
(COD4MW2REMASTERED switches 4 times, BEATLES1964REMASTER twice); both
implementations, identical on the fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:01:18 +02:00
Daan MeijerandClaude Opus 5.5 48f4e982f9 Rebuild: the edge workers' API in its own service
Test and deploy / test (push) Successful in 49s
Test and deploy / deploy (push) Successful in 1m16s
The node API (leases, hour-long streams, batches of up to 256 MB JSON
parsed in-process) shared the web process with the catalog API, Newznab
and the web interface; voyager 1 had to make the same split on
2026-10-06. HTTP_ROUTES picks a process's routes (node, public, all by
default): compose service node-api (same image, port 3335 on the compose
network) serves the workers, web the rest. The workers' COORDINATOR_URL
points at node-api; deploy.sh stops edge before recreating node-api.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:56 +02:00
Daan MeijerandClaude Opus 5.5 34fea342f5 REBUILD.md is the concept for what runs; the phase 2 backlog
Test and deploy / test (push) Successful in 44s
Test and deploy / deploy (push) Failing after 1m4s
From "draft for approval" to the concept of voyager 2 (voyager 1's
CONCEPT.md and D1-D42 are superseded). Phase 2: what's built (the
prober), and voyager 1's features not yet here: spots, NZB import,
passwords, SFV, RAR walks, 7z listings, body naming of obfuscated
subjects, after-probe garbage drops; where its exported data is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:48:49 +02:00
Daan MeijerandClaude Opus 5.5 93eaec8d27 Rebuild: sort indexes that match newest-first
Test and deploy / test (push) Successful in 47s
Test and deploy / deploy (push) Successful in 5m20s
The default page (no search words) took 23-27 s: SearchService sorts
posted_at DESC NULLS LAST, id DESC, the indexes were on posted_at DESC
(nulls first), so Postgres sorted all 68M releases. New indexes match
the sort (also per category), built concurrently; the old ones dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:39:42 +02:00
Daan MeijerandClaude Opus 5.5 92480174b9 Search and the web interface use the prober's findings
Test and deploy / test (push) Successful in 48s
Test and deploy / deploy (push) Successful in 2m27s
- Search matches a word in the name or the inner name (trigram index on
  inner_name, partial, built concurrently); "Readable names" includes
  releases with an inner name.
- unlocked=1 (chip "No password") leaves out passworded releases.
- Results carry passworded and innerName: a Password badge, the inner
  name under the release name.
- The release view: Inside and Inspected facts, a passworded notice, and
  Contents (per probed article its yEnc name, kind, type, encryption and
  the names inside) from release_inspections.
- Newznab: the password attribute from the prober; an obfuscated release
  with an inner name is included, titled by it (without its extension).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:33:23 +02:00
Daan MeijerandClaude Opus 5.5 5ab5399b43 Prober: the claim's random starting points drawn in code
Test and deploy / test (push) Successful in 54s
Test and deploy / deploy (push) Successful in 1m21s
Postgres evaluated random() once for all 20 slices: every claim still
came from one place in the queue (214 of 214 taken from a.b.zogg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:23 +02:00
Daan MeijerandClaude Opus 5.5 18f5d27413 Prober: claim from random points of the queue, not in id order
Test and deploy / test (push) Successful in 45s
Test and deploy / deploy (push) Successful in 1m23s
In id order the prober went group by group, and some groups' articles
take 0.6-3 s each (a.b.zogg 2016; repeated fetches the same, pipelining
4 deep no faster), where a sample across all years takes 20-60 ms for
most: 7 articles/s on 6 connections, against 67/s in its first minutes.
A claim now takes 20 slices from random points (353 ms per 200).
tools/body_timing.ts: BODY time for the oldest and newest candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:55:23 +02:00
Daan MeijerandClaude Opus 5.5 4ed7f89951 Prober: no packaging or release-name-plus-extension as inner name
Test and deploy / test (push) Successful in 43s
Test and deploy / deploy (push) Successful in 1m18s
Lone candidates are often par2 volumes of random-named sets
("42507096….vol007+08.par2"): their yEnc name was taken as the inner
name. Packaging names are skipped for lone files too, and a name that
starts with the release's own name says nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:44:08 +02:00
Daan MeijerandClaude Opus 5.5 738402aefa Prober: an inspection per candidate; no inner name that is the release's name
Test and deploy / test (push) Successful in 53s
Test and deploy / deploy (push) Successful in 1m53s
A release can have several candidates of a kind (two RAR sets): keyed by
(release, kind) both landed in one upsert ("ON CONFLICT DO UPDATE
command cannot affect row a second time"), and the batch was lost.
release_inspections is keyed by candidate now; the first run's results
are cleared and probed again. A random-named file's yEnc name is often
its random name: an inner name equal to the release's name is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:37:32 +02:00
Daan MeijerandClaude Opus 5.5 8bd6d6aa5f Rebuild: phase 2's prober reads the probe queue
Test and deploy / test (push) Successful in 50s
Test and deploy / deploy (push) Successful in 1m46s
probe:run (compose service prober, PROBE_CONNECTIONS, default 6) claims
waiting candidates of settled releases (lone, then par2, then archive),
fetches each article with BODY over connections of its own (no
compression) and inspects it: the yEnc name and size, the content type
from the first bytes, a par2 set's files, a RAR volume's entries and
whether they're encrypted. Results per release and kind in
release_inspections (CHUNK_TABLESPACE, the HDD); on the release
passworded, inner_name (the largest content name) and inspected_at.
430 = missing, not yEnc = error, both final; connection trouble puts
the candidate back.

Fetch and parse in one process: an article takes ~0.1 s per connection,
parsing a few ms (no queue in between). Parsers from voyager 1 (lib/yenc,
lib/rar, lib/par2, lib/content sniff) with their tests; XoverClient.body;
yencode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:32:49 +02:00
Daan MeijerandClaude Opus 5.5 d72fe2f13f Tests: a corrupt chunk fails its range on the third report
Test and deploy / test (push) Successful in 22s
Test and deploy / deploy (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:22:10 +02:00
Daan MeijerandClaude Opus 5.5 fa52d9256b Rebuild: a chunk reported corrupt gets a second plain try before the range fails
Test and deploy / test (push) Failing after 24s
Test and deploy / deploy (push) Skipped
Range 161820 failed with "doesn't inflate, also fetched plain" though
the chunk was likely the compressed copy again: in flight, or spooled
before the prefetcher's targets were refreshed, when the plain flag was
set. The range now fails only on the third report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:21:44 +02:00
Daan MeijerandClaude Opus 5.5 fd06b16bd9 deploy.sh: remove the images a deploy replaced
Test and deploy / test (push) Successful in 30s
Test and deploy / deploy (push) Successful in 2m1s
Each deploy left a 321 MB app image dangling on the NVMe (13 after a day
of deploys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:21:39 +02:00
Daan MeijerandClaude Opus 5.5 40df52bafd Rebuild: 8 more ranges being fetched than the connections need
Test and deploy / test (push) Successful in 27s
Test and deploy / deploy (push) Successful in 1m3s
Ranges with a chunk or two left take fewer than perRange connections:
with loops/perRange + 2 ranges, 3.2 of 40 loops were idle on average
over 15 minutes (up to 9.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:36:24 +02:00
Daan MeijerandClaude Opus 5.5 27a26c9614 Rebuild: a release key taken by another group's writer gets the next number
Test and deploy / test (push) Successful in 25s
Test and deploy / deploy (push) Successful in 2m29s
Writers lock their group only, so two groups' batches can number the
same identity key at once (a spam post crossposted to 67 groups:
duplicate key ...:65, HTTP 500, the batch retried). Releases are now
inserted with ON CONFLICT (release_key) DO NOTHING, and those skipped
take the next number (up to 20 tries).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:22:49 +02:00
Daan MeijerandClaude Opus 5.5 b87203e41a edge-node: a range whose batch is too large goes on in smaller batches
Test and deploy / test (push) Successful in 22s
Test and deploy / deploy (push) Successful in 2m25s
Range 154218 (a.b.hdtv.tv-episodes) looped 13 times on HTTP 413: a
500k-article batch over the server's 256 MB body limit, though under
100k files. On a 413 the worker gives the range back and posts that
range's batches at half the articles and files from then on (again on
another 413, down to 1/64); stored batches stay. The message says how
large the batch was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:07:54 +02:00
Daan MeijerandClaude Opus 5.5 7b07e00f90 Rebuild: the stream waits up to an hour for a chunk
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Successful in 1m1s
Some groups' chunks take 20+ minutes while still coming in (range
154153: 17 MB in 22 minutes); the stream's 15-minute wait failed their
workers (two in 15 minutes). Stalled requests are given up separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:21:24 +02:00
Daan MeijerandClaude Opus 5.5 887df12ead Rebuild: give up stalled requests, not slow ones
Test and deploy / test (push) Successful in 24s
Test and deploy / deploy (push) Successful in 56s
a.b.0day.stuffz sends steadily but slowly (~400 kB a minute, 5-7
minutes per 100k chunk): the fixed deadline (90 s, doubling up to 360 s)
gave its chunks up again and again, and two of its ranges' worker
streams timed out. A stall is a request that got less than
NNTP_STALL_KB_PER_MINUTE (100) in the last minute (the stuck replies
got ~25 kB a minute); then its connection is closed and the chunk
fetched again. Replaces NNTP_CHUNK_DEADLINE_SECONDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:51:55 +02:00
Daan MeijerandClaude Opus 5.5 dbccec932c Rebuild: a chunk's deadline doubles each time it's hit
Test and deploy / test (push) Successful in 25s
Test and deploy / deploy (push) Successful in 1m8s
At 90 s a chunk that always takes longer would be given up forever
(range 155437's worker gave up waiting on its stream). Each deadline a
chunk hits doubles its next one (90, 180, 360 ... up to 16x), reset
once it comes in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:36 +02:00
Daan MeijerandClaude Opus 5.5 d81a202172 Rebuild: chunk deadline 90 s
Test and deploy / test (push) Successful in 25s
Test and deploy / deploy (push) Successful in 1m1s
At 180 s, 60 chunks were given up in 15 minutes: each held a connection
3 minutes, ~30% of the 40 connections' time, while finished chunks
took 24 s on average (Eweka slow this afternoon).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:06:30 +02:00
Daan MeijerandClaude Opus 5.5 fc1c64d5c0 Rebuild: drop NUL from batches; chunk deadline 180 s
Test and deploy / test (push) Successful in 24s
Test and deploy / deploy (push) Successful in 1m0s
A file name with a NUL ("…Brutal Erpresst.ra\u0000…") failed every
batch of range 155595 (HTTP 500, jsonb: \u0000 cannot be converted to
text), so the range looped. fromWire drops NUL from every string.

The chunk deadline was given up 258 times in 3 hours at 300 s (~1.4 a
minute, each holding a connection for 5 minutes): 180 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:12:27 +02:00
Daan MeijerandClaude Opus 5.5 f4c0417066 Rebuild: enough ranges being fetched for every connection
Test and deploy / test (push) Successful in 25s
Test and deploy / deploy (push) Successful in 1m1s
With 2 connections per range the prefetcher needs 20+ ranges with
chunks left; fully spooled ranges counted as ready (SPOOL_AHEAD_PER_LEASE)
but take no connection, so on small ranges 30 of 40 connections were
busy (13-15 ranges). Fresh ranges now also fill up to loops/perRange + 2
ranges being fetched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:44:09 +02:00
Daan MeijerandClaude Opus 5.5 40aa78cda3 Rebuild: a deadline per chunk; batches with a huge fragment
Test and deploy / test (push) Successful in 29s
Test and deploy / deploy (push) Successful in 1m30s
A worker waited on one chunk of a leased range for an hour (range
146477: five 15-minute stream timeouts, then the range took 63 s; 88
"stream: Peer disconnected" overnight). The silence timeout doesn't
catch a reply that trickles: a chunk still coming in after
NNTP_CHUNK_DEADLINE_SECONDS (300) is given up, its connection closed,
and fetched again. The report names the oldest request in flight (range,
chunk, age, bytes in) and counts deadlines.

Batches failed with "Maximum call stack size exceeded" (8 overnight):
Math.min(...segments) on a fragment with more segments than the stack
takes arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:30:04 +02:00
Daan MeijerandClaude Opus 5.5 e095232d89 REBUILD.md: the deployment
Test and deploy / test (push) Successful in 26s
Test and deploy / deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:10:44 +02:00
Daan MeijerandClaude Opus 5.5 84d3af5511 Rebuild: a proper deployment for voyager 2
Test and deploy / test (push) Successful in 48s
Test and deploy / deploy (push) Successful in 2m12s
Dockerfile with two targets: app (compiled AdonisJS, production
dependencies, Node 24.16 as in CI) and edge (the Rust worker, cargo
caches as build mounts). deploy/compose.yaml, project usenet-rebuild:
postgres (data on the HDD, catalog tablespace on the NVMe), migrate,
web, coordinator, edge; edge-ts and ace behind profiles.
deploy/deploy.sh (~/bin/deploy-usenet-rebuild on homelab): fetches the
commit (must be on rebuild), builds, migrates, recreates web,
coordinator and edge (the worker stopped first), never postgres.

CI deploys a push to rebuild that passes: the deploy key's forced
command passes "deploy-rebuild <sha>" on. A push to master no longer
deploys from this branch's workflow. deploy/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:01:38 +02:00
Daan MeijerandClaude Opus 5.5 678b3dd438 Rebuild: spread prefetch connections over ranges and groups
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped
At most PREFETCH_PER_RANGE (2) connections on a range and
PREFETCH_PER_GROUP (4) on a group; SPOOL_AHEAD_PER_LEASE 2 -> 4 so there
are targets enough. The report says how many ranges and groups are in
flight.

Homelab, 40 connections, alternated 12 min each: unlimited (~10 ranges
in flight) 281k and 167k articles/s; spread (~22 ranges) 319k and 296k,
chunks 11.5 s against 13-24 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:33:43 +02:00
Daan MeijerandClaude Opus 5.5 4bf526ab6e Rebuild: prefetch report samples what the connections do
Test and deploy / test (push) Successful in 44s
Test and deploy / deploy (push) Skipped
Once a second: pool connections with a command in flight, loops
fetching/writing/idle; per request GROUP time, XOVER time to the status
line and the transfer, transfers with a silence over 5 s. The old
"connections busy" (fetch time of the chunks finished in the minute)
said 59-75% while all 40 connections were in flight all the time:
dropped.

Homelab: 39-40 of 40 in flight, no idle loops; the status line comes at
once, the whole 8-35 s is the transfer, with data flowing (few gaps
over 5 s): ~3 MB per chunk at 0.1-0.4 MB/s per connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:50 +02:00
Daan MeijerandClaude Opus 5.5 8475969145 Rebuild: hedged chunk fetches and connection max age (both off by default)
Test and deploy / test (push) Successful in 24s
Test and deploy / deploy (push) Skipped
A chunk not in after NNTP_HEDGE_AFTER_SECONDS (10) is asked again on one
of NNTP_HEDGE_CONNECTIONS spare connections; the first answer wins and
the other connection is closed (lib/nntp/hedge.ts, tested). Connections
can be retired after NNTP_CONNECTION_MAX_MINUTES (±25%). The prefetch
report counts hedges and slow first requests by connection age.

Homelab, 15 min each: hedging on (40 + 6) 280k articles/s, off 273k.
The second request won about half of its races, but ~25-35% of first
requests were slow (>10 s) at every connection age, so neither helps:
Eweka is slow under our load, not on particular connections. Both
off by default; the age statistics stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:13:44 +02:00
Daan MeijerandClaude Opus 5.5 fc8ec9ff48 Rebuild: tools/compress_ab.ts, compressed vs plain XOVER side by side
Test and deploy / test (push) Successful in 24s
Test and deploy / deploy (push) Skipped
Each chunk fetched with and without XFEATURE COMPRESS GZIP on two
connections at the same moment. 30 chunks on homelab (24 bottom-500,
6 deferred): medians 2.9 s compressed, 3.2 s plain; 59 vs 350 bytes
per article. Slow fetches (27-52 s) hit either mode, rarely both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:36:43 +02:00
Daan MeijerandClaude Opus 5.5 8442c4fc44 Rebuild: 2-minute NNTP reply timeout; the Rust worker runs on homelab
Test and deploy / test (push) Successful in 1m9s
Test and deploy / deploy (push) Skipped
Eweka stopped answering on all 40 of the coordinator's long-lived
connections (quiet sockets; a fresh connection fetched the same chunk in
20 s): with a 10-minute timeout the prefetcher fetched one chunk a
minute. The timeout (silence, not duration) is 2 minutes now: 365-411k
articles/s after the restart. tools/probe_chunk.ts times a chunk
compressed and plain.

README and REBUILD.md: edge-node, the Rust worker, is the one running
(8x less CPU per header, 20x less memory than edge:node in production).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:05:37 +02:00
Daan MeijerandClaude Opus 5.5 a8a139c6d9 edge-node: the edge worker in Rust
Test and deploy / test (push) Successful in 27s
Test and deploy / deploy (push) Skipped
tools/edge-bench-rs/src/bin/edge-node.rs: leases ranges, reads the
coordinator's framed stream (one thread reads and inflates a frame
ahead, libdeflate), indexes with the shared RangeIndexer and posts
batches as gzipped JSON in order on a thread of its own (one batch may
wait). As commands/edge_node.ts: retries for network trouble and 5xx,
409 = lease lost, a batch once 500k articles or 100k files wait, a
chunk that doesn't inflate reported (fetched plain next time), the
range given back on SIGTERM/SIGINT, --max-mbit shared by the threads;
plus a heartbeat when no batch went out for a minute. One range per
thread (--threads, default one per core).

End to end against the web server (a.b.models 2017215-4017214 from
the capture, spooled): the server stored the same from both workers
(38,228 releases with every column, chunks, 481 candidates, rule
counts, 57 fragments with their JSON). Rust 9 s / 5.1 CPU-s, TypeScript
17 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:42:09 +02:00
Daan MeijerandClaude Opus 5.5 dce9967806 edge-bench-rs: a library for the worker; rules from the lease; the wire format
Test and deploy / test (push) Successful in 26s
Test and deploy / deploy (push) Skipped
- src/lib.rs: the indexing modules as a library, for edge-bench-rs and
  the coming edge-node worker.
- Rules as a lease brings them (priorities, dropped signatures and
  tools, per-article parts, sample share, executables) instead of
  DEFAULT_RULES hard-coded; RangeIndexer takes them and a resume point.
- Fragments carry everything the TypeScript Fragment has (poster,
  subject, groups, release): the server stores them for the merger.
- wire.rs: a batch as toWire makes it (camelCase JSON, payloads in
  base64). edge-bench-rs --wire writes it; check.ts --wire compares it
  with toWire of the TypeScript batch: identical on models-a and
  ath-2013.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:27:17 +02:00
Daan MeijerandClaude Opus 5.5 fc86dd00d1 Rebuild: a batch locks its range with an advisory lock, not the row
Test and deploy / test (push) Successful in 24s
Test and deploy / deploy (push) Skipped
BatchStore.store held the range's row (SELECT … FOR UPDATE) for the
whole batch transaction (15-20 s on the HDD), so the prefetcher's
spooled_chunks updates and the heartbeats of that range waited on it
(0.5 s on average): the prefetcher's NNTP connections sat idle. Now an
advisory lock per range serializes its batches, and the resume point is
checked again in the final, conditional update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:05:00 +02:00
Daan MeijerandClaude Opus 5.5 99fffe30f4 Rebuild: stats from a fixed-size sample, refreshed every 5 minutes, given up after 2
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped
TABLESAMPLE SYSTEM (0.5) read 30 s of random pages on the HDD at 7M
releases, growing with the catalog; a refresh that never finished kept
the header's totals 3.5 hours old (it blocked every later refresh).
Now TABLESAMPLE SYSTEM_ROWS (5000) (tsm_system_rows), every 5 minutes,
and a refresh is given up after 2 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:49:45 +02:00
Daan MeijerandClaude Opus 5.5 f642a49d7d Rebuild: post a batch early when 100k files wait; log failing stats refreshes
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped
A picture group's 500k articles made batches over the server's 256 MB
body limit: HTTP 413, the range given back and leased again in a loop
(148597: 39 times in 30 minutes). The worker now also posts once
100,000 completed or edge-cut files wait (RangeIndexer.pending).
A stats refresh that failed kept the previous totals silently; it
logs the error now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:45:00 +02:00
Daan MeijerandClaude Opus 5.5 508d8b5723 Rebuild: one advisory lock per group when writing releases, not per key
Test and deploy / test (push) Successful in 24s
Test and deploy / deploy (push) Skipped
A picture group's batch has 30-55k release keys; an advisory lock each
ran Postgres out of shared memory (max_locks_per_transaction), and those
batches failed with HTTP 500 (the ranges went back, nothing stored).
A chunk only matches releases of its range's group, so one lock per
group serializes exactly the writers that could both start an upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:10:48 +02:00
Daan MeijerandClaude Opus 5.5 7836436605 edge-bench-rs README: the a.b.models capture (image sets, re-uploads); where the captures are
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:55:19 +02:00
Daan MeijerandClaude Opus 5.5 cfe85abc3e Rebuild: chunks the server's compression cuts short are fetched plain
Test and deploy / test (push) Successful in 23s
Test and deploy / deploy (push) Skipped
Eweka sends some ranges' compressed XOVER truncated, the same bytes
every time, while the plain reply is whole (a.b.models 3317215-3367214:
1,675,600 bytes that don't inflate, twice; 50,000 lines plain;
tools/false_end.ts). Not a false end of reply. The worker's corrupt
report removed the chunk and the range was fetched again, with the same
bytes: one range was reported 540 times. (Before this morning's change
the inflate check waited for more data until the 10-minute timeout,
like voyager 1's 'chunk never answered' ranges.)

A reported chunk's start goes into ranges.plain_chunks; the prefetcher
fetches those over one connection without XFEATURE COMPRESS GZIP
(NntpPool.usePlain, opened on demand) and deflates them here. A plain
chunk reported again sets the range aside (status failed, error). The
capture (edge:bench --capture) fetches such a chunk plain too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:51:25 +02:00
Daan MeijerandClaude Opus 5.5 3737ea1542 Rebuild: tools to rank groups by real span (GROUP's low) and sample how full they are
Test and deploy / test (push) Successful in 22s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:55 +02:00
Daan MeijerandClaude Opus 5.5 0613984de9 Rebuild: re-uploads are releases of their own
Test and deploy / test (push) Successful in 26s
Test and deploy / deploy (push) Skipped
A poster re-uploading under the same name made one release of all
uploads: in a sample, 24% of releases with 2+ files had a file name more
than once (4.3% of files extra copies), 70% of those copies posted a day
or more apart; their NZBs listed each copy.

- File assembly keeps a re-upload's copy apart: a part already in every
  open copy, posted more than 3 h from them, starts another copy; a
  missing part fills the nearest copy (a re-send of what was missing),
  a re-sent part near a copy that has it is ignored as before.
- A key's files in a batch split into uploads where posting pauses for
  more than 3 h, counted from the latest article posted so far (a file
  that takes hours to post doesn't split it); each upload is judged and
  chunked on its own (ReleaseChunk.upload, .postedLast; candidates carry
  their upload).
- The catalog matches a chunk to the release of its key whose articles
  the range's group owns and whose posting window is within 3 h, else
  starts one (key, key:2, …): releases get identity_key, group_id,
  last_posted_at. Keys are locked in order (advisory locks), so
  concurrent batches neither deadlock nor both start one; the merger
  writes in key order too. The same upload multi-posted to other groups
  is a release per group.
- The gap: 91% of uploads post within 3 h; 73% of re-uploaded copies
  come later (releases:upload-gaps). releases:duplicates measures the
  duplicate names.

Both implementations; check.ts compares the windows and uploads too
(identical on all captures; Rust tests mirror the TypeScript cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:35 +02:00
Daan MeijerandClaude Opus 5.5 d0f3497ec9 Rebuild: completeness as indexed, file totals from any counter, pausable tails, progress by articles
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
- releases.complete is computed with every batch (files >= the
  counter's total) instead of only when a release settles after 6 quiet
  hours: the 'complete' filter matched nothing in a fresh catalog.
- A release's file total comes from any counter form (counterTotal:
  '(01/10)', 'File 27 of 132', …), not only [i/M]; the post rule's key
  keeps the [i/M] total, so keys are unchanged. Both implementations.
- groups.tail: a group's live tail can be paused (its history and owner
  priority stay), e.g. to let other groups' history go first.
- Stats: history by articles, and the current pass (history ranges
  before those deferred 2000 priorities behind) as its own figure; the
  header showed 0.1% of ranges counting 145k deferred ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:06:03 +02:00
Daan MeijerandClaude Opus 5.5 7b7175c229 Rebuild: an image without words around it is keyed as a stem
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
So a cover 'x.jpg' joins 'x.part01.rar' in a stem-named post; an image
set like 'IMG_008' keys as the stem 'IMG'. Both implementations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:23:47 +02:00
Daan MeijerandClaude Opus 5.5 14758a5b7a Rebuild: image sets, documents and audio albums as releases
Test and deploy / test (push) Successful in 22s
Test and deploy / deploy (push) Skipped
Picture groups made a release per image: 501k single-file releases in
the first 24 ranges of the bottom 500 groups (a.b.models 385k), ~120M
for all of them. Counters written out ('File 027 of 132', '[12of42]',
'104 of 472', '[2239-6389]', '02/17', '0097-1574'; only when the first
number is at most the second, so '[1948-12]' stays) and per-file sizes
('76.88 MB', '335555 Bytes') kept every subject unique.

- subjectRemainder removes those counters and sizes, and empty brackets.
- A subject-named release is bounded by its counter's total
  (counterTotal: the first form found): uploads a poster names alike
  stay apart, an upload's files (par2, nfo) together.
- Images and audio tracks don't take the poster-tag rule: the subject
  names the set or album. An image set without a counter: the image's
  name up to its last number, without numbers.
- Documents and comics (pdf, epub, mobi, azw3, djvu, cbr, cbz, cb7, chm,
  lit, doc, docx) are a release each: collections post hundreds under
  one subject ('cw-horror&mystery').

releases:regroup --group re-keys a group's indexed releases together:
cheerleaders 55,741 -> 3,165 releases, models 385,298 -> 40,313, German
audiobooks 4,106 -> 1,724, playstation 3,309 -> 2,345; comics and
e-books a release per item; boneless unchanged. Ported to edge-bench-rs;
check.ts: identical on all captures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:21:00 +02:00
Daan MeijerandClaude Opus 5.5 59481037fb Rebuild: a poster's constant tag no longer makes one release of all their uploads
Test and deploy / test (push) Successful in 28s
Test and deploy / deploy (push) Skipped
Release 145637 held four albums: the poster puts '[KiLL.dA.FuCkIn.
EaStErRaBbIt]----' before every upload, and the text outside the quotes
(when it names a release) was the key with the poster. The same made
one release of a poster's albums under '(PowBeertje80 Posted)' (a post
id, with the same [i/M]), of masterposts' films, and of one volume of
every set in a DVD9 post.

Now, when the file's stem describes something itself (12+ letters and
digits, two words of 3+ letters) and the text outside the quotes shares
no word with it (4+ letters as part of the stem, 3-letter words and
numbers whole; boilerplate like www/net/posted not counted; no 5+ digit
per-post id), that text is taken for the poster's tag and the stem
says which release the file is. Albums of track files ('FTA … Ketchup'),
'DIE_ZEIT_35' with '35_China_dl', 'FTD769997' posts, scene names with
abbreviated file names ('grp-sm') and generic sidecars ('file_id.diz')
keep their grouping.

releases:regroup re-keys a sample of the catalog with the current rule:
on homelab's test data 145637 → 4 albums, 111566 → 17 albums of 27
files, the rest of the subject releases that change mostly lose an ad
image ('Flyer-download-2-Day.gif'); stem releases unchanged. Ported to
edge-bench-rs: check.ts still finds all captures identical (ath 2013:
16 → 76 releases on both sides).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:23:33 +02:00
Daan MeijerandClaude Opus 5.5 3d256619e5 Rebuild spec: status (live tail, API, web interface, signatures)
Test and deploy / test (push) Successful in 45s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:50:54 +02:00
Daan MeijerandClaude Opus 5.5 b97edf02c5 Rebuild: the web interface, from voyager 1
Test and deploy / test (push) Successful in 42s
Test and deploy / deploy (push) Skipped
Voyager 1's page (search, sort, filters, poster filter, detail drawer,
NZB download; no framework, Usenet text only as textContent) on the new
catalog. Gone for now, as voyager 2 doesn't know them yet: passwords,
encrypted containers, spots, par2 lists, archive contents, the extension
filter (phase 2). New: a category filter, 'Collecting' for releases not
settled yet, history and live-tail progress in the header, a release's
files from its chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:47:59 +02:00
Daan MeijerandClaude Opus 5.5 8e52bff213 Rebuild: the catalog API and Newznab, from voyager 1
- /api (Newznab: caps, search, tvsearch, movie, music, book, get) for
  Prowlarr, Sonarr and Radarr: voyager 1's controller and XML on the new
  catalog; readable releases not known to miss files; NEWZNAB_API_KEY.
- /api/v1/releases (q, cat, readable, complete, source, poster, size,
  sort, paging), /api/v1/releases/:id with its files (decoded from the
  chunks; NzbService.files, shared with the NZB), /api/v1/stats
  (estimates, plus history and tail progress), /health. Open, as voyager
  1's catalog; the web interface uses it.
- release_category(name): voyager 1's categorizer without the archive
  contents, a generated column with a (category, posted_at) index.
- @adonisjs/static for the web interface (next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:45:47 +02:00
Daan MeijerandClaude Opus 5.5 57dfa3d749 Rebuild: rules:signatures, and voyager 1's 29 learned garbage signatures
rules:signatures publishes a rule set version whose dropped signatures
come from a file of tool<TAB>group lines (--add to add to the current
ones). database/data/voyager1_signatures.tsv: voyager 1's dropped ones
(>=100 probes, <=0.5% named), with their probe counts; the two builds
name the posting tools the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:42:00 +02:00
Daan MeijerandClaude Opus 5.5 fd0f2bde75 Rebuild: the live tail
coordinator:run plans the articles after each group's last range every
TAIL_EVERY_SECONDS (120): one LIST ACTIVE per name prefix gives every
group's newest article, and new articles become ranges at priority -1,
leased and prefetched before any history. A group gets a tail range once
it has TAIL_MIN_ARTICLES (100k, a chunk) new or its last planning is
TAIL_MAX_DELAY_MINUTES (15) ago, so quiet groups don't make a range every
round. History (planAll, hourly) is now only for groups not planned yet.
ranges:plan --tail plans one round.

Found on the way: with XFEATURE COMPRESS GZIP on, Eweka compresses LIST
ACTIVE's reply too ("215 NewsGroups Follow [COMPRESS=GZIP]"), and
listActive waited for a plain end that never came (groups:add --top too).
It reads the compressed reply now; the fake server in the test does both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:41:16 +02:00
Daan MeijerandClaude Opus 5.5 df89045f98 Rebuild spec: decision f, no chunks for random-named releases in the header pass
Test and deploy / test (push) Successful in 38s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:17:33 +02:00
Daan MeijerandClaude Opus 5.5 c5e0ad1f9f Rebuild: tools/xover_eras.ts, XOVER speed per era of a group
Test and deploy / test (push) Successful in 31s
Test and deploy / deploy (push) Skipped
Connections fetch chunks round-robin from several starting points of one
group at once, so each era sees the same server load; per era: seconds
per command (mean, median), articles/s per connection, bytes per article.
On a.b.boneless (2 connections, 10 min): 2008 24.9k, 2011 28.0k, 2019
22.6k, 2026 17.9k articles/s per connection; medians 2.3-3.0 s, means set
by a tail of slow commands (up to 41 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:57:19 +02:00
Daan MeijerandClaude Opus 5.5 356d8db6b6 Rebuild spec: point to the edge-bench-rs README
Test and deploy / test (push) Successful in 28s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:50 +02:00
Daan MeijerandClaude Opus 5.5 122a054eab edge-bench-rs: README with the findings and how to compare the outputs
Test and deploy / test (push) Successful in 33s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:45 +02:00
218 changed files with 17681 additions and 1653 deletions

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
export const controllers = { export const controllers = {
AccessTokens: () => import('#controllers/access_tokens_controller'), AccessTokens: () => import('#controllers/access_tokens_controller'),
NewAccount: () => import('#controllers/new_account_controller'), NewAccount: () => import('#controllers/new_account_controller'),
Newznab: () => import('#controllers/newznab_controller'),
Node: () => import('#controllers/node_controller'), Node: () => import('#controllers/node_controller'),
Profile: () => import('#controllers/profile_controller'), Profile: () => import('#controllers/profile_controller'),
Releases: () => import('#controllers/releases_controller'), Releases: () => import('#controllers/releases_controller'),
+11
View File
@@ -4,29 +4,39 @@ type ParamValue = string | number | bigint | boolean
export type ScannedRoutes = { export type ScannedRoutes = {
ALL: { ALL: {
'newznab': { paramsTuple?: []; params?: {} }
'auth.new_account.store': { paramsTuple?: []; params?: {} } 'auth.new_account.store': { paramsTuple?: []; params?: {} }
'auth.access_tokens.store': { paramsTuple?: []; params?: {} } 'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} }
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} } 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} } 'releases.index': { paramsTuple?: []; params?: {} }
'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.stats': { paramsTuple?: []; params?: {} }
'node.node.lease': { paramsTuple?: []; params?: {} } 'node.node.lease': { paramsTuple?: []; params?: {} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.batch': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.batch': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.heartbeat': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.heartbeat': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.finish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.finish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.release': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.release': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.corrupt': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
GET: { GET: {
'newznab': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} } 'releases.index': { paramsTuple?: []; params?: {} }
'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.stats': { paramsTuple?: []; params?: {} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
HEAD: { HEAD: {
'newznab': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} } 'releases.index': { paramsTuple?: []; params?: {} }
'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.stats': { paramsTuple?: []; params?: {} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
POST: { POST: {
@@ -38,6 +48,7 @@ export type ScannedRoutes = {
'node.node.heartbeat': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.heartbeat': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.finish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.finish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.release': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'node.node.release': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.corrupt': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
} }
declare module '@adonisjs/core/types/http' { declare module '@adonisjs/core/types/http' {
+6
View File
@@ -0,0 +1,6 @@
node_modules
build
tmp
.env
.git
edge/target
+5
View File
@@ -19,6 +19,11 @@ DB_USER=usenet_indexer
DB_PASSWORD= DB_PASSWORD=
DB_DATABASE=usenet_rebuild DB_DATABASE=usenet_rebuild
# Access (config/access.ts): open (NEWZNAB_API_KEY or none, no limits) or accounts
# (a Newznab key per user, tiers with daily limits; ace access:invite, access:tier, access:suspend)
# ACCESS=open
# SIGNUP=open|invite|closed (with accounts: invite unless set)
#-------------------------------------------------------------------- #--------------------------------------------------------------------
# CORS (configure allowed origins for API access) # CORS (configure allowed origins for API access)
#-------------------------------------------------------------------- #--------------------------------------------------------------------
+29 -6
View File
@@ -1,9 +1,12 @@
# Tests every push; a push to master that passes is deployed to homelab. # Tests every push; a push to rebuild (voyager 2) that passes is deployed to
# homelab.
# #
# Deploying: the job SSHes to homelab with DEPLOY_SSH_KEY. That key may only # Deploying: the job SSHes to homelab with DEPLOY_SSH_KEY. That key may only
# run ~/bin/deploy-usenet-indexer (a forced command in authorized_keys), # run ~/bin/deploy-usenet-indexer (a forced command in authorized_keys),
# which fetches the commit from this repo, builds the image, runs the # which passes "deploy-rebuild <sha>" on to ~/bin/deploy-usenet-rebuild
# migrations and recreates the app services (never postgres or redis). # (deploy/deploy.sh): it fetches the commit from this repo, builds the
# images, runs the migrations and recreates the app services (never
# postgres). See deploy/README.md.
name: Test and deploy name: Test and deploy
on: on:
@@ -63,9 +66,29 @@ jobs:
- name: Test - name: Test
run: node ace 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
deploy: deploy:
needs: test needs: [test, edge]
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/rebuild'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Deploy to homelab - name: Deploy to homelab
@@ -78,4 +101,4 @@ jobs:
chmod 600 ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes \ ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes \
daan@10.206.0.175 "deploy ${{ github.sha }}" daan@10.206.0.175 "deploy-rebuild ${{ github.sha }}"
+3
View File
@@ -24,3 +24,6 @@ yarn-error.log
# Platform specific # Platform specific
.DS_Store .DS_Store
# Generated by the dev tooling
.adonisjs/server/routes.json
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
#
# Voyager 2's images (deploy/compose.yaml):
# --target app the AdonisJS app: web server, coordinator, migrations, ace commands
# --target edge the Rust edge worker (edge/, bin edge-node)
# Node is pinned as in CI (.nvmrc): on 24.21, jsonschema 1.5.0 (used by Ace to
# validate commands) throws "Invalid URL" on $ref, so no ace command starts.
ARG NODE_VERSION=24.16
# The app, compiled, with production dependencies only.
FROM node:${NODE_VERSION}-bookworm AS app-build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN node ace build \
&& cd build \
&& npm ci --omit=dev
FROM node:${NODE_VERSION}-bookworm-slim AS app
ENV NODE_ENV=production
WORKDIR /app
COPY --from=app-build /src/build ./
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).
COPY --from=edge-build /usr/local/bin/edge-bench /usr/local/bin/edge-bench
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/edge-node"]
+132 -7
View File
@@ -1,4 +1,9 @@
# Rebuild: header-first indexer (spec, draft for approval) # Voyager 2: header-first indexer (concept)
The concept for what runs (since 2026-10-11; it started as the spec of a
rebuild, then a draft for approval). Voyager 1's CONCEPT.md and its
decisions D1–D42 are superseded; its domain model still holds, and its
features not yet here are the phase 2 backlog below.
Branch `rebuild`. Phase 1 finds releases from headers and makes their NZBs. Branch `rebuild`. Phase 1 finds releases from headers and makes their NZBs.
Everything learned from articles (names from yEnc headers, par2, archive Everything learned from articles (names from yEnc headers, par2, archive
@@ -54,8 +59,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; `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). the cap is shared by the node's processes).
Also built (2026-10-09): the live tail (new articles planned every 2
minutes from one LIST ACTIVE per name prefix, at priority −1: leased and
fetched before history); the catalog API, Newznab (`/api`) and the web
interface, from voyager 1 (the current build), with categories from the
name; voyager 1's 29 learned garbage signatures (`rules:signatures`,
`database/data/voyager1_signatures.tsv`).
Deployment (2026-10-10): `Dockerfile` (targets app and edge),
`deploy/compose.yaml` (project usenet-rebuild on homelab), `deploy/deploy.sh`;
a push to `rebuild` that passes CI is deployed. See `deploy/README.md`.
Not built yet: re-running ranges for a retracted rule (the counts and rule Not built yet: re-running ranges for a retracted rule (the counts and rule
versions are recorded), deployment (Dockerfile, compose), phase 2. versions are recorded), decision f, phase
2 (inspection of the probe queue: yEnc names, encryption, archives, par2;
passwords; spots).
## Target ## Target
@@ -101,7 +119,7 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`, 2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`,
2026-10-09, after two rounds of work): ~200–215 k headers/s per core on 2026-10-09, after two rounds of work): ~200–215 k headers/s per core on
recent a.b.boneless, ~320–340 k on a 2013 a.b.ath range, ~0.7 GB peak recent a.b.boneless, ~320–340 k on a 2013 a.b.ath range, ~0.7 GB peak
per process. **Rust** (`tools/edge-bench-rs`, the same RangeIndexer path per process. **Rust** (`edge/`, the same RangeIndexer path
over the same captures; `check.ts` finds every output identical, the over the same captures; `check.ts` finds every output identical, the
decoded payloads included): 3.5–3.8× less CPU (boneless 2.9 vs 11.1 decoded payloads included): 3.5–3.8× less CPU (boneless 2.9 vs 11.1
CPU-s per 2 M headers, ath 2.15 vs 7.5) and a third of the memory CPU-s per 2 M headers, ath 2.15 vs 7.5) and a third of the memory
@@ -111,6 +129,10 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
instead of ~6–7; the speed is still set by the NNTP connections. The instead of ~6–7; the speed is still set by the NNTP connections. The
cost: a second implementation of lib/headers, lib/release, lib/rules and cost: a second implementation of lib/headers, lib/release, lib/rules and
the chunk format, kept identical (check.ts) for as long as both exist. the chunk format, kept identical (check.ts) for as long as both exist.
Findings, how to run both and how to compare their outputs:
`edge/README.md`. Since 2026-10-09 the Rust worker
(`edge-node`, same crate) is the one running on homelab: ~8× less CPU
per header and 20× less memory than `edge:node` in production.
Per lease: compressed chunks of 10–50 k lines, in order → Per lease: compressed chunks of 10–50 k lines, in order →
parse subjects → skip crossposts outside their owner group (of the parse subjects → skip crossposts outside their owner group (of the
indexed groups in the article's Xref, the one with the lowest priority indexed groups in the article's Xref, the one with the lowest priority
@@ -186,11 +208,109 @@ of other groups whose counts show crossposts skipped for it (part 5). Start:
the 1000 largest `alt.binaries.*` groups by LIST ACTIVE (~266 k ranges of the 1000 largest `alt.binaries.*` groups by LIST ACTIVE (~266 k ranges of
2 M articles; boneless alone 80 k). 2 M articles; boneless alone 80 k).
## Not in phase 1 ## Corpus
Inspection and archive walks, par2/SFV naming, spots, NZB imports, the The grouping sweeps' dataset (2026-10-12; LOG.md 2026-10-12 (12)): the XOVER
after-probe garbage deletes, live indexing (the current indexer keeps chunks of ranges marked `capture`, kept on the HDD in
running and does those meanwhile). `corpus/<group>/<start>-<end>/` (edge-bench's layout, `range.json` once
complete), up to 300 GB. The prefetcher stores them as it fetches and reads
them from there afterwards, so a reset re-indexes them without the news
server. `deploy/corpus_sweep.py` marks more ranges each sweep (`capture`,
spread over groups and eras, the top-100 groups first), replays every stored
range with the deployed build's edge-bench (`replay`, ~seconds a range) and
reports suspects and the change since the previous build (`report`). A rule
change is judged on the whole corpus before a reset.
## Phase 2
Built: the prober (2026-10-11; `probe:run`, LOG.md 2026-10-11 (2)). It
reads the probe queue's first articles (`lone`, `par2`, `archive`) and
stores per release whether it's passworded and its inner name, which search,
the web interface and Newznab use.
Built: the par2 merge (2026-10-12; `releases:merge`, the `merger` service,
LOG.md 2026-10-12 (13)). The prober stores per probed file start the MD5 of
its first 16 KiB (`file_hashes`) and per par2 body its set's file list
(`par2_files`, `par2_sets`), which holds the same hash per file whatever the
file was renamed to. Every minute the merger merges the releases of one
group, posted within 48 hours of a release holding the set's par2, whose
files the set describes, into the one with the most files (chunks,
candidates, inspections, counts; the par2's shared name when none was
readable); `release_merges` keeps what went where. Obfuscated uploads whose
files were each a release of their own (`lone` candidates) become one.
**Backlog: voyager 1's features not in voyager 2** (CONCEPT.md decisions;
voyager 1's data exported to `/mnt/bulk/usenet-rebuild/v1-export/` on
homelab: `voyager1-tables.dump` with 2.76 M spots, upload signatures,
signature probes, dropped releases and groups; `voyager1-release-passwords.csv`
with 68,032 releases with a password):
| Feature | Voyager 1 | In voyager 2 |
|---|---|---|
| Spotnet spots | D29 | the spotter and the spot inspector (below); new spots only, the export's not imported |
| Posted NZBs read into releases (also NZBs inside archives) | D26, D28 | not yet |
| Passwords (from spots, NZB meta, names) | D29, D26 | from spots (title, text) and their NZBs' meta, in `release_passwords`; else only *whether* passworded (the prober); the export's 68 k not imported |
| SFV grouping and naming | D34 | not yet (SFV files aren't probe candidates) |
| Complete RAR listings by walking volume headers | D40 | no: the prober reads the first volume's first article only |
| 7z listings | D19 | no: `7z-tail` candidates are queued, not probed |
| Names from yEnc bodies for obfuscated subjects | D13, D21, D36 | no: obfuscated and per-article subjects are only counted (part 5); their message-ids aren't stored, so a body path needs those ranges read again |
| Garbage dropped after probing (no real name, no password) | D42 (its second filter) | the junk judge hides it (below); nothing dropped yet |
Live indexing (the tail) runs since 2026-10-09.
**Junk** (voyager 1's second filter, D42). After the header pass's rules
(garbage signatures, executables, per-article and obfuscated subjects), the
judge (`releases:judge`, service `judge`) judges releases hourly by what the
prober, the merges and the spots found, in this order (the first rule
wins): *dead* (every probed first article gone), *fake* (a film, episode
or music name whose content is a program: archive entries all
executables, or the file itself one by name or by its first bytes, sniffed
`exe`), *repeated* (the same first 16 KiB and size in 20+ releases),
*locked* (passworded, no password from a spot, its NZB or the title),
*unusable* (random names, no inner name, no par2 or inner-name merge, no
spot, a day after settling). Verdicts (`release_verdicts`, with the rule
set's version) hide releases from search (`judged` shows them); a verdict
whose rule no longer matches is taken back. Nothing is dropped yet.
**Sidecars** (the merger's third pass). An upload's .nfo, .sfv or .nzb
that came apart from it (another name, another counter: "[00/80] … .nzb"
beside "[01/94] … .part01.rar") was a release of its own. The settler
queues small settled releases (≤ 3 files, < 1 MB) in `fold_candidates`;
one whose files are all sidecars joins the poster's bigger release in the
same group posted within 10 minutes: the one sharing a word of 4+ letters
with its subject, else the one under the same counter total, else none
(`release_merges.via` 'sidecar').
**Spots** (after voyager 1's D29; `lib/spotnet` ported as is). Two
processes, one NNTP connection each:
- **The spotter** (`spots:read`, service `spotter`) reads free.pt's
overviews after the last article it saw (`spot_state`; the first time the
last `SPOT_BACKFILL`, 20,000, ~3 months), every `SPOT_EVERY_SECONDS`
(120). For each spot it reads the full header: the signature is checked
there as Spotweb does (Spotnet's keys 2–4; self-signed key 7 by hashcash),
and the X-XML gives the description, the website and the NZB's articles.
A password the title (`{{pw}}`) or the description names
(`passwordFromSpot`) is kept with the spot, with its source. A
delete/dispose message marks its target `removed` (a personal one only
from the same spotter within 5 days) and takes its links and passwords
back from the releases.
- **The spot inspector** (`spots:inspect`, service `spot-inspector`) looks
inside each listed spot, newest first: it reads the compressed NZB (≤
`SPOT_MAX_NZB_SEGMENTS` articles in alt.binaries.ftd), lists its files
in `spot_files` (named as the header pass names them, `parseSubject`),
and matches each file to the release the header pass built for it: the
same release identity (`releaseIdentity` over the file's subject and
poster) in one of its groups, the release posted nearest the file within
48 h. Matched releases are linked in `release_spots`. Placeholder NZBs
(`dummy`), gone articles (`nzb-gone`) and unreadable ones are noted on the
spot. Unmatched spots are matched again every hour; after
`SPOT_STANDALONE_AFTER_HOURS` (24) a spot becomes a release of its own
(source `spot`), whose NZB is the spot's (kept gzipped in `spots.nzb`).
- **Passwords are kept apart** in `release_passwords` (release, password,
source: `nzb` for the NZB's `<meta type="password">`, else `spot-title` or
`spot-text`, and the spot), not on the catalog row; the catalog only says
`passworded`.
## Decisions ## Decisions
@@ -201,3 +321,8 @@ c. Group list: editable, owner by priority number (above). **Accepted.**
d. Reposts: separate releases in phase 1, merged in phase 2. **Accepted.** d. Reposts: separate releases in phase 1, merged in phase 2. **Accepted.**
e. Headers through the coordinator's relay, with prefetch (part 1). e. Headers through the coordinator's relay, with prefetch (part 1).
**Accepted.** **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.
+5 -1
View File
@@ -53,6 +53,7 @@ export default defineConfig({
() => import('@adonisjs/cors/cors_provider'), () => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/auth/auth_provider'), () => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'), () => import('#providers/api_provider'),
() => import('@adonisjs/static/static_provider')
], ],
/* /*
@@ -103,7 +104,10 @@ export default defineConfig({
| the production build. | the production build.
| |
*/ */
metaFiles: [], metaFiles: [{
pattern: 'public/**',
reloadServer: false,
}],
hooks: { hooks: {
init: [ init: [
+26 -3
View File
@@ -1,13 +1,36 @@
import User from '#models/user' import User from '#models/user'
import db from '@adonisjs/lucid/services/db'
import { signupValidator } from '#validators/user' import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer' import UserTransformer from '#transformers/user_transformer'
import AccessService from '#services/access_service'
class InviteGone extends Error {}
export default class NewAccountController { export default class NewAccountController {
async store({ request, serialize }: HttpContext) { async store({ request, response, serialize }: HttpContext) {
const { fullName, email, password } = await request.validateUsing(signupValidator) const { fullName, email, password, invite } = await request.validateUsing(signupValidator)
if (AccessService.signup === 'closed') return response.forbidden({ error: 'Signup is closed' })
if (AccessService.signup === 'invite' && !invite) return response.forbidden({ error: 'An invite code is needed to sign up' })
const user = await User.create({ fullName, email, password }) // The user and their invite together: an invite that's gone rolls the signup back.
const user = await db
.transaction(async (trx) => {
const created = await User.create({ fullName, email, password }, { client: trx })
if (AccessService.signup !== 'invite') return created
const gives = await AccessService.redeem(invite!, created.id, trx)
if (!gives) throw new InviteGone()
if (gives.tier) {
AccessService.grantTier(created, gives.tier, gives.tier_days)
await created.useTransaction(trx).save()
}
return created
})
.catch((error) => {
if (error instanceof InviteGone) return null
throw error
})
if (!user) return response.forbidden({ error: 'Unknown or used invite code' })
const token = await User.accessTokens.create(user) const token = await User.accessTokens.create(user)
return serialize({ return serialize({
+141
View File
@@ -0,0 +1,141 @@
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
import NzbService from '#services/nzb_service'
import SearchService from '#services/search_service'
import AccessService, { AccessDenied } from '#services/access_service'
import { capsXml, errorXml, expandCategories, resultsXml, type NewznabServer } from '#lib/newznab/index'
const LIMITS = { max: 100, default: 50 }
/** Search functions and the category each defaults to when `cat` is absent. */
const SEARCH_FUNCTIONS: Record<string, number | undefined> = {
'search': undefined,
'tvsearch': 5000,
'tv-search': 5000,
'movie': 2000,
'movie-search': 2000,
'music': 3000,
'audio': 3000,
'book': 7000,
}
/** The configured key, if any. */
function configuredApiKey(): string | undefined {
const value: any = env.get('NEWZNAB_API_KEY')
const key = typeof value === 'string' ? value : value?.release()
return key || undefined
}
function int(value: unknown): number | undefined {
return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : undefined
}
/**
* Newznab API for Prowlarr, Sonarr, Radarr and the like (from voyager 1):
* GET /api?t=caps|search|tvsearch|movie|music|book|get&apikey=…
* Releases with readable names (or an inner name from the prober, which then is the title) that aren't known to miss files.
*/
export default class NewznabController {
async handle(ctx: HttpContext) {
const xml = (body: string, type = 'application/xml') => {
ctx.response.header('Content-Type', `${type}; charset=utf-8`)
return ctx.response.send(body)
}
try {
return await this.answer(ctx, xml)
} catch (error) {
if (error instanceof AccessDenied) return xml(errorXml(error.newznabCode, error.message))
throw error
}
}
private async answer(ctx: HttpContext, xml: (body: string, type?: string) => void) {
const { request, response } = ctx
const qs = request.qs()
const t = String(qs.t ?? '')
// With accounts the key is a user's (AccessService); else the one configured, if any.
const grant = await AccessService.authorize(ctx, qs.apikey)
const apiKey = grant ? grant.user.apiKey : configuredApiKey()
if (!grant && apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials'))
const server = this.server(request, apiKey)
if (t === 'caps') return xml(capsXml(server, LIMITS, { available: AccessService.accounts && AccessService.signup !== 'closed', open: AccessService.accounts && AccessService.signup === 'open' }))
if (t === 'get') {
const id = int(qs.id)
if (id === undefined) return xml(errorXml(200, 'Missing parameter (id)'))
// Unknown ids and releases past the tier's retention aren't counted.
if (!(await AccessService.checkRelease(grant, id))) return xml(errorXml(300, 'No such item'))
await AccessService.charge(grant, 'grab')
const nzb = await NzbService.build(id)
if (!nzb) return xml(errorXml(300, 'No such item'))
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
response.header('Content-Disposition', `attachment; filename="${filename}"`)
return xml(nzb.xml, 'application/x-nzb')
}
if (!(t in SEARCH_FUNCTIONS)) return xml(errorXml(202, `No such function (${t || 'none'})`))
await AccessService.charge(grant, 'api')
const requested = String(qs.cat ?? '')
.split(',')
.map((c) => int(c.trim()))
.filter((c): c is number => c !== undefined)
const fallback = SEARCH_FUNCTIONS[t]
const categories = expandCategories(requested.length ? requested : fallback ? [fallback] : [])
const limit = Math.min(int(qs.limit) ?? LIMITS.default, LIMITS.max)
const offset = int(qs.offset) ?? 0
// ID-only lookups (tvdbid, imdbid, …) aren't supported: an empty result rather than everything.
const q = typeof qs.q === 'string' ? qs.q.trim() : ''
const idOnly = !q && ['tvdbid', 'tvmazeid', 'rid', 'imdbid', 'tmdbid'].some((p) => qs[p])
const result = idOnly
? null
: await SearchService.search({
q,
categories,
readable: true,
notIncomplete: true,
maxAgeDays: AccessService.maxAgeDays(grant, int(qs.maxage)),
season: int(qs.season),
episode: int(qs.season) !== undefined ? int(qs.ep) : undefined,
sort: 'newest',
perPage: limit,
offset,
})
const items = (result?.releases ?? []).map((r) => {
const tv = /(?:^|[._ -])S(\d{1,2})E(\d{1,3})/i.exec(r.name)
return {
id: r.id,
// An obfuscated release by what the prober found inside, without its extension (what clients parse).
title: r.readable || !r.innerName ? r.name : r.innerName.replace(/\.[a-z0-9]{2,4}$/i, ''),
category: r.category ?? 8010,
size: r.size,
files: r.fileCount,
postedAt: r.postedAt ? new Date(r.postedAt) : null,
poster: r.poster,
groups: r.groups ?? [],
// From the prober (phase 2): encrypted archive entries or headers.
password: r.passworded === true,
season: tv ? `S${tv[1].padStart(2, '0')}` : undefined,
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
}
})
// What's left of the day, for clients that show it (newznab:apilimits).
const limits = grant && {
apiCurrent: grant.usage.apiHits,
apiMax: grant.limits.apiHits,
grabCurrent: grant.usage.grabs,
grabMax: grant.limits.grabs,
}
return xml(resultsXml(server, items, offset, result?.total ?? 0, limits || undefined), 'application/rss+xml')
}
/** Download links carry the caller's key. */
private server(request: HttpContext['request'], apiKey: string | undefined): NewznabServer {
const proto = request.header('x-forwarded-proto') ?? 'http'
const host = request.header('x-forwarded-host') ?? request.header('host') ?? 'localhost'
return { title: 'Voyager 2', baseUrl: `${proto}://${host}`, apiKey }
}
}
+6 -2
View File
@@ -6,8 +6,12 @@ import BatchStore, { StaleBatch } from '#services/batch_store'
import Spool from '#services/spool' import Spool from '#services/spool'
import { KEEPALIVE, encodeFrame, fromWire, type WireBatch } from '#lib/edge/index' import { KEEPALIVE, encodeFrame, fromWire, type WireBatch } from '#lib/edge/index'
/** How long the stream waits for a chunk the prefetcher hasn't spooled yet. */ /**
const CHUNK_WAIT_MS = 15 * 60 * 1000 * 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). */ /** 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 const KEEPALIVE_MS = 20_000
+12 -1
View File
@@ -1,8 +1,19 @@
import UserTransformer from '#transformers/user_transformer' import UserTransformer from '#transformers/user_transformer'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import AccessService from '#services/access_service'
export default class ProfileController { export default class ProfileController {
/** The user, their tier, Newznab key and what they used today. */
async show({ auth, serialize }: HttpContext) { async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.use('api').getUserOrFail())) const user = auth.use('api').getUserOrFail()
return serialize({ user: UserTransformer.transform(user), access: await AccessService.summary(user) })
}
/** POST /api/v1/account/api-key: a new Newznab key (the old one stops working). */
async resetApiKey({ auth }: HttpContext) {
const user = auth.use('api').getUserOrFail()
user.apiKey = AccessService.newKey()
await user.save()
return { apiKey: user.apiKey }
} }
} }
+67 -17
View File
@@ -1,27 +1,77 @@
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import NzbService from '#services/nzb_service' import NzbService from '#services/nzb_service'
import AccessService from '#services/access_service'
import SearchService, { type SortOrder } from '#services/search_service'
import { expandCategories } from '#lib/newznab/index'
/** The catalog (REBUILD.md, part 9): a name search and a release's NZB. */ const flag = (value: unknown) => value === '1' || value === 'true' || value === true
/** A size in MB from the query string, as bytes. */
function megabytes(value: unknown): number | undefined {
const mb = Number(value)
return value !== undefined && value !== '' && Number.isFinite(mb) && mb >= 0 ? Math.round(mb * 1024 * 1024) : undefined
}
/** The catalog for the web interface (REBUILD.md, part 9). */
export default class ReleasesController { export default class ReleasesController {
/** Releases whose name has every word of `q` (dots, dashes, underscores and spaces alike), newest first. */ /** GET /api/v1/releases?q=&cat=5000,2040&readable=1&complete=1&unlocked=1&min_size=&max_size=(MB)&source=&poster=&sort=&page=&per_page= */
async index({ request }: HttpContext) { async index(ctx: HttpContext) {
const q = String(request.input('q', '')).trim() const qs = ctx.request.qs()
const limit = Math.min(Number(request.input('limit', 50)) || 50, 200) const grant = await AccessService.authorize(ctx, qs.apikey)
const query = db await AccessService.charge(grant, 'api')
.from('releases') const categories = String(qs.cat ?? '')
.select('id', 'name', 'poster', 'groups', 'posted_at', 'files', 'file_total', 'bytes', 'complete', 'readable') .split(',')
.orderBy('posted_at', 'desc') .map((c) => Number(c.trim()))
.limit(limit) .filter((c) => Number.isInteger(c) && c > 0)
for (const word of q.split(/[\s._-]+/).filter(Boolean)) query.whereILike('name', `%${word.replace(/[\\%_]/g, '\\$&')}%`) return SearchService.search({
return { releases: await query } maxAgeDays: AccessService.maxAgeDays(grant),
q: typeof qs.q === 'string' ? qs.q : undefined,
categories: categories.length ? expandCategories(categories) : undefined,
readable: flag(qs.readable),
complete: flag(qs.complete),
unlocked: flag(qs.unlocked),
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined,
poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined,
minSize: megabytes(qs.min_size),
maxSize: megabytes(qs.max_size),
sort: qs.sort as SortOrder | undefined,
page: Number(qs.page) || 1,
perPage: Number(qs.per_page) || 50,
// The page shows "1,000+"; counting further made filter clicks slow.
countLimit: 1000,
})
} }
async nzb({ params, response }: HttpContext) { /** GET /api/v1/releases/:id, with its files */
async show(ctx: HttpContext) {
const grant = await AccessService.authorize(ctx, ctx.request.qs().apikey)
if (!(await AccessService.checkRelease(grant, Number(ctx.params.id)))) return ctx.response.notFound({ error: 'Release not found' })
await AccessService.charge(grant, 'api')
const release = await SearchService.release(Number(ctx.params.id))
return release ?? ctx.response.notFound({ error: 'Release not found' })
}
/** GET /api/v1/releases/:id/nzb (?apikey= for a plain link, as the web interface's) */
async nzb(ctx: HttpContext) {
const { params, response } = ctx
const grant = await AccessService.authorize(ctx, ctx.request.qs().apikey)
if (!(await AccessService.checkRelease(grant, Number(params.id)))) return response.notFound({ error: 'Release not found' })
await AccessService.charge(grant, 'grab')
const nzb = await NzbService.build(Number(params.id)) const nzb = await NzbService.build(Number(params.id))
if (!nzb) return response.notFound({ error: 'No such release' }) if (!nzb) return response.notFound({ error: 'Release not found' })
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
response.header('Content-Type', 'application/x-nzb') response.header('Content-Type', 'application/x-nzb')
response.header('Content-Disposition', `attachment; filename="${nzb.name.replace(/["\\/]/g, '_')}.nzb"`) response.header('Content-Disposition', `attachment; filename="${filename}"`)
return nzb.xml return response.send(nzb.xml)
}
/** GET /api/v1/access: whether the web interface needs an account, and how one is made. */
async access() {
return { mode: AccessService.mode, signup: AccessService.signup }
}
/** GET /api/v1/stats (open: what the index holds, not what's in it) */
async stats() {
return SearchService.stats()
} }
} }
+3
View File
@@ -8,6 +8,9 @@ export default class HttpExceptionHandler extends ExceptionHandler {
*/ */
protected debug = !app.inProduction protected debug = !app.inProduction
/** A refused request (AccessService: no key, a limit reached) is an answer, not a fault. */
protected ignoreCodes = ['E_ACCESS_DENIED']
/** /**
* The method is used for handling errors and returning * The method is used for handling errors and returning
* response to the client * response to the client
+8
View File
@@ -1,4 +1,6 @@
import { randomBytes } from 'node:crypto'
import { UserSchema } from '#database/schema' import { UserSchema } from '#database/schema'
import { beforeCreate } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash' import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers' import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
@@ -8,6 +10,12 @@ export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User) static accessTokens = DbAccessTokensProvider.forModel(User)
declare currentAccessToken?: AccessToken declare currentAccessToken?: AccessToken
/** The Newznab key (AccessService), known on the model right away rather than from the column's default. */
@beforeCreate()
static assignApiKey(user: User) {
user.apiKey ??= randomBytes(16).toString('hex')
}
get initials() { get initials() {
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@') const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
if (first && last) { if (first && last) {
+171
View File
@@ -0,0 +1,171 @@
import { randomBytes } from 'node:crypto'
import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import User from '#models/user'
import { ACCESS, SIGNUP, TIERS, type TierLimits } from '#config/access'
export type Use = 'api' | 'grab'
/** Newznab's error codes for access (the catalog answers with the HTTP status). */
const STATUS = { 100: 401, 101: 403, 102: 403, 500: 429, 501: 429 } as const
/** Why a request is refused; the catalog's answer (Newznab catches it for its own). */
export class AccessDenied extends Exception {
constructor(
readonly newznabCode: keyof typeof STATUS,
message: string
) {
super(message, { status: STATUS[newznabCode], code: 'E_ACCESS_DENIED' })
}
async handle(error: this, ctx: HttpContext) {
ctx.response.status(error.status).send({ error: error.message, code: error.newznabCode })
}
}
export interface Usage {
apiHits: number
grabs: number
}
/** A caller let in: who, their tier as it is now, and what they used today. */
export interface Grant {
user: User
tier: string
limits: TierLimits
usage: Usage
}
const COLUMNS: Record<Use, string> = { api: 'api_hits', grab: 'grabs' }
const TODAY = `(now() AT TIME ZONE 'UTC')::date`
const DAY_MS = 86_400_000
/** Accounts, tiers and their daily limits (config/access.ts). */
export default class AccessService {
/** From config/access.ts; tests switch it. */
static mode: 'open' | 'accounts' = ACCESS
static signup: 'open' | 'invite' | 'closed' = SIGNUP
static get accounts() {
return this.mode === 'accounts'
}
static newKey() {
return randomBytes(16).toString('hex')
}
/** The user's tier as it is now: one that ran out (or isn't configured) is free. */
static tierOf(user: User): { tier: string; limits: TierLimits } {
const expired = user.tierUntil !== null && user.tierUntil.toMillis() <= Date.now()
const tier = !expired && TIERS[user.tier] ? user.tier : 'free'
return { tier, limits: TIERS[tier] }
}
/** The user a request names: the web interface's token, else an apikey (Newznab, the catalog's NZB links). */
static async caller(ctx: HttpContext, apikey?: unknown): Promise<User | null> {
const guard = ctx.auth.use('api')
if (guard.isAuthenticated || (await guard.check())) return guard.user!
if (typeof apikey === 'string' && /^[0-9a-f]{32}$/.test(apikey)) return User.findBy('apiKey', apikey)
return null
}
/** Lets a request in (counting nothing yet); null when access is open. */
static async authorize(ctx: HttpContext, apikey?: unknown): Promise<Grant | null> {
if (!this.accounts) return null
const user = await this.caller(ctx, apikey)
if (!user) throw new AccessDenied(100, 'Incorrect user credentials')
if (user.suspended) throw new AccessDenied(101, 'Account suspended')
return { user, ...this.tierOf(user), usage: await this.usage(user.id) }
}
/** Counts one use against the grant's tier, or refuses it once the day's limit is reached. */
static async charge(grant: Grant | null, use: Use) {
if (!grant) return
const usage = await this.count(grant.user.id, use, use === 'api' ? grant.limits.apiHits : grant.limits.grabs)
if (!usage) throw use === 'api' ? new AccessDenied(500, 'Request limit reached') : new AccessDenied(501, 'Download limit reached')
grant.usage = usage
}
/**
* Adds one use to today's row unless the limit is reached (null then): one
* statement, so concurrent requests can't both take the last one.
*/
static async count(userId: number, use: Use, limit: number | null): Promise<Usage | null> {
if (limit !== null && limit <= 0) return null
const column = COLUMNS[use]
const { rows } = await db.rawQuery(
`INSERT INTO usage (user_id, day, ${column}) VALUES (?, ${TODAY}, 1)
ON CONFLICT (user_id, day) DO UPDATE SET ${column} = usage.${column} + 1
${limit === null ? '' : `WHERE usage.${column} < ?`}
RETURNING api_hits, grabs`,
limit === null ? [userId] : [userId, limit]
)
return rows[0] ? { apiHits: rows[0].api_hits, grabs: rows[0].grabs } : null
}
static async usage(userId: number): Promise<Usage> {
const { rows } = await db.rawQuery(`SELECT api_hits, grabs FROM usage WHERE user_id = ? AND day = ${TODAY}`, [userId])
return { apiHits: rows[0]?.api_hits ?? 0, grabs: rows[0]?.grabs ?? 0 }
}
/** A search's age bound: the asked one within the tier's retention. */
static maxAgeDays(grant: Grant | null, requested?: number): number | undefined {
const retention = grant?.limits.retentionDays
if (!retention) return requested
return requested ? Math.min(requested, retention) : retention
}
/** Refuses a release posted before the tier's retention (one without a date is let through). */
static checkPosted(grant: Grant | null, postedAt: Date | string | null) {
const retention = grant?.limits.retentionDays
if (!retention || !postedAt) return
if (new Date(postedAt).getTime() < Date.now() - retention * DAY_MS) {
throw new AccessDenied(102, `Older than your tier's retention (${retention} days)`)
}
}
/** Whether a release exists, refusing it when it's older than the tier sees; asked before an NZB is counted. */
static async checkRelease(grant: Grant | null, id: number): Promise<boolean> {
const release = await db.from('releases').where('id', id).select('posted_at').first()
if (!release) return false
this.checkPosted(grant, release.posted_at)
return true
}
/** Takes an unused invite for a new user: what it gives, or null when it's unknown or used. */
static async redeem(code: string, userId: number, trx: TransactionClientContract) {
const { rows } = await trx.rawQuery(
`UPDATE invites SET used_by = ?, used_at = now() WHERE code = ? AND used_by IS NULL AND used_at IS NULL RETURNING tier, tier_days`,
[userId, code]
)
return (rows[0] as { tier: string | null; tier_days: number | null } | undefined) ?? null
}
/**
* Gives a user a tier for `days` (null: without end). The same tier still
* running is extended from where it ends, so a renewal paid early loses nothing.
*/
static grantTier(user: User, tier: string, days: number | null) {
if (!TIERS[tier]) throw new Error(`No such tier: ${tier} (config/access.ts has ${Object.keys(TIERS).join(', ')})`)
const now = DateTime.utc()
const running = user.tier === tier && user.tierUntil && user.tierUntil > now ? user.tierUntil : now
user.tier = tier
user.tierUntil = days === null ? null : running.plus({ days })
}
/** What a profile shows of the account's access. */
static async summary(user: User) {
const { tier, limits } = this.tierOf(user)
return {
mode: this.mode,
tier,
tierUntil: tier === user.tier ? user.tierUntil : null,
apiKey: user.apiKey,
limits,
usage: await this.usage(user.id),
}
}
}
+161 -41
View File
@@ -1,7 +1,8 @@
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import type { EdgeBatch, ProbeCandidate, ReleaseChunk } from '#lib/edge/index' import { UPLOAD_GAP_MS, type EdgeBatch, type ProbeCandidate, type ReleaseChunk } from '#lib/edge/index'
import ChunkPartitions from '#services/chunk_partitions' import ChunkPartitions from '#services/chunk_partitions'
import { decodeChunk } from '#lib/chunks/index'
export class StaleBatch extends Error {} export class StaleBatch extends Error {}
@@ -23,7 +24,11 @@ class BatchStore {
async store(rangeId: number, nodeId: number, batch: EdgeBatch): Promise<StoredBatch> { async store(rangeId: number, nodeId: number, batch: EdgeBatch): Promise<StoredBatch> {
await ChunkPartitions.ensure() await ChunkPartitions.ensure()
return db.transaction(async (trx) => { return db.transaction(async (trx) => {
const range = await trx.from('ranges').where('id', rangeId).forUpdate().first() // 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])
const range = await trx.from('ranges').where('id', rangeId).first()
if (!range || range.status !== 'leased' || Number(range.node_id) !== nodeId) if (!range || range.status !== 'leased' || Number(range.node_id) !== nodeId)
throw new StaleBatch(`Range ${rangeId} is not leased to this node`) throw new StaleBatch(`Range ${rangeId} is not leased to this node`)
if (Number(range.next_id) !== batch.from) if (Number(range.next_id) !== batch.from)
@@ -73,29 +78,88 @@ class BatchStore {
const rows = batch.fragments.slice(i, i + 2000).map((f) => ({ const rows = batch.fragments.slice(i, i + 2000).map((f) => ({
range_id: rangeId, range_id: rangeId,
file_key: f.fileKey, file_key: f.fileKey,
article_number: Math.min(...f.segments.map((s) => s[3])), // 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, segments: f.segments.length,
fragment: JSON.stringify(f), fragment: JSON.stringify(f),
})) }))
await trx.insertQuery().table('fragments').multiInsert(rows) await trx.insertQuery().table('fragments').multiInsert(rows)
} }
await trx const moved = await trx
.from('ranges') .from('ranges')
.where('id', rangeId) .where('id', rangeId)
.where('status', 'leased')
.where('node_id', nodeId)
.where('next_id', batch.from)
.update({ .update({
next_id: batch.next, next_id: batch.next,
headers: db.raw('headers + ?', [batch.headers]), 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]),
heartbeat_at: new Date(), heartbeat_at: new Date(),
}) })
if (Number(moved) !== 1 && !(Array.isArray(moved) && moved.length === 1)) throw new StaleBatch(`Range ${rangeId} moved on meanwhile`)
return result 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)
}
}
}
/** /**
* Release aggregates in the catalog, one write-once chunk per release, and * Release aggregates in the catalog, one write-once chunk per release, and
* probe candidates (a batch's, or files the fragment merger completed). * probe candidates (a batch's, or files the fragment merger completed).
* Releases are sorted by key, so concurrent writers lock them in the same order. * 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.
*/ */
async writeReleases( async writeReleases(
trx: TransactionClientContract, trx: TransactionClientContract,
@@ -104,38 +168,35 @@ class BatchStore {
releases: ReleaseChunk[], releases: ReleaseChunk[],
candidates: ProbeCandidate[] candidates: ProbeCandidate[]
) { ) {
const out = { newReleases: 0, candidates: 0, ids: new Map<string, number>() } const out = { newReleases: 0, candidates: 0 }
const ids = out.ids const ids = new Map<string, number>()
// Releases, sorted by key so concurrent batches lock them in the same order. const slot = (key: string, upload: number) => `${key}\n${upload}`
const sorted = [...releases].sort((a, b) => a.key.localeCompare(b.key)) 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 sorted = [...releases].sort((a, b) => a.key.localeCompare(b.key) || a.upload - b.upload)
for (let i = 0; i < sorted.length; i += 1000) { for (let i = 0; i < sorted.length; i += 1000) {
const chunk = sorted.slice(i, i + 1000) const part = sorted.slice(i, i + 1000)
const { rows } = await trx.rawQuery( const windows = part.map((r, idx) => ({ idx, key: r.key, first: r.postedAt, last: r.postedLast ?? r.postedAt }))
`INSERT INTO releases (release_key, name, source, poster, groups, posted_at, file_total, files, segments, bytes, chunks, first_article, last_article, readable, has_par2) const { rows: matches } = await trx.rawQuery(
SELECT v.key, v.name, v.source, v.poster, v.groups, to_timestamp(v.posted_at / 1000.0), v.file_total, v.files, v.segments, v.bytes, 1, v.first_article, v.last_article, v.readable, v.has_par2 `SELECT DISTINCT ON (v.idx) v.idx, r.id
FROM jsonb_to_recordset(?::jsonb) AS v(key text, name text, source text, poster text, groups jsonb, posted_at bigint, file_total int, files int, segments bigint, bytes bigint, first_article bigint, last_article bigint, readable boolean, has_par2 boolean) FROM jsonb_to_recordset(?::jsonb) AS v(idx int, key text, first bigint, last bigint)
ON CONFLICT (release_key) DO UPDATE SET JOIN releases r ON r.identity_key = v.key AND r.group_id = ?
files = releases.files + excluded.files, WHERE v.first IS NULL OR r.posted_at IS NULL
segments = releases.segments + excluded.segments, 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))
bytes = releases.bytes + excluded.bytes, ORDER BY v.idx, r.last_posted_at DESC NULLS LAST, r.id DESC`,
chunks = releases.chunks + 1, [JSON.stringify(windows), groupId, UPLOAD_GAP_MS, UPLOAD_GAP_MS]
posted_at = least(releases.posted_at, excluded.posted_at), )
file_total = coalesce(releases.file_total, excluded.file_total), const joined = new Map<number, number>(matches.map((m: { idx: number; id: string }) => [m.idx, Number(m.id)]))
first_article = least(releases.first_article, excluded.first_article), await this.joinAcrossGroups(trx, part, windows, groupId, joined)
last_article = greatest(releases.last_article, excluded.last_article), const row = (r: ReleaseChunk) => ({
readable = releases.readable OR excluded.readable,
has_par2 = releases.has_par2 OR excluded.has_par2,
last_seen_at = now()
RETURNING id, release_key, (xmax = 0) AS inserted`,
[
JSON.stringify(
chunk.map((r) => ({
key: r.key,
name: r.name, name: r.name,
source: r.source, source: r.source,
poster: r.poster, poster: r.poster,
groups: r.groups, groups: r.groups,
posted_at: r.postedAt, first: r.postedAt,
last: r.postedLast ?? r.postedAt,
file_total: r.fileTotal, file_total: r.fileTotal,
files: r.files, files: r.files,
segments: r.segments, segments: r.segments,
@@ -144,20 +205,79 @@ class BatchStore {
last_article: r.lastArticle, last_article: r.lastArticle,
readable: r.readable, readable: r.readable,
has_par2: r.hasPar2, 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.
complete = CASE WHEN coalesce(r.file_total, a.file_total) IS NULL THEN NULL 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)]
) )
for (const row of rows) { part.forEach((r, idx) => joined.has(idx) && ids.set(slot(r.key, r.upload), joined.get(idx)!))
ids.set(row.release_key, Number(row.id)) }
if (row.inserted) out.newReleases++ 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, …
const { rows: counts } = await trx.rawQuery(`SELECT identity_key, count(*) 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) }
}
// 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)
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
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)
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)
}
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))!))
}
} }
} }
// One write-once chunk per release. // One write-once chunk per release.
for (let i = 0; i < sorted.length; i += 500) { for (let i = 0; i < sorted.length; i += 500) {
const rows = sorted.slice(i, i + 500).map((r) => ({ const rows = sorted.slice(i, i + 500).map((r) => ({
release_id: ids.get(r.key)!, release_id: ids.get(slot(r.key, r.upload))!,
range_id: rangeId, range_id: rangeId,
batch_from: batchFrom, batch_from: batchFrom,
files: r.files, files: r.files,
@@ -167,12 +287,12 @@ class BatchStore {
if (rows.length > 0) await trx.insertQuery().table('release_chunks').multiInsert(rows) if (rows.length > 0) await trx.insertQuery().table('release_chunks').multiInsert(rows)
} }
const kept = candidates.filter((c) => ids.has(c.releaseKey)) const kept = candidates.filter((c) => ids.has(slot(c.releaseKey, c.upload ?? 0)))
for (let i = 0; i < kept.length; i += 2000) { for (let i = 0; i < kept.length; i += 2000) {
const rows = kept const rows = kept
.slice(i, i + 2000) .slice(i, i + 2000)
.map((c) => ({ .map((c) => ({
release_id: ids.get(c.releaseKey)!, release_id: ids.get(slot(c.releaseKey, c.upload ?? 0))!,
kind: c.kind, kind: c.kind,
message_id: c.messageId, message_id: c.messageId,
file: c.file, file: c.file,
+38
View File
@@ -0,0 +1,38 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import coordinatorConfig from '#config/coordinator'
/** How often the catalog's size is measured (pg_tablespace_size walks its files). */
const CHECK_MS = 60_000
/**
* The catalog's room on the NVMe (CATALOG_MAX_NVME_GB, like voyager 1's
* BACKFILL_MAX_NVME_GB): once its tablespace (the database's
* default_tablespace) is over the cap, only the tail and the stored ranges
* (priority below 0) are leased and prefetched, and history waits. After
* reset 24 the catalog grew ~3.7 GB an hour, root down to 31 GB free with
* 600k history ranges to go (LOG.md 2026-10-14 (53)). Off when unset.
*/
class CatalogSpace {
private checkedAt = 0
private over = false
async full(): Promise<boolean> {
const cap = coordinatorConfig.catalogMaxNvmeGb
if (!cap) return false
if (Date.now() - this.checkedAt < CHECK_MS) return this.over
this.checkedAt = Date.now()
const { rows } = await db.rawQuery(
`SELECT pg_tablespace_size(coalesce(nullif(current_setting('default_tablespace'), ''), 'pg_default')) AS bytes`
)
const bytes = Number(rows[0].bytes)
const was = this.over
this.over = bytes > cap * 1e9
if (this.over !== was) {
logger.info(`Catalog ${(bytes / 1e9).toFixed(1)} GB of ${cap}: history ${this.over ? 'paused (tail and stored ranges only)' : 'resumed'}.`)
}
return this.over
}
}
export default new CatalogSpace()
+90
View File
@@ -0,0 +1,90 @@
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { unzip } from 'node:zlib'
import { promisify } from 'node:util'
import coordinatorConfig from '#config/coordinator'
import type { ChunkSpan } from '#services/spool'
const inflate = promisify(unzip)
export interface CorpusRange {
group: string
start: number
end: number
}
/**
* Stored XOVER chunks of the ranges marked `capture` (REBUILD.md, corpus):
* `<dir>/<group>/<start>-<end>/<nnnn>-<from>-<to>.gz` with a range.json once
* every chunk is there, the layout edge-bench reads (edge/fixtures). The
* grouping sweeps replay them with each build's rules instead of waiting for
* a re-index; the prefetcher reads a stored chunk instead of fetching it
* again. Off without CORPUS_DIR.
*/
class Corpus {
get dir(): string | undefined {
return coordinatorConfig.corpus.dir
}
private rangeDir(range: CorpusRange) {
return join(this.dir!, range.group, `${range.start}-${range.end}`)
}
private path(range: CorpusRange, span: ChunkSpan) {
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`)
}
/** Whether every chunk of the range is stored (its range.json). */
async complete(range: CorpusRange): Promise<boolean> {
if (!this.dir) return false
return access(join(this.rangeDir(range), 'range.json')).then(
() => true,
() => false
)
}
/** Whether one chunk is stored. */
async has(range: CorpusRange, span: ChunkSpan): Promise<boolean> {
if (!this.dir) return false
return access(this.path(range, span)).then(
() => true,
() => false
)
}
/** 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)
}
/**
* Stores a chunk that inflates (a reply cut short isn't kept), and the
* range.json once the range is complete (`total` chunks).
*/
async write(range: CorpusRange, span: ChunkSpan, data: Buffer, total: number) {
if (!this.dir) return
try {
await inflate(data)
} catch {
return
}
const dir = this.rangeDir(range)
const file = this.path(range, span)
await mkdir(dir, { recursive: true })
await writeFile(`${file}.part`, data)
await rename(`${file}.part`, file)
const chunks = (await readdir(dir)).filter((f) => f.endsWith('.gz')).length
if (chunks >= total) await writeFile(join(dir, 'range.json'), JSON.stringify(range))
}
/** A chunk a worker couldn't inflate after all: not served again, and the range not complete. */
async removeChunk(range: CorpusRange, span: ChunkSpan) {
if (!this.dir) return
await rm(this.path(range, span), { force: true })
await rm(join(this.rangeDir(range), 'range.json'), { force: true })
}
}
export default new Corpus()
+5 -3
View File
@@ -3,7 +3,7 @@ import BatchStore from '#services/batch_store'
import ChunkPartitions from '#services/chunk_partitions' import ChunkPartitions from '#services/chunk_partitions'
import RuleSets from '#services/rule_sets' import RuleSets from '#services/rule_sets'
import { RuleSet } from '#lib/rules/index' import { RuleSet } from '#lib/rules/index'
import { candidatesOf, chunkOf, mergeFragments, releaseKeyOf, type Fragment, type ProbeCandidate, type ReleaseChunk } from '#lib/edge/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 { AggregatedFile } from '#lib/files/index'
import type { EdgeSegment } from '#lib/edge/index' import type { EdgeSegment } from '#lib/edge/index'
@@ -83,12 +83,14 @@ class FragmentMerger {
} }
const releases: { rangeId: number; chunk: ReleaseChunk; candidates: ProbeCandidate[] }[] = [] const releases: { rangeId: number; chunk: ReleaseChunk; candidates: ProbeCandidate[] }[] = []
for (const [key, { files, rangeId }] of byRelease) { 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 }))) 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 if (verdict.rule !== null) continue
releases.push({ rangeId, chunk: chunkOf(key, files), candidates: candidatesOf(key, files) }) uploadsOf(files).forEach((list, upload) => releases.push({ rangeId, chunk: chunkOf(key, list, upload), candidates: candidatesOf(key, list, upload) }))
} }
await ChunkPartitions.ensure() await ChunkPartitions.ensure()
await db.transaction(async (trx) => { 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) 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)] 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 (let i = 0; i < done.length; i += 5000) await trx.from('fragments').whereIn('id', done.slice(i, i + 5000)).delete()
+33 -4
View File
@@ -2,6 +2,8 @@ import db from '@adonisjs/lucid/services/db'
import coordinatorConfig from '#config/coordinator' import coordinatorConfig from '#config/coordinator'
import RuleSets from '#services/rule_sets' import RuleSets from '#services/rule_sets'
import Spool from '#services/spool' import Spool from '#services/spool'
import Corpus from '#services/corpus'
import CatalogSpace from '#services/catalog_space'
import type { Lease } from '#lib/edge/index' import type { Lease } from '#lib/edge/index'
export class LeaseLost extends Error {} export class LeaseLost extends Error {}
@@ -15,19 +17,31 @@ export class LeaseLost extends Error {}
* prefetched (LOG.md 2026-10-09). A lease whose worker went quiet for * prefetched (LOG.md 2026-10-09). A lease whose worker went quiet for
* LEASE_TTL_SECONDS is taken over and resumes at its resume point. * LEASE_TTL_SECONDS is taken over and resumes at its resume point.
*/ */
/**
* A group's batches wait for each other (the batch store locks the group): more workers than this on one group only
* queue. Eight on one picture group's gap ranges (priority -1, in id order) held the node API's batches for minutes
* (LOG.md 2026-10-16 (68)).
*/
const MAX_LEASES_PER_GROUP = 2
class Leases { class Leases {
async lease(nodeId: number): Promise<Lease | null> { async lease(nodeId: number): Promise<Lease | null> {
const rules = await RuleSets.current() const rules = await RuleSets.current()
// The catalog at its cap on the NVMe: the tail and the stored ranges only (CatalogSpace).
const history = (await CatalogSpace.full()) ? 'AND priority < 0' : ''
const take = (spooled: boolean) => const take = (spooled: boolean) =>
db.rawQuery( db.rawQuery(
`UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ? `UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ?
WHERE r.id = ( WHERE r.id = (
SELECT id FROM ranges SELECT id FROM ranges
WHERE ${spooled ? 'spooled_chunks > 0' : 'true'} AND next_id <= end_id WHERE ${spooled ? 'spooled_chunks > 0' : 'true'} AND next_id <= end_id ${history}
AND (status = 'planned' OR (status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?))) AND (status = 'planned' OR (status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?)))
AND (SELECT count(*) FROM ranges l
WHERE l.status = 'leased' AND l.group_id = ranges.group_id AND l.id <> ranges.id
AND l.heartbeat_at >= now() - make_interval(secs => ?)) < ${MAX_LEASES_PER_GROUP}
ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED) ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING r.id, r.group_id, r.start_id, r.end_id, r.next_id`, RETURNING r.id, r.group_id, r.start_id, r.end_id, r.next_id`,
[nodeId, rules.version, coordinatorConfig.leaseTtlSeconds] [nodeId, rules.version, coordinatorConfig.leaseTtlSeconds, coordinatorConfig.leaseTtlSeconds]
) )
let { rows } = await take(true) let { rows } = await take(true)
if (rows.length === 0) ({ rows } = await take(false)) if (rows.length === 0) ({ rows } = await take(false))
@@ -66,8 +80,23 @@ class Leases {
async corrupt(rangeId: number, nodeId: number, from: number) { async corrupt(rangeId: number, nodeId: number, from: number) {
const range = await this.leased(rangeId, nodeId) const range = await this.leased(rangeId, nodeId)
const span = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }).find((s) => s.from === from) const span = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }).find((s) => s.from === from)
if (span) await Spool.removeChunk(rangeId, span) if (span) {
await db.from('ranges').where('id', rangeId).update({ status: 'planned', node_id: null }) await Spool.removeChunk(rangeId, span)
if (range.capture) {
const group = await db.from('groups').where('id', range.group_id).select('name').first()
await Corpus.removeChunk({ group: group.name, start: Number(range.start_id), end: Number(range.end_id) }, span)
}
}
const plain: number[] = (range.plain_chunks ?? []).map(Number)
// Reported once already: the compressed copy may have been in flight (or the prefetcher's targets not
// refreshed yet) and spooled again (range 161820, LOG.md 2026-10-11), so a second try plain first.
if (plain.filter((p) => p === from).length >= 2) {
// Fetched plain (deflated here) and still not inflating: not the server's compression; set aside.
await db.from('ranges').where('id', rangeId).update({ status: 'failed', node_id: null, error: `chunk ${from} doesn't inflate, also fetched plain` })
return
}
// The server's compressed reply is cut short (the same bytes every time): fetched plain next time.
await db.rawQuery(`UPDATE ranges SET status = 'planned', node_id = NULL, plain_chunks = array_append(plain_chunks, ?::bigint) WHERE id = ?`, [from, rangeId])
} }
/** A stopping worker gives its range back; the next one resumes it. */ /** A stopping worker gives its range back; the next one resumes it. */
+69 -4
View File
@@ -6,20 +6,43 @@ import { XoverClient } from '#lib/nntp/index'
/** /**
* The coordinator's header connections (REBUILD.md, decision e: workers * The coordinator's header connections (REBUILD.md, decision e: workers
* have no NNTP account). At most NNTP_CONNECTIONS, reused; a connection * have no NNTP account). At most NNTP_CONNECTIONS, reused; a connection
* that failed is closed and replaced on the next use. * that failed, or is older than NNTP_CONNECTION_MAX_MINUTES, is closed and
* replaced on the next use.
*/ */
class NntpPool { class NntpPool {
private idle: XoverClient[] = [] private idle: XoverClient[] = []
private busy = new Set<XoverClient>() private busy = new Set<XoverClient>()
private open = 0 private open = 0
private waiting: ((client: XoverClient) => void)[] = [] private waiting: ((client: XoverClient) => void)[] = []
private openedAt = new Map<XoverClient, number>()
/** When a connection gets closed once free: its max age ±25%, so they don't all reconnect at once. */
private retireAt = new Map<XoverClient, number>()
get size() { get size() {
return coordinatorConfig.nntpConnections return coordinatorConfig.nntpConnections
} }
async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> { async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
const client = await this.acquire() return this.run(await this.acquire(), work)
}
/** As use(), if a connection is free or can be opened now; else null, without waiting. */
tryUse<T>(work: (client: XoverClient) => Promise<T>): Promise<T> | null {
if (this.idle.length === 0 && this.open >= this.size) return null
return this.acquire().then((client) => this.run(client, work))
}
/** Connections with a command in flight (the prefetcher's, the planner's). */
get busyCount() {
return this.busy.size
}
/** How long ago the connection was opened, in ms. */
ageOf(client: XoverClient): number {
return Date.now() - (this.openedAt.get(client) ?? Date.now())
}
private async run<T>(client: XoverClient, work: (client: XoverClient) => Promise<T>): Promise<T> {
this.busy.add(client) this.busy.add(client)
let broken = false let broken = false
try { try {
@@ -39,7 +62,7 @@ class NntpPool {
if (this.open < this.size) { if (this.open < this.size) {
this.open++ this.open++
try { try {
return await XoverClient.connect({ const client = await XoverClient.connect({
host: env.get('NNTP_HOST')!, host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563), port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'), user: env.get('NNTP_USER'),
@@ -48,6 +71,10 @@ class NntpPool {
compress: true, compress: true,
timeoutMs: coordinatorConfig.nntpTimeoutMs, timeoutMs: coordinatorConfig.nntpTimeoutMs,
}) })
this.openedAt.set(client, Date.now())
const maxMs = coordinatorConfig.nntpConnectionMaxMs
if (maxMs > 0) this.retireAt.set(client, Date.now() + maxMs * (0.75 + Math.random() / 2))
return client
} catch (error) { } catch (error) {
this.open-- this.open--
throw error throw error
@@ -57,8 +84,10 @@ class NntpPool {
} }
private release(client: XoverClient, broken: boolean) { private release(client: XoverClient, broken: boolean) {
if (broken) { if (broken || Date.now() >= (this.retireAt.get(client) ?? Infinity)) {
this.open-- this.open--
this.openedAt.delete(client)
this.retireAt.delete(client)
try { try {
client.close() client.close()
} catch (error: any) { } catch (error: any) {
@@ -75,9 +104,45 @@ class NntpPool {
} }
/** Closes every connection, also those in use: their commands fail. */ /** Closes every connection, also those in use: their commands fail. */
private plain: Promise<XoverClient> | null = null
private plainQueue: Promise<unknown> = Promise.resolve()
/**
* One connection without XFEATURE COMPRESS GZIP, opened when first needed,
* one command at a time: for chunks whose compressed reply the server
* cuts short (Leases.corrupt). Its replies are deflated here.
*/
async usePlain<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
const run = this.plainQueue.then(async () => {
this.plain ??= 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,
})
const client = await this.plain
try {
return await work(client)
} catch (error) {
this.plain = null
client.close()
throw error
}
})
this.plainQueue = run.catch(() => {})
return run
}
async close() { async close() {
void this.plain?.then((c) => c.close()).catch(() => {})
this.plain = null
for (const client of [...this.idle.splice(0), ...this.busy]) client.close() for (const client of [...this.idle.splice(0), ...this.busy]) client.close()
this.busy.clear() this.busy.clear()
this.openedAt.clear()
this.retireAt.clear()
this.open = 0 this.open = 0
} }
} }
+20 -11
View File
@@ -1,25 +1,34 @@
import { gunzipSync } from 'node:zlib'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import { decodeChunk } from '#lib/chunks/index' import { decodeChunk, type ChunkFile } from '#lib/chunks/index'
import { buildNzb } from '#lib/nzb/index' import { buildNzb } from '#lib/nzb/index'
/** /**
* A release's NZB (REBUILD.md, part 9), from its chunks in the chunk store: * A release's files and NZB (REBUILD.md, part 9), from its chunks in the
* every chunk decoded, its files in name order. A file stored twice (the same * chunk store: every chunk decoded, its files in name order. A file stored
* name and first article, e.g. a range read again) is taken once. * twice (the same name and first article, e.g. a range read again) is taken
* once.
*/ */
class NzbService { class NzbService {
async build(releaseId: number): Promise<{ name: string; xml: string } | null> { async files(releaseId: number): Promise<ChunkFile[]> {
const release = await db.from('releases').where('id', releaseId).select('name').first()
if (!release) return null
const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload') const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload')
const files = new Map<string, ReturnType<typeof decodeChunk>[number]>() const files = new Map<string, ChunkFile>()
for (const chunk of chunks) { for (const chunk of chunks) {
for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file) for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file)
} }
return [...files.values()].sort((a, b) => a.name.localeCompare(b.name))
}
async build(releaseId: number): Promise<{ name: string; xml: string } | null> {
const release = await db.from('releases').where('id', releaseId).select('name', 'source').first()
if (!release) return null
// 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') {
const spot = await db.from('spots').where('release_id', releaseId).whereNotNull('nzb').select('nzb').first()
if (spot) return { name: release.name, xml: gunzipSync(spot.nzb).toString('utf8') }
}
const xml = buildNzb( const xml = buildNzb(
[...files.values()] (await this.files(releaseId)).map((f) => ({
.sort((a, b) => a.name.localeCompare(b.name))
.map((f) => ({
name: f.name, name: f.name,
poster: f.poster, poster: f.poster,
date: f.postedAt ?? 0, date: f.postedAt ?? 0,
+315
View File
@@ -0,0 +1,315 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { par2ReleaseName } from '#lib/release/index'
import { looksRandom } from '#lib/release/random'
import { SidecarFolder } from '#services/sidecar_folder'
import { Par2Pruner } from '#services/par2_pruner'
/** Releases of a set further apart than this (posted) aren't one upload: a repost is merged separately (decision d). */
const MAX_SPREAD_MS = 48 * 3600 * 1000
/**
* A set held by more releases nearby than this is one posted again and again, not one upload's par2 (every
* "kantodoNNN.bin" test upload carries the same set: 64 releases became one of 631 files; LOG.md 2026-10-12 (30)).
*/
const MAX_HOLDERS = 4
/** Members with more files than this many times the set's, plus SLACK_FILES (its par2 volumes), are more than one upload. */
const MAX_FILES_PER_SET_FILE = 3
const SLACK_FILES = 10
/** Between rounds. */
const ROUND_MS = 60_000
/** A round looks back this far past the previous one's start (rows committed while it ran). */
const OVERLAP_MS = 60_000
/** The sidecar fold also runs this often inside a long par2 pass: its queue grows by the settler's pace. */
const FOLD_EVERY_MS = 5 * 60_000
/** Between par2_files prunes (complete and judged sets' file lists). */
const PRUNE_EVERY_MS = 60 * 60_000
/** The merger's row in worker_cursors: the start of its last whole round, kept over a restart. */
const CURSOR = 'merger'
export interface MergeRound {
sets: number
merges: number
releases: number
/** The inner-name pass: stems checked, merges, releases in them. */
stems: number
stemMerges: number
stemReleases: number
/** The sidecar fold: candidates looked at, sidecar-only ones among them, folded into their upload. */
folded?: { looked: number; sidecars: number; folded: number }
}
/**
* A lone inspection's inner stem: its yEnc name up to the first dot, when 16–64 letters and digits
* ("bX147zx8I0oEgj7nzFCiorCT0ps2jEcb.7z.001"). The same expression as release_inspections_inner_stem_idx.
*/
const INNER_STEM = `substring(i.result->>'yencName' from '^([A-Za-z0-9]{16,64})\\.')`
interface Member {
id: number
release_key: string
group_id: number
files: number
posted_at: Date | null
readable: boolean
poster?: string | null
file_total?: number | null
}
/**
* The par2 merge (REBUILD.md, phase 2): releases whose files a par2 set
* describes are one upload. Obfuscated uploads post each file under a name of
* its own ("[9/20] - "14f5999a7bd4680b0720e524cac0b3b5"", hep: 13,232
* one-file releases), so the header pass can't group them; the prober reads
* each such file's first article (a `lone` candidate) and stores the MD5 of
* its first 16 KiB, and a par2 body's file list holds the same hash per file,
* whatever the file was renamed to. Releases in one group, posted within
* MAX_SPREAD_MS of a release that holds the set's par2, become the one with
* the most files: its chunks, probe candidates, inspections and hashes, the
* sums of its counts, and when none of them has a readable name the name the
* par2's file names share. Each merge is kept in release_merges.
*
* A second pass merges by inner name: the yEnc name in a lone file's first
* article keeps the upload's random stem when the subject names each file
* apart ("[1/7] - "471044d8…"" holds "bX147zx8I0oEgj7nzFCiorCT0ps2jEcb.7z.001";
* hep, hephaestus, gyges: every stem under one counter total; LOG.md
* 2026-10-12 (32)). Releases whose lone inspections share such a stem, in one
* group, by one poster, under the same counter total and within MAX_SPREAD_MS,
* are one upload. Only random stems: a readable one is grouped from the
* headers already, and a repost would share it.
*/
export class Par2Merger {
private stopped = false
private folder = new SidecarFolder((trx, found, key, via) => this.merge(trx, found, key, via))
/** Null until read from worker_cursors (1970 the first time: everything). */
private since: Date | null = null
stop() {
this.stopped = true
}
async run() {
let prunedAt = 0
while (!this.stopped) {
const started = Date.now()
try {
// First, at start too: a prune needn't wait for a long round.
if (!this.stopped && Date.now() - prunedAt >= PRUNE_EVERY_MS) {
prunedAt = Date.now()
const pruned = await new Par2Pruner().round()
if (pruned.rows) logger.info(`Pruned ${pruned.rows} par2 file rows: ${pruned.complete} complete sets, ${pruned.judged} judged.`)
}
const round = await this.round()
if (round.merges > 0) logger.info(`Merged ${round.releases} releases into ${round.merges} by par2 set (${round.sets} sets checked).`)
if (round.stemMerges > 0) logger.info(`Merged ${round.stemReleases} releases into ${round.stemMerges} by inner name (${round.stems} stems checked).`)
if (round.folded?.folded) logger.info(`Folded ${round.folded.folded} sidecars into their uploads (${round.folded.sidecars} sidecar-only of ${round.folded.looked} small releases).`)
} catch (error: any) {
logger.error(`Par2 merge round failed: ${error.message}`)
}
for (let waited = Date.now() - started; waited < ROUND_MS && !this.stopped; waited += 1000) await new Promise((r) => setTimeout(r, 1000))
}
}
/** The sets with something new since the last round: a par2 found, or a hash of one of their files. */
async round(): Promise<MergeRound> {
const { rows: now } = await db.rawQuery(`SELECT now() AS at`)
if (!this.since) {
const saved = await db.from('worker_cursors').where('name', CURSOR).first()
this.since = saved ? new Date(saved.at) : new Date(0)
}
const since = new Date(this.since.getTime() - OVERLAP_MS)
// First: sidecars (.nfo, .sfv, .nzb) apart from their upload join it. A queue, not since: it needn't wait for the
// par2 pass (hours when that starts over).
const folded = await this.folder.round()
let foldedAt = Date.now()
const foldAgain = async () => {
if (Date.now() - foldedAt < FOLD_EVERY_MS) return
const more = await this.folder.round()
folded.looked += more.looked
folded.sidecars += more.sidecars
folded.folded += more.folded
foldedAt = Date.now()
if (more.folded) logger.info(`Folded ${more.folded} sidecars into their uploads (${more.sidecars} sidecar-only of ${more.looked} small releases).`)
}
const { rows } = await db.rawQuery(
`SELECT set_id FROM par2_sets WHERE created_at >= ?
UNION
SELECT f.set_id FROM file_hashes h JOIN par2_files f ON f.hash16k = h.hash16k AND f.length = h.size WHERE h.created_at >= ?`,
[since, since]
)
const round: MergeRound = { sets: rows.length, merges: 0, releases: 0, stems: 0, stemMerges: 0, stemReleases: 0, folded }
for (const { set_id: setId } of rows) {
if (this.stopped) return round
await foldAgain()
// One set that fails doesn't hold up the others (nor the next round, which would start from the same point).
const merged = await this.mergeSet(setId.trim()).catch((error) => {
logger.error(`Par2 merge of set ${setId.trim()} failed: ${error.message}`)
return 0
})
if (merged > 0) {
round.merges++
round.releases += merged + 1
}
}
const { rows: stems } = await db.rawQuery(
`SELECT DISTINCT ${INNER_STEM} AS stem FROM release_inspections i WHERE i.kind = 'lone' AND i.created_at >= ? AND ${INNER_STEM} IS NOT NULL`,
[since]
)
for (const { stem } of stems) {
if (this.stopped) return round
if (!looksRandom(stem)) continue
round.stems++
const merged = await this.mergeStem(stem).catch((error) => {
logger.error(`Inner-name merge of ${stem} failed: ${error.message}`)
return 0
})
if (merged > 0) {
round.stemMerges++
round.stemReleases += merged + 1
}
}
// Only after a whole round: a restart halfway goes on from the round before.
this.since = now[0].at
await db.rawQuery(
`INSERT INTO worker_cursors (name, at) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET at = excluded.at`,
[CURSOR, this.since]
)
return round
}
/** Merges the releases whose lone files' inner names share a random stem; how many went into another. */
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
FROM release_inspections i JOIN releases r ON r.id = i.release_id
WHERE i.kind = 'lone' AND ${INNER_STEM} = ?`,
[stem]
)
const sorted: Member[] = rows.sort((a: Member, b: Member) => (a.posted_at ? new Date(a.posted_at).getTime() : 0) - (b.posted_at ? new Date(b.posted_at).getTime() : 0))
const anchor = sorted[0]
if (!anchor) return 0
const at = (r: Member) => (r.posted_at ? new Date(r.posted_at).getTime() : null)
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 ?? '') &&
(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>()
for (const r of sorted.filter(near)) found.set(String(r.id), r)
if (found.size < 2) return 0
// No more files than the counter says, with room for par2 volumes posted under it.
const counted = Math.max(0, ...[...found.values()].map((r) => total(r) ?? 0))
const files = [...found.values()].reduce((n, m) => n + Number(m.files), 0)
if (counted > 0 && files > counted + SLACK_FILES) return 0
return this.merge(trx, found, stem, 'inner')
})
}
/** Merges the releases of one set; how many went into another. */
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
WHERE s.set_id = ? ORDER BY r.id`,
[setId]
)
const anchor: Member | undefined = holders[0]
if (!anchor) return 0
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
FROM par2_files f JOIN file_hashes h ON h.hash16k = f.hash16k AND h.size = f.length JOIN releases r ON r.id = h.release_id
WHERE f.set_id = ?`,
[setId]
)
// A file of the set that matches more than one release nearby is ambiguous: files that share their first
// 16 KiB and size (131 uploads of 1 GiB test files "kantodoNNN.bin" matched one set's single file and became
// one release of 1,284 files; LOG.md 2026-10-12 (16)). A plain repost of the same file stays apart too.
const byFile = new Map<string, Map<string, Member>>()
for (const m of matches.filter(near)) {
const releases = byFile.get(m.file_id) ?? new Map<string, Member>()
releases.set(String(m.id), m)
byFile.set(m.file_id, releases)
}
const nearHolders = holders.filter(near)
if (nearHolders.length > MAX_HOLDERS) return 0
const found = new Map<string, Member>()
for (const r of nearHolders) found.set(String(r.id), r)
for (const releases of byFile.values()) if (releases.size === 1) for (const [id, r] of releases) found.set(id, r)
if (found.size < 2) return 0
const { rows: described } = await trx.rawQuery(`SELECT count(*) AS n FROM par2_files WHERE set_id = ?`, [setId])
const setFiles = Math.max(Number(described[0].n), 1)
if ([...found.values()].reduce((n, m) => n + Number(m.files), 0) > MAX_FILES_PER_SET_FILE * setFiles + SLACK_FILES) return 0
return this.merge(trx, found, setId, 'par2')
})
}
/**
* Merges the found releases into the one with the most files (named by the par2 set's files when none
* is readable); how many went into it. Each merge is kept in release_merges, with its set or stem.
*/
private async merge(trx: any, found: Map<string, Member>, key: string, via: 'par2' | 'inner' | 'sidecar'): Promise<number> {
{
// Locked in id order (the batch store and other rounds take the same rows).
const { rows: locked } = await trx.rawQuery(`SELECT id FROM releases WHERE id = ANY(?) ORDER BY id FOR UPDATE`, [[...found.keys()]])
const members: Member[] = locked.map((r: { id: string }) => found.get(String(r.id))!)
if (members.length < 2) return 0
const target = members.reduce((a, b) => (Number(b.files) > Number(a.files) ? b : a))
const others = members.filter((m) => m.id !== target.id).map((m) => Number(m.id))
const name = via === 'par2' && members.every((m) => !m.readable) ? await this.setName(trx, key) : undefined
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(?)`,
[target.id, key, via, others]
)
for (const table of ['release_chunks', 'probe_candidates', 'release_inspections', 'file_hashes']) {
await trx.rawQuery(`UPDATE ${table} SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others])
}
// A set held by the target already, or by two of the others (a par2 and its repost), isn't held twice (every
// round failed on the primary key from 20:28 UTC on 2026-10-12; LOG.md 2026-10-12 (30)).
await trx.rawQuery(
`DELETE FROM par2_sets p WHERE p.release_id = ANY(?)
AND (EXISTS (SELECT 1 FROM par2_sets t WHERE t.set_id = p.set_id AND t.release_id = ?)
OR EXISTS (SELECT 1 FROM par2_sets o WHERE o.set_id = p.set_id AND o.release_id = ANY(?) AND o.release_id < p.release_id))`,
[others, target.id, others]
)
await trx.rawQuery(`UPDATE par2_sets SET release_id = ? WHERE release_id = ANY(?)`, [target.id, others])
await trx.rawQuery(
`UPDATE releases t SET
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),
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,
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
WHERE t.id = ?`,
[name ?? '', others, target.id]
)
// Completeness as the settler decides it, once settled.
await trx.rawQuery(
`UPDATE releases SET complete = CASE WHEN file_total IS NULL THEN NULL ELSE files >= file_total END WHERE id = ? AND settled_at IS NOT NULL`,
[target.id]
)
await trx.rawQuery(`DELETE FROM releases WHERE id = ANY(?)`, [others])
return others.length
}
}
/** The name a set's files share (par2ReleaseName), for an upload none of whose names is readable. */
private async setName(trx: any, setId: string): Promise<string | undefined> {
const { rows } = await trx.rawQuery(`SELECT name FROM par2_files WHERE set_id = ?`, [setId])
const name = par2ReleaseName(rows.map((r: { name: string }) => r.name))
// A par2 made after the renaming names the random names again.
return name && !looksRandom(name) ? name.slice(0, 255) : undefined
}
}
+81
View File
@@ -0,0 +1,81 @@
import db from '@adonisjs/lucid/services/db'
/** A set this long past its last holder's par2 has had its merges: one posted later is a repost (decision d). */
const SETTLED_MS = 2 * 86400_000
/** Sets per statement. */
const BATCH = 20_000
/** The complete pass's row in worker_cursors: the newest holder's created_at of the last set it looked at. */
const CURSOR = 'par2-prune'
/** Verdicts whose releases needn't merge any more; locked isn't one (a password found takes it back). */
const JUNK = ['dead', 'fake', 'repeated']
export interface PruneRound {
complete: number
judged: number
rows: number
}
/**
* Drops par2_files rows the merge can't use any more (LOG.md 2026-10-16 (60)): the table only grew, 35M rows and
* 13.6 GB at 1.8M sets. A set's file list matches file hashes to releases and names an unreadable upload once; it's
* done when
* - complete: SETTLED_MS after its last holder's par2, every file it describes has its hash in a release holding the
* set (all of it merged into one);
* - judged: every release holding it has a junk verdict (dead, fake, repeated).
* par2_sets stays (the judge's unusable rule, the merge's holders). A set comes up again only with a new holder: its
* par2 reposted, whose probe writes the rows again; without rows its merge finds no files and the count check holds.
*/
export class Par2Pruner {
async round(): Promise<PruneRound> {
const round: PruneRound = { complete: 0, judged: 0, rows: 0 }
const saved = await db.from('worker_cursors').where('name', CURSOR).first()
let since: Date = saved ? new Date(saved.at) : new Date(0)
const until = new Date(Date.now() - SETTLED_MS)
for (;;) {
const { rows: sets } = await db.rawQuery(
`SELECT set_id, max(created_at) AS at FROM par2_sets GROUP BY set_id
HAVING max(created_at) > ? AND max(created_at) <= ? ORDER BY 2 LIMIT ?`,
[since, until, BATCH]
)
if (sets.length === 0) break
const ids = sets.map((s: { set_id: string }) => s.set_id)
const { rows: done } = await db.rawQuery(
`SELECT c.set_id FROM unnest(?::char(32)[]) AS c(set_id)
WHERE EXISTS (SELECT 1 FROM par2_files f WHERE f.set_id = c.set_id)
AND NOT EXISTS (
SELECT 1 FROM par2_files f WHERE f.set_id = c.set_id AND NOT EXISTS (
SELECT 1 FROM file_hashes h JOIN par2_sets s ON s.release_id = h.release_id AND s.set_id = f.set_id
WHERE h.hash16k = f.hash16k AND h.size = f.length))`,
[ids]
)
round.complete += done.length
round.rows += await this.drop(done.map((s: { set_id: string }) => s.set_id))
since = sets.at(-1).at
await db.rawQuery(
`INSERT INTO worker_cursors (name, at) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET at = excluded.at`,
[CURSOR, since]
)
if (sets.length < BATCH) break
}
// Judged: no cursor (verdicts come and go); only sets with rows left.
const { rows: judged } = await db.rawQuery(
`SELECT DISTINCT s.set_id FROM release_verdicts v JOIN par2_sets s ON s.release_id = v.release_id
WHERE v.rule = ANY(?)
AND EXISTS (SELECT 1 FROM par2_files f WHERE f.set_id = s.set_id)
AND NOT EXISTS (
SELECT 1 FROM par2_sets o LEFT JOIN release_verdicts ov ON ov.release_id = o.release_id AND ov.rule = ANY(?)
WHERE o.set_id = s.set_id AND ov.release_id IS NULL)`,
[JUNK, JUNK]
)
round.judged = judged.length
const judgedIds = judged.map((s: { set_id: string }) => s.set_id)
for (let i = 0; i < judgedIds.length; i += BATCH) round.rows += await this.drop(judgedIds.slice(i, i + BATCH))
return round
}
private async drop(setIds: string[]): Promise<number> {
if (setIds.length === 0) return 0
const result = await db.rawQuery(`DELETE FROM par2_files WHERE set_id = ANY(?::char(32)[])`, [setIds])
return result.rowCount ?? 0
}
}
+74 -1
View File
@@ -54,8 +54,9 @@ class Planner {
return rows.length return rows.length
} }
/** History for groups not planned yet (new ones); what comes after is the tail's (planTail). */
async planAll(): Promise<{ groups: number; ranges: number }> { async planAll(): Promise<{ groups: number; ranges: number }> {
const groups = await db.from('groups').where('active', true).orderBy('priority').select('id', 'name') const groups = await db.from('groups').where('active', true).whereNull('planned_at').orderBy('priority').select('id', 'name')
let ranges = 0 let ranges = 0
for (const group of groups) { for (const group of groups) {
// One group failing (e.g. gone from the server) doesn't stop the others. // One group failing (e.g. gone from the server) doesn't stop the others.
@@ -67,6 +68,78 @@ class Planner {
} }
return { groups: groups.length, ranges } return { groups: groups.length, ranges }
} }
/**
* The live tail: ranges for the articles after each planned group's last
* range, at the tail's priority (before any history). The newest article
* 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 }> {
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)]))
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 })),
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 }
}
}
/** A LIST ACTIVE pattern for a group: its first two name parts (`alt.binaries.*`), or the name itself. */
export function wildmatOf(name: string): string {
const parts = name.split('.')
return parts.length > 2 ? `${parts[0]}.${parts[1]}.*` : name
}
export interface TailGroup {
id: number
/** End of its last range (null: none, then it has no tail yet). */
plannedTo: number | null
/** When its last range was planned. */
plannedAt: Date
/** The server's newest article (null: not listed). */
high: number | null
}
/**
* 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.
*/
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 }[] = []
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) {
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
} }
export default new Planner() export default new Planner()
+294 -16
View File
@@ -3,16 +3,34 @@ import logger from '@adonisjs/core/services/logger'
import coordinatorConfig from '#config/coordinator' import coordinatorConfig from '#config/coordinator'
import NntpPool from '#services/nntp_pool' import NntpPool from '#services/nntp_pool'
import Spool, { type ChunkSpan } from '#services/spool' import Spool, { type ChunkSpan } from '#services/spool'
import Corpus from '#services/corpus'
import CatalogSpace from '#services/catalog_space'
import { hedge, type Request, type XoverClient } from '#lib/nntp/index'
interface Target { interface Target {
id: number id: number
group: string group: string
/** Chunk starts to fetch without compression (ranges.plain_chunks). */
plain: Set<number>
/** Chunks not spooled yet, in order. */ /** Chunks not spooled yet, in order. */
missing: ChunkSpan[] missing: ChunkSpan[]
/** All of the range's chunks, from its start. */ /** All of the range's chunks, from its start. */
total: number total: number
start: number
end: number
/** Its chunks are kept in the corpus (ranges.capture), and read from there when stored. */
capture: boolean
/** Fetched into the corpus only, not the spool: a capture range no worker leases soon (corpus.ahead). */
corpusOnly?: boolean
} }
/** A request's bytes in are sampled this often; STALL_SAMPLES of them make the minute a stall is judged over. */
const STALL_SAMPLE_MS = 15_000
const STALL_SAMPLES = 60_000 / STALL_SAMPLE_MS
/** Connection ages the report counts slow first requests by, in minutes. */
const AGE_BUCKETS = [2, 10, Infinity]
/** How often the targets are read again from the database. */ /** How often the targets are read again from the database. */
const REFRESH_MS = 2000 const REFRESH_MS = 2000
/** How often the spool's size is measured (a stat per file). */ /** How often the spool's size is measured (a stat per file). */
@@ -21,11 +39,13 @@ const SIZE_MS = 30_000
/** /**
* Keeps the news server busy ahead of the workers (REBUILD.md, prefetch). * Keeps the news server busy ahead of the workers (REBUILD.md, prefetch).
* Every connection takes the next missing chunk: the leased ranges' first * Every connection takes the next missing chunk: the leased ranges' first
* (a worker waits for them; all connections work on the same range, in * (a worker waits for them), then the next planned ranges, with at most
* order), then the next planned ranges, about SPOOL_AHEAD_PER_LEASE per * PREFETCH_PER_RANGE connections on a range and PREFETCH_PER_GROUP on a
* group (Eweka answers faster spread out), about SPOOL_AHEAD_PER_LEASE per
* active lease (at least SPOOL_MIN_AHEAD), within SPOOL_MAX_GB. One * active lease (at least SPOOL_MIN_AHEAD), within SPOOL_MAX_GB. One
* connection fills ~26 k overview lines/s, so a range on one connection * connection fills ~26 k overview lines/s, so a range on one connection
* kept its worker waiting (LOG.md 2026-10-09). * kept its worker waiting (LOG.md 2026-10-09). A chunk that takes long is
* asked again on a spare connection (fetchHedged).
*/ */
export class Prefetcher { export class Prefetcher {
/** /**
@@ -37,22 +57,75 @@ export class Prefetcher {
private targets: Target[] = [] private targets: Target[] = []
private claimed = new Set<string>() private claimed = new Set<string>()
/** Chunks being fetched per range and per group (spool.perRange, spool.perGroup). */
private perRange = new Map<number, number>()
private perGroup = new Map<string, number>()
private running = 0 private running = 0
private refreshedAt = 0 private refreshedAt = 0
private stopped = false private stopped = false
private spoolBytes = 0 private spoolBytes = 0
private sizedAt = 0 private sizedAt = 0
/** Fetched since the last report: chunks, articles, ms spent fetching. */ /** Fetched since the last report: chunks, articles, ms spent fetching. */
private stats = { chunks: 0, articles: 0, fetchMs: 0, since: Date.now() } private stats = this.freshStats()
/** Loops in a fetch or a spool write right now. */
private fetchingNow = 0
private writingNow = 0
private sampler?: NodeJS.Timeout
/** Requests in flight: what, since when, on which connection (the report names the oldest). */
private inFlight = new Set<{ rangeId: number; from: number; to: number; since: number; client: XoverClient }>()
/** Capture ranges found complete in the corpus (not looked at again). */
private stored = new Set<number>()
private freshStats() {
return {
chunks: 0,
/** Chunks read from the corpus instead of fetched. */
stored: 0,
/** Chunks fetched into the corpus only (corpus.ahead). */
captured: 0,
articles: 0,
fetchMs: 0,
hedged: 0,
hedgeWon: 0,
/** First requests per connection-age bucket, and how many of them were slow. */
byAge: AGE_BUCKETS.map(() => ({ requests: 0, slow: 0 })),
/** Spool writes and the range's update, ms. */
writeMs: 0,
/** Per answered request, ms: GROUP, XOVER until its status line, the transfer; transfers with a silence over 5 s. */
requests: 0,
groupMs: 0,
replyMs: 0,
bodyMs: 0,
silent: 0,
/** Once a second: loops fetching, writing, not running; pool connections with a command in flight. */
samples: 0,
fetching: 0,
writing: 0,
idle: 0,
inFlight: 0,
inFlightMin: Infinity,
/** Requests given up as stalled (nntpStallBytesPerMinute). */
deadlines: 0,
ranges: 0,
groups: 0,
since: Date.now(),
}
}
/** The prefetcher's loops: the pool less the connections kept for hedging. */
private get loops() {
return Math.max(1, NntpPool.size - coordinatorConfig.nntpHedgeConnections)
}
/** One round: refreshes the targets now and then, and puts idle connections to work. */ /** One round: refreshes the targets now and then, and puts idle connections to work. */
async tick() { async tick() {
this.sampler ??= setInterval(() => this.sample(), 1000).unref()
if (Date.now() - this.refreshedAt >= REFRESH_MS) { if (Date.now() - this.refreshedAt >= REFRESH_MS) {
this.refreshedAt = Date.now() this.refreshedAt = Date.now()
this.targets = await this.readTargets() this.targets = await this.readTargets()
} }
this.report() this.report()
while (!this.stopped && this.running < NntpPool.size && this.peek()) { while (!this.stopped && this.running < this.loops && this.peek()) {
this.running++ this.running++
this.work() this.work()
.catch((error) => logger.warn(`Prefetching: ${error.message}`)) .catch((error) => logger.warn(`Prefetching: ${error.message}`))
@@ -62,6 +135,7 @@ export class Prefetcher {
stop() { stop() {
this.stopped = true this.stopped = true
clearInterval(this.sampler)
} }
get active() { get active() {
@@ -76,13 +150,35 @@ export class Prefetcher {
const { target, span } = next const { target, span } = next
try { try {
const fetchedAt = Date.now() const fetchedAt = Date.now()
const data = await NntpPool.use(async (client) => { this.fetchingNow++
const fetch = async (client: Parameters<Parameters<typeof NntpPool.use>[0]>[0]) => {
await client.group(target.group) await client.group(target.group)
return client.xoverRaw(span.from, span.to) return client.xoverRaw(span.from, span.to)
}) }
const corpusRange = { group: target.group, start: target.start, end: target.end }
// A range in the corpus is read from disk (a reset re-indexes it without the news server).
let data = target.capture && !target.corpusOnly ? await Corpus.read(corpusRange, span) : null
const stored = data !== null
try {
if (!data) data = target.plain.has(span.from) ? await NntpPool.usePlain(fetch) : await this.fetchHedged(target.id, target.group, span)
} finally {
this.fetchingNow--
}
if (stored) {
this.stats.stored++
} else {
this.stats.chunks++ this.stats.chunks++
this.stats.articles += span.to - span.from + 1 this.stats.articles += span.to - span.from + 1
this.stats.fetchMs += Date.now() - fetchedAt this.stats.fetchMs += Date.now() - fetchedAt
if (target.capture) await Corpus.write(corpusRange, span, data, target.total)
}
if (target.corpusOnly) {
this.stats.captured++
continue
}
const writtenAt = Date.now()
this.writingNow++
try {
const existed = await Spool.has(target.id, span) const existed = await Spool.has(target.id, span)
await Spool.write(target.id, span, data) await Spool.write(target.id, span, data)
if (existed) continue if (existed) continue
@@ -92,37 +188,180 @@ export class Prefetcher {
[target.id] [target.id]
) )
if (Number(rows[0]?.spooled_chunks) >= target.total) await db.from('ranges').where('id', target.id).update({ spooled_at: new Date() }) if (Number(rows[0]?.spooled_chunks) >= target.total) await db.from('ranges').where('id', target.id).update({ spooled_at: new Date() })
} finally {
this.writingNow--
this.stats.writeMs += Date.now() - writtenAt
}
} finally { } finally {
this.claimed.delete(`${target.id}:${span.from}`) this.claimed.delete(`${target.id}:${span.from}`)
this.count(target, -1)
} }
} }
} }
/**
* A chunk over a pooled connection. Not in after nntpHedgeAfterMs, it's
* asked again on a free connection (if there is one): Eweka stalls single
* requests, and the same chunk on another connection mostly comes in
* seconds (LOG.md 2026-10-09 (15)). The first answer wins; the other
* request can't be cancelled, so its connection is closed (the pool opens
* a fresh one).
*/
private async fetchHedged(rangeId: number, group: string, span: ChunkSpan): Promise<Buffer> {
let age: number | undefined
const request = (start: (work: (client: XoverClient) => Promise<Buffer>) => Promise<Buffer> | null, first: boolean): Request<Buffer> | null => {
let client: XoverClient | undefined
let cancelled = false
const done = start(async (c) => {
// Given up before it got a connection: the connection goes back unused.
if (cancelled) return Buffer.alloc(0)
client = c
if (first) age = NntpPool.ageOf(c)
const entry = { rangeId, from: span.from, to: span.to, since: Date.now(), client: c }
this.inFlight.add(entry)
// Bytes in, sampled every STALL_SAMPLE_MS: less than nntpStallBytesPerMinute over the last minute is a stall.
let late = false
const samples: number[] = []
const watch = setInterval(() => {
samples.push(c.buffered)
if (samples.length <= STALL_SAMPLES) return
const minuteAgo = samples.shift()!
if (c.buffered - minuteAgo < coordinatorConfig.nntpStallBytesPerMinute) {
late = true
this.stats.deadlines++
c.close()
}
}, STALL_SAMPLE_MS)
let data: Buffer
let groupMs = 0
try {
const groupAt = performance.now()
await c.group(group)
groupMs = performance.now() - groupAt
data = await c.xoverRaw(span.from, span.to)
} catch (error) {
if (!late) throw error
throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} stalled (under ${coordinatorConfig.nntpStallBytesPerMinute / 1000} kB in a minute, ${Math.round(c.buffered / 1000)} kB in); fetching it again`)
} finally {
clearInterval(watch)
this.inFlight.delete(entry)
}
if (!cancelled) {
const t = c.lastTiming
this.stats.requests++
this.stats.groupMs += groupMs
this.stats.replyMs += t.replyMs
this.stats.bodyMs += t.bodyMs
if (t.longestGapMs > 5000) this.stats.silent++
}
return data
})
if (!done) return null
done.catch(() => {})
return {
done,
// The request can't be called off on the wire: its connection is closed, and the pool opens a fresh one.
cancel: () => {
cancelled = true
client?.close()
},
}
}
const result = await hedge(
() => request((work) => NntpPool.use(work), true)!,
() => request((work) => NntpPool.tryUse(work), false),
coordinatorConfig.nntpHedgeAfterMs
)
if (age !== undefined) {
const bucket = this.stats.byAge[AGE_BUCKETS.findIndex((minutes) => age! < minutes * 60_000)]
bucket.requests++
if (result.late) bucket.slow++
}
if (result.hedged) this.stats.hedged++
if (result.secondWon) this.stats.hedgeWon++
return result.value
}
/** The longest-running request: range, chunk, how long, how much came in. */
private oldest() {
let oldest: { rangeId: number; from: number; to: number; since: number; client: XoverClient } | undefined
for (const r of this.inFlight) if (!oldest || r.since < oldest.since) oldest = r
if (!oldest) return 'none in flight'
return `oldest request ${Math.round((Date.now() - oldest.since) / 1000)} s (range ${oldest.rangeId}, ${oldest.from}–${oldest.to}, ${Math.round(oldest.client.buffered / 1000)} kB in)`
}
private sample() {
const s = this.stats
s.samples++
s.fetching += this.fetchingNow
s.writing += this.writingNow
s.idle += Math.max(0, this.loops - this.fetchingNow - this.writingNow)
s.inFlight += NntpPool.busyCount
s.inFlightMin = Math.min(s.inFlightMin, NntpPool.busyCount)
s.ranges += this.perRange.size
s.groups += this.perGroup.size
}
/** A line a minute: what the connections fetched, and how long a chunk took. */ /** A line a minute: what the connections fetched, and how long a chunk took. */
private report() { private report() {
const seconds = (Date.now() - this.stats.since) / 1000 const seconds = (Date.now() - this.stats.since) / 1000
if (seconds < 60) return if (seconds < 60) return
const { chunks, articles, fetchMs } = this.stats const { chunks, articles, fetchMs, hedged, hedgeWon, byAge, samples, requests } = this.stats
if (chunks > 0) { if (chunks > 0) {
const ages = byAge
.map((b, i) => `${i === 0 ? '<' : `${AGE_BUCKETS[i - 1]}–`}${AGE_BUCKETS[i] === Infinity ? '' : AGE_BUCKETS[i]} min ${b.slow}/${b.requests}`.replace('– min', '+ min'))
.join(', ')
logger.info( logger.info(
`Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` + `Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` +
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk, connections busy ${Math.round((fetchMs / (seconds * 1000 * NntpPool.size)) * 100)}%.` `${(fetchMs / chunks / 1000).toFixed(1)} s a chunk; ` +
`hedged ${hedged} (second won ${hedgeWon}); slow by connection age: ${ages}` +
(this.stats.stored > 0 ? `; ${this.stats.stored} chunks from the corpus` : '') +
(this.stats.captured > 0 ? `; ${this.stats.captured} fetched into the corpus only` : '') +
'.'
)
const st = this.stats
const avg = (n: number) => (samples > 0 ? n / samples : 0)
const per = (ms: number) => (requests > 0 ? (ms / requests / 1000).toFixed(1) : '-')
logger.info(
`Connections: ${avg(st.inFlight).toFixed(1)} of ${NntpPool.size} with a command in flight (min ${st.inFlightMin}), ` +
`on ${avg(st.ranges).toFixed(1)} ranges of ${avg(st.groups).toFixed(1)} groups; ` +
`loops ${avg(st.fetching).toFixed(1)} fetching, ${avg(st.writing).toFixed(1)} writing, ${avg(st.idle).toFixed(1)} idle; ` +
`a request: GROUP ${per(st.groupMs)} s, XOVER reply ${per(st.replyMs)} s, transfer ${per(st.bodyMs)} s; ` +
`${st.silent} of ${requests} transfers silent >5 s; writing ${(st.writeMs / Math.max(1, chunks)).toFixed(0)} ms a chunk; ` +
`${st.deadlines} stalled (under ${coordinatorConfig.nntpStallBytesPerMinute / 1000} kB a minute); ${this.oldest()}.`
) )
} }
this.stats = { chunks: 0, articles: 0, fetchMs: 0, since: Date.now() } this.stats = this.freshStats()
} }
private peek(): boolean { private peek(): boolean {
return this.targets.some((t) => t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`))) return this.targets.some((t) => this.open(t) && t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`)))
} }
/** The first unclaimed missing chunk, in target order. */ /** Whether the target is under its range's and its group's limit of connections. */
private open(target: Target) {
const { perRange, perGroup } = coordinatorConfig.spool
return (perRange <= 0 || (this.perRange.get(target.id) ?? 0) < perRange) && (perGroup <= 0 || (this.perGroup.get(target.group) ?? 0) < perGroup)
}
private count(target: Target, by: number) {
const range = (this.perRange.get(target.id) ?? 0) + by
const group = (this.perGroup.get(target.group) ?? 0) + by
if (range > 0) this.perRange.set(target.id, range)
else this.perRange.delete(target.id)
if (group > 0) this.perGroup.set(target.group, group)
else this.perGroup.delete(target.group)
}
/** The first unclaimed missing chunk, in target order, of a target under its limits. */
private take(): { target: Target; span: ChunkSpan } | null { private take(): { target: Target; span: ChunkSpan } | null {
for (const target of this.targets) { for (const target of this.targets) {
if (!this.open(target)) continue
const index = target.missing.findIndex((s) => !this.claimed.has(`${target.id}:${s.from}`)) const index = target.missing.findIndex((s) => !this.claimed.has(`${target.id}:${s.from}`))
if (index === -1) continue if (index === -1) continue
const [span] = target.missing.splice(index, 1) const [span] = target.missing.splice(index, 1)
this.claimed.add(`${target.id}:${span.from}`) this.claimed.add(`${target.id}:${span.from}`)
this.count(target, 1)
return { target, span } return { target, span }
} }
return null return null
@@ -134,7 +373,7 @@ export class Prefetcher {
this.spoolBytes = await Spool.size() this.spoolBytes = await Spool.size()
} }
const mine = (q: any) => (this.shard.count > 1 ? q.whereRaw('r.id % ? = ?', [this.shard.count, this.shard.index]) : q) const mine = (q: any) => (this.shard.count > 1 ? q.whereRaw('r.id % ? = ?', [this.shard.count, this.shard.index]) : q)
const rows = (q: any) => mine(q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id')) const rows = (q: any) => mine(q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id', 'r.plain_chunks', 'r.capture'))
// Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them. // Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them.
const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at') const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at')
// Planned ranges ahead: those started already, then new ones up to SPOOL_AHEAD_PER_LEASE per lease. // Planned ranges ahead: those started already, then new ones up to SPOOL_AHEAD_PER_LEASE per lease.
@@ -142,8 +381,16 @@ export class Prefetcher {
const started = await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', '>', 0).whereNull('r.spooled_at').orderBy([{ column: 'r.priority' }, { column: 'r.id' }]) const started = await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', '>', 0).whereNull('r.spooled_at').orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
const [{ ready }] = await mine(db.from('ranges as r').where('r.status', 'planned').where('r.spooled_chunks', '>', 0)).count('* as ready') const [{ ready }] = await mine(db.from('ranges as r').where('r.status', 'planned').where('r.spooled_chunks', '>', 0)).count('* as ready')
const room = this.spoolBytes < coordinatorConfig.spool.maxBytes const room = this.spoolBytes < coordinatorConfig.spool.maxBytes
const fresh = room && Number(ready) < ahead // With at most perRange connections a range, enough ranges still being fetched for all loops: fully
? await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', 0).whereRaw('r.next_id <= r.end_id').orderBy([{ column: 'r.priority' }, { column: 'r.id' }]).limit(ahead - Number(ready)) // spooled ones count as ready but need no connection (30 of 40 busy on small ranges; LOG.md 2026-10-10 (2)).
// 8 more than that: ranges with a chunk or two left take fewer than perRange (with +2, 3 of 40 loops idle).
const { perRange } = coordinatorConfig.spool
const short = perRange > 0 ? Math.ceil(this.loops / perRange) + 8 - (leased.length + started.length) : 0
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()
const fresh = room && want > 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' }]).limit(want)
: [] : []
const targets: Target[] = [] const targets: Target[] = []
for (const row of [...leased, ...(room ? started : []), ...fresh]) { for (const row of [...leased, ...(room ? started : []), ...fresh]) {
@@ -154,7 +401,38 @@ export class Prefetcher {
for (const span of Spool.chunksOf({ start, end }, Number(row.next_id))) { 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))) missing.push(span)
} }
if (missing.length > 0) targets.push({ id: Number(row.id), group: row.group, missing, total }) if (missing.length > 0) {
targets.push({ id: Number(row.id), group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total, start, end, capture: row.capture === true })
}
}
// Last, for loops with nothing else to do: capture ranges not stored yet, into the corpus only.
for (const target of await this.corpusTargets(rows, new Set(targets.map((t) => t.id)))) targets.push(target)
return targets
}
/** Up to corpus.ahead planned capture ranges, not leased soon, whose chunks aren't all in the corpus. */
private async corpusTargets(rows: (q: any) => any, taken: Set<number>): Promise<Target[]> {
const { dir, ahead } = coordinatorConfig.corpus
if (!dir || ahead <= 0) return []
const candidates = await rows(db.query()).where('r.status', 'planned').where('r.capture', true).where('r.spooled_chunks', 0).orderBy('r.id')
const targets: Target[] = []
for (const row of candidates) {
if (targets.length >= ahead) break
const id = Number(row.id)
if (taken.has(id) || this.stored.has(id)) continue
const start = Number(row.start_id)
const end = Number(row.end_id)
const range = { group: row.group, start, end }
if (await Corpus.complete(range)) {
this.stored.add(id)
continue
}
const chunks = Spool.chunksOf({ start, end })
const missing: ChunkSpan[] = []
for (const span of chunks) if (!this.claimed.has(`${id}:${span.from}`) && !(await Corpus.has(range, span))) missing.push(span)
if (missing.length > 0) {
targets.push({ id, group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total: chunks.length, start, end, capture: true, corpusOnly: true })
}
} }
return targets return targets
} }
+297
View File
@@ -0,0 +1,297 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { setTimeout as sleep } from 'node:timers/promises'
import env from '#start/env'
import coordinatorConfig from '#config/coordinator'
import { XoverClient } from '#lib/nntp/index'
import { YencError } from '#lib/yenc/index'
import { inspect, innerName, PROBE_VERSION, type Inspection } from '#lib/probe/index'
interface Candidate {
id: number
releaseId: number
kind: string
messageId: string
}
type Outcome = { candidate: Candidate; status: 'done' | 'missing' | 'error' | 'waiting'; inspection?: Inspection; cached?: boolean }
/**
* The order candidates are probed in: what the par2 merge needs first (a random-named file's own start, then the
* par2 sets and archives of releases without a readable name), then the rest (names and passwords of readable ones).
*/
const TIERS: { kind: string; unreadable: boolean }[] = [
{ kind: 'lone', unreadable: false },
{ kind: 'par2', unreadable: true },
{ kind: 'archive', unreadable: true },
{ kind: 'par2', unreadable: false },
{ kind: 'archive', unreadable: false },
]
const FLUSH_MS = 2000
/** Slices a claim takes from random points of the queue. */
const SLICES = 20
const REPORT_MS = 60_000
/**
* Phase 2's first step (REBUILD.md, part 6): reads the probe queue's
* articles (settled releases first, by kind) over PROBE_CONNECTIONS
* connections of its own, without compression, and stores what they show:
* per release and kind an Inspection, and on the release whether it's
* passworded and its inner name. One process: fetching (~0.1 s an article)
* dwarfs parsing (a few ms), so there's no queue between them.
*/
export class Prober {
private queue: Candidate[] = []
private outcomes: Outcome[] = []
private refilling: Promise<void> | null = null
private stopped = false
private stats = Prober.freshStats()
/** 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() }
}
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 connections = coordinatorConfig.probe.connections
logger.info(`Probing with ${connections} connections.`)
const flusher = (async () => {
while (!this.stopped || this.outcomes.length > 0) {
await sleep(FLUSH_MS)
const flushed = Date.now()
await this.flush().catch((error) => logger.error(`Probing: storing results failed: ${error.message}`))
this.stats.flushMs += Date.now() - flushed
this.report()
}
})()
await Promise.all(Array.from({ length: connections }, () => this.lane()))
// What was claimed and not probed goes back.
for (const candidate of this.queue.splice(0)) this.outcomes.push({ candidate, status: 'waiting' })
this.stopped = true
await flusher
}
stop() {
this.stopped = true
}
/** One connection's loop: PROBE_PIPELINE articles at a time (pipelined), reconnecting after a failure. */
private async lane() {
let client: XoverClient | null = null
while (!this.stopped) {
const batch: Candidate[] = []
const waited = Date.now()
while (batch.length < coordinatorConfig.probe.pipeline) {
const candidate = await this.next()
if (!candidate) break
batch.push(candidate)
}
this.stats.waitMs += Date.now() - waited
if (batch.length === 0) {
this.stats.idleMs += 30_000
await sleep(30_000)
continue
}
try {
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,
})
const fetched = Date.now()
const bodies = await client.bodies(batch.map((c) => c.messageId))
this.stats.fetchMs += Date.now() - fetched
const inspected = Date.now()
for (const [i, candidate] of batch.entries()) {
const body = bodies[i]
this.stats.articles++
if (body === null) {
// Taken down or expired: final.
this.outcomes.push({ candidate, status: 'missing' })
continue
}
this.stats.bytes += body.length
try {
this.outcomes.push({ candidate, status: 'done', inspection: inspect(body) })
} catch (error: any) {
// Not yEnc, a broken one, or data a parser chokes on: a final answer, not a connection problem.
if (!(error instanceof YencError)) logger.warn(`Probing ${candidate.messageId}: ${error.message}`)
this.outcomes.push({ candidate, status: 'error' })
}
}
this.stats.inspectMs += Date.now() - inspected
} catch (error: any) {
logger.warn(`Probing ${batch[0].messageId} and ${batch.length - 1} more: ${error.message}`)
for (const candidate of batch) this.outcomes.push({ candidate, status: 'waiting' })
client?.close()
client = null
await sleep(5000)
}
}
client?.close()
}
private async next(): Promise<Candidate | undefined> {
if (this.queue.length < coordinatorConfig.probe.batch / 2) {
this.refilling ??= this.claim().finally(() => (this.refilling = null))
if (this.queue.length === 0) await this.refilling
}
return this.queue.shift()
}
/**
* Waiting candidates of settled releases, by kind, claimed so another round
* doesn't take them: SLICES slices from random points of the queue. In id
* order it went group by group, and some groups' articles take 0.6–3 s
* where most take 20–60 ms (a.b.zogg 2016: 7 articles/s on 6 connections).
*/
private async claim() {
const started = Date.now()
try {
await this.claimTiers()
} finally {
this.stats.claimMs += Date.now() - started
}
}
private async claimTiers() {
for (const { kind, unreadable } of TIERS) {
const want = coordinatorConfig.probe.batch - this.queue.length
if (want <= 0) return
// The starting points chosen here: in the query Postgres drew random() once for all slices.
// Over the claimable ones of this tier only: the rest of the queue isn't settled yet.
const [{ lo, hi }] = (
await db.rawQuery(`SELECT min(id) AS lo, max(id) AS hi FROM probe_candidates WHERE status = 'waiting' AND ready AND kind = ? AND unreadable >= ?`, [kind, unreadable])
).rows
if (lo === null) continue
const starts = Array.from({ length: SLICES }, () => Number(lo) + Math.floor(Math.random() * (Number(hi) - Number(lo) + 1)))
const { rows } = await db.rawQuery(
`UPDATE probe_candidates SET status = 'taken' WHERE id IN (
SELECT pc.id FROM unnest(?::bigint[]) AS o(start)
CROSS JOIN LATERAL (
SELECT pc.id FROM probe_candidates pc
WHERE pc.status = 'waiting' AND pc.ready AND pc.kind = ? AND pc.unreadable >= ? AND pc.id >= o.start
ORDER BY pc.kind, pc.unreadable, pc.id LIMIT ? FOR UPDATE SKIP LOCKED) pc)
RETURNING id, release_id, kind, message_id`,
[starts, kind, unreadable, Math.ceil(want / SLICES)]
)
const claimed = rows.map((r: any) => ({ id: Number(r.id), releaseId: Number(r.release_id), kind: r.kind, messageId: r.message_id }))
// Probed before (an earlier catalog's candidate of the same article): answered from probe_cache.
const { rows: cached } = await db.rawQuery(`SELECT message_id, status, result FROM probe_cache WHERE message_id = ANY(?) AND version = ?`, [
claimed.map((c: Candidate) => c.messageId),
PROBE_VERSION,
])
const known = new Map(cached.map((c: any) => [c.message_id, c]))
for (const candidate of claimed) {
const hit: any = known.get(candidate.messageId)
if (hit) {
this.stats.cached++
this.outcomes.push({ candidate, status: hit.status, inspection: hit.result ?? undefined, cached: true })
} else {
this.queue.push(candidate)
}
}
}
}
/** The outcomes so far: candidates' status, inspections, and the releases' passworded and inner name. */
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]++
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`,
[JSON.stringify(outcomes.map((o) => ({ id: o.candidate.id, status: o.status })))]
)
// Final answers read from the news server, kept by message-id for the next catalog (a reset).
const fresh = outcomes.filter((o) => !o.cached && o.status !== 'waiting')
if (fresh.length > 0) {
await trx.rawQuery(
`INSERT INTO probe_cache (message_id, status, result, version)
SELECT v.message_id, v.status, v.result, ? FROM jsonb_to_recordset(?::jsonb) AS v(message_id text, status text, result jsonb)
ON CONFLICT (message_id) DO UPDATE SET status = excluded.status, result = excluded.result, version = excluded.version, created_at = now()`,
[PROBE_VERSION, JSON.stringify(fresh.map((o) => ({ message_id: o.candidate.messageId, status: o.status, result: o.inspection ?? null })))]
)
}
const done = outcomes.filter((o) => o.inspection)
if (done.length === 0) return
await trx.rawQuery(
`INSERT INTO release_inspections (release_id, candidate_id, kind, result)
SELECT v.release_id, v.candidate_id, v.kind, v.result FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, candidate_id bigint, kind text, result jsonb)
ON CONFLICT (release_id, candidate_id) DO UPDATE SET result = excluded.result, created_at = now()`,
// A par2 set's file list goes to par2_files, not into every inspection.
[JSON.stringify(done.map((o) => ({ release_id: o.candidate.releaseId, candidate_id: o.candidate.id, kind: o.candidate.kind, result: { ...o.inspection, par2: undefined } })))]
)
await this.storeForMerge(trx, done)
// Each touched release from all its inspections (earlier rounds may have stored others).
const ids = [...new Set(done.map((o) => o.candidate.releaseId))]
const { rows } = await trx.rawQuery(
`SELECT i.release_id, i.kind, i.result, r.name FROM release_inspections i JOIN releases r ON r.id = i.release_id WHERE i.release_id = ANY(?)`,
[ids]
)
const byRelease = new Map<number, { name: string; all: { kind: string; result: Inspection }[] }>()
for (const r of rows) {
const id = Number(r.release_id)
const entry = byRelease.get(id) ?? { name: r.name as string, all: [] as { kind: string; result: Inspection }[] }
entry.all.push({ kind: r.kind, result: r.result })
byRelease.set(id, entry)
}
const updates = [...byRelease].map(([id, { name, all }]) => ({
id,
passworded: all.some((i) => i.result.passworded === true),
inner_name: innerName(all, name),
}))
await trx.rawQuery(
`UPDATE releases r SET passworded = v.passworded, inner_name = v.inner_name, inspected_at = now()
FROM jsonb_to_recordset(?::jsonb) AS v(id bigint, passworded boolean, inner_name text) WHERE r.id = v.id`,
[JSON.stringify(updates)]
)
})
}
/** What the par2 merge matches (app/services/par2_merger.ts): files' first-16-KiB hashes, par2 sets' file lists. */
private async storeForMerge(trx: any, done: Outcome[]) {
const hashed = done.filter((o) => o.inspection!.hash16k)
if (hashed.length > 0) {
await trx.rawQuery(
`INSERT INTO file_hashes (candidate_id, release_id, hash16k, size)
SELECT v.candidate_id, v.release_id, v.hash16k, v.size FROM jsonb_to_recordset(?::jsonb) AS v(candidate_id bigint, release_id bigint, hash16k text, size bigint)
ON CONFLICT (candidate_id) DO NOTHING`,
[JSON.stringify(hashed.map((o) => ({ candidate_id: o.candidate.id, release_id: o.candidate.releaseId, hash16k: o.inspection!.hash16k, size: o.inspection!.size })))]
)
}
for (const o of done) {
const set = o.inspection!.par2
if (!set) continue
await trx.rawQuery(
`INSERT INTO par2_files (set_id, file_id, hash16k, length, name)
SELECT ?, v.file_id, v.hash16k, v.length, v.name FROM jsonb_to_recordset(?::jsonb) AS v(file_id text, hash16k text, length bigint, name text)
ON CONFLICT DO NOTHING`,
[set.setId, JSON.stringify(set.files.map((f) => ({ file_id: f.fileId, hash16k: f.hash16k, length: f.length, name: f.name })))]
)
await trx.rawQuery(`INSERT INTO par2_sets (set_id, release_id, files) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`, [set.setId, o.candidate.releaseId, set.files.length])
}
}
private report() {
const seconds = (Date.now() - this.stats.since) / 1000
if (seconds < REPORT_MS / 1000) return
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. ` +
`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.`
)
this.stats = Prober.freshStats()
}
}
+31
View File
@@ -0,0 +1,31 @@
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()
+159
View File
@@ -0,0 +1,159 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
/** Raised when a rule changes: verdicts of an older version are judged again. */
export const JUDGE_VERSION = 1
/** Between rounds: each rule is a scan of its table. */
const ROUND_MS = 60 * 60 * 1000
/** A file's first 16 KiB and size in this many releases is one file posted again and again (test files, spam). */
const REPEATED_RELEASES = 20
/**
* A name that says it's a film, an episode or music: a resolution, a codec, a rip source, a media extension. No "?" in
* these patterns: a raw query takes it for a binding ("h[ .]?26[45]" and "web[ .-]?dl" never matched; LOG.md
* 2026-10-16 (64)), so {0,1}.
*/
const MEDIA =
`(^|[^a-z0-9])(s[0-9]{1,2}e[0-9]{1,3}|[0-9]{3,4}p|x26[45]|h[ .]{0,1}26[45]|hevc|xvid|divx|bluray|bdrip|brrip|web[ .-]{0,1}dl|webrip|hdtv|dvdrip|dvdr|remux|mkv|mp4|avi|wmv|m4v|flac|mp3)([^a-z0-9]|$)`
/** The posters of scraped celebrity pictures. */
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)$`
export interface Rule {
name: string
/** Why, in a few words (the log, the web interface). */
says: string
/** SELECT of the release ids the rule judges. */
sql: string
}
/**
* The junk judge's rules, in order: a release takes the first that matches (REBUILD.md, junk). Each runs on what
* the header pass, the prober, the merges and the spots know by now.
*/
export const RULES: Rule[] = [
{
name: 'dead',
says: 'every probed first article is gone from the server (taken down or expired)',
sql: `SELECT release_id FROM probe_candidates WHERE status IN ('done', 'missing', 'error') GROUP BY 1 HAVING bool_and(status = 'missing')`,
},
{
name: 'fake',
says: 'named as a film, episode or music, but the content is a program',
// An archive whose entries are all executables, under a media name (its own or its entries'); or a file that is
// an executable by name or by its first bytes, under a media name.
sql: `SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id
WHERE i.kind = 'archive' AND jsonb_array_length(i.result->'names') > 0
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(i.result->'names') n WHERE n !~* '${EXECUTABLE}')
AND (r.name ~* '${MEDIA}' OR (i.result->>'names') ~* '${MEDIA}')
UNION
SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id
WHERE i.kind = 'lone' AND ((i.result->>'yencName') ~* '${EXECUTABLE}' OR i.result->>'type' = 'exe')
AND (r.name ~* '${MEDIA}' OR (i.result->>'yencName') ~* '${MEDIA}')`,
},
{
name: 'repeated',
says: `the same file content in ${REPEATED_RELEASES}+ releases (test files, spam)`,
sql: `SELECT DISTINCT h.release_id FROM file_hashes h
JOIN (SELECT hash16k, size FROM file_hashes GROUP BY 1, 2 HAVING count(DISTINCT release_id) >= ${REPEATED_RELEASES}) x USING (hash16k, size)`,
},
{
name: 'scraped',
says: 'celebrity pictures scraped from the web, reposted by one spam family',
// "@_CelebMovie_@", "NudeCelebs@CelebMovie.com", "@_CelebWow_@", "@_CelebFX_@", "@_iLuvCelebs_@", "@nude-celebs", …:
// a celebrity's scraped pictures as a folder under a typed subject, the same files again and again under other
// titles (LOG.md 2026-10-16 (64)). Not Celeborn, CelebTA or other posters with "celeb" in their name.
sql: `SELECT r.id FROM releases r
WHERE r.poster ~* '${SCRAPED_POSTERS}' AND r.source = 'subject' AND r.file_total IS NULL`,
},
{
name: 'locked',
says: 'passworded, and no spot, NZB or title gives the password',
sql: `SELECT r.id FROM releases r WHERE r.passworded AND NOT EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id)`,
},
{
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)).
sql: `SELECT r.id FROM releases r
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 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)`,
},
]
export interface JudgeRound {
/** Per rule: releases it judges now, verdicts added, verdicts taken back. */
rules: Record<string, { matched: number; added: number; removed: number }>
}
/**
* The junk judge (REBUILD.md, junk; voyager 1's second filter, D42): after the header pass's rules (garbage
* signatures, executables, …) a release is judged again once it's probed, by what its articles showed. Each
* round recomputes every rule: a release a rule matches gets a verdict (the first rule in order, over a later one's), one it no longer
* matches loses its verdict. Verdicts only hide (search leaves them out); nothing is dropped yet.
*/
export class ReleaseJudge {
private stopped = false
stop() {
this.stopped = true
}
async run() {
while (!this.stopped) {
const started = Date.now()
try {
const round = await this.round()
const parts = Object.entries(round.rules).map(([n, r]) => `${n} ${r.matched} (+${r.added} −${r.removed})`)
logger.info(`Judged: ${parts.join(', ')}.`)
} catch (error: any) {
logger.error(`Judge round failed: ${error.message}`)
}
for (let waited = Date.now() - started; waited < ROUND_MS && !this.stopped; waited += 1000) await new Promise((r) => setTimeout(r, 1000))
}
}
/** One round over every rule; with dry, only counts. */
async round(dry = false): Promise<JudgeRound> {
const round: JudgeRound = { rules: {} }
// Verdicts of an older rule set are judged again from scratch.
if (!dry) await db.from('release_verdicts').where('rule_version', '<>', JUDGE_VERSION).delete()
const judged = new Set<string>()
for (const rule of RULES) {
if (this.stopped) break
const { rows } = await db.rawQuery(rule.sql)
const ids = rows.map((r: Record<string, unknown>) => String(Object.values(r)[0])).filter((id: string) => !judged.has(id))
for (const id of ids) judged.add(id)
const stats = { matched: ids.length, added: 0, removed: 0 }
if (!dry) {
await db.transaction(async (trx) => {
// Dropped first: inside an outer transaction (the tests) ON COMMIT DROP comes only at its end.
await trx.rawQuery(`DROP TABLE IF EXISTS judged`)
await trx.rawQuery(`CREATE TEMP TABLE judged (release_id bigint PRIMARY KEY) ON COMMIT DROP`)
for (let i = 0; i < ids.length; i += 50_000) {
await trx.rawQuery(`INSERT INTO judged SELECT unnest(?::bigint[]) ON CONFLICT DO NOTHING`, [ids.slice(i, i + 50_000)])
}
const removed = await trx.rawQuery(
`DELETE FROM release_verdicts v WHERE v.rule = ? AND NOT EXISTS (SELECT 1 FROM judged j WHERE j.release_id = v.release_id)`,
[rule.name]
)
const added = await trx.rawQuery(
`INSERT INTO release_verdicts (release_id, rule, rule_version)
SELECT j.release_id, ?, ? FROM judged j JOIN releases r ON r.id = j.release_id
ON CONFLICT (release_id) DO UPDATE SET rule = excluded.rule, rule_version = excluded.rule_version
WHERE release_verdicts.rule <> excluded.rule`,
[rule.name, JUDGE_VERSION]
)
stats.removed = removed.rowCount ?? 0
stats.added = added.rowCount ?? 0
})
}
round.rules[rule.name] = stats
}
return round
}
}
+317
View File
@@ -0,0 +1,317 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import NzbService from '#services/nzb_service'
export type SortOrder = 'newest' | 'oldest' | 'largest' | 'files' | 'name'
/** Voyager 1's search parameters, on the catalog's columns (REBUILD.md, part 9). */
export interface SearchParams {
q?: string
/** Also releases the junk judge hid (release_verdicts). */
judged?: boolean
/** Only releases with a file whose name is readable (not an obfuscated token), or an inner name from the prober. */
readable?: boolean
/** Only releases known to have all their files. */
complete?: boolean
/** Leave out releases known to miss files (Newznab). */
notIncomplete?: boolean
/** Leave out releases the prober found passworded (encrypted archive entries or headers). */
unlocked?: boolean
/** How the release was recognised: stem, subject or post. */
source?: string
poster?: string
/** Exact category ids (already expanded from parents). */
categories?: number[]
maxAgeDays?: number
/** TV: season and episode, matched as S01E02 or 1x02 in the name. */
season?: number
episode?: number
/** Size bounds in bytes. */
minSize?: number
maxSize?: number
sort?: SortOrder
page?: number
perPage?: number
/** Overrides `page` (Newznab pages by offset). */
offset?: number
/** Count matches up to this many past the page; `total` is capped there. */
countLimit?: number
}
/** A release in search results. */
export interface ReleaseSummary {
id: number
name: string
source: string
poster: string | null
groups: string[]
postedAt: Date | null
fileTotal: number | null
fileCount: number
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). */
final: boolean
category: number
settled: boolean
/** The prober found encrypted archive entries or headers; null: not probed (yet). */
passworded: boolean | null
/** What the prober found inside (an archive entry, a par2 set's file, a yEnc name), when it says more than the name. */
innerName: string | null
}
/** Posters a poster search looks in at most: a short fragment matches thousands of random ones (1,000 took 9.9 s, 200 0.2 s; LOG.md 2026-10-16 (70)). */
const MAX_POSTERS = 200
const SORTS: Record<SortOrder, string> = {
newest: 'r.posted_at DESC NULLS LAST, r.id DESC',
oldest: 'r.posted_at ASC NULLS LAST, r.id ASC',
largest: 'r.bytes DESC, r.id DESC',
files: 'r.files DESC, r.id DESC',
name: 'r.name ASC, r.id ASC',
}
/** A broad search needn't count every match (each counted row is a heap read). */
const COUNT_LIMIT = 10_000
const STATS_TTL_MS = 5 * 60 * 1000
/** A refresh that takes longer is given up (the next request tries again). */
const STATS_TIMEOUT_MS = 2 * 60 * 1000
/** ILIKE pattern of one word (the words are split on dots, dashes, underscores and spaces). */
function contains(word: string): string {
return `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
}
/** Searching and browsing the catalog, for the web interface and Newznab. */
class SearchService {
async search(params: SearchParams) {
const where: string[] = ['r.files > 0']
// 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.
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))
}
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))')
if (params.unlocked) where.push('r.passworded IS NOT TRUE')
if (params.source) {
where.push('r.source = ?')
bindings.push(params.source)
}
// 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
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)
}
if (params.categories?.length) {
where.push('r.category = ANY(?)')
bindings.push(params.categories)
}
if (params.maxAgeDays) {
where.push('r.posted_at >= now() - make_interval(days => ?)')
bindings.push(params.maxAgeDays)
}
if (params.minSize !== undefined) {
where.push('r.bytes >= ?')
bindings.push(params.minSize)
}
if (params.maxSize !== undefined) {
where.push('r.bytes <= ?')
bindings.push(params.maxSize)
}
if (params.season !== undefined) {
const s = params.season
const e = params.episode
where.push('r.name ~* ?')
bindings.push(e !== undefined ? `(^|[._ -])(S0*${s}E0*${e}|0*${s}x0*${e})([^0-9]|$)` : `(^|[._ -])(S0*${s}([._ -]|E\\d|$)|0*${s}x\\d)`)
}
const perPage = Math.min(Math.max(params.perPage ?? 50, 1), 200)
const page = Math.max(params.page ?? 1, 1)
const offset = params.offset ?? (page - 1) * perPage
const order = SORTS[params.sort ?? 'newest'] ?? SORTS.newest
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.
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`,
where: 'true',
bindings: [posters] as unknown[],
}
const counting =
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`,
[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,
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 ?`,
[...source.bindings, ...bindings, perPage, offset]
),
counting,
])
return {
page,
perPage,
offset,
total: Math.min(Number(counted[0].total), countLimit),
/** More than `total` match (the count stops at `countLimit`). */
totalCapped: Number(counted[0].total) > countLimit,
releases: (rows as any[]).map((r): ReleaseSummary => ({
id: Number(r.id),
name: r.name,
source: r.source,
poster: r.poster,
groups: r.groups,
postedAt: r.posted_at,
fileTotal: r.file_total,
fileCount: Number(r.files),
size: Number(r.bytes),
readable: r.readable,
complete: r.complete,
final: r.final,
category: r.category,
settled: r.settled_at !== null,
passworded: r.passworded,
innerName: r.inner_name,
})),
}
}
/** A release with its files, read from its chunks. */
async release(id: number) {
const r = await db.from('releases').where('id', id).first()
if (!r) return null
const [files, { rows: inspections }, { rows: finality }] = 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]),
])
return {
id: Number(r.id),
name: r.name,
source: r.source,
poster: r.poster,
groups: r.groups,
postedAt: r.posted_at,
fileTotal: r.file_total,
fileCount: Number(r.files),
size: Number(r.bytes),
segments: Number(r.segments),
readable: r.readable,
complete: r.complete,
final: finality[0].final,
hasPar2: r.has_par2,
category: r.category,
firstSeenAt: r.first_seen_at,
lastSeenAt: r.last_seen_at,
settledAt: r.settled_at,
passworded: r.passworded,
innerName: r.inner_name,
inspectedAt: r.inspected_at,
/** What the prober read from the release's articles (lib/probe Inspection), by probe kind. */
inspections: (inspections as any[]).map((i) => ({ kind: i.kind, ...i.result, at: i.created_at })),
files: files.map((f) => ({
name: f.name,
subject: f.subject,
poster: f.poster,
postedAt: f.postedAt === null ? null : new Date(f.postedAt),
size: f.segments.reduce((n, [, , bytes]) => n + bytes, 0),
parts: f.segments.length,
})),
}
}
/** Totals for the web interface, computed at most once a minute (callers in between share them). */
async stats() {
if (!this.statsCache || Date.now() - this.statsCache.at > STATS_TTL_MS) {
this.statsRefresh ??= Promise.race([
this.computeStats(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), STATS_TIMEOUT_MS).unref()),
])
.then((value) => {
this.statsCache = { at: Date.now(), value }
})
.catch((error) => {
logger.warn(`Stats: ${error.message}`)
if (!this.statsCache) throw error
})
.finally(() => {
this.statsRefresh = undefined
})
if (!this.statsCache) await this.statsRefresh
}
return this.statsCache!.value
}
private statsCache?: { at: number; value: Awaited<ReturnType<SearchService['computeStats']>> }
private statsRefresh?: Promise<void>
/**
* Estimates (a catalog of 100M+ rows can't be counted on every visit):
* the row count from Postgres's statistics, below a million rows exact;
* readable share, files and size from a sample of 5,000 rows; ranges
* counted exactly (a few hundred thousand rows).
*/
private async computeStats() {
const { rows } = await db.rawQuery(`SELECT reltuples::bigint AS n FROM pg_class WHERE relname = 'releases'`)
const estimate = Number(rows[0]?.n ?? 0)
// A fixed-size sample: a percentage of a big catalog is many random reads on the HDD.
const sample = estimate < 1_000_000 ? '' : 'TABLESAMPLE SYSTEM_ROWS (5000)'
const { rows: s } = await db.rawQuery(
`SELECT count(*) AS sampled, count(*) FILTER (WHERE readable) AS readable, coalesce(sum(files), 0) AS files, coalesce(sum(bytes), 0) AS bytes
FROM releases ${sample} WHERE files > 0`
)
const sampled = Number(s[0].sampled)
const releases = sample ? Math.max(estimate, 0) : sampled
const scale = (n: unknown) => (sampled > 0 ? Math.round((Number(n) / sampled) * releases) : 0)
// 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),
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,
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,
(SELECT max(done_at) FROM ranges) AS last_done_at,
(SELECT max(done_at) FROM ranges WHERE priority < 0) AS tail_done_at
FROM h`
)
const g = ranges[0]
return {
approximate: sample !== '',
releases,
readableReleases: scale(s[0].readable),
files: scale(s[0].files),
size: scale(s[0].bytes),
ranges: { done: Number(g.done), total: Number(g.total) },
/** History and the current pass, in articles. */
history: { done: Number(g.history_done), total: Number(g.history) },
pass: { done: Number(g.pass_done), total: Number(g.pass) },
lastIndexedAt: g.last_done_at,
tailIndexedAt: g.tail_done_at,
}
}
}
export default new SearchService()
+28 -9
View File
@@ -2,26 +2,45 @@ import db from '@adonisjs/lucid/services/db'
/** Releases unchanged for this long are settled (ranges are read in order; the merger adds within minutes). */ /** Releases unchanged for this long are settled (ranges are read in order; the merger adds within minutes). */
const QUIET_HOURS = 6 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 const BATCH = 50_000
/** /**
* Settles releases (REBUILD.md, part 8): once nothing has been added for * Settles releases (REBUILD.md, part 8): once nothing has been added for
* QUIET_HOURS, its completeness is decided: complete when every file the * 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 * [i/N] counter announced was found, unknown without a counter (phase 2
* reads par2 sets for those). Phase 2 picks its work from settled releases. * reads par2 sets for those). Phase 2 picks its work from settled releases.
*/ */
class Settler { class Settler {
async run(): Promise<number> { /** Up to `batches` batches of BATCH (all there are without). */
async run(batches = Infinity): Promise<number> {
let settled = 0 let settled = 0
while (true) { for (let batch = 0; batch < batches; batch++) {
const { rowCount } = await db.rawQuery( // Their probe candidates become claimable (Prober.claim), the unreadable ones first.
`UPDATE releases SET settled_at = now(), complete = CASE WHEN file_total IS NULL THEN NULL ELSE files >= file_total END const { rows } = await db.rawQuery(
WHERE id IN (SELECT id FROM releases WHERE settled_at IS NULL AND last_seen_at < now() - make_interval(hours => ?) LIMIT ?)`, `WITH settled AS (
[QUIET_HOURS, BATCH] UPDATE releases SET settled_at = now(), complete = CASE WHEN file_total IS NULL THEN NULL 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),
marked AS (UPDATE probe_candidates pc SET ready = true, unreadable = NOT s.readable FROM settled s WHERE pc.release_id = s.id),
-- Small ones 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 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]
) )
settled += rowCount ?? 0 const n = rows[0].n
if ((rowCount ?? 0) < BATCH) return settled settled += n
if (n < BATCH) return settled
} }
return settled
} }
} }
+91
View File
@@ -0,0 +1,91 @@
import db from '@adonisjs/lucid/services/db'
import { decodeChunk, type ChunkFile } from '#lib/chunks/index'
/** Candidates looked at per round. */
const BATCH = 2000
/** An upload's sidecar is posted with it: its release is looked for this far from it. */
const WINDOW_MINUTES = 10
/** The files an upload carries beside its content: an index, a checksum list, a scene release's own files. */
const SIDECAR = /\.(nfo|sfv|nzb|srr|srs|txt|m3u|m3u8|diz|md5)$/i
/** The words of a name worth comparing: 4+ letters or digits. */
function words(text: string): Set<string> {
return new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4))
}
export interface Sibling {
id: number
name: string
files: number
file_total: number | null
}
/**
* Which of the nearby releases a sidecar belongs to: the one sharing a word of 4+ letters with the sidecar's subject
* ("Murdoch Mysteries S8D1 [00/80] - "Murdoch Mysteries S8D1.nzb"" beside "Murdoch Mysteries S8D1"), else the one
* under the same counter total ("(ALADIN) [01/34] - "bud.txt"" beside the poster's 34-file upload); none when that
* isn't one release.
*/
export function pickSibling(files: ChunkFile[], fileTotal: number | null, siblings: Sibling[]): Sibling | null {
const said = words(files.map((f) => f.subject).join(' '))
const named = siblings.filter((s) => [...words(s.name)].some((w) => said.has(w)))
if (named.length === 1) return named[0]
if (named.length > 1) return null
const counted = fileTotal ? siblings.filter((s) => s.file_total === fileTotal) : []
return counted.length === 1 ? counted[0] : null
}
export interface FoldRound {
looked: number
sidecars: number
folded: number
}
/**
* The sidecar fold (REBUILD.md, junk): an .nfo, .sfv or .nzb that came apart from its upload (another name, another
* counter: "[00/80] … .nzb" beside "[01/94] … .part01.rar") was a release of its own. The settler queues small
* settled releases (fold_candidates); a candidate whose files are all sidecars joins the poster's bigger release in
* the same group posted within WINDOW_MINUTES, the one pickSibling chooses, through the merger's merge (via
* 'sidecar'). The rest leave the queue untouched.
*/
export class SidecarFolder {
constructor(private merge: (trx: any, found: Map<string, any>, key: string, via: 'sidecar') => Promise<number>) {}
async round(): Promise<FoldRound> {
const round: FoldRound = { looked: 0, sidecars: 0, folded: 0 }
const { rows: queued } = await db.rawQuery(
`SELECT r.id, r.release_key, r.group_id, r.poster, r.posted_at, r.files, r.file_total, r.readable
FROM fold_candidates f JOIN releases r ON r.id = f.release_id ORDER BY f.created_at LIMIT ?`,
[BATCH]
)
const { rows: stale } = await db.rawQuery(
`SELECT f.release_id FROM fold_candidates f LEFT JOIN releases r ON r.id = f.release_id WHERE r.id IS NULL LIMIT ?`,
[BATCH]
)
const done: number[] = stale.map((r: { release_id: string }) => Number(r.release_id))
for (const c of queued) {
round.looked++
done.push(Number(c.id))
const { rows: chunks } = await db.rawQuery(`SELECT payload FROM release_chunks WHERE release_id = ?`, [c.id])
const files: ChunkFile[] = chunks.flatMap((x: { payload: Buffer }) => decodeChunk(x.payload))
if (files.length === 0 || !files.every((f) => SIDECAR.test(f.name))) continue
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
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]
)
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
const full = siblings.find((s: any) => Number(s.id) === sibling.id)
const merged = await db.transaction((trx) =>
this.merge(trx, new Map([[String(c.id), c], [String(full.id), full]]), `sidecar:${full.id}`, 'sidecar')
)
if (merged > 0) round.folded++
}
if (done.length > 0) await db.from('fold_candidates').whereIn('release_id', done).delete()
return round
}
}
+359
View File
@@ -0,0 +1,359 @@
import { createHash } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import coordinatorConfig from '#config/coordinator'
import { XoverClient } from '#lib/nntp/index'
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'
/** Spots claimed per round, newest first. */
const BATCH = 20
/** Between rounds when there was nothing to do. */
const IDLE_MS = 60_000
/** Unmatched spots are matched again this often (the header pass may have built their releases since). */
const REMATCH_MS = 60 * 60 * 1000
/**
* Unmatched spots matched again per hour: after a reset every spot is (16.5k at 500 an hour took 33 h). Never tried
* first, then the newest: the oldest inspection first tried a 3-month backfill before today's spots, the ones whose
* releases the tail has (2 of 16.7k matched 3 h after reset 24).
*/
const REMATCH_BATCH = 5000
/** Unmatched spots are matched again for this long after their inspection. */
const REMATCH_DAYS = 30
/** A file's release posted further apart than this from the NZB's date is another upload of the same name. */
const MAX_SPREAD_MS = 48 * 3600 * 1000
export interface InspectRound {
inspected: number
matched: number
standalone: number
outcomes: Record<string, number>
}
interface SpotFile {
idx: number
name: string
subject: string
poster: string
groups: string[]
postedAt: Date | null
bytes: number
segments: number
}
/**
* A placeholder NZB (Dummy.rar + Dummy.nzb, shared by many spots): under 1% of the size the spot announces, or under
* 16 KB in all (text-only spots). Its files aren't listed (voyager 1's isDummyNzb).
*/
export function isDummyNzb(nzb: ParsedNzb, spotSize: number): boolean {
const bytes = nzb.files.reduce((sum, f) => sum + f.segments.reduce((s, x) => s + x.bytes, 0), 0)
return bytes < 16 * 1024 || (spotSize > 0 && bytes < spotSize / 100)
}
/** A file of an NZB as the header pass names it: the subject's file name, by the same parser. */
export function nzbFileName(subject: string): { name: string; base: string } {
const parsed = parseSubject(subject) ?? parseSubject(`${subject} (1/1)`)
return parsed ? { name: parsed.name, base: parsed.base } : { name: subject, base: subject }
}
/**
* The spot inspector (REBUILD.md, spots): looks inside each listed spot, on
* one NNTP connection of its own. It reads the spot's compressed NZB (its
* articles in alt.binaries.ftd), keeps the list of files in `spot_files`,
* and matches every file to the release the header pass built for it: the
* release identity of the file's subject and poster (lib/release, as the
* edge workers compute it), in one of the file's groups, posted within 48 h.
* Each matched release is linked in `release_spots`, and gets the spot's
* password in `release_passwords` (the NZB's own `<meta type="password">`
* first, else what the spot's title or text names) and `passworded`. A spot
* whose files match nothing is matched again every hour, for 30 days; once
* SPOT_STANDALONE_AFTER_HOURS have passed since its inspection it becomes a
* release of its own (source `spot`) with its NZB, until the header pass
* builds the releases after all (a spot older than what the catalog has
* indexed): then it moves to those, and its own release goes.
*/
export class SpotInspector {
private stopped = false
private client?: XoverClient
private groupIds = new Map<string, number | null>()
private rematchedAt = 0
stop() {
this.stopped = true
this.client?.close()
}
async run() {
while (!this.stopped) {
let round: InspectRound | null = null
try {
round = await this.round()
if (round.inspected > 0 || round.standalone > 0) {
const outcomes = Object.entries(round.outcomes).map(([k, n]) => `${n} ${k}`).join(', ')
logger.info(`Spots inspected: ${round.inspected} (${outcomes}); ${round.matched} matched releases, ${round.standalone} of their own.`)
}
} catch (error: any) {
logger.error(`Spot inspection failed: ${error.message}`)
this.client?.close()
this.client = undefined
}
if (!round || round.inspected === 0) {
for (let waited = 0; waited < IDLE_MS && !this.stopped; waited += 1000) await new Promise((r) => setTimeout(r, 1000))
}
}
}
private async connect(): Promise<XoverClient> {
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: false,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
return this.client
}
async round(): Promise<InspectRound> {
const round: InspectRound = { inspected: 0, matched: 0, standalone: 0, outcomes: {} }
const spots = await db.from('spots').where('status', 'listed').orderBy('posted_at', 'desc').limit(BATCH)
for (const spot of spots) {
if (this.stopped) break
const outcome = await this.inspect(spot)
round.inspected++
round.outcomes[outcome.status] = (round.outcomes[outcome.status] ?? 0) + 1
round.matched += outcome.matched
}
if (Date.now() - this.rematchedAt >= REMATCH_MS && !this.stopped) {
this.rematchedAt = Date.now()
const again = await this.rematch()
round.matched += again.matched
round.standalone += again.standalone
}
return round
}
/** One spot: its NZB read, its files listed and matched. */
async inspect(spot: any): Promise<{ status: string; matched: number }> {
const done = async (status: string, extra: Record<string, unknown> = {}) => {
await db.from('spots').where('message_id', spot.message_id).update({ status, inspected_at: new Date(), ...extra })
return { status, matched: 0 }
}
const segments: string[] = spot.nzb_segments ?? []
if (segments.length === 0) return done('no-nzb')
if (segments.length > coordinatorConfig.spots.maxNzbSegments) return done('no-nzb', { error: `${segments.length} NZB articles` })
const client = await this.connect()
const bodies = await client.bodies(segments)
if (bodies.some((b) => b === null)) return done('nzb-gone', { error: `${bodies.filter((b) => b === null).length} of ${segments.length} NZB articles gone` })
let xml: string
try {
xml = decodeSpotBinary(bodies as Buffer[]).toString('utf8')
} catch (error: any) {
return done('nzb-unreadable', { error: String(error.message).slice(0, 500) })
}
return this.inspectNzb(spot, xml)
}
/** The files of a spot's NZB listed and matched (the tests feed this directly). */
async inspectNzb(spot: any, xml: string): Promise<{ status: string; matched: number }> {
const nzb = parseNzb(xml)
const finish = (status: string, extra: Record<string, unknown> = {}) =>
db.from('spots').where('message_id', spot.message_id).update({ status, inspected_at: new Date(), ...extra })
if (!nzb || nzb.files.length === 0) {
await finish('no-nzb')
return { status: 'no-nzb', matched: 0 }
}
if (isDummyNzb(nzb, Number(spot.size ?? 0))) {
await finish('dummy')
return { status: 'dummy', matched: 0 }
}
const files: SpotFile[] = nzb.files.map((f, idx) => ({
idx,
name: nzbFileName(f.subject).name.slice(0, 1000),
subject: f.subject.slice(0, 2000),
poster: f.poster,
groups: f.groups,
postedAt: f.date ? new Date(f.date * 1000) : null,
bytes: f.segments.reduce((s, x) => s + x.bytes, 0),
segments: f.segments.length,
}))
// The NZB's own password over what the spot's text names.
const password = nzb.meta.password ? { password: nzb.meta.password.slice(0, 200), source: 'nzb' } : spot.password ? { password: spot.password, source: spot.password_source } : null
await db.transaction(async (trx) => {
await trx.from('spot_files').where('spot_id', spot.message_id).delete()
for (let i = 0; i < files.length; i += 1000) {
await trx.table('spot_files').multiInsert(
files.slice(i, i + 1000).map((f) => ({
spot_id: spot.message_id,
idx: f.idx,
name: f.name,
subject: f.subject,
poster: f.poster,
groups: f.groups,
posted_at: f.postedAt,
bytes: f.bytes,
segments: f.segments,
}))
)
}
await trx
.from('spots')
.where('message_id', spot.message_id)
.update({
status: 'inspected',
inspected_at: new Date(),
files: files.length,
matched: 0,
nzb: gzipSync(xml),
...(password && password.source === 'nzb' ? { password: password.password, password_source: 'nzb' } : {}),
})
})
const matched = await this.match(spot.message_id)
return { status: 'inspected', matched }
}
private async groupId(name: string): Promise<number | null> {
if (!this.groupIds.has(name)) {
const row = await db.from('groups').where('name', name).select('id').first()
this.groupIds.set(name, row ? Number(row.id) : null)
}
return this.groupIds.get(name)!
}
/**
* Matches a spot's listed files to releases and links them; how many releases. A file's release: the same
* identity (releaseIdentity over its subject and poster) in one of its groups, the one posted nearest the file.
*/
async match(spotId: string): Promise<number> {
const spot = await db.from('spots').where('message_id', spotId).first()
if (!spot || spot.status !== 'inspected') return 0
const files = await db.from('spot_files').where('spot_id', spotId).orderBy('idx')
const wanted: { idx: number; key: string; groups: number[]; at: number | null }[] = []
for (const f of files) {
if (!f.subject) continue
const { name, base } = nzbFileName(f.subject)
const identity = releaseIdentity({ poster: f.poster ?? '', subject: base, name, weak: false })
if (!identity) continue
const groups = (await Promise.all((f.groups ?? []).map((g: string) => this.groupId(g)))).filter((g): g is number => g !== null)
if (groups.length > 0) wanted.push({ idx: f.idx, key: identity.key, groups, at: f.posted_at ? new Date(f.posted_at).getTime() : null })
}
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))]]
)
const byKey = new Map<string, { id: number; group: number; at: number | null }[]>()
for (const r of rows) {
const list = byKey.get(r.identity_key) ?? []
list.push({ id: Number(r.id), group: Number(r.group_id), at: r.posted_at ? new Date(r.posted_at).getTime() : null })
byKey.set(r.identity_key, list)
}
const fileRelease = new Map<number, number>()
for (const w of wanted) {
const near = (byKey.get(w.key) ?? [])
.filter((r) => w.groups.includes(r.group))
.map((r) => ({ ...r, spread: r.at === null || w.at === null ? 0 : Math.abs(r.at - w.at) }))
.filter((r) => r.spread <= MAX_SPREAD_MS)
.sort((a, b) => a.spread - b.spread)
if (near[0]) fileRelease.set(w.idx, near[0].id)
}
const perRelease = new Map<number, number>()
for (const id of fileRelease.values()) perRelease.set(id, (perRelease.get(id) ?? 0) + 1)
if (perRelease.size === 0) return 0
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()
if (own) {
await trx.from('release_spots').where('release_id', own.id).delete()
await trx.from('release_passwords').where('release_id', own.id).delete()
await trx.from('releases').where('id', own.id).delete()
}
}
for (const [idx, releaseId] of fileRelease) await trx.from('spot_files').where({ spot_id: spotId, idx }).update({ release_id: releaseId })
for (const [releaseId, n] of perRelease) {
await trx.table('release_spots').insert({ release_id: releaseId, spot_id: spotId, files: n }).onConflict(['release_id', 'spot_id']).merge({ files: n })
}
if (spot.password && perRelease.size > 0) {
await trx
.table('release_passwords')
.multiInsert([...perRelease.keys()].map((id) => ({ release_id: id, password: spot.password, source: spot.password_source ?? 'spot-text', spot_id: spotId })))
.onConflict(['release_id', 'password'])
.ignore()
await trx.from('releases').whereIn('id', [...perRelease.keys()]).update({ passworded: true })
}
const top = [...perRelease.entries()].sort((a, b) => b[1] - a[1])[0]
// The NZB is only kept for a release of the spot's own: the header pass's releases have their chunks.
await trx.from('spots').where('message_id', spotId).update({ matched: fileRelease.size, release_id: top ? top[0] : null, nzb: null })
})
return perRelease.size
}
/** Unmatched inspected spots matched again; the ones that waited long enough become releases of their own. */
async rematch(): Promise<{ matched: number; standalone: number }> {
const out = { matched: 0, standalone: 0 }
const { rows: spots } = await db.rawQuery(
`SELECT message_id, inspected_at, release_id FROM spots
WHERE status = 'inspected' AND matched = 0 AND inspected_at > now() - make_interval(days => ?)
ORDER BY rematched_at NULLS FIRST, posted_at DESC NULLS LAST LIMIT ?`,
[REMATCH_DAYS, REMATCH_BATCH]
)
const cutoff = Date.now() - coordinatorConfig.spots.standaloneAfterHours * 3600 * 1000
for (const spot of spots) {
if (this.stopped) break
const n = await this.match(spot.message_id)
await db.from('spots').where('message_id', spot.message_id).update({ rematched_at: new Date() })
out.matched += n
if (n === 0 && !spot.release_id && new Date(spot.inspected_at).getTime() < cutoff && (await this.standalone(spot.message_id))) out.standalone++
}
return out
}
/** A release of the spot's own (source `spot`), from its listed files; false when it has none. */
async standalone(spotId: string): Promise<boolean> {
const spot = await db.from('spots').where('message_id', spotId).first()
const files = await db.from('spot_files').where('spot_id', spotId)
if (!spot || files.length === 0) return false
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')
const [row] = await db
.table('releases')
.insert({
release_key: `spot:${key}`,
identity_key: key,
group_id: groupId,
name: spot.title.slice(0, 1000),
source: 'spot',
poster: spot.poster,
groups: JSON.stringify(groups),
posted_at: spot.posted_at,
last_posted_at: spot.posted_at,
files: files.length,
segments: files.reduce((s: number, f: any) => s + Number(f.segments), 0),
bytes: files.reduce((s: number, f: any) => s + Number(f.bytes), 0),
readable: true,
passworded: spot.password ? true : null,
settled_at: new Date(),
})
.onConflict('release_key')
.ignore()
.returning('id')
if (!row) return false
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()
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()
}
await trx.from('spots').where('message_id', spotId).update({ release_id: releaseId })
})
return true
}
}
+184
View File
@@ -0,0 +1,184 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import coordinatorConfig from '#config/coordinator'
import { MissingArticle, XoverClient } from '#lib/nntp/index'
import {
decodeSpotText,
moderationTarget,
parseSpotArticleHeaders,
parseSpotHeader,
parseSpotXml,
passwordFromSpot,
spotterId,
verifySpotHeader,
} from '#lib/spotnet/index'
/** Overviews asked for at a time. */
const XOVER_STEP = 1000
/** A personal dispose counts within this long of the spot (Spotweb). */
const DISPOSE_WINDOW_SECONDS = 5 * 86400
export interface SpotterRound {
read: number
listed: number
unverified: number
moderation: number
}
/**
* The spotter (REBUILD.md, spots): Spotnet's spots, from the headers of
* free.pt into `spots`, on one NNTP connection of its own. Every few
* minutes it reads the overviews after the last article it saw (the first
* time the last SPOT_BACKFILL), and for each spot the full header: its
* signature is checked there (lib/spotnet, as Spotweb does), and the X-XML
* gives the description, the website and the NZB's articles. A password the
* title (`{{pw}}`) or the description names is kept with the spot; the spot
* inspector carries it to the releases. A delete/dispose message marks its
* target `removed` and takes back what the target gave the releases.
*/
export class Spotter {
private stopped = false
private client?: XoverClient
stop() {
this.stopped = true
this.client?.close()
}
async run() {
while (!this.stopped) {
const started = Date.now()
try {
const round = await this.round()
if (round.read > 0) {
logger.info(`Spots: ${round.read} articles read, ${round.listed} spots listed, ${round.unverified} unverified, ${round.moderation} moderation.`)
}
} catch (error: any) {
logger.error(`Spot round failed: ${error.message}`)
this.client?.close()
this.client = undefined
}
const wait = coordinatorConfig.spots.everySeconds * 1000
for (let waited = Date.now() - started; waited < wait && !this.stopped; waited += 1000) await new Promise((r) => setTimeout(r, 1000))
}
}
private async connect(): Promise<XoverClient> {
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: false,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
return this.client
}
/** The articles after the last one seen, up to the group's high. */
async round(): Promise<SpotterRound> {
const { group, backfill } = coordinatorConfig.spots
const client = await this.connect()
const { low, high } = await client.group(group)
const state = await db.from('spot_state').where('group_name', group).first()
let next = state ? Number(state.last_article) + 1 : Math.max(low, high - backfill + 1)
const round: SpotterRound = { read: 0, listed: 0, unverified: 0, moderation: 0 }
while (next <= high && !this.stopped) {
const to = Math.min(high, next + XOVER_STEP - 1)
const overviews = await client.xover(next, to)
round.read += overviews.length
for (const [number, fields] of overviews) {
if (this.stopped) return round
const messageId = (fields['message-id'] ?? '').replace(/^<|>$/g, '')
if (!messageId || !parseSpotHeader(fields.from ?? '', fields.subject ?? '', messageId, fields.date)) continue
const outcome = await this.read(client, messageId, number)
if (outcome) round[outcome]++
}
await db
.table('spot_state')
.insert({ group_name: group, last_article: to })
.onConflict('group_name')
.merge({ last_article: to })
next = to + 1
}
return round
}
/** One spot by its full header; what it was, or null when it was there already or is gone. */
async read(client: XoverClient, messageId: string, articleNumber: number | null): Promise<'listed' | 'unverified' | 'moderation' | null> {
if (await db.from('spots').where('message_id', messageId).first()) return null
let raw: Buffer
try {
raw = await client.head(messageId)
} catch (error) {
if (error instanceof MissingArticle) return null
throw error
}
return this.store(raw.toString('latin1'), messageId, articleNumber)
}
/** Verifies and stores a spot from its raw header (the tests feed this directly). */
async store(raw: string, messageId: string, articleNumber: number | null): Promise<'listed' | 'unverified' | 'moderation' | null> {
const { headers, xml: xmlText } = parseSpotArticleHeaders(raw)
const header = parseSpotHeader(headers.from ?? '', headers.subject ?? '', messageId, headers.date)
if (!header) return null
const verified = verifySpotHeader(header)
const xml = verified ? parseSpotXml(decodeSpotText(xmlText)) : null
const target = verified ? moderationTarget(header) : null
const title = xml?.title || header.title
// Kept apart by where it came from: the title's {{pw}}, else the description's "Wachtwoord: …".
const fromTitle = verified && !target ? passwordFromSpot(title, null) : null
const fromText = verified && !target && !fromTitle ? passwordFromSpot('', xml?.description) : null
const status = !verified ? 'unverified' : target ? 'moderation' : 'listed'
await db
.table('spots')
.insert({
message_id: messageId,
article_number: articleNumber,
title: title.slice(0, 2000),
tag: header.tag || null,
poster: header.poster,
category: header.category,
subcats: JSON.stringify(header.subcats),
size: header.size || null,
posted_at: header.stamp ? new Date(header.stamp * 1000) : null,
key_id: header.keyId,
spotter_id: header.selfSignedPubKey.length > 50 ? spotterId(header.selfSignedPubKey) : null,
description: xml?.description || null,
website: xml?.website || null,
nzb_segments: xml?.nzbSegments.length ? xml.nzbSegments : null,
password: fromTitle ?? fromText,
password_source: fromTitle ? 'spot-title' : fromText ? 'spot-text' : null,
status,
})
.onConflict('message_id')
.ignore()
if (target) await this.moderate(header.selfSignedPubKey, header.stamp, target)
return status
}
/**
* A delete/dispose/remove: the target is `removed`, and its links and passwords go from the releases. As in
* Spotweb, a personal dispose (the poster's own key) counts only from the same spotter within 5 days; a
* moderator's (Spotnet's key) always.
*/
private async moderate(pubKey: string, stamp: number, targetId: string) {
const target = await db.from('spots').where('message_id', targetId).first()
if (!target) return
const personal = pubKey.length > 50 ? spotterId(pubKey) : ''
if (personal) {
const age = stamp - (target.posted_at ? new Date(target.posted_at).getTime() / 1000 : 0)
if (target.spotter_id !== personal || age > DISPOSE_WINDOW_SECONDS) return
}
await db.transaction(async (trx) => {
await trx.from('spots').where('message_id', targetId).update({ status: 'removed' })
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()
})
logger.info(`Spot "${target.title}" removed by ${personal ? 'its poster' : 'a moderator'}.`)
}
}
+2
View File
@@ -14,6 +14,8 @@ export const signupValidator = vine.create({
email: email().unique({ table: 'users', column: 'email' }), email: email().unique({ table: 'users', column: 'email' }),
password: password(), password: password(),
passwordConfirmation: password().sameAs('password'), passwordConfirmation: password().sameAs('password'),
// Needed when SIGNUP is `invite` (config/access.ts).
invite: vine.string().trim().maxLength(32).optional(),
}) })
/** /**
+37
View File
@@ -0,0 +1,37 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import AccessService from '#services/access_service'
import { TIERS } from '#config/access'
/** Makes invite codes (SIGNUP=invite), optionally with a tier for the new account, e.g. a sold VIP year. */
export default class AccessInvite extends BaseCommand {
static commandName = 'access:invite'
static description = 'Makes invite codes, optionally giving a tier (e.g. --tier=vip --days=365)'
static options: CommandOptions = { startApp: true }
@flags.number({ description: 'How many codes', default: 1 })
declare count: number
@flags.string({ description: `Tier the new account gets (${Object.keys(TIERS).join(', ')})` })
declare tier?: string
@flags.number({ description: 'For how many days the tier holds (without: no end)' })
declare days?: number
@flags.string({ description: 'A note, e.g. who it is for or the payment' })
declare note?: string
async run() {
if (this.tier && !TIERS[this.tier]) {
this.logger.error(`No such tier: ${this.tier}`)
this.exitCode = 1
return
}
const codes = Array.from({ length: this.count }, () => AccessService.newKey())
await db.table('invites').multiInsert(
codes.map((code) => ({ code, tier: this.tier ?? null, tier_days: this.tier ? (this.days ?? null) : null, note: this.note ?? null }))
)
for (const code of codes) this.logger.log(code)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import User from '#models/user'
/** Suspends a user (Newznab error 101, the catalog 403), e.g. for a shared key; --undo lifts it. */
export default class AccessSuspend extends BaseCommand {
static commandName = 'access:suspend'
static description = 'Suspends a user (or lifts it with --undo)'
static options: CommandOptions = { startApp: true }
@args.string({ description: 'The user’s email' })
declare email: string
@flags.boolean({ description: 'Lift the suspension' })
declare undo: boolean
async run() {
const user = await User.findBy('email', this.email)
if (!user) {
this.logger.error(`No user ${this.email}`)
this.exitCode = 1
return
}
user.suspended = !this.undo
await user.save()
this.logger.info(`${user.email}: ${user.suspended ? 'suspended' : 'active'}`)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import User from '#models/user'
import AccessService from '#services/access_service'
/** Gives a user a tier (a payment, by hand until one is taken on the site); a running one is extended. */
export default class AccessTier extends BaseCommand {
static commandName = 'access:tier'
static description = "Sets a user's tier, e.g. access:tier someone@example.com vip --days=365"
static options: CommandOptions = { startApp: true }
@args.string({ description: 'The user’s email' })
declare email: string
@args.string({ description: 'Tier (config/access.ts)' })
declare tier: string
@flags.number({ description: 'For how many days (without: no end); the same tier still running is extended' })
declare days?: number
async run() {
const user = await User.findBy('email', this.email)
if (!user) {
this.logger.error(`No user ${this.email}`)
this.exitCode = 1
return
}
AccessService.grantTier(user, this.tier, this.days ?? null)
await user.save()
this.logger.info(`${user.email}: ${user.tier} ${user.tierUntil ? `until ${user.tierUntil.toISO()}` : 'without end'}`)
}
}
+35 -2
View File
@@ -5,13 +5,24 @@ import { Prefetcher } from '#services/prefetcher'
import Planner from '#services/planner' import Planner from '#services/planner'
import FragmentMerger from '#services/fragment_merger' import FragmentMerger from '#services/fragment_merger'
import Settler from '#services/settler' import Settler from '#services/settler'
import RangeBlocks from '#services/range_blocks'
import NntpPool from '#services/nntp_pool' import NntpPool from '#services/nntp_pool'
import coordinatorConfig from '#config/coordinator'
const TICK_MS = 2000 const TICK_MS = 2000
const PLAN_EVERY_MS = 60 * 60 * 1000 const PLAN_EVERY_MS = 60 * 60 * 1000
const MERGE_EVERY_MS = 60 * 1000 const MERGE_EVERY_MS = 60 * 1000
const MERGE_BUDGET_MS = 30 * 1000 const MERGE_BUDGET_MS = 30 * 1000
const STOP_WAIT_MS = 15 * 1000 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
/** /**
* The coordinator's background work (REBUILD.md, part 1): the prefetcher, * The coordinator's background work (REBUILD.md, part 1): the prefetcher,
@@ -55,17 +66,39 @@ export default class CoordinatorRun extends BaseCommand {
await sleep(TICK_MS) 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 () => {
while (main && !stopping) {
try {
await RangeBlocks.rebuild()
} catch (error: any) {
this.logger.error(`Range blocks failed: ${error.message}`)
}
for (let waited = 0; waited < BLOCKS_EVERY_MS && !stopping; waited += TICK_MS) await sleep(TICK_MS)
}
})()
// History for new groups hourly; the tail every few minutes.
let plannedAt = 0 let plannedAt = 0
let tailedAt = 0
let mergedAt = 0 let mergedAt = 0
let settledAt = 0
while (!stopping) { while (!stopping) {
try { try {
if (main && Date.now() - plannedAt >= PLAN_EVERY_MS) { if (main && Date.now() - plannedAt >= PLAN_EVERY_MS) {
plannedAt = Date.now() plannedAt = Date.now()
const { groups, ranges } = await Planner.planAll() const { groups, ranges } = await Planner.planAll()
if (ranges > 0) this.logger.info(`Planned ${ranges} ranges over ${groups} groups.`) if (ranges > 0) this.logger.info(`Planned ${ranges} ranges over ${groups} groups.`)
const settled = await Settler.run() }
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 (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) { if (main && Date.now() - mergedAt >= MERGE_EVERY_MS) {
mergedAt = Date.now() mergedAt = Date.now()
// Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS. // Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS.
@@ -90,7 +123,7 @@ export default class CoordinatorRun extends BaseCommand {
} }
await sleep(TICK_MS) await sleep(TICK_MS)
} }
await prefetching await Promise.all([prefetching, blocks])
// The chunks in flight, for at most STOP_WAIT_MS (a connection the server holds back can take minutes); then closed. // The chunks in flight, for at most STOP_WAIT_MS (a connection the server holds back can take minutes); then closed.
const waitUntil = Date.now() + STOP_WAIT_MS const waitUntil = Date.now() + STOP_WAIT_MS
while (prefetcher.active > 0 && Date.now() < waitUntil) await sleep(200) while (prefetcher.active > 0 && Date.now() < waitUntil) await sleep(200)
+11 -1
View File
@@ -78,7 +78,17 @@ export default class EdgeBench extends BaseCommand {
for (let from = this.start; from <= end; from += this.chunk, index++) { for (let from = this.start; from <= end; from += this.chunk, index++) {
const to = Math.min(from + this.chunk - 1, end) const to = Math.min(from + this.chunk - 1, end)
const started = Date.now() const started = Date.now()
const raw = await client.xoverRaw(from, to) let raw = await client.xoverRaw(from, to)
// Eweka cuts some ranges' compressed reply short (the same bytes every time): fetched plain then.
try {
unzipSync(raw)
} catch {
const plain = await XoverClient.connect({ host: env.get('NNTP_HOST')!, port: env.get('NNTP_PORT')!, user: env.get('NNTP_USER'), password: env.get('NNTP_PASSWORD'), secure: env.get('NNTP_SECURE', true), compress: false, timeoutMs: 300_000 })
await plain.group(this.group)
raw = await plain.xoverRaw(from, to)
plain.close()
this.logger.warning(`chunk ${index}: the compressed reply didn't inflate; fetched plain`)
}
await writeFile(join(this.dir, `${String(index).padStart(4, '0')}-${from}-${to}.gz`), raw) await writeFile(join(this.dir, `${String(index).padStart(4, '0')}-${from}-${to}.gz`), raw)
this.logger.info(`chunk ${index}: ${from}–${to}, ${(raw.length / 1e6).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(1)} s`) this.logger.info(`chunk ${index}: ${from}–${to}, ${(raw.length / 1e6).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(1)} s`)
} }
+4 -1
View File
@@ -8,6 +8,8 @@ import { gzip, unzip } from 'node:zlib'
import { RangeIndexer, decodeFrames, isKeepalive, throttle, toWire, type EdgeBatch, type Lease } from '#lib/edge/index' import { RangeIndexer, decodeFrames, isKeepalive, throttle, toWire, type EdgeBatch, type Lease } from '#lib/edge/index'
const IDLE_MS = 30_000 const IDLE_MS = 30_000
/** A batch is posted once this many files (completed, or cut at an edge) wait, whatever its articles. */
const MAX_PENDING_FILES = 100_000
const unzipAsync = promisify(unzip) const unzipAsync = promisify(unzip)
const gzipAsync = promisify(gzip) const gzipAsync = promisify(gzip)
const RETRIES = 8 const RETRIES = 8
@@ -166,7 +168,8 @@ export default class EdgeNode extends BaseCommand {
indexer.addText(text.toString('utf8'), chunk.to + 1) indexer.addText(text.toString('utf8'), chunk.to + 1)
reachedEnd = chunk.to >= range.end reachedEnd = chunk.to >= range.end
if (reachedEnd) indexer.finish() if (reachedEnd) indexer.finish()
if (reachedEnd || chunk.to + 1 - batchFrom >= this.batchArticles) { // Also early when many files wait: a picture group's 500k articles made batches over the server's 256 MB limit (HTTP 413).
if (reachedEnd || chunk.to + 1 - batchFrom >= this.batchArticles || indexer.pending >= MAX_PENDING_FILES) {
post(indexer.take()) post(indexer.take())
batchFrom = chunk.to + 1 batchFrom = chunk.to + 1
} }
+25
View File
@@ -0,0 +1,25 @@
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { Prober } from '#services/prober'
/** Phase 2's prober (REBUILD.md, part 6): reads the probe queue's articles and stores what they show. */
export default class ProbeRun extends BaseCommand {
static commandName = 'probe:run'
static description = "Reads the probe queue's articles (PROBE_CONNECTIONS) and stores what they show"
static options: CommandOptions = { startApp: true, staysAlive: true }
async run() {
const prober = new Prober()
let done: () => void
const finished = new Promise<void>((resolve) => (done = resolve))
this.app.terminating(async () => {
this.logger.info('Stopping: finishing the articles in flight.')
prober.stop()
await finished
})
await prober.run()
this.logger.info('Prober stopped.')
done!()
await this.terminate()
}
}
+8 -5
View File
@@ -1,18 +1,21 @@
import { BaseCommand } from '@adonisjs/core/ace' import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace' import type { CommandOptions } from '@adonisjs/core/types/ace'
import Planner from '#services/planner' import Planner from '#services/planner'
import NntpPool from '#services/nntp_pool' import NntpPool from '#services/nntp_pool'
/** Cuts every active group's history (and new articles since) into ranges. */ /** Cuts new groups' history into ranges, or (--tail) plans one round of the live tail. */
export default class RangesPlan extends BaseCommand { export default class RangesPlan extends BaseCommand {
static commandName = 'ranges:plan' static commandName = 'ranges:plan'
static description = "Plans ranges for the indexed groups' articles" static description = "Plans ranges for new groups' history, or one round of the live tail"
static options: CommandOptions = { startApp: true } static options: CommandOptions = { startApp: true }
@flags.boolean({ description: 'Plan the live tail once (what coordinator:run does every few minutes)' })
declare tail: boolean
async run() { async run() {
try { try {
const { groups, ranges } = await Planner.planAll() const { groups, ranges } = this.tail ? await Planner.planTail() : await Planner.planAll()
this.logger.info(`${ranges} ranges planned over ${groups} groups.`) this.logger.info(`${ranges} ${this.tail ? 'tail ' : ''}ranges planned over ${groups} groups.`)
} finally { } finally {
await NntpPool.close() await NntpPool.close()
} }
+82
View File
@@ -0,0 +1,82 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import { decodeChunk } from '#lib/chunks/index'
/**
* How often a release holds a file name more than once (decoded from its
* chunks): copies of one upload posted again (multi-posting, reposts that
* share a key) or different files that share a name. Copies of the same
* size are counted as the same file posted again.
*/
export default class ReleasesDuplicates extends BaseCommand {
static commandName = 'releases:duplicates'
static description = 'Counts file names a release holds more than once, in a sample'
static options: CommandOptions = { startApp: true }
@flags.number({ description: 'Releases sampled (with more than one file)', default: 20000 })
declare sample: number
async run() {
const ids: string[] = (
await db.rawQuery(`SELECT id FROM releases TABLESAMPLE SYSTEM (5) WHERE files > 1 LIMIT ?`, [this.sample])
).rows.map((r: { id: string }) => r.id)
const kind = (name: string) =>
/\.(jpe?g|png|gif|bmp|webp)$/i.test(name) ? 'image'
: /\.(mp3|flac|m4a|ogg|wav)\b/i.test(name) ? 'audio'
: /\.(par2)$/i.test(name) ? 'par2'
: /\.(rar|r\d\d|7z|zip|\d{3})$/i.test(name) ? 'archive'
: /\.(pdf|epub|mobi|cbr|cbz|djvu|chm)$/i.test(name) ? 'document'
: /\.(nfo|sfv|nzb|txt|diz)$/i.test(name) ? 'sidecar'
: 'other'
let releases = 0
let withDuplicates = 0
let files = 0
let extra = 0
let completeOnlyByCopies = 0
const byKind = new Map<string, number>()
const how = { sameFile: 0, differentFile: 0, sameDay: 0, laterRepost: 0 }
const examples: string[] = []
for (const id of ids) {
const release = await db.from('releases').where('id', id).select('name', 'file_total', 'files').first()
const chunks = await db.from('release_chunks').where('release_id', id).select('payload')
const byName = new Map<string, Map<string, { parts: number; bytes: number; at: number | null }>>()
for (const c of chunks) {
for (const f of decodeChunk(c.payload)) {
const copies = byName.get(f.name) ?? new Map()
copies.set(f.segments[0]?.[1] ?? '', { parts: f.segments.length, bytes: f.segments.reduce((n, [, , b]) => n + b, 0), at: f.postedAt })
byName.set(f.name, copies)
}
}
releases++
let dup = false
for (const [name, copies] of byName) {
files += copies.size
if (copies.size < 2) continue
dup = true
extra += copies.size - 1
byKind.set(kind(name), (byKind.get(kind(name)) ?? 0) + copies.size - 1)
const list = [...copies.values()]
// The same file again: as many parts, within 3% in bytes (article sizes include headers).
const same = list.every((c) => c.parts === list[0].parts && Math.abs(c.bytes - list[0].bytes) <= 0.03 * list[0].bytes)
if (same) how.sameFile++
else how.differentFile++
const times = list.map((c) => c.at).filter((t): t is number => t !== null)
if (times.length > 1 && Math.max(...times) - Math.min(...times) > 86_400_000) how.laterRepost++
else how.sameDay++
if (!same && examples.length < 8) examples.push(`${id} ${release.name.slice(0, 60)} | ${name.slice(0, 60)} | ${list.map((c) => `${c.parts}p ${(c.bytes / 1e6).toFixed(1)}MB`).join(', ')}`)
}
if (!dup) continue
withDuplicates++
const total = Number(release.file_total ?? 0)
if (total && Number(release.files) >= total && byName.size < total) completeOnlyByCopies++
}
const pct = (n: number, of: number) => `${((100 * n) / Math.max(1, of)).toFixed(2)}%`
this.logger.info(`${releases} releases with 2+ files sampled`)
this.logger.info(` with a file name more than once: ${withDuplicates} (${pct(withDuplicates, releases)})`)
this.logger.info(` extra copies: ${extra} of ${files} files (${pct(extra, files)}); by type: ${[...byKind].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(', ')}`)
this.logger.info(` duplicated names: same file again ${how.sameFile}, different (parts or size) ${how.differentFile}; copies the same day ${how.sameDay}, a day or more apart ${how.laterRepost}`)
this.logger.info(` 'complete' only by counting copies: ${completeOnlyByCopies}`)
for (const e of examples) this.logger.info(` different: ${e}`)
}
}
+38
View File
@@ -0,0 +1,38 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { ReleaseJudge, RULES } from '#services/release_judge'
/** The junk judge (REBUILD.md, junk): releases judged after their probes; verdicts hide them from search. */
export default class ReleasesJudge extends BaseCommand {
static commandName = 'releases:judge'
static description = 'Judges releases by what their probes showed (dead, fake, repeated, locked, unusable), hourly'
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.boolean({ description: 'One round, then exit' })
declare once: boolean
@flags.boolean({ description: 'With --once: count only, write no verdicts' })
declare dry: boolean
async run() {
const judge = new ReleaseJudge()
if (this.once) {
const round = await judge.round(this.dry)
for (const rule of RULES) {
const r = round.rules[rule.name]
if (r) this.logger.info(`${rule.name}: ${r.matched} (+${r.added} −${r.removed}) — ${rule.says}`)
}
await this.terminate()
return
}
let done: () => void
const finished = new Promise<void>((resolve) => (done = resolve))
this.app.terminating(async () => {
judge.stop()
await finished
})
await judge.run()
this.logger.info('Judge stopped.')
done!()
}
}
+33
View File
@@ -0,0 +1,33 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { Par2Merger } from '#services/par2_merger'
/** The par2 merge (REBUILD.md, phase 2): releases whose files one par2 set describes become one. */
export default class ReleasesMerge extends BaseCommand {
static commandName = 'releases:merge'
static description = 'Merges the releases whose files one par2 set describes (what the prober read), every minute'
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.boolean({ description: 'One round over every set, then exit' })
declare once: boolean
async run() {
const merger = new Par2Merger()
if (this.once) {
const round = await merger.round()
this.logger.info(`${round.sets} sets checked: ${round.releases} releases merged into ${round.merges}.`)
await this.terminate()
return
}
let done: () => void
const finished = new Promise<void>((resolve) => (done = resolve))
this.app.terminating(async () => {
merger.stop()
await finished
})
await merger.run()
this.logger.info('Merger stopped.')
done!()
await this.terminate()
}
}
+86
View File
@@ -0,0 +1,86 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
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'
/**
* What lib/release's identity makes of releases already in the catalog: a
* sample of releases (by source), their files decoded from the chunks and
* each given its release key again. Shows how many would now split, and
* into what: for checking an identity change on real data.
*/
export default class ReleasesRegroup extends BaseCommand {
static commandName = 'releases:regroup'
static description = 'Shows how the current release identity would regroup a sample of the catalog'
static options: CommandOptions = { startApp: true }
@flags.number({ description: 'Releases sampled per source', default: 2000 })
declare sample: number
@flags.array({ description: 'Release ids to show in full' })
declare show?: string[]
@flags.array({ description: 'Groups whose indexed releases are re-keyed together (merges show too)' })
declare group?: string[]
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 = ?`,
[name]
)
const ids = rows.map((r: { release_id: string }) => r.release_id)
const all = new Map<string, { name: string; source: string; files: number; subject?: string }>()
for (const id of ids) {
for (const [key, g] of await this.regroup(Number(id))) {
const known = all.get(key)
if (known) known.files += g.files
else all.set(key, { ...g })
}
}
const sizes = [...all.values()].map((g) => g.files)
const single = sizes.filter((n) => n === 1).length
this.logger.info(`${name}: ${ids.length} releases now, ${all.size} with the current rule (${single} of one file)`)
for (const g of [...all.values()].sort((a, b) => b.files - a.files).slice(0, 6)) this.logger.info(` ${g.files} files: ${g.name.slice(0, 120)} (${g.source})`)
const singles = [...all.values()].filter((g) => g.files === 1)
for (let i = 0; i < Math.min(8, singles.length); i++) this.logger.info(` single: ${singles[Math.floor((i * singles.length) / 8)].subject?.slice(0, 150)}`)
}
if (this.group?.length) return
for (const id of this.show ?? []) {
const groups = await this.regroup(Number(id))
this.logger.info(`${id}: ${groups.size} release(s) now`)
for (const [, g] of groups) this.logger.info(` ${g.files} files: ${g.name} (${g.source})`)
}
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')
let split = 0
let pieces = 0
const examples: string[] = []
for (const r of rows) {
const groups = await this.regroup(Number(r.id))
if (groups.size <= 1) continue
split++
pieces += groups.size
if (examples.length < 5) examples.push(`${r.id} ${r.name} (${r.files} files) → ${groups.size}: ${[...groups.values()].slice(0, 3).map((g) => `${g.name} [${g.files}]`).join(' | ')}`)
}
this.logger.info(`${source}: ${rows.length} sampled, ${split} would split (into ${pieces} releases)`)
for (const e of examples) this.logger.info(` ${e.slice(0, 260)}`)
}
}
private async regroup(id: number) {
const chunks = await db.from('release_chunks').where('release_id', id).select('payload')
const groups = new Map<string, { name: string; source: string; files: number; subject?: string }>()
for (const c of chunks) {
for (const f of decodeChunk(c.payload)) {
const r = releaseIdentity({ poster: f.poster, subject: f.subject, name: f.name, weak: false })!
const g = groups.get(r.key)
if (g) g.files++
else groups.set(r.key, { name: r.name, source: r.source, files: 1, subject: f.subject })
}
}
return groups
}
}
+58
View File
@@ -0,0 +1,58 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import { decodeChunk } from '#lib/chunks/index'
/**
* For telling uploads apart (re-uploads under one release key): how long
* one upload takes to post (the span of a release's files' dates, in
* releases without a file name twice), and how far apart the copies of a
* file name are in releases that have one twice.
*/
export default class ReleasesUploadGaps extends BaseCommand {
static commandName = 'releases:upload-gaps'
static description = 'Posting spans of uploads and gaps between re-uploads, in a sample'
static options: CommandOptions = { startApp: true }
@flags.number({ description: 'Releases sampled (with more than one file)', default: 20000 })
declare sample: number
async run() {
const ids: string[] = (await db.rawQuery(`SELECT id FROM releases TABLESAMPLE SYSTEM (5) WHERE files > 1 LIMIT ?`, [this.sample])).rows.map((r: { id: string }) => r.id)
const bins = [10, 60, 180, 360, 720, 1440, 4320, Number.POSITIVE_INFINITY]
const label = (m: number) => (m === Number.POSITIVE_INFINITY ? '>3d' : m >= 1440 ? `≤${m / 1440}d` : m >= 60 ? `≤${m / 60}h` : `≤${m}min`)
const spans = new Array(bins.length).fill(0)
const gaps = new Array(bins.length).fill(0)
const bin = (minutes: number) => bins.findIndex((b) => minutes <= b)
for (const id of ids) {
const chunks = await db.from('release_chunks').where('release_id', id).select('payload')
const byName = new Map<string, number[]>()
const times: number[] = []
for (const c of chunks) {
for (const f of decodeChunk(c.payload)) {
if (f.postedAt === null) continue
times.push(f.postedAt)
byName.set(f.name, [...(byName.get(f.name) ?? []), f.postedAt])
}
}
if (times.length < 2) continue
const twice = [...byName.values()].filter((t) => t.length > 1)
if (twice.length === 0) {
spans[bin((Math.max(...times) - Math.min(...times)) / 60_000)]++
continue
}
// The gap between a name's copies (sorted; each step), so a release reposted 3 times counts 2 gaps per name.
for (const t of twice) {
t.sort((a, b) => a - b)
for (let i = 1; i < t.length; i++) gaps[bin((t[i] - t[i - 1]) / 60_000)]++
}
}
const show = (counts: number[]) => {
const total = counts.reduce((a, b) => a + b, 0)
let cumulative = 0
return bins.map((b, i) => `${label(b)} ${((100 * (cumulative += counts[i])) / Math.max(1, total)).toFixed(1)}%`).join(', ') + ` (n=${total})`
}
this.logger.info(`one upload's posting span (cumulative): ${show(spans)}`)
this.logger.info(`gap between copies of a name (cumulative): ${show(gaps)}`)
}
}
+52
View File
@@ -0,0 +1,52 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { readFile } from 'node:fs/promises'
import RuleSets from '#services/rule_sets'
import { signatureKey } from '#lib/rules/index'
/**
* Sets the dropped upload signatures (REBUILD.md, rules and recovery) from a
* file of "tool<TAB>group" lines, e.g. voyager 1's learned ones:
*
* psql … -At -F $'\t' -c "SELECT tool, group_name FROM upload_signatures WHERE dropped"
*
* Publishes a new rule set version (ranges record the version they ran with).
*/
export default class RulesSignatures extends BaseCommand {
static commandName = 'rules:signatures'
static description = 'Publishes a rule set with the dropped upload signatures from a file'
static options: CommandOptions = { startApp: true }
@args.string({ description: 'File of "tool<TAB>group" lines (more columns and # lines are ignored), e.g. database/data/voyager1_signatures.tsv' })
declare file: string
@flags.string({ description: 'Note stored with the rule set version' })
declare note?: string
@flags.boolean({ description: 'Add to the current signatures instead of replacing them' })
declare add: boolean
async run() {
const lines = (await readFile(this.file, 'utf8')).split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
const signatures = new Set<string>()
for (const line of lines) {
const [tool, group] = line.split('\t')
if (!tool || !group) throw new Error(`Not "tool<TAB>group": ${line}`)
signatures.add(signatureKey(tool, group))
}
const { version: _v, priorities: _p, ...current } = await RuleSets.current()
const before = new Set(current.droppedSignatures)
const next = this.add ? new Set([...before, ...signatures]) : signatures
if (next.size === before.size && [...next].every((s) => before.has(s))) {
this.logger.info(`Unchanged: ${before.size} dropped signatures already.`)
return
}
const version = await RuleSets.publish(
{ ...current, droppedSignatures: [...next].sort() },
this.note ?? `${next.size} dropped signatures from ${this.file}`
)
const added = [...next].filter((s) => !before.has(s)).length
const removed = [...before].filter((s) => !next.has(s)).length
this.logger.info(`Rule set ${version}: ${next.size} dropped signatures (${added} added, ${removed} removed).`)
}
}
+33
View File
@@ -0,0 +1,33 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { SpotInspector } from '#services/spot_inspector'
/** The spot inspector (REBUILD.md, spots): each spot's NZB read, its files listed and matched to releases. */
export default class SpotsInspect extends BaseCommand {
static commandName = 'spots:inspect'
static description = "Reads each listed spot's NZB, lists its files and links them to the releases they match"
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.boolean({ description: 'One round, then exit' })
declare once: boolean
async run() {
const inspector = new SpotInspector()
if (this.once) {
const round = await inspector.round()
this.logger.info(`${round.inspected} spots inspected, ${round.matched} matched releases, ${round.standalone} of their own.`)
inspector.stop()
await this.terminate()
return
}
let done: () => void
const finished = new Promise<void>((resolve) => (done = resolve))
this.app.terminating(async () => {
inspector.stop()
await finished
})
await inspector.run()
this.logger.info('Spot inspector stopped.')
done!()
}
}
+33
View File
@@ -0,0 +1,33 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { Spotter } from '#services/spotter'
/** The spotter (REBUILD.md, spots): Spotnet's spots from free.pt's headers into `spots`, on one connection. */
export default class SpotsRead extends BaseCommand {
static commandName = 'spots:read'
static description = "Reads Spotnet's spots (free.pt) into the spots table, verified, every few minutes"
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.boolean({ description: 'One round, then exit' })
declare once: boolean
async run() {
const spotter = new Spotter()
if (this.once) {
const round = await spotter.round()
this.logger.info(`${round.read} articles read: ${round.listed} spots listed, ${round.unverified} unverified, ${round.moderation} moderation.`)
spotter.stop()
await this.terminate()
return
}
let done: () => void
const finished = new Promise<void>((resolve) => (done = resolve))
this.app.terminating(async () => {
spotter.stop()
await finished
})
await spotter.run()
this.logger.info('Spotter stopped.')
done!()
}
}
+31
View File
@@ -0,0 +1,31 @@
import env from '#start/env'
/**
* Who may use the catalog and the Newznab API.
*
* `open` (the default): as before, one key for Newznab (NEWZNAB_API_KEY, or
* none) and an open web catalog, no limits. `accounts`: every request names a
* user (a Newznab `apikey`, or the web interface's token), whose tier sets what
* they may do per UTC day and how far back they see.
*/
export const ACCESS = env.get('ACCESS', 'open')
/** Who may sign up: anyone, with an unused invite code, or no one. With accounts, invites unless set. */
export const SIGNUP = env.get('SIGNUP', ACCESS === 'accounts' ? 'invite' : 'open')
export interface TierLimits {
/** Searches and release details per day (Newznab t=search & co., the catalog); null: no limit. */
apiHits: number | null
/** NZBs per day; null: no limit. */
grabs: number | null
/** How many days back releases are found and fetched; null: everything. */
retentionDays: number | null
}
/** A tier that runs out (users.tier_until) falls back to `free`. */
export const TIERS: Record<string, TierLimits> = {
free: { apiHits: 100, grabs: 10, retentionDays: 365 },
vip: { apiHits: 10_000, grabs: 1_000, retentionDays: null },
// Staff and friends.
unlimited: { apiHits: null, grabs: null, retentionDays: null },
}
+104 -3
View File
@@ -12,18 +12,119 @@ export default {
* 2026-10-09). * 2026-10-09).
*/ */
chunkArticles: env.get('CHUNK_ARTICLES', 100_000), chunkArticles: env.get('CHUNK_ARTICLES', 100_000),
/**
* The live tail (REBUILD.md, coordinator): new articles become ranges
* every `everySeconds` (one LIST ACTIVE per name prefix), leased and
* fetched before any history. A group gets a tail range once it has
* `minArticles` new (a chunk) or its oldest unplanned article waited
* `maxDelayMinutes`, so a quiet group doesn't make a range every round.
*/
tail: {
everySeconds: env.get('TAIL_EVERY_SECONDS', 120),
minArticles: env.get('TAIL_MIN_ARTICLES', 100_000),
maxDelayMinutes: env.get('TAIL_MAX_DELAY_MINUTES', 15),
/**
* Before everything: the history's 0 (oldest) … 1000 (newest), the stored ranges' -2 and ranges raised to -1 (the
* short gaps between done blocks went before the tail at -1 and it fell ~4 h behind; LOG.md 2026-10-16 (70)).
*/
priority: -3,
},
/** A leased range without a heartbeat for this long goes back to the queue. */ /** A leased range without a heartbeat for this long goes back to the queue. */
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60), leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
/** NNTP connections the coordinator fetches headers with. */ /** NNTP connections the coordinator fetches headers with. */
nntpConnections: env.get('NNTP_CONNECTIONS', 4), nntpConnections: env.get('NNTP_CONNECTIONS', 4),
/** A reply with no data for this long fails (old ranges answer slowly). */ /**
nntpTimeoutMs: env.get('NNTP_TIMEOUT_MS', 10 * 60 * 1000), * A reply with no data for this long fails, and its connection is replaced.
* Eweka sometimes stops answering on long-lived connections (all 40 quiet,
* a fresh one answers in seconds; LOG.md 2026-10-09 (14)): at 10 minutes
* the prefetcher nearly stopped. Data arrives within seconds otherwise.
*/
nntpTimeoutMs: env.get('NNTP_TIMEOUT_MS', 2 * 60 * 1000),
/**
* A request that got less than this in the last minute (kB) is given up,
* its connection closed, and the chunk fetched again: a reply that trickles
* (6,204 → 6,229 kB in a minute) never goes quiet long enough for
* nntpTimeoutMs, and kept a worker waiting on one chunk for an hour. Not a
* deadline: some groups send steadily but slowly (a.b.0day.stuffz ~400 kB a
* minute, 5–7 minutes a chunk), and a fixed 90–360 s gave those up again
* and again (LOG.md 2026-10-10 (3)).
*/
nntpStallBytesPerMinute: env.get('NNTP_STALL_KB_PER_MINUTE', 100) * 1000,
/**
* A chunk not fetched after this long is asked again on another
* connection; the first answer wins and the other connection is closed.
* Eweka stalls single requests: the same chunk took 27–52 s on one
* connection and 1–9 s on another at the same moment; median ~3 s
* (LOG.md 2026-10-09 (15)). Measured in production (40 + 6 connections vs
* 40, 15 min each): 280k vs 273k articles/s, within the noise; the second
* request won half the time. Off by default.
*/
nntpHedgeAfterMs: env.get('NNTP_HEDGE_AFTER_SECONDS', 10) * 1000,
/** Of nntpConnections, kept for those second requests (the prefetcher's loops are the rest). 0: no hedging. */
nntpHedgeConnections: env.get('NNTP_HEDGE_CONNECTIONS', 0),
/**
* A connection is closed after this long (when it's free), and a fresh one
* opened. 0: never. Requests were as often slow (>10 s) on connections
* under 2 minutes old as on ones over 10 (~25–35%; LOG.md 2026-10-09 (16)).
*/
nntpConnectionMaxMs: env.get('NNTP_CONNECTION_MAX_MINUTES', 0) * 60 * 1000,
probe: {
/**
* NNTP connections the prober reads articles with, besides the
* coordinator's: the account has 50, the coordinator 40 + 1 plain, and a
* couple stay free for downloads. ~7 MB/s each (~10 articles/s).
*/
connections: env.get('PROBE_CONNECTIONS', 6),
/** Candidates claimed at a time. */
batch: 400,
/** BODY commands in flight per connection (pipelined; XoverClient.bodies). */
pipeline: env.get('PROBE_PIPELINE', 8),
},
/**
* The catalog's tablespace may grow to this many GB (app/services/catalog_space.ts); above it only the tail and
* the stored ranges are leased and prefetched. 0: no cap.
*/
catalogMaxNvmeGb: env.get('CATALOG_MAX_NVME_GB', 0),
/**
* Spotnet spots (app/services/spotter.ts, spot_inspector.ts): the group the spots are posted in, how many of
* its last articles the spotter reads the first time (~215 spots a day: ~3 months), how often it looks again,
* the most articles an NZB may take, and how long an inspected spot whose files match no release waits for
* the header pass before it becomes a release of its own.
*/
spots: {
group: env.get('SPOT_GROUP', 'free.pt'),
backfill: env.get('SPOT_BACKFILL', 20_000),
everySeconds: env.get('SPOT_EVERY_SECONDS', 120),
maxNzbSegments: env.get('SPOT_MAX_NZB_SEGMENTS', 64),
standaloneAfterHours: env.get('SPOT_STANDALONE_AFTER_HOURS', 24),
},
/** Stored XOVER chunks of ranges marked `capture`, for the grouping sweeps (app/services/corpus.ts). Off when unset. */
corpus: {
dir: env.get('CORPUS_DIR'),
/**
* Capture ranges fetched into the corpus by connections the spool leaves idle, this many at a time (8, 2 connections each, left 20 of 40 idle): the
* spool only fetches what workers lease soon, and the tail comes first, so after a reset the capture
* ranges waited behind it for hours (1,400 marked, none stored, 34 of 40 connections idle; LOG.md 2026-10-12 (27)).
*/
ahead: env.get('CORPUS_AHEAD', 16),
},
spool: { spool: {
dir: env.get('SPOOL_DIR', app.tmpPath('spool')), dir: env.get('SPOOL_DIR', app.tmpPath('spool')),
/** Ranges kept fetched ahead, per active lease. */ /** Ranges kept fetched ahead, per active lease. */
aheadPerLease: env.get('SPOOL_AHEAD_PER_LEASE', 2), aheadPerLease: env.get('SPOOL_AHEAD_PER_LEASE', 4),
/** At least this many ranges ahead, so the first leases start at once. */ /** At least this many ranges ahead, so the first leases start at once. */
minAhead: env.get('SPOOL_MIN_AHEAD', 2), minAhead: env.get('SPOOL_MIN_AHEAD', 2),
maxBytes: env.get('SPOOL_MAX_GB', 10) * 1e9, maxBytes: env.get('SPOOL_MAX_GB', 10) * 1e9,
/**
* At most this many connections on one range, and on one group, at a
* time (0: no limit): spreads the requests over ranges and groups instead
* of all on the next chunks of the first ranges. Homelab, 40 connections,
* 12 min each, alternated: unlimited (on ~10 ranges) 281k and 167k
* articles/s, 2 per range and 4 per group (on ~22) 319k and 296k, with
* shorter chunk times (11.5 vs 13–24 s; LOG.md 2026-10-09 (18)). Needs
* targets for it: aheadPerLease 4.
*/
perRange: env.get('PREFETCH_PER_RANGE', 2),
perGroup: env.get('PREFETCH_PER_GROUP', 4),
}, },
} }
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from '@adonisjs/static'
/**
* Configuration options to tweak the static files middleware.
* The complete set of options are documented on the
* official documentation website.
*
* https://docs.adonisjs.com/guides/static-assets
*/
const staticServerConfig = defineConfig({
enabled: true,
etag: true,
lastModified: true,
dotFiles: 'ignore',
})
export default staticServerConfig
+30
View File
@@ -0,0 +1,30 @@
# Voyager 1 upload_signatures WHERE dropped, 2026-10-09: tool, group, probed, named
other alt.binaries.ath 10988 24
ngPost alt.binaries.ath 3843 0
hex32 alt.binaries.test 1890 2
ngPost alt.binaries.a51 1516 0
nyuu alt.binaries.a51 700 0
nyuu alt.binaries.mom 656 0
hex32 alt.binaries.boneless 453 1
nyuu alt.binaries.misc 356 0
nyuu alt.binaries.games.encrypted 353 0
other alt.binaries.etc 317 1
other alt.binaries.misc 310 0
nyuu alt.binaries.warez 292 0
other alt.binaries.usenet2day 255 0
other alt.binaries.ijsklontje 238 0
ngPost alt.binaries.bloaf 237 0
nyuu alt.binaries.nl 196 0
other alt.binaries.archive.encrypted 189 0
nyuu alt.binaries.frogs 172 0
other alt.binaries.encrypted 160 0
yuun alt.binaries.nl 152 0
ngPost alt.binaries.nl 149 0
other alt.binaries.bloaf 134 0
nyuu alt.binaries.erotica 119 0
other alt.binaries.warez 119 0
other alt.binaries.ftn 107 0
nyuu alt.binaries.ijsklontje 106 0
random alt.binaries.newznzb.delta 103 0
ngPost alt.binaries.mom 102 0
random alt.binaries.newznzb.charlie 101 0
1 # Voyager 1 upload_signatures WHERE dropped, 2026-10-09: tool, group, probed, named
2 other alt.binaries.ath 10988 24
3 ngPost alt.binaries.ath 3843 0
4 hex32 alt.binaries.test 1890 2
5 ngPost alt.binaries.a51 1516 0
6 nyuu alt.binaries.a51 700 0
7 nyuu alt.binaries.mom 656 0
8 hex32 alt.binaries.boneless 453 1
9 nyuu alt.binaries.misc 356 0
10 nyuu alt.binaries.games.encrypted 353 0
11 other alt.binaries.etc 317 1
12 other alt.binaries.misc 310 0
13 nyuu alt.binaries.warez 292 0
14 other alt.binaries.usenet2day 255 0
15 other alt.binaries.ijsklontje 238 0
16 ngPost alt.binaries.bloaf 237 0
17 nyuu alt.binaries.nl 196 0
18 other alt.binaries.archive.encrypted 189 0
19 nyuu alt.binaries.frogs 172 0
20 other alt.binaries.encrypted 160 0
21 yuun alt.binaries.nl 152 0
22 ngPost alt.binaries.nl 149 0
23 other alt.binaries.bloaf 134 0
24 nyuu alt.binaries.erotica 119 0
25 other alt.binaries.warez 119 0
26 other alt.binaries.ftn 107 0
27 nyuu alt.binaries.ijsklontje 106 0
28 random alt.binaries.newznzb.delta 103 0
29 ngPost alt.binaries.mom 102 0
30 random alt.binaries.newznzb.charlie 101 0
@@ -0,0 +1,39 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* A release's Newznab category from its name (REBUILD.md, part 9): voyager
* 1's release_category() without the archive contents (phase 2 has them).
* TV (5030/5040/5045), movies (2030/2040/2045), audio (3010/3040),
* books (7020), otherwise 8010.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(String.raw`
CREATE OR REPLACE FUNCTION release_category(name text) RETURNS integer IMMUTABLE LANGUAGE sql AS $$
SELECT CASE
-- TV: S01E02, 1x02, or a season pack (S01).
WHEN name ~* '(^|[._ -])(S\d{1,2}E\d{1,3}|\d{1,2}x\d{2})([._ -]|$)' OR name ~* '[._ -]S\d{1,2}([._ -]|$)' THEN
CASE WHEN name ~* '2160p|(^|[._ -])(uhd|4k)([._ -]|$)' THEN 5045
WHEN name ~* '720p|1080p' THEN 5040 ELSE 5030 END
-- Movies: a year plus a source or quality marker.
WHEN name ~* '(^|[._ (-])(19|20)\d{2}([._ )-]|$)'
AND name ~* '(480p|576p|720p|1080p|2160p|blu-?ray|web-?dl|webrip|bdrip|brrip|dvdrip|hdtv|x26[45]|h\.?26[45]|hevc|remux)' THEN
CASE WHEN name ~* '2160p|(^|[._ -])(uhd|4k)([._ -]|$)' THEN 2045
WHEN name ~* '720p|1080p|blu-?ray' THEN 2040 ELSE 2030 END
-- Audio: lossless, mp3, or scene music names (-WEB-2026-GROUP).
WHEN name ~* '(^|[._ -])flac([._ -]|$)' THEN 3040
WHEN name ~* '(^|[._ -])mp3([._ -]|$)' OR name ~* '-(WEB|CD|CDM|CDS|EP|VINYL|VLS|SAT|FM|DAB)-(19|20)\d{2}-[A-Za-z0-9]+$' THEN 3010
WHEN name ~* '(^|[._ -])(epub|ebook|mobi)([._ -]|$)' THEN 7020
ELSE 8010
END
$$`)
this.schema.raw(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS category integer GENERATED ALWAYS AS (release_category(name)) STORED`)
// Newznab and the web interface list a category newest first.
this.schema.raw(`CREATE INDEX IF NOT EXISTS releases_category_posted_idx ON releases (category, posted_at DESC)`)
}
async down() {
this.schema.raw(`ALTER TABLE releases DROP COLUMN IF EXISTS category`)
this.schema.raw(`DROP FUNCTION IF EXISTS release_category(text)`)
}
}
@@ -0,0 +1,15 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* A group's live tail can be paused (its history and its place in the owner
* rule stay): e.g. to let other groups' history go first.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS tail boolean NOT NULL DEFAULT true`)
}
async down() {
this.schema.raw(`ALTER TABLE groups DROP COLUMN IF EXISTS tail`)
}
}
@@ -0,0 +1,26 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Re-uploads as releases of their own (REBUILD.md, decision d): releases
* keep the identity key their files gave (identity_key; release_key is that
* for the first upload, then key:2, key:3, …), the group that owns their
* articles, and their posting window (posted_at … last_posted_at). A batch's
* chunk joins a release with its key and group whose window is within
* UPLOAD_GAP_MS, else starts one.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS identity_key varchar(40)`)
this.schema.raw(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS group_id integer`)
this.schema.raw(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS last_posted_at timestamptz`)
this.schema.raw(`UPDATE releases SET identity_key = release_key, last_posted_at = posted_at WHERE identity_key IS NULL`)
this.schema.raw(`ALTER TABLE releases ALTER COLUMN identity_key SET NOT NULL`)
this.schema.raw(`ALTER TABLE releases ALTER COLUMN release_key TYPE varchar(48)`)
this.schema.raw(`CREATE INDEX IF NOT EXISTS releases_identity_idx ON releases (identity_key, group_id)`)
}
async down() {
this.schema.raw(`DROP INDEX IF EXISTS releases_identity_idx`)
this.schema.raw(`ALTER TABLE releases DROP COLUMN IF EXISTS identity_key, DROP COLUMN IF EXISTS group_id, DROP COLUMN IF EXISTS last_posted_at`)
}
}
@@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Chunks of a range to fetch without compression: Eweka sends some ranges'
* compressed XOVER truncated, the same bytes every time, while the plain
* reply is whole (LOG.md 2026-10-09 (11)). A worker reports such a chunk;
* its start goes here and the prefetcher fetches it plain.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE ranges ADD COLUMN IF NOT EXISTS plain_chunks bigint[] NOT NULL DEFAULT '{}'`)
this.schema.raw(`ALTER TABLE ranges ADD COLUMN IF NOT EXISTS error text`)
}
async down() {
this.schema.raw(`ALTER TABLE ranges DROP COLUMN IF EXISTS plain_chunks, DROP COLUMN IF EXISTS error`)
}
}
@@ -0,0 +1,12 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** TABLESAMPLE SYSTEM_ROWS: the stats' sample of the catalog at a fixed size (a percentage read 30 s of random pages on the HDD at 7M releases). */
export default class extends BaseSchema {
async up() {
this.schema.raw(`CREATE EXTENSION IF NOT EXISTS tsm_system_rows`)
}
async down() {
this.schema.raw(`DROP EXTENSION IF EXISTS tsm_system_rows`)
}
}
@@ -0,0 +1,38 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import env from '#start/env'
/**
* Phase 2's probe results (REBUILD.md, part 6): per release and probe kind
* what its article showed (yEnc name, content type, a few names inside,
* encryption), in CHUNK_TABLESPACE (the HDD) when set: they're read per
* release, not searched. On the catalog only what search needs: whether the
* release is passworded and its best inner name.
*/
export default class extends BaseSchema {
async up() {
const tablespace = env.get('CHUNK_TABLESPACE')
const where = tablespace ? ` TABLESPACE ${tablespace}` : ''
this.schema.raw(`
CREATE TABLE release_inspections (
release_id bigint NOT NULL,
kind varchar(8) NOT NULL,
result jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, kind)${tablespace ? ` USING INDEX TABLESPACE ${tablespace}` : ''}
)${where}`)
this.schema.alterTable('releases', (table) => {
table.boolean('passworded').nullable()
table.string('inner_name', 255).nullable()
table.timestamp('inspected_at', { useTz: true }).nullable()
})
}
async down() {
this.schema.alterTable('releases', (table) => {
table.dropColumn('passworded')
table.dropColumn('inner_name')
table.dropColumn('inspected_at')
})
this.schema.dropTable('release_inspections')
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** One inspection per probe candidate: a release can have several of a kind (two RAR sets). */
export default class extends BaseSchema {
async up() {
this.schema.raw(`DELETE FROM release_inspections`)
this.schema.raw(`ALTER TABLE release_inspections DROP CONSTRAINT release_inspections_pkey`)
this.schema.raw(`ALTER TABLE release_inspections ADD COLUMN candidate_id bigint NOT NULL`)
this.schema.raw(`ALTER TABLE release_inspections ADD PRIMARY KEY (release_id, candidate_id)`)
// Their results went with the deleted rows: probed again.
this.schema.raw(`UPDATE probe_candidates SET status = 'waiting' WHERE status = 'done'`)
this.schema.raw(`UPDATE releases SET passworded = NULL, inner_name = NULL, inspected_at = NULL WHERE inspected_at IS NOT NULL`)
}
async down() {
this.schema.raw(`ALTER TABLE release_inspections DROP CONSTRAINT release_inspections_pkey`)
this.schema.raw(`ALTER TABLE release_inspections DROP COLUMN candidate_id`)
this.schema.raw(`ALTER TABLE release_inspections ADD PRIMARY KEY (release_id, kind)`)
}
}
@@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Search matches the prober's inner names too (SearchService): a trigram
* index on them, only where there is one. Built concurrently: the catalog
* keeps taking batches meanwhile.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_inner_name_trgm ON releases USING gin (inner_name gin_trgm_ops) WHERE inner_name IS NOT NULL`)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS releases_inner_name_trgm`)
}
}
@@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Indexes that match the newest-first sort (SearchService: posted_at DESC
* NULLS LAST, id DESC). The old ones (posted_at DESC: nulls first) didn't,
* so the default page sorted all 68M releases: 23 s. Built concurrently, the
* old ones dropped after.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_newest_idx ON releases (posted_at DESC NULLS LAST, id DESC)`)
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_category_newest_idx ON releases (category, posted_at DESC NULLS LAST, id DESC)`)
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS releases_posted_at_idx`)
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS releases_category_posted_idx`)
}
async down() {
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_posted_at_idx ON releases (posted_at DESC)`)
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_category_posted_idx ON releases (category, posted_at DESC)`)
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS releases_newest_idx`)
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS releases_category_newest_idx`)
}
}
@@ -0,0 +1,12 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** Ranges whose XOVER chunks are kept in the corpus (CORPUS_DIR) for the grouping sweeps. */
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE ranges ADD COLUMN IF NOT EXISTS capture boolean NOT NULL DEFAULT false`)
}
async down() {
this.schema.raw(`ALTER TABLE ranges DROP COLUMN IF EXISTS capture`)
}
}
@@ -0,0 +1,60 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The par2 merge (REBUILD.md, phase 2; app/services/par2_merger.ts): what the
* prober read that ties files to a par2 set, and the merges made from it.
* - file_hashes: per probed article that starts a file, the MD5 of its first 16 KiB and the file's size.
* - par2_files: per par2 recovery set, every file it describes (FileDesc: the same 16 KiB MD5, length, name).
* - par2_sets: which releases hold a par2 file of a set.
* - release_merges: releases merged into another, by set.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE file_hashes (
candidate_id bigint PRIMARY KEY,
release_id bigint NOT NULL,
hash16k char(32) NOT NULL,
size bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`)
this.schema.raw(`CREATE INDEX file_hashes_hash_idx ON file_hashes (hash16k)`)
this.schema.raw(`CREATE INDEX file_hashes_release_idx ON file_hashes (release_id)`)
this.schema.raw(`CREATE INDEX file_hashes_created_idx ON file_hashes (created_at)`)
this.schema.raw(`
CREATE TABLE par2_files (
set_id char(32) NOT NULL,
file_id char(32) NOT NULL,
hash16k char(32) NOT NULL,
length bigint NOT NULL,
name text NOT NULL,
PRIMARY KEY (set_id, file_id)
)`)
this.schema.raw(`CREATE INDEX par2_files_hash_idx ON par2_files (hash16k)`)
this.schema.raw(`
CREATE TABLE par2_sets (
set_id char(32) NOT NULL,
release_id bigint NOT NULL,
files integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (set_id, release_id)
)`)
this.schema.raw(`CREATE INDEX par2_sets_release_idx ON par2_sets (release_id)`)
this.schema.raw(`CREATE INDEX par2_sets_created_idx ON par2_sets (created_at)`)
this.schema.raw(`
CREATE TABLE release_merges (
id bigserial PRIMARY KEY,
from_id bigint NOT NULL,
from_key varchar(48) NOT NULL,
into_id bigint NOT NULL,
set_id char(32) NOT NULL,
files integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`)
this.schema.raw(`CREATE INDEX release_merges_into_idx ON release_merges (into_id)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS file_hashes, par2_files, par2_sets, release_merges`)
}
}
@@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* What a probed article showed, by message-id (app/services/prober.ts): an
* article's inspection doesn't depend on the grouping rules, so a catalog
* reset (which empties the probe queue) needn't read it from the news server
* again. `version` is PROBE_VERSION when it was inspected: a change to the
* inspection makes older rows stale. Not truncated by a reset.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE probe_cache (
message_id text PRIMARY KEY,
status varchar(8) NOT NULL,
result jsonb,
version integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS probe_cache`)
}
}
@@ -0,0 +1,19 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The prober claims waiting candidates by kind from random points of the
* queue (Prober.claim): by the id index alone a slice filtered ~700k rows for
* the rarer kinds (0.37 s a slice, 20 slices and up to 5 tiers a claim, the
* lanes waiting meanwhile; LOG.md 2026-10-12 (17)). Built concurrently.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS probe_candidates_kind_waiting_idx ON probe_candidates (kind, id) WHERE status = 'waiting'`)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS probe_candidates_kind_waiting_idx`)
}
}
@@ -0,0 +1,30 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Candidates the prober may claim (Prober.claim): `ready` once their release
* settled (the settler sets it), `unreadable` when that release had no
* readable name then. The claims joined releases for both and started at
* random points of the queue; in its newer half nothing had settled, so a
* slice scanned to the end (0.3 s a slice, 20 slices, 5 tiers: the prober
* stood still for minutes per claim; LOG.md 2026-10-12 (20)). With an index
* over the claimable rows only.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`ALTER TABLE probe_candidates ADD COLUMN IF NOT EXISTS ready boolean NOT NULL DEFAULT false`)
this.schema.raw(`ALTER TABLE probe_candidates ADD COLUMN IF NOT EXISTS unreadable boolean NOT NULL DEFAULT false`)
this.schema.raw(
`UPDATE probe_candidates pc SET ready = true, unreadable = NOT r.readable FROM releases r
WHERE r.id = pc.release_id AND r.settled_at IS NOT NULL AND NOT pc.ready`
)
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS probe_candidates_ready_idx ON probe_candidates (kind, unreadable, id) WHERE status = 'waiting' AND ready`)
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS probe_candidates_kind_waiting_idx`)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS probe_candidates_ready_idx`)
this.schema.raw(`ALTER TABLE probe_candidates DROP COLUMN IF EXISTS ready, DROP COLUMN IF EXISTS unreadable`)
}
}
@@ -0,0 +1,30 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The merge by inner name (app/services/par2_merger.ts, InnerStemMerger):
* obfuscated uploads post each file under a subject name of its own, but the
* yEnc name inside its first article shares a random stem with the rest of
* the upload ("[1/7] - "471044d8…"" holds "bX147zx8I0oEgj7nzFCiorCT0ps2jEcb.7z.001";
* hep, hephaestus, gyges: 31,000 one-file releases; LOG.md 2026-10-12 (32)).
* - An index over a lone inspection's stem: the yEnc name up to its first dot, when 16–64 letters and digits.
* - release_merges: which kind of merge (par2 or inner), and room for a stem in set_id.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS release_inspections_inner_stem_idx ON release_inspections
((substring(result->>'yencName' from '^([A-Za-z0-9]{16,64})\\.'))) WHERE kind = 'lone'`
)
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS release_inspections_created_idx ON release_inspections (created_at)`)
this.schema.raw(`ALTER TABLE release_merges ALTER COLUMN set_id TYPE varchar(64)`)
this.schema.raw(`ALTER TABLE release_merges ADD COLUMN IF NOT EXISTS via varchar(8) NOT NULL DEFAULT 'par2'`)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS release_inspections_inner_stem_idx`)
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS release_inspections_created_idx`)
this.schema.raw(`ALTER TABLE release_merges DROP COLUMN IF EXISTS via`)
}
}
@@ -0,0 +1,27 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* release_inspections in the catalog's tablespace (default_tablespace: nvme
* on homelab), not CHUNK_TABLESPACE: the prober writes it every flush and
* the merger looks stems up in it (release_inspections_inner_stem_idx), so it
* isn't only read per release any more (migration 1700). Its flushes took
* 35–83 s a minute with the table on the HDD (LOG.md 2026-10-13 (43)).
* Homelab was moved by hand first; here a no-op when it's there already, or
* without a default tablespace (tests).
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
const { rows } = await db.rawQuery(
`SELECT current_setting('default_tablespace') AS target,
(SELECT t.spcname FROM pg_class c LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE c.relname = 'release_inspections') AS current`
)
const { target, current } = rows[0]
if (!target || current === target) return
await db.rawQuery(`ALTER TABLE release_inspections SET TABLESPACE ${target}`)
await db.rawQuery(`ALTER INDEX release_inspections_pkey SET TABLESPACE ${target}`)
})
}
async down() {}
}
@@ -0,0 +1,96 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Spotnet spots (REBUILD.md, spots; after voyager 1's D29), in two processes:
* - the spotter (`spots:read`) reads free.pt's headers, verifies each spot's
* signature from its full header (X-XML) and keeps it in `spots`, with a
* password its title or description names;
* - the spot inspector (`spots:inspect`) reads each spot's compressed NZB
* (alt.binaries.ftd), keeps its files in `spot_files`, and matches them to
* the releases the header pass built (the same release identity), linking
* them in `release_spots`. A spot whose files match no release becomes a
* release of its own (source `spot`) a day after its inspection (until
* the header pass builds its releases after all: migration 2900).
* The NZB itself is kept (gzip) for such a release's downloads.
* Passwords are kept apart from the catalog in `release_passwords`, one row
* per release and password, with where it came from (the NZB's meta, the
* spot's title or its text); the catalog only says `passworded`.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE spots (
message_id text PRIMARY KEY,
article_number bigint,
title text NOT NULL,
tag text,
poster text NOT NULL,
category integer,
subcats jsonb,
size bigint,
posted_at timestamptz,
key_id integer,
spotter_id text,
description text,
website text,
nzb_segments text[],
-- The NZB as read (gzip): a release the spot makes of its own has no chunks to build one from.
nzb bytea,
password text,
password_source varchar(16),
-- listed: verified, waiting for the inspector; inspected, no-nzb, dummy, nzb-gone, nzb-unreadable: after it;
-- moderation: a delete/dispose message; removed: disposed of by a moderator or its spotter.
status varchar(16) NOT NULL DEFAULT 'listed',
files integer,
matched integer,
release_id bigint,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
inspected_at timestamptz
)`)
this.schema.raw(`CREATE INDEX spots_listed_idx ON spots (posted_at DESC) WHERE status = 'listed'`)
this.schema.raw(`CREATE INDEX spots_unmatched_idx ON spots (inspected_at) WHERE status = 'inspected' AND matched = 0 AND release_id IS NULL`)
this.schema.raw(`
CREATE TABLE spot_files (
spot_id text NOT NULL,
idx integer NOT NULL,
name text NOT NULL,
subject text NOT NULL,
poster text,
groups text[],
posted_at timestamptz,
bytes bigint NOT NULL,
segments integer NOT NULL,
release_id bigint,
PRIMARY KEY (spot_id, idx)
)`)
this.schema.raw(`CREATE INDEX spot_files_release_idx ON spot_files (release_id) WHERE release_id IS NOT NULL`)
this.schema.raw(`
CREATE TABLE release_spots (
release_id bigint NOT NULL,
spot_id text NOT NULL,
files integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, spot_id)
)`)
this.schema.raw(`CREATE INDEX release_spots_spot_idx ON release_spots (spot_id)`)
this.schema.raw(`
CREATE TABLE release_passwords (
release_id bigint NOT NULL,
password text NOT NULL,
source varchar(16) NOT NULL,
spot_id text,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, password)
)`)
this.schema.raw(`
CREATE TABLE spot_state (
group_name text PRIMARY KEY,
last_article bigint NOT NULL
)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS spots, spot_files, release_spots, release_passwords, spot_state`)
}
}
@@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Unmatched spots are matched again in turns (`rematched_at`), for 30 days after their inspection, also once they
* became a release of their own: the header pass may build their releases later (a spot's history before the
* catalog's), and then the spot moves to that release (LOG.md 2026-10-13 (45)).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE spots ADD COLUMN IF NOT EXISTS rematched_at timestamptz`)
// Inspected before the inspector set it (2026-10-13).
this.schema.raw(`UPDATE spots SET matched = 0 WHERE status = 'inspected' AND matched IS NULL`)
this.schema.raw(`DROP INDEX IF EXISTS spots_unmatched_idx`)
this.schema.raw(`CREATE INDEX IF NOT EXISTS spots_unmatched_idx ON spots (coalesce(rematched_at, inspected_at)) WHERE status = 'inspected' AND matched = 0`)
}
async down() {
this.schema.raw(`DROP INDEX IF EXISTS spots_unmatched_idx`)
this.schema.raw(`ALTER TABLE spots DROP COLUMN IF EXISTS rematched_at`)
}
}
@@ -0,0 +1,10 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** A matched spot's NZB isn't kept (its releases have their chunks): only a spot of its own needs one (2.7 GB at 9k spots). */
export default class extends BaseSchema {
async up() {
this.schema.raw(`UPDATE spots SET nzb = NULL WHERE matched > 0 AND nzb IS NOT NULL`)
}
async down() {}
}
@@ -0,0 +1,26 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import env from '#start/env'
/**
* release_chunks' indexes with its chunks in CHUNK_TABLESPACE (the HDD), not the catalog's tablespace: a chunk is
* read from the HDD anyway, and the October partition's three indexes took 2.9 GB of the NVMe (its pkey never
* scanned; LOG.md 2026-10-14 (54)). The parent's partitioned indexes too, so new partitions put theirs there.
* Homelab was moved by hand first; a no-op where they're there already.
*/
export default class extends BaseSchema {
async up() {
const tablespace = env.get('CHUNK_TABLESPACE')
if (!tablespace) return
this.defer(async (db) => {
const { rows } = await db.rawQuery(
`SELECT i.relname FROM pg_class c JOIN pg_index x ON x.indrelid = c.oid JOIN pg_class i ON i.oid = x.indexrelid
LEFT JOIN pg_tablespace t ON t.oid = i.reltablespace
WHERE c.relname LIKE 'release_chunks%' AND coalesce(t.spcname, 'pg_default') <> ?`,
[tablespace]
)
for (const { relname } of rows) await db.rawQuery(`ALTER INDEX ${relname} SET TABLESPACE ${tablespace}`)
})
}
async down() {}
}
@@ -0,0 +1,32 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import env from '#start/env'
/**
* par2_files, fragments and probe_cache, with their indexes, in CHUNK_TABLESPACE (the HDD): ~20 GB of the NVMe at
* 42M releases, read by key only (the merger, the fragment merger, the prober). The catalog was at its
* CATALOG_MAX_NVME_GB cap and history paused (LOG.md 2026-10-14 (55)). Homelab was moved by hand first (3 min,
* with the writers stopped); a no-op where they're there already.
*/
const TABLES = ['par2_files', 'fragments', 'probe_cache']
export default class extends BaseSchema {
async up() {
const tablespace = env.get('CHUNK_TABLESPACE')
if (!tablespace) return
this.defer(async (db) => {
for (const table of TABLES) {
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 coalesce(t.spcname, 'pg_default') <> ?`,
[table, table, tablespace]
)
for (const { relname, relkind } of rows) {
await db.rawQuery(`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${tablespace}`)
}
}
})
}
async down() {}
}
@@ -0,0 +1,49 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Accounts with limits (config/access.ts): a Newznab key and a tier per user,
* invites, and what each user used per day.
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('users', (table) => {
// Existing users get a key of their own (a volatile default is evaluated per row).
table.string('api_key', 32).notNullable().unique().defaultTo(this.raw(`md5(random()::text || clock_timestamp()::text)`))
table.string('tier', 16).notNullable().defaultTo('free')
// Null: the tier doesn't run out.
table.timestamp('tier_until', { useTz: true }).nullable()
table.boolean('suspended').notNullable().defaultTo(false)
})
this.schema.createTable('invites', (table) => {
table.string('code', 32).primary()
// What the invite gives (e.g. a sold VIP invite); null: the free tier.
table.string('tier', 16).nullable()
table.integer('tier_days').nullable()
table.string('note').nullable()
table.integer('used_by').nullable().references('id').inTable('users').onDelete('SET NULL')
table.timestamp('used_at', { useTz: true }).nullable()
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
})
this.schema.createTable('usage', (table) => {
table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')
// A UTC day.
table.date('day').notNullable()
table.integer('api_hits').notNullable().defaultTo(0)
table.integer('grabs').notNullable().defaultTo(0)
table.primary(['user_id', 'day'])
})
}
async down() {
this.schema.dropTable('usage')
this.schema.dropTable('invites')
this.schema.alterTable('users', (table) => {
table.dropColumn('api_key')
table.dropColumn('tier')
table.dropColumn('tier_until')
table.dropColumn('suspended')
})
}
}
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The junk judge's verdicts (app/services/release_judge.ts): per release the rule that judged it, after its
* probes, and the rule set's version. Search leaves judged releases out. Hidden only for now (nothing dropped):
* a verdict whose rule no longer matches (a password found, a spot linked) is taken back by the next round.
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE release_verdicts (
release_id bigint PRIMARY KEY,
rule varchar(16) NOT NULL,
verdict varchar(8) NOT NULL DEFAULT 'hidden',
rule_version integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`)
this.schema.raw(`CREATE INDEX release_verdicts_rule_idx ON release_verdicts (rule)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS release_verdicts`)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Small releases the settler just settled (at most 3 files, under 1 MB), queued for the merger's sidecar fold
* (app/services/sidecar_folder.ts): an .nfo, .sfv or .nzb that came apart from its upload (another counter, another
* name) joins it. A queue, so the fold needn't scan the releases (LOG.md 2026-10-15 (57)).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE fold_candidates (
release_id bigint PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now()
)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS fold_candidates`)
}
}
@@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* fragments, with its indexes, back in the catalog's tablespace (default_tablespace: nvme on homelab), out of
* CHUNK_TABLESPACE where 3200 put it: it isn't read by key only. The fragment merger walks its file_key index and
* reads each row, written and deleted all the time; on the HDD one round of its 30 s budget took 12+ minutes, and the
* coordinator's settling and tail planning waited behind it (LOG.md 2026-10-15 (57)). 13 GB. Homelab was moved by
* hand first, with the writers stopped; 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 = 'fragments' OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'fragments'::regclass))
AND coalesce(t.spcname, 'pg_default') <> ?`,
[target]
)
for (const { relname, relkind } of rows) {
await db.rawQuery(`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${target}`)
}
})
}
async down() {}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Where a worker's incremental rounds go on after a restart. The merger kept its "since" in memory only: each deploy
* or pause started it from 1970, a first round over every par2 set and inner stem ever found (25+ min with par2_files
* on the HDD) before the sidecar fold's turn (LOG.md 2026-10-15 (58)).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`
CREATE TABLE worker_cursors (
name text PRIMARY KEY,
at timestamptz NOT NULL
)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS worker_cursors`)
}
}
@@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* par2_files, with its indexes, back in the catalog's tablespace (default_tablespace: nvme on homelab), out of
* CHUNK_TABLESPACE where 3200 put it: the merger matches every new file hash against it and reads each set's rows, a
* random read per row on the HDD (~1,090 sets/min, a full round ~28 h; LOG.md 2026-10-15 (58)). Homelab rebuilt it
* there by hand without the complete and judged sets' rows (app/services/par2_pruner.ts; LOG.md 2026-10-16 (60)); 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 = 'par2_files' OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'par2_files'::regclass))
AND coalesce(t.spcname, 'pg_default') <> ?`,
[target]
)
for (const { relname, relkind } of rows) {
await db.rawQuery(`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${target}`)
}
})
}
async down() {}
}
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* A rule set version with droppedPosters (lib/rules/rule_set.ts): the newest set plus the CelebMovie family, whose
* scraped celebrity pictures workers drop at the header pass from now on (LOG.md 2026-10-16 (65)). A new version, so the
* ranges record which ran with it. Nothing where there's no rule set yet (the default gets it) or the newest has it.
*/
const POSTERS = ['celebmovie', 'celebwow', 'celebfx', 'iluvcelebs', 'nudecelebs', 'celebsextapes']
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
const latest = await db.from('rule_sets').orderBy('version', 'desc').first()
if (!latest || 'droppedPosters' in latest.rules) return
await db.table('rule_sets').insert({
version: Number(latest.version) + 1,
rules: JSON.stringify({ ...latest.rules, droppedPosters: POSTERS }),
note: 'dropped posters: the CelebMovie family (2026-10-16)',
})
})
}
async down() {}
}
@@ -0,0 +1,45 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Final completeness by time window (LOG.md 2026-10-16 (67)). A release settles once quiet, but history is read
* newest first and in islands: one next to an unread range was "incomplete" twice as often (52.5% against 25.7%),
* its missing files only not read yet. An upload is posted within hours, so a release is final (its complete verdict
* definite) once done ranges cover its posting times and FINAL_MARGIN either side.
* - ranges.posted_low/high: the posting time span the workers report per batch (postedSpan: without the earliest and
* latest 1%), the min and max over the range's batches.
* - range_blocks: runs of consecutive done ranges per group with their span (RangeBlocks rebuilds it); at_group_start
* when the run starts at the group's first range (nothing older to wait for).
* - release_final(): whether one block holds the release's articles and reaches FINAL_MARGIN past its posting times.
* Computed when asked, so no release row is written (50M updates would double the table on the NVMe).
*/
export default class extends BaseSchema {
async up() {
this.schema.raw(`ALTER TABLE ranges ADD COLUMN IF NOT EXISTS posted_low timestamptz, ADD COLUMN IF NOT EXISTS posted_high timestamptz`)
this.schema.raw(`
CREATE TABLE range_blocks (
group_id integer NOT NULL,
start_id bigint NOT NULL,
end_id bigint NOT NULL,
posted_low timestamptz,
posted_high timestamptz,
at_group_start boolean NOT NULL,
PRIMARY KEY (group_id, start_id)
)`)
// FINAL_MARGIN: 6 hours.
this.schema.raw(`
CREATE FUNCTION release_final(g integer, first_article bigint, last_article bigint, posted timestamptz, last_posted timestamptz)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM range_blocks b
WHERE b.group_id = g AND b.start_id <= first_article AND b.end_id >= last_article
AND (b.at_group_start OR b.posted_low <= posted - interval '6 hours')
AND b.posted_high >= coalesce(last_posted, posted) + interval '6 hours')
$$`)
}
async down() {
this.schema.raw(`DROP FUNCTION IF EXISTS release_final(integer, bigint, bigint, timestamptz, timestamptz)`)
this.schema.raw(`DROP TABLE IF EXISTS range_blocks`)
this.schema.raw(`ALTER TABLE ranges DROP COLUMN IF EXISTS posted_low, DROP COLUMN IF EXISTS posted_high`)
}
}
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Poster search (LOG.md 2026-10-16 (70)): "poster ILIKE '%…%'" on releases had no index, so it walked all 50M releases
* newest first; a poster with few releases took 30+ minutes. posters holds each poster once (3M, many random per file
* of an obfuscated upload) with a trigram index; releases_poster_newest_idx gives a poster's releases newest first.
* Both by the poster's first 200 characters: a spam poster of 3,024 bytes is over a btree entry's 2,704. The batch store
* adds new posters. Homelab built the releases index by hand first (concurrently); a no-op there.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
this.schema.raw(`CREATE TABLE IF NOT EXISTS posters (poster text PRIMARY KEY)`)
this.schema.raw(`INSERT INTO posters (poster) SELECT DISTINCT left(poster, 200) FROM releases WHERE poster IS NOT NULL ON CONFLICT DO NOTHING`)
this.schema.raw(`CREATE INDEX IF NOT EXISTS posters_trgm ON posters USING gin (poster gin_trgm_ops)`)
this.schema.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_poster_newest_idx ON releases (left(poster, 200), posted_at DESC NULLS LAST, id DESC)`)
}
async down() {
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS releases_poster_newest_idx`)
this.schema.raw(`DROP TABLE IF EXISTS posters`)
}
}
@@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* release_final() with the release's complete flag: a release with every file its counter announced is final as it is
* (nothing it lacks can come); the time window only decides for the incomplete and those without a counter (LOG.md
* 2026-10-16 (70): a complete episode posted an hour before was "pending" for six hours more).
*/
export default class extends BaseSchema {
async up() {
// Beside the five-argument one, which the services still running call until the deploy restarts them.
this.schema.raw(`
CREATE FUNCTION release_final(g integer, first_article bigint, last_article bigint, posted timestamptz, last_posted timestamptz, complete boolean)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT coalesce(complete, false) OR EXISTS (
SELECT 1 FROM range_blocks b
WHERE b.group_id = g AND b.start_id <= first_article AND b.end_id >= last_article
AND (b.at_group_start OR b.posted_low <= posted - interval '6 hours')
AND b.posted_high >= coalesce(last_posted, posted) + interval '6 hours')
$$`)
}
async down() {}
}
+161 -5
View File
@@ -32,6 +32,21 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
export class FileHashSchema extends BaseModel {
static $columns = ['candidateId', 'createdAt', 'hash16K', 'releaseId', 'size'] as const
$columns = FileHashSchema.$columns
@column({ isPrimary: true })
declare candidateId: bigint | number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare hash16K: string
@column()
declare releaseId: bigint | number
@column()
declare size: bigint | number
}
export class FragmentSchema extends BaseModel { export class FragmentSchema extends BaseModel {
static $columns = ['articleNumber', 'fileKey', 'fragment', 'id', 'rangeId', 'segments'] as const static $columns = ['articleNumber', 'fileKey', 'fragment', 'id', 'rangeId', 'segments'] as const
$columns = FragmentSchema.$columns $columns = FragmentSchema.$columns
@@ -50,7 +65,7 @@ export class FragmentSchema extends BaseModel {
} }
export class GroupSchema extends BaseModel { export class GroupSchema extends BaseModel {
static $columns = ['active', 'createdAt', 'high', 'id', 'low', 'name', 'plannedAt', 'priority'] as const static $columns = ['active', 'createdAt', 'high', 'id', 'low', 'name', 'plannedAt', 'priority', 'tail'] as const
$columns = GroupSchema.$columns $columns = GroupSchema.$columns
@column() @column()
declare active: boolean declare active: boolean
@@ -68,6 +83,27 @@ export class GroupSchema extends BaseModel {
declare plannedAt: DateTime | null declare plannedAt: DateTime | null
@column() @column()
declare priority: number declare priority: number
@column()
declare tail: boolean
}
export class InviteSchema extends BaseModel {
static $columns = ['code', 'createdAt', 'note', 'tier', 'tierDays', 'usedAt', 'usedBy'] as const
$columns = InviteSchema.$columns
@column({ isPrimary: true })
declare code: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare note: string | null
@column()
declare tier: string | null
@column()
declare tierDays: number | null
@column.dateTime()
declare usedAt: DateTime | null
@column()
declare usedBy: number | null
} }
export class NodeAccessTokenSchema extends BaseModel { export class NodeAccessTokenSchema extends BaseModel {
@@ -108,8 +144,51 @@ export class NodeSchema extends BaseModel {
declare name: string declare name: string
} }
export class Par2FileSchema extends BaseModel {
static $columns = ['fileId', 'hash16K', 'length', 'name', 'setId'] as const
$columns = Par2FileSchema.$columns
@column()
declare fileId: string
@column()
declare hash16K: string
@column()
declare length: bigint | number
@column()
declare name: string
@column({ isPrimary: true })
declare setId: string
}
export class Par2SetSchema extends BaseModel {
static $columns = ['createdAt', 'files', 'releaseId', 'setId'] as const
$columns = Par2SetSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare files: number
@column()
declare releaseId: bigint | number
@column({ isPrimary: true })
declare setId: string
}
export class ProbeCacheSchema extends BaseModel {
static $columns = ['createdAt', 'messageId', 'result', 'status', 'version'] as const
$columns = ProbeCacheSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare messageId: string
@column()
declare result: any | null
@column()
declare status: string
@column()
declare version: number
}
export class ProbeCandidateSchema extends BaseModel { export class ProbeCandidateSchema extends BaseModel {
static $columns = ['createdAt', 'file', 'id', 'kind', 'messageId', 'releaseId', 'status'] as const static $columns = ['createdAt', 'file', 'id', 'kind', 'messageId', 'ready', 'releaseId', 'status', 'unreadable'] as const
$columns = ProbeCandidateSchema.$columns $columns = ProbeCandidateSchema.$columns
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
declare createdAt: DateTime declare createdAt: DateTime
@@ -122,16 +201,22 @@ export class ProbeCandidateSchema extends BaseModel {
@column() @column()
declare messageId: string declare messageId: string
@column() @column()
declare ready: boolean
@column()
declare releaseId: bigint | number declare releaseId: bigint | number
@column() @column()
declare status: string declare status: string
@column()
declare unreadable: boolean
} }
export class RangeSchema extends BaseModel { export class RangeSchema extends BaseModel {
static $columns = ['attempts', 'doneAt', 'endId', 'error', 'groupId', 'headers', 'heartbeatAt', 'id', 'leasedAt', 'nextId', 'nodeId', 'priority', 'ruleVersion', 'spooledAt', 'spooledChunks', 'startId', 'status'] as const static $columns = ['attempts', 'capture', 'doneAt', 'endId', 'error', 'groupId', 'headers', 'heartbeatAt', 'id', 'leasedAt', 'nextId', 'nodeId', 'plainChunks', 'priority', 'ruleVersion', 'spooledAt', 'spooledChunks', 'startId', 'status'] as const
$columns = RangeSchema.$columns $columns = RangeSchema.$columns
@column() @column()
declare attempts: number declare attempts: number
@column()
declare capture: boolean
@column.dateTime() @column.dateTime()
declare doneAt: DateTime | null declare doneAt: DateTime | null
@column() @column()
@@ -153,6 +238,8 @@ export class RangeSchema extends BaseModel {
@column() @column()
declare nodeId: number | null declare nodeId: number | null
@column() @column()
declare plainChunks: any
@column()
declare priority: number declare priority: number
@column() @column()
declare ruleVersion: number | null declare ruleVersion: number | null
@@ -187,12 +274,48 @@ export class ReleaseChunkSchema extends BaseModel {
declare segments: number declare segments: number
} }
export class ReleaseInspectionSchema extends BaseModel {
static $columns = ['candidateId', 'createdAt', 'kind', 'releaseId', 'result'] as const
$columns = ReleaseInspectionSchema.$columns
@column()
declare candidateId: bigint | number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare kind: string
@column({ isPrimary: true })
declare releaseId: bigint | number
@column()
declare result: any
}
export class ReleaseMergeSchema extends BaseModel {
static $columns = ['createdAt', 'files', 'fromId', 'fromKey', 'id', 'intoId', 'setId'] as const
$columns = ReleaseMergeSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare files: number
@column()
declare fromId: bigint | number
@column()
declare fromKey: string
@column({ isPrimary: true })
declare id: bigint | number
@column()
declare intoId: bigint | number
@column()
declare setId: string
}
export class ReleaseSchema extends BaseModel { export class ReleaseSchema extends BaseModel {
static $columns = ['bytes', 'chunks', 'complete', 'fileTotal', 'files', 'firstArticle', 'firstSeenAt', 'groups', 'hasPar2', 'id', 'lastArticle', 'lastSeenAt', 'name', 'postedAt', 'poster', 'readable', 'releaseKey', 'segments', 'settledAt', 'source'] as const static $columns = ['bytes', 'category', 'chunks', 'complete', 'fileTotal', 'files', 'firstArticle', 'firstSeenAt', 'groupId', 'groups', 'hasPar2', 'id', 'identityKey', 'innerName', 'inspectedAt', 'lastArticle', 'lastPostedAt', 'lastSeenAt', 'name', 'passworded', 'postedAt', 'poster', 'readable', 'releaseKey', 'segments', 'settledAt', 'source'] as const
$columns = ReleaseSchema.$columns $columns = ReleaseSchema.$columns
@column() @column()
declare bytes: bigint | number declare bytes: bigint | number
@column() @column()
declare category: number | null
@column()
declare chunks: number declare chunks: number
@column() @column()
declare complete: boolean | null declare complete: boolean | null
@@ -205,17 +328,29 @@ export class ReleaseSchema extends BaseModel {
@column.dateTime() @column.dateTime()
declare firstSeenAt: DateTime declare firstSeenAt: DateTime
@column() @column()
declare groupId: number | null
@column()
declare groups: any declare groups: any
@column() @column()
declare hasPar2: boolean declare hasPar2: boolean
@column({ isPrimary: true }) @column({ isPrimary: true })
declare id: bigint | number declare id: bigint | number
@column() @column()
declare identityKey: string
@column()
declare innerName: string | null
@column.dateTime()
declare inspectedAt: DateTime | null
@column()
declare lastArticle: bigint | number | null declare lastArticle: bigint | number | null
@column.dateTime() @column.dateTime()
declare lastPostedAt: DateTime | null
@column.dateTime()
declare lastSeenAt: DateTime declare lastSeenAt: DateTime
@column() @column()
declare name: string declare name: string
@column()
declare passworded: boolean | null
@column.dateTime() @column.dateTime()
declare postedAt: DateTime | null declare postedAt: DateTime | null
@column() @column()
@@ -283,9 +418,24 @@ export class RuleSetSchema extends BaseModel {
declare version: number declare version: number
} }
export class UsageSchema extends BaseModel {
static $columns = ['apiHits', 'day', 'grabs', 'userId'] as const
$columns = UsageSchema.$columns
@column()
declare apiHits: number
@column.date()
declare day: DateTime
@column()
declare grabs: number
@column({ isPrimary: true })
declare userId: number
}
export class UserSchema extends BaseModel { export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const static $columns = ['apiKey', 'createdAt', 'email', 'fullName', 'id', 'password', 'suspended', 'tier', 'tierUntil', 'updatedAt'] as const
$columns = UserSchema.$columns $columns = UserSchema.$columns
@column()
declare apiKey: string
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
declare createdAt: DateTime declare createdAt: DateTime
@column() @column()
@@ -296,6 +446,12 @@ export class UserSchema extends BaseModel {
declare id: number declare id: number
@column({ serializeAs: null }) @column({ serializeAs: null })
declare password: string declare password: string
@column()
declare suspended: boolean
@column()
declare tier: string
@column.dateTime()
declare tierUntil: DateTime | null
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
+76
View File
@@ -0,0 +1,76 @@
# Deploying voyager 2
Voyager 2 (branch `rebuild`) runs on homelab as the compose project
`usenet-rebuild`, separate from voyager 1's (`usenet-indexer`, branch
`master`): its own Postgres, network, images and directory.
A push to `rebuild` that passes CI is deployed: the deploy job SSHes to
homelab with the deploy key, whose forced command
(`~/bin/deploy-usenet-indexer`) passes `deploy-rebuild <sha>` on to
`~/bin/deploy-usenet-rebuild` (this directory's `deploy.sh`).
## What runs
| Service | Image | Does |
|---|---|---|
| `postgres` | postgres:17 | the catalog (tablespace `nvme`) and the WAL (`PG_WAL_DIR`, `data/pg_wal` a symlink to it) on the NVMe; the chunks (HDD) |
| `migrate` | `usenet-rebuild` (target `app`) | `migration:run`, before every deploy |
| `web` | `usenet-rebuild` | catalog API, Newznab, web interface; port 3334 (`HTTP_ROUTES=public`) |
| `node-api` | `usenet-rebuild` | the edge workers' API (leases, streams, batches); port 3335 on the compose network only (`HTTP_ROUTES=node`) |
| `coordinator` | `usenet-rebuild` | prefetching (all NNTP connections), planning, the tail, merging, settling |
| `prober` | `usenet-rebuild` | phase 2: reads the probe queue's articles over `PROBE_CONNECTIONS` (6) connections, stores what they show |
| `merger` | `usenet-rebuild` | the par2 merge: releases whose files one par2 set describes become one (no NNTP) |
| `judge` | `usenet-rebuild` | the junk judge: releases judged hourly by what their probes showed (dead, fake, repeated, locked, unusable); verdicts in `release_verdicts` hide them from search (no NNTP) |
| `spotter` | `usenet-rebuild` | Spotnet: free.pt's spots into `spots`, signatures checked, the passwords they name kept; 1 connection |
| `spot-inspector` | `usenet-rebuild` | each spot's NZB: its files in `spot_files`, linked to the releases they match (`release_spots`), passwords in `release_passwords`; 1 connection |
| `edge` | `usenet-rebuild-edge` (target `edge`) | the Rust worker, `--threads $EDGE_THREADS` (8) |
| `edge-ts` | `usenet-rebuild` | the TypeScript worker, profile `edge-ts`, not started |
| `ace` | `usenet-rebuild` | one-off ace commands, profile `tools` |
## On homelab
`/mnt/bulk/usenet-rebuild` (`/home` is nearly full):
| Path | |
|---|---|
| `compose.yaml` | copied from `src/deploy/compose.yaml` on every deploy |
| `.env` | credentials and tuning, mode 600; not in git (below) |
| `src/` | the code at the deployed commit (the build context) |
| `pg/` | Postgres data (HDD) |
| `spool/` | prefetched XOVER chunks |
| `corpus/` | stored XOVER chunks of ranges marked `capture`, for the grouping sweeps (up to 300 GB) |
| `corpus-out/` | the sweeps' replays, per edge image (`python3 src/deploy/corpus_sweep.py`) |
| `deployed.log` | one line per deploy: sha, time (local) |
| `.cache/repo.git` | bare clone the deploys fetch into |
| `app/` | an older copy of the code with dev dependencies, for `tools/*.ts` (`r.sh`); not what runs |
The catalog's tablespace is `/var/lib/usenet-rebuild-catalog` on the NVMe
(`PG_NVME_DIR`). Keep 10 GB free on the root filesystem.
`.env` holds `APP_KEY`, `DB_USER`, `DB_PASSWORD`, `DB_DATABASE`, the `NNTP_*`
account, `NODE_TOKEN` (the edge worker's token, from `nodes:create`),
`CHUNK_TABLESPACE=pg_default`, `TZ`, `LOG_LEVEL`, `SESSION_DRIVER` and
optional tuning (`NNTP_CONNECTIONS`, `EDGE_THREADS`, `PG_SHARED_BUFFERS`,
anything in `start/env.ts`). Hosts, ports and paths are set in compose.yaml.
## By hand
```sh
cd /mnt/bulk/usenet-rebuild
~/bin/deploy-usenet-rebuild <sha> # what CI runs
docker compose ps
docker compose logs -f --since 5m coordinator
docker compose run --rm ace releases:duplicates
docker compose stop edge coordinator # pause indexing (the worker first)
docker compose up -d --no-deps edge coordinator
```
- Recreate app services only, with `--no-deps`; Postgres only on purpose,
with the worker and coordinator stopped first.
- Stop `edge` before `node-api`: a worker holding a stream keeps the server
from exiting.
- The NNTP account is shared with voyager 1 and downloads: `NNTP_CONNECTIONS`
(40) plus one plain connection, plus the prober's `PROBE_CONNECTIONS` (6),
out of 50.
- `deploy.sh` doesn't update itself: after changing it, install it again
(`install -m 755 src/deploy/deploy.sh ~/bin/deploy-usenet-rebuild`).
+197
View File
@@ -0,0 +1,197 @@
# Voyager 2 on homelab (deploy/README.md). deploy.sh copies this file to the
# deployment root (/mnt/bulk/usenet-rebuild), next to src/ (the code at the
# deployed commit), .env (credentials, tuning), pg/ and spool/; run
# `docker compose` there. Nothing is shared with voyager 1's compose project.
name: usenet-rebuild
x-app: &app
image: usenet-rebuild:latest
build:
context: ./src
target: app
restart: unless-stopped
env_file: .env
environment: &app-env
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3334
DB_HOST: postgres
DB_PORT: 5432
APP_URL: http://web:3334
SPOOL_DIR: /spool
# Stored XOVER chunks of the ranges marked `capture` (the grouping sweeps' corpus, HDD; app/services/corpus.ts).
CORPUS_DIR: /corpus
volumes:
- ./spool:/spool
- ./corpus:/corpus
stop_grace_period: 60s
depends_on:
migrate:
condition: service_completed_successfully
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_DATABASE}
volumes:
# Chunks (write-once payloads) on the HDD; the catalog in tablespace
# nvme, the database's default_tablespace (LOG.md 2026-10-09 (14)).
- ./pg:/var/lib/postgresql/data
- ${PG_NVME_DIR:-/var/lib/usenet-rebuild-catalog}:/var/lib/postgresql/nvme
# The WAL on the NVMe too: data/pg_wal is a symlink to /var/lib/postgresql/wal. On the HDD every commit
# waited on it (WALWrite, 56–70 ms a write with the HDD 100% busy; LOG.md 2026-10-13 (43)).
- ${PG_WAL_DIR:-/var/lib/usenet-rebuild-wal}:/var/lib/postgresql/wal
# synchronous_commit = off is set with ALTER SYSTEM (postgresql.auto.conf in the data dir).
# Memory for 62 GiB of RAM shared with other services (LOG.md 2026-10-16 (69)): the catalog's hot part in
# shared_buffers, the planner told the OS caches the rest; random_page_cost for the NVMe (the HDD tablespace
# pg_default keeps 4: ALTER TABLESPACE … SET (random_page_cost = 4)).
command:
- postgres
- -c
- shared_preload_libraries=pg_stat_statements
- -c
- shared_buffers=${PG_SHARED_BUFFERS:-8GB}
- -c
- effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE:-32GB}
- -c
- random_page_cost=1.1
- -c
- work_mem=16MB
- -c
- maintenance_work_mem=1GB
- -c
- max_wal_size=8GB
- -c
- checkpoint_timeout=15min
- -c
- wal_buffers=64MB
# Parallel hash joins' shared memory: 1g failed with "could not resize shared memory segment" (LOG.md 2026-10-16 (61)).
shm_size: 2g
stop_grace_period: 120s
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 5s
retries: 30
migrate:
<<: *app
restart: 'no'
command: ['node', 'ace', 'migration:run', '--force']
volumes: []
depends_on:
postgres:
condition: service_healthy
# Catalog API, Newznab and the web interface.
web:
<<: *app
command: ['node', 'bin/server.js']
environment:
<<: *app-env
HTTP_ROUTES: public
ports:
- '3334:3334'
# The edge workers' API (leases, streams, batches), apart from the web
# interface: streams hold a connection up to an hour, batches of up to
# 256 MB are parsed in-process. Only the compose network reaches it.
node-api:
<<: *app
command: ['node', 'bin/server.js']
environment:
<<: *app-env
HTTP_ROUTES: node
PORT: 3335
# Prefetching (all NNTP connections), planning, the tail, merging, settling.
coordinator:
<<: *app
command: ['node', 'ace', 'coordinator:run']
environment:
<<: *app-env
NNTP_CONNECTIONS: ${NNTP_CONNECTIONS:-38}
# Phase 2's prober: reads the probe queue's articles over PROBE_CONNECTIONS
# connections of its own. The account has 50: the coordinator's 38 (+ its
# plain one), the prober's 8, the spotter's and the spot inspector's 1 each,
# 2 to spare (a hedged request closes its connection and opens another)
# (with 10 the spot inspector got 502 Too many connections).
prober:
<<: *app
command: ['node', 'ace', 'probe:run']
volumes: []
environment:
<<: *app-env
PROBE_CONNECTIONS: ${PROBE_CONNECTIONS:-8}
# The par2 merge: releases whose files one par2 set describes (what the prober read) become one. No NNTP.
merger:
<<: *app
command: ['node', 'ace', 'releases:merge']
volumes: []
# The junk judge: releases judged hourly by what their probes showed (dead, fake, repeated, locked, unusable);
# verdicts hide them from search. No NNTP.
judge:
<<: *app
command: ['node', 'ace', 'releases:judge']
volumes: []
# Spotnet: the spotter reads free.pt's spots (verified, with the passwords
# they name) into the spots table; the spot inspector reads each spot's NZB,
# lists its files and links the releases they match. One connection each.
spotter:
<<: *app
command: ['node', 'ace', 'spots:read']
volumes: []
spot-inspector:
<<: *app
command: ['node', 'ace', 'spots:inspect']
volumes: []
# The Rust worker; it has no NNTP account and talks to node-api only.
# Stopped before node-api (a worker holding a stream keeps the server from
# exiting).
edge:
image: usenet-rebuild-edge:latest
build:
context: ./src
target: edge
restart: unless-stopped
command: ['--threads', '${EDGE_THREADS:-8}']
environment:
COORDINATOR_URL: http://node-api:3335
NODE_TOKEN: ${NODE_TOKEN}
stop_grace_period: 60s
depends_on:
node-api:
condition: service_started
# The TypeScript worker, for comparing: docker compose --profile edge-ts up -d edge-ts
edge-ts:
<<: *app
profiles: [edge-ts]
command: ['node', 'ace', 'edge:node', '--processes', '${EDGE_THREADS:-8}']
environment:
<<: *app-env
COORDINATOR_URL: http://node-api:3335
depends_on:
node-api:
condition: service_started
# One-off ace commands: docker compose run --rm ace releases:duplicates
ace:
<<: *app
profiles: [tools]
restart: 'no'
entrypoint: ['node', 'ace']
command: ['list']
networks:
default:
name: usenet-rebuild
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""The grouping sweeps' corpus (REBUILD.md, corpus): stored XOVER chunks replayed with each build's rules.
Run on homelab from the deployment root (/mnt/bulk/usenet-rebuild):
python3 src/deploy/corpus_sweep.py capture [N] mark N more ranges to store (default 100), fetched first
python3 src/deploy/corpus_sweep.py replay edge-bench over every stored range with the deployed edge image
python3 src/deploy/corpus_sweep.py report what the replay grouped: suspects, and the change since the last build
The prefetcher stores the chunks of ranges marked `capture` in corpus/<group>/<start>-<end>/ (range.json once
complete) and reads them from there afterwards, so a reset doesn't fetch them again. Replays go to
corpus-out/<image id>/<group>/<start>-<end>.jsonl, one per build; `report` compares the newest two.
"""
import collections
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime, timezone
ROOT = os.getcwd()
CORPUS = os.path.join(ROOT, 'corpus')
OUT = os.path.join(ROOT, 'corpus-out')
IMAGE = 'usenet-rebuild-edge:latest'
# The corpus may take this much of the HDD (user, 2026-10-12: up to 300 GB).
MAX_BYTES = 300e9
# Ranks in groups.tsv the new ranges come from first (the top 100), then the rest of what's indexed.
TOP = 100
def env(name):
for line in open(os.path.join(ROOT, '.env')):
if line.startswith(name + '='):
return line.split('=', 1)[1].strip()
raise SystemExit(f'{name} not in .env')
def psql(sql):
"""Runs SQL in the catalog; rows as lists of strings."""
out = subprocess.run(
['docker', 'compose', 'exec', '-T', 'postgres', 'psql', '-U', env('DB_USER'), '-d', env('DB_DATABASE'), '-At', '-F', '\t', '-v', 'ON_ERROR_STOP=1'],
input=sql, capture_output=True, text=True, check=True,
).stdout
# Without the command tag a RETURNING prints after its rows ("UPDATE 100").
return [line.split('\t') for line in out.splitlines() if line and not line.startswith(('UPDATE ', 'INSERT ', 'DELETE '))]
def corpus_bytes():
total = 0
for dirpath, _, files in os.walk(CORPUS):
total += sum(os.path.getsize(os.path.join(dirpath, f)) for f in files)
return total
def capture(n):
used = corpus_bytes()
# Marked ranges not complete yet take room too: at the stored ranges' mean size, less what they hold already
# (2026-10-13: 2,300 complete at 296.6 GB and 100 more marked would have made ~310 GB).
complete = sum(1 for _ in complete_ranges())
marked = int(psql('SELECT count(*) FROM ranges WHERE capture;')[0][0])
mean = used / complete if complete else 0
pending = max(0, marked - complete) * mean
room = MAX_BYTES - max(used, complete * mean + pending)
n = min(n, int(room // mean)) if mean else n
if n <= 0:
print(f'corpus at {used / 1e9:.1f} GB of {MAX_BYTES / 1e9:.0f} (with {marked - complete} ranges still filling, ~{(complete * mean + pending) / 1e9:.0f} GB): nothing more marked')
return
top = [line.split('\t')[1] for line in open(os.path.join(ROOT, 'groups.tsv')).read().splitlines()[:TOP]]
names = ','.join("'" + g.replace("'", "''") + "'" for g in top)
# Spread over the groups and their history: per group, the ranges farthest from those stored already
# (at random in a group without any), the top-100 groups first. Only ranges not started (a range half
# spooled would miss its first chunks; a done one is stored at the next reset, which re-reads them all).
# Priority 0: fetched before the rest of the history (the tail stays first).
rows = psql(f"""
WITH stored AS (SELECT group_id, array_agg(start_id) AS starts FROM ranges WHERE capture GROUP BY 1),
pick AS (
SELECT r.id, g.name IN ({names}) AS top, row_number() OVER (PARTITION BY r.group_id ORDER BY
coalesce((SELECT min(abs(r.start_id - s)) FROM unnest(st.starts) s), 0) DESC, random()) AS n
FROM ranges r JOIN groups g ON g.id = r.group_id LEFT JOIN stored st ON st.group_id = r.group_id
WHERE r.priority >= 0 AND NOT r.capture AND r.status = 'planned' AND r.spooled_chunks = 0 AND r.next_id = r.start_id
),
chosen AS (SELECT id FROM pick ORDER BY top DESC, n, random() LIMIT {int(n)})
UPDATE ranges r SET capture = true, priority = 0 FROM chosen WHERE r.id = chosen.id RETURNING r.id;
""")
total = psql('SELECT count(*), count(*) FILTER (WHERE status = \'done\') FROM ranges WHERE capture;')[0]
print(f'marked {len(rows)} ranges; {total[0]} marked in all, {total[1]} indexed; corpus {used / 1e9:.1f} GB')
def complete_ranges():
for group in sorted(os.listdir(CORPUS)) if os.path.isdir(CORPUS) else []:
for rng in sorted(os.listdir(os.path.join(CORPUS, group))):
if os.path.exists(os.path.join(CORPUS, group, rng, 'range.json')):
yield group, rng
def image_id():
return subprocess.run(['docker', 'image', 'inspect', '-f', '{{.Id}}', IMAGE], capture_output=True, text=True, check=True).stdout.strip().split(':')[-1][:12]
def replay():
build = image_id()
out = os.path.join(OUT, build)
todo = [(g, r) for g, r in complete_ranges() if not os.path.exists(os.path.join(out, g, r + '.jsonl'))]
for g, _ in todo:
os.makedirs(os.path.join(out, g), exist_ok=True)
print(f'build {build}: {len(todo)} ranges to replay', flush=True)
if not todo:
return
# One container, 6 at a time (the edge worker keeps its threads).
script = 'xargs -P 6 -n 2 sh -c \'edge-bench "/corpus/$0/$1" --releases "/out/$0/$1.jsonl.part" >/dev/null 2>&1 && mv "/out/$0/$1.jsonl.part" "/out/$0/$1.jsonl"\''
started = datetime.now()
subprocess.run(
['docker', 'run', '--rm', '-i', '--user', '1000:1000', '-v', f'{CORPUS}:/corpus:ro', '-v', f'{out}:/out', '--entrypoint', 'sh', IMAGE, '-c', script],
input=''.join(f'{g} {r}\n' for g, r in todo), text=True, check=True,
)
print(f'replayed in {(datetime.now() - started).total_seconds():.0f} s')
# Older builds' replays beyond the last two go.
builds = sorted((os.path.getmtime(os.path.join(OUT, b)), b) for b in os.listdir(OUT))
for _, b in builds[:-2]:
shutil.rmtree(os.path.join(OUT, b))
def replayed(build):
"""The ranges a build replayed, as paths relative to its directory."""
root = os.path.join(OUT, build)
return {os.path.relpath(os.path.join(d, f), root) for d, _, files in os.walk(root) for f in files if f.endswith('.jsonl')}
def load(build, only=None):
"""Releases of one build's replay (of the ranges in `only`, when given), merged by (group, key) as the catalog does."""
releases = {}
for rel in sorted(replayed(build)):
if only is not None and rel not in only:
continue
dirpath, f = os.path.split(os.path.join(OUT, build, rel))
if True:
for line in open(os.path.join(dirpath, f), encoding='utf-8', errors='replace'):
r = json.loads(line)
k = (r['group'], r['key'])
have = releases.get(k)
if have is None:
releases[k] = r
else:
have['files'] += r['files']
have['segments'] += r['segments']
have['subjects'] = (have['subjects'] + r['subjects'])[:3]
have['fileTotal'] = have['fileTotal'] or r['fileTotal']
return releases
def hour(r):
return datetime.fromtimestamp((r['postedAt'] or 0) / 1000, timezone.utc).strftime('%Y-%m-%d %H')
def show(r, prefix=' '):
print(f"{prefix}{r['files']} files, total {r['fileTotal']}, {r['source']}: {r['name'][:80]!r} ({r['poster'][:30]}, {r['group']})")
for s in r['subjects'][:2]:
print(f'{prefix} {s[:150]}')
def report():
builds = sorted((os.path.getmtime(os.path.join(OUT, b)), b) for b in os.listdir(OUT)) if os.path.isdir(OUT) else []
if not builds:
raise SystemExit('nothing replayed yet')
build = builds[-1][1]
rel = load(build)
ranges = sum(1 for _ in complete_ranges())
print(f'build {build}: {len(rel):,} releases, {sum(r["files"] for r in rel.values()):,} files from {ranges} stored ranges ({corpus_bytes() / 1e9:.1f} GB)')
print('\n== same name, poster and hour: 4+ releases (under-grouping)')
clusters = collections.defaultdict(list)
for r in rel.values():
clusters[(r['group'], r['poster'], r['name'], hour(r))].append(r)
for (g, p, name, h), rs in sorted(clusters.items(), key=lambda kv: -len(kv[1]))[:25]:
if len(rs) < 4:
break
print(f" {len(rs)} releases, {sum(r['files'] for r in rs)} files: {name[:70]!r} ({p[:25]}, {g}, {h})")
for s in rs[0]['subjects'][:1] + rs[-1]['subjects'][:1]:
print(f' {s[:150]}')
print('\n== single files of a counter of 5+, per group and poster (under-grouping)')
singles = collections.defaultdict(list)
for r in rel.values():
if r['files'] == 1 and (r['fileTotal'] or 0) >= 5:
singles[(r['group'], r['poster'])].append(r)
for (g, p), rs in sorted(singles.items(), key=lambda kv: -len(kv[1]))[:15]:
if len(rs) < 20:
break
print(f' {len(rs)} single files: {p[:30]} ({g})')
for s in rs[0]['subjects'][:1] + rs[-1]['subjects'][:1]:
print(f' {s[:150]}')
print('\n== 2000+ files, or 3x the counter (over-grouping)')
big = [r for r in rel.values() if r['files'] > 2000 or (r['fileTotal'] and r['files'] > 3 * r['fileTotal'] and r['files'] > 50)]
for r in sorted(big, key=lambda r: -r['files'])[:20]:
show(r, ' ')
if len(builds) >= 2:
# Over the ranges both replayed: the corpus grows between builds.
both = replayed(builds[-2][1]) & replayed(build)
before, after = load(builds[-2][1], both), load(build, both)
print(f'\n== change since build {builds[-2][1]} on the {len(both)} ranges both replayed: {len(before):,} → {len(after):,} releases')
names = lambda rs: collections.Counter((r['group'], r['name']) for r in rs.values())
a, b = names(before), names(after)
moved = sorted(set(a) | set(b), key=lambda k: -abs(a[k] - b[k]))[:20]
for k in moved:
if a[k] != b[k]:
print(f' {a[k]:>6} → {b[k]:<6} {k[1][:80]!r} ({k[0]})')
if __name__ == '__main__':
command = sys.argv[1] if len(sys.argv) > 1 else 'report'
if command == 'capture':
capture(int(sys.argv[2]) if len(sys.argv) > 2 else 100)
elif command == 'replay':
replay()
elif command == 'report':
report()
else:
raise SystemExit(__doc__)
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
# Deploys voyager 2 (branch rebuild) on homelab at one commit. Installed as
# ~/bin/deploy-usenet-rebuild (by hand: it doesn't update itself). Gitea CI
# runs it through the forced SSH command of the deploy key, as
# "deploy-rebuild <sha>" (~/bin/deploy-usenet-indexer passes that on); by
# hand: deploy-usenet-rebuild <sha>. The code comes from the Gitea repo, not
# from the CI job.
set -eu
REPO=https://gitea.exp.sforum.nl/daan/usenet-indexer.git
BRANCH=rebuild
ROOT=/mnt/bulk/usenet-rebuild
CACHE="$ROOT/.cache"
GIT="$CACHE/repo.git"
SHA="${1:-}"
case "$SHA" in
*[!0-9a-f]* | "") echo "usage: deploy-usenet-rebuild <40-character commit sha>" >&2; exit 2 ;;
esac
[ "${#SHA}" -eq 40 ] || { echo "usage: deploy-usenet-rebuild <40-character commit sha>" >&2; exit 2; }
mkdir -p "$CACHE"
# One deploy at a time; a second one waits.
exec 9>"$CACHE/deploy.lock"
flock 9
echo "== fetching $SHA"
[ -d "$GIT" ] || git clone --quiet --bare "$REPO" "$GIT"
git -C "$GIT" fetch --quiet --prune "$REPO" '+refs/heads/*:refs/heads/*'
git -C "$GIT" cat-file -e "$SHA^{commit}"
git -C "$GIT" merge-base --is-ancestor "$SHA" "refs/heads/$BRANCH" || { echo "$SHA is not on $BRANCH" >&2; exit 2; }
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
git -C "$GIT" archive "$SHA" | tar -x -C "$STAGE"
rsync -a --delete "$STAGE"/ "$ROOT/src/"
install -m 644 "$ROOT/src/deploy/compose.yaml" "$ROOT/compose.yaml"
cd "$ROOT"
echo "== building images"
docker compose build migrate edge
echo "== running migrations"
docker compose run --rm --no-deps migrate
# Only the app services, never postgres. The worker first: one holding a
# stream keeps the node API from exiting.
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
else
docker compose up -d --no-deps node-api web coordinator prober merger judge spotter spot-inspector
docker compose up -d --no-deps edge
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).
docker image prune -f --filter label=com.docker.compose.project=usenet-rebuild >/dev/null
docker compose ps --format '{{.Service}} {{.Status}}' node-api web coordinator prober merger judge spotter spot-inspector edge
echo "== deployed $SHA"
+12
View File
@@ -0,0 +1,12 @@
#!/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.
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 ps --format "{{.Service}} {{.Status}}"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Installed as ~/bin/usenet-rebuild-resume.sh (by hand). Nightly (homelab crontab, 07:45): voyager 2's processing back after usenet-rebuild-pause.sh (23:45). Takes the
# deploy lock, like the pause.
cd /mnt/bulk/usenet-rebuild || exit 1
exec 9>.cache/deploy.lock
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
docker compose ps --format "{{.Service}} {{.Status}}"
File renamed without changes.
+369 -3
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@@ -32,6 +38,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -47,6 +59,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.6.0" version = "1.6.0"
@@ -93,6 +111,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -114,10 +141,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "edge-bench-rs" name = "edge"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"chrono", "chrono",
"flate2", "flate2",
"libdeflater", "libdeflater",
@@ -126,10 +153,24 @@ dependencies = [
"mimalloc", "mimalloc",
"regex", "regex",
"rustc-hash", "rustc-hash",
"serde",
"serde_json",
"sha1", "sha1",
"signal-hook",
"ureq",
"zstd", "zstd",
] ]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@@ -142,6 +183,8 @@ version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [ dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs", "zlib-rs",
] ]
@@ -179,6 +222,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@@ -190,6 +244,22 @@ dependencies = [
"r-efi", "r-efi",
] ]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@@ -214,13 +284,19 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.35" version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.4.3",
"libc", "libc",
] ]
@@ -299,6 +375,16 @@ dependencies = [
"libmimalloc-sys", "libmimalloc-sys",
] ]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -314,6 +400,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -379,18 +471,110 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.7" version = "0.10.7"
@@ -408,12 +592,44 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -448,12 +664,59 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.129" version = "0.2.129"
@@ -499,6 +762,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -558,12 +830,106 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.3" version = "0.13.3"
+34
View File
@@ -0,0 +1,34 @@
[package]
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)"
[[bin]]
name = "edge-bench"
path = "src/main.rs"
[[bin]]
name = "edge-node"
path = "src/bin/edge-node.rs"
[dependencies]
base64 = "0.22"
chrono = "0.4"
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
libdeflater = "1.26.1"
md-5 = "0.10"
memchr = "2.8.3"
mimalloc = "0.1.52"
regex = "1"
rustc-hash = "2"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha1 = "0.10"
signal-hook = "0.4.5"
ureq = "3.4.2"
zstd = "0.13"
[profile.release]
lto = true
codegen-units = 1
+238
View File
@@ -0,0 +1,238 @@
# edge: voyager 2's edge worker (Rust)
The product: `edge-node` (`src/bin/edge-node.rs`) is the worker that runs
on homelab (compose service `edge`, image target `edge`). It leases ranges
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.
```sh
cargo test --release --manifest-path edge/Cargo.toml
edge/check-fixtures.sh # from the repository root
```
In production since 2026-10-09: 122M headers in 10 minutes with 0.37 cores
and 565 MB, next to the TypeScript workers' 24M with 0.61 cores and 11.3 GB
(8 threads vs 8 processes): about 8× less CPU per header and 20× less
memory. See "The worker" below.
It started as a measurement (moved from `tools/edge-bench-rs` on
2026-10-11): whether a Rust worker would be worth a second implementation.
The findings below are from then.
## Findings (2026-10-09)
Same machine (the workstation, otherwise idle), same captures, 2M headers
each:
| Capture | TypeScript (`edge:bench`) | Rust (`edge-bench`) | Less CPU |
|---|---|---|---|
| a.b.boneless 2026 (160950000000–160951999999) | 11.1 CPU-s (~215k headers/s), 0.70 GB peak | 2.9 CPU-s (~690k/s), 0.24 GB | 3.8× |
| a.b.boneless 2026 (160952000000–160953999999) | 11.8 CPU-s (~205k/s), 0.70 GB | 3.1 CPU-s (~640k/s), 0.24 GB | 3.8× |
| a.b.ath 2013 (2549992990–2551992989) | 7.5 CPU-s (~320k/s), 0.74 GB | 2.15 CPU-s (~930k/s), 0.20 GB | 3.5× |
| a.b.models (2017215–4017214; image sets, re-uploads) | 16.7 CPU-s (~139k/s) | 5.0 CPU-s (~400k/s) | 3.3× |
**Comparing the numbers:**
- **CPU seconds are the fair comparison.** `edge:bench`'s headers/s leaves
out the time Node's GC threads use; the Rust binary is single-threaded, so
its wall time is its CPU time.
- **Homelab's cores** (Xeon E5-1650 v4) run the TypeScript ~10% slower than
the workstation's.
**The Rust optimizations,** measured on boneless 2026 (all checked identical
after each step):
| Step | Headers/s |
|---|---|
| Straight port: regex crate for the patterns, chrono for dates, zlib-rs, std allocator | 360k |
| File stems and subject remainders by hand: their 12 + 5 regexes were most of a new file's description (333k per boneless range, many of them per-article random subjects) | |
| mimalloc for the many small strings | 495k |
| Upload tools, release-name checks and the common Date formats by hand | 500k |
| libdeflate: inflates a chunk whole in half of zlib's time (1.09 → 0.51 s per range) | |
| memchr (SIMD) for line ends and tabs | ~700k |
| Fewer allocations in `describe` (hashing the parts without joining them): no measurable gain, reverted | |
Rows without a figure weren't measured on their own; each figure covers the
rows above it.
**What's left** is spread out: scanning and building segments, two sha1s per
new file, aggregation, cut-off and chunk encoding. `perf` isn't available on
the workstation (`perf_event_paranoid` = 3), so these come from coarse
timers. The binary prints them as "of which aggregate …, evict+cutoff …,
judge+encode …" on stderr.
**What it means for the full target** (~1.25M articles/s with 50 NNTP
connections):
- **Worker cores:** ~1.5–2 instead of ~6–7. Spreading workers over other
machines (batcave, poweredge-kantoor) becomes mostly unnecessary.
- **Total speed:** unchanged. The account's NNTP connections set it (~30k
articles/s each), not the worker.
- **The cost:** a second implementation of `lib/headers`, `lib/release`,
`lib/rules`, `lib/files` (aggregation, volumes) and the chunk format.
Every rule change has to land in both and be checked identical (below)
for as long as both exist.
## Running it
You need a captured range. `edge:bench --capture` saves a range's compressed
XOVER chunks once (it needs `NNTP_*` in the environment and one NNTP
connection):
```sh
node ace edge:bench --capture --dir /path/to/capture --group alt.binaries.boneless --start 160950000000
# 2M articles in chunks of 50k (--articles, --chunk), plus range.json
```
Benchmark both on it:
```sh
node ace edge:bench --dir /path/to/capture # TypeScript: headers/s, CPU, inflate/index, peak RSS
cd edge && cargo build --release
./target/release/edge-bench /path/to/capture # Rust: the same summary
```
Both take results every 10 chunks (`--batch-chunks`) and give a file up after
5 idle chunks (`--idle`). Keep these equal on both sides when comparing.
The rules are `DEFAULT_RULES` with only the range's group indexed and no
dropped signatures, as in `edge:bench`.
The two "kept: …" summary lines should already agree: release chunks,
files, segments, payload size, candidates, fragments and samples. The
summary can hide compensating differences, so compare the outputs
themselves.
## Comparing the output
```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:
| Line `t` | Contents |
|---|---|
| `b` | batch boundaries: from, next, headers |
| `r` | releases: every ReleaseChunk field, the payload in base64 |
| `s` | samples: dropped releases kept in full, with their rule |
| `c` | probe candidates |
| `n` | rule counts |
| `f` | fragments |
`check.ts` runs the TypeScript `RangeIndexer` (from `lib/edge`, not a copy)
on the same capture with the same batching. It matches items by batch and
key: release key, candidate (release, kind, message-id), rule and key,
fragment file key. Then it compares them field by field:
```
same batches: 4 TypeScript, 4 Rust
same releases: 1055 TypeScript, 1055 Rust
same samples: 2 TypeScript, 2 Rust
same candidates: 1090 TypeScript, 1090 Rust
same rule counts: 34 TypeScript, 34 Rust
same fragments: 130 TypeScript, 130 Rust
payload bytes: 13728351 TypeScript, 13727247 Rust
```
It exits 0 only when everything matches. Otherwise each kind says how many
items are only on one side or differ, and shows the first differing fields.
- **Payloads are compared decoded.** Both sides go through `decodeChunk`
from `lib/chunks`, and their files are compared, ordered by name and
subject. Node's built-in zstd and the zstd crate frame the same data a
little differently, so the compressed sizes differ by about 0.1%. The
checker prints them, but they don't count as a difference. Decoding the
Rust payloads with the TypeScript decoder also checks the Rust encoder.
- **Order doesn't count.** Order within a batch isn't compared (the two
hash maps iterate differently). Grouping into batches, the order of a
release's files and the order of a fragment's segments are compared,
because they're part of the values.
Run it after any change on either side. All four captures above are
identical as of cfe85ab. The captures are kept on homelab in
`/mnt/bulk/usenet-rebuild/bench/` (boneless-2026, boneless-2026b,
ath-2013, models-a). models-a is a bottom-500 group (rank 501–1000 by
span) with what the other three lack: image sets (38,630 release chunks
for 412k files), documents, and re-uploads (521 chunks of a second or
later upload of a key in a batch). Its chunk 26 is the plain fetch of a
range whose compressed reply Eweka cuts short (LOG.md 2026-10-09 (11)).
## JavaScript semantics the port keeps (`src/js.rs`, `src/names.rs`)
Differences found with `check.ts` or the tests, kept as JavaScript has
them:
- **Regexes without the `u` flag:** `\d` is [0-9], `\b` an ASCII word
boundary, `/i` ASCII-only. They run as `(?-u)` byte regexes, or by hand.
The hand-written versions (file stems, subject remainders, upload tools,
release-name and file-counter checks) are tested against the regex
versions, which are kept for that (`*_re`).
- **JS `\s` and `trim()`** include Unicode spaces (NBSP, U+2000–200A,
U+3000, BOM…); `split(/\s+/)` keeps leading and trailing empty words.
- **`lastIndexOf(c, position)`** finds a match at or before the position
(the owner rule, the counter and filename scans). **`.length`** is in
UTF-16 units (`len16`). **`parseInt`** reads leading digits.
- **`Date.parse`:** chrono follows RFC 5322 and rejects "UTC", which
`Date.parse` takes. It ends about a quarter of boneless's Date headers, so
it's mapped to GMT; check.ts found this. The common formats are parsed by
hand:
```
[Day, ]D[D] Mon YY[YY] HH:MM:SS (GMT|UTC|UT|±hhmm)
```
They ignore the weekday as `Date.parse` does (chrono rejects a weekday
that doesn't fit). Anything else goes to chrono. Rare forms `Date.parse`
takes but chrono doesn't would show up in check.ts as a `postedAt`
difference.
- **Invalid UTF-8** becomes U+FFFD on both sides (`toString('utf8')`,
`from_utf8_lossy`).
## Files
| File | Contents |
|---|---|
| `src/main.rs` | arguments, the replay loop, timing, `--dump` |
| `src/indexer.rs` | scan.ts (SegmentScanner, scanOverviewText), aggregator.ts, range_indexer.ts |
| `src/names.rs` | lib/release (identity, random), lib/files/volumes, lib/rules (upload tools, judge) |
| `src/codec.rs` | lib/chunks encodeChunk, format v2 |
| `src/js.rs` | JavaScript semantics, sha1 |
| `check.ts` | the comparison |
## The worker
```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]
```
- **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.
- **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
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.
- **Wire format:** `src/wire.rs` writes a batch as `toWire` does.
`edge-bench --wire` and `check.ts --wire` compare it with the
TypeScript's, field by field.
- **End to end:** both workers indexed the a.b.models capture (spooled
as a range) through the web server; what the server stored was the
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`.
+18
View File
@@ -0,0 +1,18 @@
#!/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
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more