Author SHA1 Message Date
Daan MeijerandClaude Opus 5.5 b1a6f02de6 Rebuild spec: TypeScript vs Rust for the edge worker, measured
Test and deploy / test (push) Successful in 42s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:38:03 +02:00
Daan MeijerandClaude Opus 5.5 c7b4ebe470 edge-bench-rs: libdeflate for chunks, memchr for lines and tabs
libdeflate inflates a chunk whole, in half the time of zlib-rs (1.09 ->
0.51 s per boneless range); memchr finds line ends and tabs with SIMD.
The per-article timers are gone. Boneless 2026 ~700k headers/s, ath 2013
~920k; all three captures still identical to the TypeScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:34:38 +02:00
Daan MeijerandClaude Opus 5.5 b1c2db5984 edge-bench-rs: upload tools, release-name checks and common Date headers by hand
Each with its regex/chrono version as the reference in tests; check.ts
still finds all three captures identical. Boneless 2026 ~500k headers/s,
ath 2013 ~650k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:31:56 +02:00
Daan MeijerandClaude Opus 5.5 43e74199d4 edge-bench-rs: file stems and subject remainders by hand, mimalloc
The suffix and remainder regexes were most of a new file's description
(333k on boneless 2026): now plain byte matching, with the regex versions
kept as the reference in a test of tricky names and subjects. mimalloc
for the many small strings. Boneless 2026: 360k -> 495k headers/s;
check.ts still finds the outputs identical on all three captures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:28:08 +02:00
Daan MeijerandClaude Opus 5.5 bacc9aa3b3 Rebuild: edge-bench-rs, the edge worker's indexing in Rust, for comparison
The RangeIndexer path of edge:bench (inflate, scan, owner rule, segments,
file aggregation, cut-off, rules, release identity, chunk encoding v2,
probe candidates) as a Rust binary over a captured range, with JS
semantics where they matter (\s, trim, \b, parseInt, lastIndexOf, UTF-16
lengths). check.ts runs the TypeScript RangeIndexer on the same capture
and compares every output: identical on all three captures (releases and
their decoded payloads, samples, candidates, rule counts, fragments),
after mapping Date headers ending in UTC to GMT (chrono follows RFC
5322; Date.parse takes UTC, a quarter of a.b.boneless). Compressed
payloads differ by <0.1% in size (zstd framing), not in content.

First run on boneless 2026: 360k headers/s and 228 MB peak (TypeScript:
~217k/s, ~0.7-0.8 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:20:53 +02:00
Daan MeijerandClaude Opus 5.5 42b6cac3ee Rebuild: edge:node --max-mbit caps a node's download
Test and deploy / test (push) Successful in 38s
Test and deploy / deploy (push) Skipped
For workers on a shared line (poweredge-kantoor: 160 Mbit/s): the range
stream is read at most at the cap, TCP holds the coordinator back. The
cap is the node's, split over its processes (also EDGE_MAX_MBIT).
REBUILD.md: where workers run (connections set the speed; remote workers
take CPU off homelab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:57 +02:00
Daan MeijerandClaude Opus 5.5 104248d8a3 Rebuild spec: homelab measurements
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 07:48:14 +02:00
Daan MeijerandClaude Opus 5.5 babd3d781d Rebuild: test the corrupt-chunk report
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:48:07 +02:00
Daan MeijerandClaude Opus 5.5 a979b5d11c Rebuild: the coordinator spools replies without inflating them
On homelab (1 Gbit, 20 connections, ~630k articles/s) the coordinator
used 1.3-1.7 cores, mostly GC of the text it inflated only to know a
compressed reply was complete, plus a copy of every reply. A raw reply
is now complete when it ends in ".\r\n" (no copy): 0.4-0.5 cores at
the same rate.

".\r\n" can also end a network read inside the compressed data (~1 in
16M reads, a few dozen times over the whole target). Such a chunk
doesn't inflate on the worker: it reports it (POST ranges/:id/corrupt),
the spooled chunk is removed and the range goes back to the queue; the
prefetcher fetches it again. The rest of that reply breaks the
connection's next command, so the connection is replaced.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:59:30 +02:00
Daan MeijerandClaude Opus 5.5 583ea1ded6 Rebuild spec: status and measurements so far
Test and deploy / test (push) Successful in 19s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:53:36 +02:00
Daan MeijerandClaude Opus 5.5 821de668d0 Rebuild: the merger stops when a pass over the file keys decides nothing
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
Waiting files counted as work, so the merge loop re-read them for its
whole 30 s budget every minute: Postgres used ~a core even without
workers. Now ~4% at rest, and storing a boneless range costs Postgres
~0.7–1.9 s of CPU (it was 33 s with the loop running). Chunk payloads
are stored EXTERNAL (zstd already; no pglz attempt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:49:45 +02:00
Daan MeijerandClaude Opus 5.5 73fc9aabdf Rebuild: strings that outlive their chunk are copied (worker 1.14 → 0.66 GB)
Test and deploy / test (push) Successful in 18s
Test and deploy / deploy (push) Skipped
A slice of a chunk's text keeps the whole text alive (tens of MB) for as
long as the file it belongs to stays open: message-ids, file details and
cache keys are now copies. edge:bench on boneless: peak RSS 1,137 → ~660
MB, external memory 570 → 40–75 MB, the same speed (~190k headers/s);
the same results on 800k real lines. edge:bench prints the breakdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:36:34 +02:00
Daan MeijerandClaude Opus 5.5 80c920f678 Rebuild: settling quiet releases
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
Hourly in coordinator:run: a release unchanged for 6 hours is settled,
complete when every file its [i/N] counter announced was found, unknown
without a counter (phase 2 reads par2 sets for those). Partial index on
unsettled releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:33:47 +02:00
Daan MeijerandClaude Opus 5.5 fde1e6cc90 Rebuild: NZBs from the chunk store, and a catalog search
lib/nzb (buildNzb, parseNzb) from the current app; NzbService builds a
release's NZB from its chunks; GET /api/v1/releases?q= (every word of the
name) and GET /api/v1/releases/:id/nzb behind the api guard. A test stores
a batch, finds the release and reads its NZB back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:33:27 +02:00
Daan MeijerandClaude Opus 5.5 e9592dfd33 Rebuild: chunk format v2 — nyuu and hex message-ids as bits
Test and deploy / test (push) Successful in 20s
Test and deploy / deploy (push) Skipped
nyuu's 12 capital+small letter pairs in 15 bytes (10 bits a pair), its
timestamp as a delta within the file, the suffix once per file;
ngPost-style 32 hex digits in 16 bytes; other ids as zstd text as before.
Recent boneless: 25.2 → 20.3 B per segment (and 25.0 → 19.5 on the next
range); a 2013 ath range stays at 17.9 (JBinUp ids are text). Every chunk
of three real ranges decodes to itself (edge:bench --verify). Encoding is
no slower than v1 (less text for zstd). Version 1 chunks stay readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:31:59 +02:00
Daan MeijerandClaude Opus 5.5 31493a98be Rebuild: merger rounds with one neighbour query, a cursor and a time budget; planning skips failing groups
Test and deploy / test (push) Successful in 22s
Test and deploy / deploy (push) Skipped
The neighbour check was three queries per file (~2,000 files/minute); now
one per round of 20k files, with a cursor so waiting files don't take
every round's places, and rounds until nothing is left or 30 s. On the
dev backlog (old-style fragments): ~18k files/minute in the 30 s budget;
at full throughput the new fragment rule leaves ~1,200 files/minute.
A group that fails GROUP (gone from the server) no longer stops planning
the others; edge:bench's groups are inactive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:25:45 +02:00
Daan MeijerandClaude Opus 5.5 b862201991 Rebuild: the fragment merger
Test and deploy / test (push) Successful in 19s
Test and deploy / deploy (push) Skipped
Files cut by a range's edge are put together once the ranges around them
are done (coordinator:run, every minute): a complete file is judged by the
rules and written as a release chunk with probe candidates; one still
missing parts while both neighbouring ranges are done is dropped and
counted (incomplete/edge). A neighbour not planned yet counts as not done,
except before the group's oldest article. BatchStore.writeReleases is the
shared write path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:19:53 +02:00
Daan MeijerandClaude Opus 5.5 2c14315f94 Rebuild: fragments only for files an edge cut; lone random articles are per-article
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
Measured on two neighbouring boneless ranges (merging their fragments):
- 99.8% of fragment files were single articles of a random-named
  'file' of >100 parts: a random subject per article, which never
  completes from headers. They now count as per-article.
- A file is kept as a fragment when its first part seen is above 1 and
  comes within the start window (the idle window: uploads in busy groups
  spread over minutes, and a 50k-article edge zone is ~30 s of boneless),
  or when it was still open at the range's end with later parts missing.
  Anything else missing parts is incomplete and only counted.
Per range: 70,308 → 130 fragment files; merged, 47 of the 48 files met
complete (57,558 segments, ~8% of the range's kept segments).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:17:22 +02:00
Daan MeijerandClaude Opus 5.5 5ba93f558c Rebuild: XOVER chunks of 100k lines; batches by articles
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
Eweka takes ~1.5–2 s per XOVER command whatever its size. One connection
on recent a.b.boneless: 9–15k lines/s in chunks of 25k, ~20k in 50k,
~30k in 100k–200k. Four connections at once: 15k each at 50k, 22k each
at 100k (the same as one connection alone at that moment): connections
don't slow each other. CHUNK_ARTICLES defaults to 100k; edge:node posts a
batch every --batch-articles (500k) instead of every N chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:09:04 +02:00
Daan MeijerandClaude Opus 5.5 08c876b690 Rebuild: XOVER replies are joined once, when complete
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
XoverClient joined all received pieces into one buffer on every arrival
(quadratic for a 2.6 MB reply), and turned the inflated check into text
the raw path doesn't use. The coordinator under load (3 workers, 4
connections): busy 40% → 13% of a core, Buffer.concat gone from the
profile. (master's relay uses the same client.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:36:28 +02:00
Daan MeijerandClaude Opus 5.5 ead2790515 Rebuild: edge:node --processes (one worker per core, supervised)
Test and deploy / test (push) Successful in 19s
Test and deploy / deploy (push) Skipped
On the 4-core workstation (shared with coordinator, web server and
database): 3 processes indexed 6 boneless ranges (12M headers) in 37 s,
~324k headers/s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:32:37 +02:00
Daan MeijerandClaude Opus 5.5 fa7d4f3394 Rebuild: end-to-end fixes — spool accounting, keep-alive frames, resume point, overlapped worker
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
End to end (workstation, recent a.b.boneless ranges from the spool): a 2M
range in 10–12 s, 166k–202k headers/s per worker process (edge:bench on
one core: 215k).

- Prefetcher: spooled_at was set against a total counted from the range's
  resume point, which shrinks as the worker advances: the last chunks were
  never fetched and the worker waited. Now counted against the whole
  range, only new chunks counted, and leased ranges are always checked
  against the files. Planned ranges ahead are bounded (it had spooled ever
  more); the spool size is measured every 30 s (a stat per file).
- The stream sends an empty frame every 20 s while waiting for a chunk
  (Node's fetch gave up on a quiet body after 5 minutes).
- A range taken over midway: the first batch starts at the resume point
  (it started at the range's start and was refused).
- edge:node inflates the next chunk on the thread pool while indexing the
  current one, and doesn't wait for a post before indexing on (posts stay
  in order; a failed post stops the range).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:31:19 +02:00
Daan MeijerandClaude Opus 5.5 db882f0b48 Rebuild: the segment counter by a backwards scan, one cache from poster+subject to the file
edge:bench (one core): boneless 193k → 215k headers/s, ath 303k → 343k.
Same results on 800k real lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:16:04 +02:00
Daan MeijerandClaude Opus 5.5 638cfa1dcd Rebuild: a lean overview scan for the edge worker
Test and deploy / test (push) Successful in 22s
Test and deploy / deploy (push) Skipped
The worker reads the XOVER reply's text directly: fields by position, the
owner from the Xref without arrays (crossposts leave before any subject
parsing), a subject parsed once per file (cached without its segment
counter), a file's details as one shared object, dates as numbers.
Garbage collection had been ~22% of the time.

Same results as parseOverviews + segmentFromHeader on 800k real lines.
edge:bench (one core): boneless 137k → 193k headers/s (CPU 21 → 12.8 s),
ath (2013) 143k → 303k; peak RSS 1.15 / 0.87 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:13:41 +02:00
Daan MeijerandClaude Opus 5.5 981bc01810 Rebuild: a file's identity is computed once per batch, not per article
The file key and release identity (two SHA-1 hashes and the release rules)
were ~25% of the edge worker's time. edge:bench: boneless 100k → 137k
headers/s on one core, ath (2013) 70k → 143k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:08:58 +02:00
Daan MeijerandClaude Opus 5.5 cd06f96f85 Rebuild: first end-to-end runs; prefetch per chunk, executable rule, wire fixes
Test and deploy / test (push) Successful in 20s
Test and deploy / deploy (push) Skipped
End to end on the workstation (coordinator:run, the API, edge:node, real
NNTP): a.b.e-book.french and a.b.cd.image.playstation2.dvdiso ranges.

- Prefetcher: every connection takes the next missing chunk (leased
  ranges first, in order); one connection per range (~26k lines/s) kept
  the worker waiting
- executable rule: releases of bare executables (malware floods named
  after films and software) are dropped; they were 92% of the catalog rows
  of the test ranges, 21% of the segments
- fragments per file (details once) and gzipped batch bodies; the JSON
  limit (1 MB by default) raised to 256 MB, empty strings kept (a rule
  count's key is '')
- release_chunks.batch_from is a bigint (boneless article numbers)
- tests get their own database; edge:bench --store times BatchStore:
  1.3 s for a boneless range (1.24 ms per release chunk)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:07:11 +02:00
Daan MeijerandClaude Opus 5.5 d5f168d8d2 Rebuild: coordinator (planner, NNTP pool, spool, prefetcher, leases, rule sets), node API and the edge:node worker
Test and deploy / test (push) Successful in 21s
Test and deploy / deploy (push) Skipped
- groups:add / ranges:plan / nodes:create / coordinator:run commands
- the node API: lease, the range's chunks as one framed stream from the
  spool, batches, heartbeat, finish, release (node guard, token per node)
- edge:node: needs COORDINATOR_URL and NODE_TOKEN only (placeholders for
  the rest), posts a batch every 10 chunks, gives its range back on SIGTERM
- releases are gathered per batch: 1.04 chunk rows per release at 10
  chunks per batch (1.35 when built per XOVER chunk)
- partitions are checked per batch instead of remembered

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:31:06 +02:00
Daan MeijerandClaude Opus 5.5 20910647fa Rebuild: the batch store (catalog upserts, write-once chunks, candidates, rule counts, fragments, resume point)
Test and deploy / test (push) Successful in 20s
Test and deploy / deploy (push) Skipped
A batch is stored in one transaction and only at its range's resume
point, so a retried batch can't count twice. Garbage is judged before
per-article: a dropped tool isn't worth reading bodies for either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:21:17 +02:00
Daan MeijerandClaude Opus 5.5 0714b2c81a Rebuild: a dedicated Postgres for development (its own database, as in production)
The shared database's public schema leaked the old indexer's tables into
the generated schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:19:51 +02:00
Daan MeijerandClaude Opus 5.5 2209921846 Rebuild: schema for groups, rule sets, ranges, release catalog, chunk store, fragments, probe queue, rule bookkeeping and nodes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:19:00 +02:00
Daan MeijerandClaude Opus 5.5 a18ead666b Rebuild: edge:bench, and its result in the spec (TypeScript is fast enough)
Test and deploy / test (push) Successful in 19s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:17:26 +02:00
Daan MeijerandClaude Opus 5.5 7dc316c987 Rebuild: the edge worker's range indexer
Overview chunks in, per batch out: releases as encoded chunks, probe
candidates, counts per rule (crossposts per owner group, obfuscated,
garbage, per-article, incomplete), a sample of what was dropped, and
fragments near the range's edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:10:16 +02:00
Daan MeijerandClaude Opus 5.5 d773c91ad0 Rebuild: phase 1 modules from the current app, ordered by pipeline stage
Test and deploy / test (push) Successful in 29s
Test and deploy / deploy (push) Skipped
lib/nntp (XOVER client, Xref, GROUP), lib/headers (subjects, file keys,
segments from overview lines; the yEnc path stays behind for phase 2),
lib/release (identity, random names, now in lib), lib/files (aggregator,
volume names), lib/rules (posting tools, owner by priority, release
verdicts), lib/chunks (release chunk codec). Their unit tests came along;
new ones for codec, rules and volumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:08:44 +02:00
Daan MeijerandClaude Opus 5.5 96f0ade10e Rebuild: fresh AdonisJS 7 API app on Postgres
The rebuild branch starts over (REBUILD.md): the API starter kit, with
Postgres instead of SQLite (its own database, DB_SEARCH_PATH for a
shared one in development) and the #lib alias. Phase 1's code is
imported from the current app module by module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:05:24 +02:00
Daan MeijerandClaude Opus 5.5 699395c51f Rebuild spec: a worker streams its whole range in one request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:01:03 +02:00
Daan MeijerandClaude Opus 5.5 06f5d81fbb Rebuild spec: d and e decided; coordinator prefetches ranges into a spool
Test and deploy / test (push) Successful in 1m11s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:57:17 +02:00
Daan MeijerandClaude Opus 5.5 76ecef81af Rebuild spec: a, b, c decided; group list with priority-based crosspost owners
Test and deploy / test (push) Successful in 1m9s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:54:20 +02:00
Daan MeijerandClaude Opus 5.5 1931c7f66c Rebuild spec: rules and recovery (counts per range and rule, rule versions, a sample)
Test and deploy / test (push) Successful in 1m3s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:39:36 +02:00
Daan MeijerandClaude Opus 5.5 2d4f3f48cf Rebuild spec: workers return probe candidates per release; a probe queue
Test and deploy / test (push) Successful in 1m4s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:31:18 +02:00
Daan MeijerandClaude Opus 5.5 16e504a8fb Rebuild: spec draft (header-first indexer)
Test and deploy / test (push) Successful in 1m21s
Test and deploy / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:37:26 +02:00
Daan MeijerandClaude Opus 5.5 fad1716d3a Content type and archive listing move from files to file_details
Test and deploy / test (push) Successful in 1m26s
Test and deploy / deploy (push) Successful in 1m54s
Every probe rewrote its file's row on the HDD (content_type, archive):
a new row version on a random page and an entry in each of the five
indexes. They now live in file_details on the NVMe; files rows are
inserted once. files:move-details copies the existing values after
the deploy; a later migration drops the old columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:36:36 +02:00
Daan MeijerandClaude Opus 5.5 1520a8dc35 Spots: a corrupt NZB is a result, not a reason to read the listing again
Test and deploy / test (push) Successful in 59s
Test and deploy / deploy (push) Successful in 1m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:23:24 +02:00
Daan MeijerandClaude Opus 5.5 ee898be8c8 Spots: more password phrasings; read listed spots' articles and NZBs
Test and deploy / test (push) Successful in 57s
Test and deploy / deploy (push) Successful in 4m18s
passwordFromSpot finds 30 more passwords in the stored descriptions (480
→ 510): words between keyword and separator, trailing "!!", the value on
the next line, "wachtwoord is x", "… wachtwoord. Deze is: x", "WW x" as a
whole line.

Listed spots were read from their overview only, so the NZB's own
password and the description were unknown for 2.27M of them.
SpotService.readListing reads the spot's article and NZB without
importing files: details, the NZB password (over a spot-text one), the
spot-text password, placeholder NZBs. worker:spots does a batch when
idle; spot:postprocess works through the history (nzb_read_at).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:46:32 +02:00
Daan MeijerandClaude Opus 5.5 36f84ae3e6 Garbage: retry the apply after the deleting transaction has committed
Test and deploy / test (push) Successful in 55s
Test and deploy / deploy (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:50:46 +02:00
Daan MeijerandClaude Opus 5.5 3a60a0aa2c Garbage: retry a probe's apply once when a release it joins was deleted meanwhile
Test and deploy / test (push) Successful in 58s
Test and deploy / deploy (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:36:34 +02:00
Daan MeijerandClaude Opus 5.5 52fe911bcc Garbage: a probe whose release another probe deleted is not an error
Test and deploy / test (push) Successful in 1m4s
Test and deploy / deploy (push) Successful in 1m25s
Two probes of one release can be applied at once; when one deletes the
release as garbage, the other's inserts (e.g. a par2 listing) violated
the foreign key and the node got a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:28:32 +02:00
Daan MeijerandClaude Opus 5.5 211820067a Drop garbage uploads early (D42)
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 2m33s
Random-named uploads that never get a real name or password were ~90% of
what seeding stores. Two filters:

- before storing (seeding, ingest): random-named uploads of a dropped
  signature (posting tool from the Message-ID, first group) or of a
  release dropped after its probe aren't stored; 2% of a dropped
  signature's releases are still kept and probed. Stored files of dropped
  signatures aren't probed.
- after a probe: a random-named release that is still unnamed, without
  password, names from files or spot, with all its probes done, and
  encrypted, a container or missing its first article, is deleted and
  tombstoned in dropped_releases.

Signatures are learned by gc:scheduler from a sample of probed releases
(signature_probes); garbage:learn --seed fills it from existing files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:21:01 +02:00
Daan MeijerandClaude Opus 5.5 35d0b2da65 Inspection backlog in Postgres; BullMQ gets a working set
Test and deploy / test (push) Successful in 2m14s
Test and deploy / deploy (push) Successful in 2m51s
5.4M waiting inspection jobs took 3.3 GB of Redis and most of its single
thread. Producers now add file ids to inspection_queue; index-scheduler
feeds up to INSPECTION_FEED_MAX (50k) into BullMQ. inspection:from-redis
moves the existing backlog over once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:12:13 +02:00
Daan MeijerandClaude Opus 5.5 e7e338213a Release completeness: note only releases where it can change; names from files
Test and deploy / test (push) Successful in 1m19s
Test and deploy / deploy (push) Successful in 2m2s
A sweep checked 430k noted releases in 51 minutes, nearly all without a
par2 listing or file total, where completeness can't change. Listed names
are now matched against the files themselves rather than through
binaries, which seeding mostly no longer creates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:47:05 +02:00
Daan MeijerandClaude Opus 5.5 06de0f21c5 A /health endpoint for the Docker health checks
Test and deploy / test (push) Successful in 2m3s
Test and deploy / deploy (push) Successful in 2m33s
The health checks of web, seed-api and seed-relay called /api/v1/stats
every 30 s, whose release statistics query kept about a core busy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:20:26 +02:00
Daan MeijerandClaude Opus 5.5 f8ee7e8662 files partitions: no deadlock, four ahead, and a failure no longer stops the GC sweep
Test and deploy / test (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 2m37s
Creating a partition deadlocked with file inserts 11 times in an hour, and
each failure ended the GC sweep before its deletes. The parent is now
locked first (lock_timeout 10 s), four partitions are kept ahead, and an
error is logged while the sweep goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:17:53 +02:00
Daan MeijerandClaude Opus 5.5 e3e3ecc93d Scheduler and worker loops survive a database restart
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 2m52s
index-scheduler's loop ended when Postgres restarted (the active-groups
query was outside its try), leaving live indexing stopped for ~3 hours
while the process stayed up. Each step's error is now caught and retried
next round; worker:spots and worker:deferred wait 30 s and claim again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:02:08 +02:00
Daan MeijerandClaude Opus 5.5 61192690a7 A missing first article is an inspection result, not a failure
Test and deploy / test (push) Successful in 1m35s
Test and deploy / deploy (push) Successful in 1m59s
Eweka answers 'no such article' after ~450 ms; a quarter of inspection
requests were for missing articles and each failed job was retried up
to three times. Files get content_type 'missing' instead; homelab also
records it from failure reports of nodes not rebuilt yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:54:01 +02:00
Daan MeijerandClaude Opus 5.5 bef68075be Content probe: don't download the first segment again for whole files
Test and deploy / test (push) Successful in 1m34s
Test and deploy / deploy (push) Successful in 1m40s
Par2 sets, NZBs and SFVs were sniffed from their first segment and then
read whole, downloading that segment a second time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:37:26 +02:00
Daan MeijerandClaude Opus 5.5 23bbdcc5ee Seed batches take file ids from the insert; merge split uploads hourly
Test and deploy / test (push) Successful in 1m29s
Test and deploy / deploy (push) Successful in 1m58s
SeedService looked up every file of a batch across all files partitions
after inserting it (15% of Postgres' active time); the merge job read
every release on each GC sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:09:04 +02:00
Daan MeijerandClaude Opus 5.5 b2cc75fdd0 Keep binaries until inspected; seeding writes binaries only for inspection
Test and deploy / test (push) Successful in 1m24s
Test and deploy / deploy (push) Successful in 6m43s
Par2 naming needs the fingerprint inspection stores on a binary, and the
GC deleted seeded binaries (6 h unfingerprinted, 48 h stale) long before
the inspection queue reached them. binaries.awaiting_inspection protects
them for up to 7 days; the GC deletes use partial indexes so they don't
read past waiting rows. Seeding no longer creates binaries for files that
aren't inspected (~85% of a.b.ath), which only churned the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:03:43 +02:00
Daan MeijerandClaude Opus 5.5 cc861ce8ae Seed leases alternate between priorities; article relay can be off
Test and deploy / test (push) Successful in 59s
Test and deploy / deploy (push) Successful in 1m33s
Half the live leases go to higher-priority ranges, the rest to the others,
so a slow recent gap runs beside older history. With no article
connections the article relay answers 503 instead of waiting forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:19:37 +02:00
Daan MeijerandClaude Opus 5.5 3cc69a617f Seed relay: compressed overviews by default again
Test and deploy / test (push) Successful in 1m3s
Test and deploy / deploy (push) Successful in 1m51s
Measured with 20 relay connections, plain and gzip both gave ~1.5 chunks/s
for 2026 a.b.ath articles; plain used many times the CPU and traffic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:14:47 +02:00
Daan MeijerandClaude Opus 5.5 3e6b5bed56 Seed relay fetches plain overviews by default
Test and deploy / test (push) Successful in 1m6s
Test and deploy / deploy (push) Successful in 1m43s
For 2026 a.b.ath articles Eweka's gzip replies have a long tail (p90 12.4 s
per 20k vs 3.9 s plain; 6.1k vs 10.5k headers/s). SEED_NNTP_COMPRESS turns
compression back on; plain replies are deflated for nodes asynchronously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:49:21 +02:00
Daan MeijerandClaude Opus 5.5 7893876435 seed:node splits a batch the seed API finds too large
Test and deploy / test (push) Successful in 1m18s
Test and deploy / deploy (push) Successful in 1m54s
Dense recent a.b.ath ranges produced batches over the 64 MB body limit
(413). The node now posts such a batch in halves; only the second moves
the range's resume point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:21:41 +02:00
Daan MeijerandClaude Opus 5.5 f0ff8e200f file_keys: fill in the background, use it once complete
Test and deploy / test (push) Successful in 1m37s
Test and deploy / deploy (push) Successful in 2m23s
Copying all existing keys in the migration read the files rows from the
HDD for over half an hour and held up the deploy. gc:scheduler now copies
them in slices (FILE_KEYS_FILL_SECONDS per sweep) and FileStore checks
every partition until the fill is complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:54:12 +02:00
Daan MeijerandClaude Opus 5.5 693e6c3244 Seed plans fill every uncovered gap, with priorities; cheaper ingest and file storing
Test and deploy / test (push) Successful in 1m27s
Test and deploy / deploy (push) Failing after 33m7s
- seed:plan plans every part of a group's history no range covers (it
  started below the lowest known range and left a.b.ath's 13 most recent
  months out); seed_ranges.priority orders leases (seed:plan --priority).
- binaries.part_count, kept by the parts insert in the same statement:
  claimCompleted compares it instead of counting parts every batch.
- file_keys: 64-bit hashes of all file keys; FileStore.insert asks it
  before probing every files partition.
- worker:file stores 100 completed binaries per job with set-based queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:18:01 +02:00
Daan MeijerandClaude Opus 5.5 61b683a71f Release bookkeeping migration: take the files locks first
Test and deploy / test (push) Successful in 1m19s
Test and deploy / deploy (push) Successful in 2m6s
The trigger swap deadlocked with ingest on homelab (DROP TRIGGER locks
the partitions one by one); LOCK TABLE up front cannot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:53:52 +02:00
Daan MeijerandClaude Opus 5.5 be2112f4ad Par2 size matching 100x faster; release bookkeeping once per statement
Test and deploy / test (push) Successful in 1m33s
Test and deploy / deploy (push) Failing after 1m29s
matchPar2Releases checked every same-size release_files row's set and
date (18 s per batch of body-derived binaries); now an index on
release_files (release_id, size) drops the repeated sizes first: 0.16 s.

File inserts updated their release in four row triggers; one statement
trigger now updates each release once per INSERT. Completeness is noted
in release_dirty and recomputed by gc:scheduler every 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:48:59 +02:00
Daan MeijerandClaude Opus 5.5 288a6bab8d seed:node spreads its ranges and inspections over worker processes
Test and deploy / test (push) Successful in 2m51s
Test and deploy / deploy (push) Successful in 2m27s
One process ran everything on one core. SEED_PROCESSES (default: the
number of cores) worker processes now each run seed:node with a share of
the slots; stop signals are passed on and crashed workers restarted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:25:00 +02:00
Daan MeijerandClaude Opus 5.5 4d17c5a8e8 Release completeness check without reading every binary; SEED_RANGES
Test and deploy / test (push) Successful in 2m15s
Test and deploy / deploy (push) Successful in 2m26s
release_complete() matched each par2-listed file through a correlated
subquery that scanned all binaries per listed file, run twice per
inserted release_files row: one insert of a few thousand rows ran for
20+ minutes. It now reads the release's files once by release id, and
release_files refreshes once per INSERT statement.

seed:node's SEED_CONNECTIONS/--connections is now SEED_RANGES/--ranges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:57 +02:00
Daan MeijerandClaude Opus 5.5 2dbca9b152 Content inspection and archive walks on seeding nodes (relay)
Test and deploy / test (push) Successful in 1m49s
Test and deploy / deploy (push) Successful in 2m15s
Inspection is split into prepare (database), probe (lib/content/probe:
downloads, decoding and parsing, no database) and apply (database).
worker:collection runs all three; seed:node with SEED_INSPECTORS leases
jobs from the same queues through the seed API (BullMQ manual leases),
fetches articles through the seed API and posts the probes back.

A node now needs only SEED_URL and SEED_TOKEN: the settings the app
requires but a node never uses get placeholders for seed:node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:17:02 +02:00
Daan MeijerandClaude Opus 5.5 51975fe603 Hidden archive entries no longer name releases
Test and deploy / test (push) Successful in 1m44s
Test and deploy / deploy (push) Successful in 2m6s
One poster adds the same 22 GB hidden '.1917-….mkv' decoy to every 7z
upload; the largest-entry rule named 840 releases after it. Entries with
a hidden path part are skipped; release:rename --hidden renames the
existing ones from their stored listing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:29:05 +02:00
Daan MeijerandClaude Opus 5.5 4d3cb6474c 7z: encrypted headers are known from the encoded header, without bsdtar
Test and deploy / test (push) Successful in 2m6s
Test and deploy / deploy (push) Successful in 2m25s
85% of the 7z archives inspected have encrypted headers (-mhe). Their
encoded header lists the 7zAES coder, so the packed header stream is no
longer downloaded and bsdtar no longer runs for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:23:32 +02:00
Daan MeijerandClaude Opus 5.5 9ae15ed26f Seed batches: test chunked inserts with a lowered chunk size
Test and deploy / test (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 4m7s
The 6,000-file test took 29 s and failed CI; 120 files in chunks of 50
cover the same path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:20:17 +02:00
Daan MeijerandClaude Opus 5.5 953bbe9e39 Seed batches: insert files and binaries in chunks of 2,000 rows
Test and deploy / test (push) Failing after 3m16s
Test and deploy / deploy (push) Skipped
A batch of a dense range (over ~5,400 files) exceeded Postgres' 65,535
bind parameters per statement, so its post failed with a 500 until the
range was given up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:01:32 +02:00
Daan MeijerandClaude Opus 5.5 f4ded062df Complete archive listings by walking RAR headers, at low priority
Test and deploy / test (push) Successful in 1m24s
Test and deploy / deploy (push) Successful in 1m58s
Inspection lists only the entries whose headers are in the first segment
of a first volume. A walk hops from header to header, fetching one segment
per entry and volume, in its own queue (one job per collection worker) on
NNTP connections inspection isn't waiting for. content:walk-archives queues
releases inspected earlier (CONCEPT D40).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:32:52 +02:00
Daan MeijerandClaude Opus 5.5 b7ae8267c6 Seed relay: a stalled compressed XOVER is fetched again uncompressed
Test and deploy / test (push) Successful in 1m9s
Test and deploy / deploy (push) Successful in 1m41s
The four ranges kept failing on fresh connections too: in gzip mode Eweka
stops some chunks partway (a.b.ath 49701720000: 19,672 of 20,000
overviews, the last line cut, then silence), while the same chunk without
compression comes in 0.3 s. A failed relay chunk is now retried once
without compression on a connection of its own (dead pooled connections are
covered by the same retry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:16:39 +02:00
Daan MeijerandClaude Opus 5.5 028dd90a6f Seed relay: dead NNTP connections found in 90 s and retried
Test and deploy / test (push) Successful in 1m14s
Test and deploy / deploy (push) Successful in 1m46s
Five ranges "never answered" in 10 minutes, yet their chunk came back in
0.3 s on a fresh connection: the news server drops idle connections
without telling, and the pool's next request on one waited for the
10-minute inactivity timeout. Now: SEED_NNTP_TIMEOUT_MS 90 s (inactivity;
slow ranges keep streaming), one retry on a fresh connection, and pooled
connections idle over 60 s are replaced before use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:51:19 +02:00
Daan MeijerandClaude Opus 5.5 8d86f7d489 Seed fragments: keep only those near the range edges, unstaged
Test and deploy / test (push) Successful in 1m5s
Test and deploy / deploy (push) Successful in 1m48s
About 5% of seeded headers are fragments. They went through staging,
where they waited 30 minutes for parts that were never coming (the
neighbouring range is worked on by another node, often hours later) and
expired: the staging table grew to 22 GB and the NVMe part of the database
reached its 60 GB limit, which stopped leasing and the backfill. Files cut
by a range edge can still be completed by the neighbouring range, so
fragments within 50k articles of an edge go straight into binaries and
parts; the rest, files with articles missing for good, are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:30:24 +02:00
Daan MeijerandClaude Opus 5.5 530a752cca Seed: finished ranges marked done; relay XOVER timeout 10 minutes
Test and deploy / test (push) Successful in 1m37s
Test and deploy / deploy (push) Successful in 2m10s
- A range reported to its end but not finished (its node went away at
  the very end) was leased again with start past end; nodes asked for
  "from 49208000000 to 49207999999", got 400, and the range failed after
  five leases. The orchestrator now marks such ranges done before
  leasing, and runRange reports them done without fetching.
- Some old ranges come so slowly that one XOVER of 20k headers takes over
  two minutes: five ranges failed on "NNTP reply timed out". The relay
  pool's timeout is SEED_NNTP_TIMEOUT_MS (default 10 minutes, the node's
  HTTP timeout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:21:30 +02:00
Daan MeijerandClaude Opus 5.5 ef09c07d6e files.id migration in one transaction
Test and deploy / test (push) Successful in 1m51s
Test and deploy / deploy (push) Successful in 49s
A failure after the old parent is dropped would leave files half-converted;
in one transaction it rolls back completely. Tested on a fresh Postgres 17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:48:11 +02:00
Daan MeijerandClaude Opus 5.5 9201e1200e files.id to 64 bits
Test and deploy / test (push) Successful in 1m52s
Test and deploy / deploy (push) Skipped
Seeding adds ~14M files a day; 32-bit ids run out at 2.1 billion in about
five months. Postgres can't change a partition key's type, so the
migration detaches the partitions, rewrites each as a plain table (a CHECK
added first spares the attach scan), and attaches them to a new 64-bit
parent with the same indexes, foreign keys and triggers. binaries.file_id
and the id sequence become 64-bit too. Meant to run with the writers
stopped and Postgres on wal_level=minimal (compose: PG_WAL_LEVEL,
PG_MAX_WAL_SENDERS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:53 +02:00
Daan MeijerandClaude Opus 5.5 f1773744c0 Partition files by age (id)
Test and deploy / test (push) Successful in 54s
Test and deploy / deploy (push) Successful in 7m42s
New rows went into free space scattered through the 9.6 GB heap on the HDD
(left behind by inspection's updates): one random HDD read per insert, and
seed batches took a median 13 s to store. The existing table becomes the
first partition as it is (a CHECK scan, no rewrite; indexes, its foreign
key and the triggers are taken over by the partitioned parent); new
partitions of 4M ids go in the same tablespace and stay in memory while
they fill.

- Every partition has a unique file_key index; FileStore.insert looks keys
  up in all partitions first (a key can't be unique across partitions).
- FilePartitions.ensure() (gc:scheduler) keeps two partitions ahead.
- Stats count files from the partitions; seed:node waits ~20 minutes for
  the API before giving a range up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:57:51 +02:00
Daan MeijerandClaude Opus 5.5 41859636d3 Seed relay: nodes get their headers through the seed API
Test and deploy / test (push) Successful in 1m28s
Test and deploy / deploy (push) Successful in 2m5s
The seed API keeps a pool of NNTP connections (SEED_NNTP_CONNECTIONS) and
serves GET /api/v1/seed/ranges/:id/chunk?from=&to= for leased ranges:
the gzip stream the news server sent, passed on unopened. seed:node fetches
that way by default (SEED_FETCH=relay), so a node needs only a token and
SEED_CONNECTIONS; SEED_FETCH=direct keeps its own NNTP connections. All
fetching then comes from the indexer's IP (the account allows two).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:39 +02:00
Daan MeijerandClaude Opus 5.5 3d5cfb2527 Seeding nodes lease first, connect for the lease, close when idle
Test and deploy / test (push) Successful in 1m36s
Test and deploy / deploy (push) Successful in 1m58s
A failed NNTP login gives the range back uncounted (no attempt used), and a
node with nothing to lease holds no connection. Replaces log-in-before-lease,
which kept idle connections and logged in again every minute while idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:18:38 +02:00
Daan MeijerandClaude Opus 5.5 70036847cf Seeding nodes log in before leasing; TLS through tunnels
Test and deploy / test (push) Successful in 1m29s
Test and deploy / deploy (push) Successful in 1m59s
- seed:node logs in to NNTP before taking a range and keeps the connection
  across ranges (replaced after 2 minutes idle or a failed range). A node
  that can't reach its news server takes no range: two ranges were set
  aside as failed while an office node's tunnel was being set up
  (ECONNREFUSED 127.0.0.1:1563, five leases each). After an IP-address
  limit refusal it waits 5 minutes.
- NNTP_TLS_SERVERNAME: the hostname the TLS certificate is checked
  against when NNTP_HOST is a tunnel ("Hostname/IP does not match
  certificate's altnames: Host: localhost").
- seed-node/README: the IP-address limit and tunnelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:14:15 +02:00
Daan MeijerandClaude Opus 5.5 04f945055f XoverClient times replies only, not idle time between commands
Test and deploy / test (push) Successful in 1m12s
Test and deploy / deploy (push) Successful in 1m37s
A seeding node that waited over two minutes for the seed API (restarting)
had its idle NNTP connection closed by its own timeout, and gave its range
back. Now the timeout runs only while a reply is awaited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:40:15 +02:00
Daan MeijerandClaude Opus 5.5 00f6187b8f Seed leases respect the backfill's disk limits
Test and deploy / test (push) Successful in 1m8s
Test and deploy / deploy (push) Successful in 1m37s
No new range while the database's NVMe part or the hdd tablespace is over
BACKFILL_MAX_NVME_GB / BACKFILL_MAX_HDD_GB; nodes ask again a minute later.
seed:status says when leasing is paused. The NVMe part was at 47 GB of 50
with a plan for all of a.b.ath queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:30:46 +02:00
Daan MeijerandClaude Opus 5.5 f9e9295d3e seed-node: step-by-step guide for running a node on a remote server
Test and deploy / test (push) Successful in 1m12s
Test and deploy / deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:11:17 +02:00
Daan MeijerandClaude Opus 5.5 2b14b0dca5 seed:node retries 404 from the proxy (seed API restarting)
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 1m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:17:54 +02:00
Daan MeijerandClaude Opus 5.5 e2acfae98d seed:node posts batches in order beside fetching
Test and deploy / test (push) Successful in 57s
Test and deploy / deploy (push) Successful in 1m44s
At most two posts waiting; a failed post stops the chain, so nothing is
stored past a missing batch. The first remote node did 7k headers/s while
waiting for every post.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:09:16 +02:00
Daan MeijerandClaude Opus 5.5 8c36744088 Seed orchestrator: remote nodes lease ranges and post results over HTTPS
Test and deploy / test (push) Successful in 1m4s
Test and deploy / deploy (push) Successful in 1m37s
- seed_nodes (hashed bearer tokens) and seed_ranges (planned ranges with
  lease, resume point and counts); migration …140.
- SeedOrchestrator: lease (oldest plan first, FOR UPDATE SKIP LOCKED; a
  lease without a batch for 15 min passes to the next node and resumes at
  next_id), batch (stores files and fragments, moves next_id), finish,
  release, plan (from below live indexing / the backfill / earlier plans
  down to N days back; takes over the group's backfill), status.
- Seed API under /api/v1/seed (lease, ranges/:id/batches|done|release,
  status) behind a node-token middleware; JSON bodies up to 64 MB after
  gzip.
- seed:node runs a node: no database or Redis, outgoing HTTPS only, N
  connections, gzip batches, retries for network errors and 5xx, drops a
  range on 409, gives its ranges back on SIGTERM. seed-node/ has a compose
  file that builds the image from this repository.
- seed:token, seed:plan, seed:status for the operator. Header parsing moved
  to lib/ingest and the range loop to lib/seed/runner, shared by seed:range
  and seed:node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:02:01 +02:00
Daan MeijerandClaude Opus 5.5 97bdf8e982 Seeding node: gzip overviews, batched SFV lookups, fragments beside fetching
Test and deploy / test (push) Successful in 1m0s
Test and deploy / deploy (push) Successful in 1m43s
- lib/nntp/xover_client: a minimal one-connection client for seed:range
  with XFEATURE COMPRESS GZIP (a zlib stream after "224 … [COMPRESS=GZIP]",
  then a "." line; 420 for an empty range). About 6x less header traffic.
  Overviews come out with the field names nntp-js uses.
- SfvService.claimAll: one sfv_entries lookup per stored batch.
- Fragments go through the regular ingest path beside fetching (at most
  two batches in flight); a failure fails the run at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:11:41 +02:00
Daan MeijerandClaude Opus 5.5 68eb9a44fd Edge aggregation: seed:range indexes history one row per file
Test and deploy / test (push) Successful in 1m36s
Test and deploy / deploy (push) Successful in 3m37s
A seeding node fetches a range over one NNTP connection, parses headers
with the live rules, groups them into files in memory (lib/aggregate) and
stores per complete file its release, the file and a completed binary
(SeedService), instead of one parts row per article. What doesn't complete
within the window goes through the regular ingest path as fragments.
`--dry-run` only fetches and aggregates, to measure a node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:41:18 +02:00
Daan MeijerandClaude Opus 5.5 7ca338d4f3 Extension filter via a releases.extensions column
Test and deploy / test (push) Successful in 1m28s
Test and deploy / deploy (push) Successful in 58m9s
The first version matched extensions in search_text, which was wrong and
slow: file names enter search_text as stems without extension (so plainly
posted .mkv files were missed), and matching the TOASTed text took 3-4 s
for common types.

- Migration …130: releases.extensions text[] (two to four characters with
  a letter; .7z.001 and .r00 count as 7z and rar), kept current by triggers
  on files (name, release) and on releases (archive listing), filled with
  one sequential pass over files, with a GIN index.
- Search: ext matches with `extensions && ?`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:54:38 +02:00
Daan MeijerandClaude Opus 5.5 e1392608d8 Search filters for size (MB) and file extensions
Test and deploy / test (push) Successful in 1m27s
Test and deploy / deploy (push) Successful in 2m9s
- API: min_size and max_size in MB, ext as a comma-separated list. An
  extension matches as a whole word in search_text (file and entry names,
  dots as spaces), so "iso" finds .iso files but not "ISOLATION", which a
  plain search word matched as a substring. The trigram index serves it.
- Web UI: a size range and an extension field in the filter row, kept in
  the URL like the other filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:43:55 +02:00
Daan MeijerandClaude Opus 5.5 95f1d427df Inspect fewer files: skip later volumes under random stems
Test and deploy / test (push) Successful in 1m25s
Test and deploy / deploy (push) Successful in 2m58s
Of 5,000 waiting inspection jobs, 3,212 were queued only because their
stem looked random, and 2,044 of those were later RAR volumes
(xyz.part47.rar, .r00) or par2 recovery volumes (.vol03+04.par2). Their
first segment adds nothing: the set is listed from its first volume and
named from its index .par2, as for readable names already. Each one cost a
~700 KB download; inspection pulled ~85 MB/s and fell behind the backfill.

- isCandidate() rejects later volumes (isLaterVolume) before the random-
  name rule.
- worker:file only queues files that are candidates, so the ~30% that
  inspection skips anyway no longer take queue slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:32:40 +02:00
Daan MeijerandClaude Opus 5.5 2178847513 Stop refilling parts of binaries that are already a file
Test and deploy / test (push) Successful in 52s
Test and deploy / deploy (push) Successful in 1m23s
Crossposts, reposts and backfill ranges keep bringing articles of files
that worker:file already created. Ingest inserted their header parts again,
and GC's copied-parts pass (which searched all binaries for parts on every
batch) removed only 150k-450k per sweep: parts grew to 68M rows, 56M of
them belonging to finished files, and the 23 GB table paused the backfill.

- Ingest skips header parts (no per-part CRC) for binaries with a file_id.
- GC drops copied parts with a cursor over binaries, 2000 per statement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:53:11 +02:00
Daan MeijerandClaude Opus 5.5 d3e774a489 Add Gitea Actions: test every push, deploy master to homelab
Test and deploy / test (push) Successful in 4m20s
Test and deploy / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:04:48 +02:00
Daan MeijerandClaude Opus 5.5 be8a3d4580 Spotnet import, header backfill, staging and counter grouping, faster search
Spotnet (CONCEPT D29):
- Index free.pt fully and import spots with their descriptions, categories
  and NZBs; a spot renames its release, since Spotnet names are more
  trustworthy than names derived from subjects.
- Find passwords in spot descriptions too, and tell "needs a password we
  have" from "needs a password we don't have".

Content inspection:
- List the contents of 7z (.7z.001) archives, SFV sets, nicovideo uploads
  and other archive-like files; mark yEncBin .bin containers as encrypted.
- Take real names from yEnc headers for randomly named files, and merge
  uploads that were split across posts.

Storage (D31, D32):
- Store segment lists zstd-compressed behind the FileStore accessor.
- group:backfill indexes older headers at low priority and pauses when
  the NVMe or HDD part of the database gets too large.

Grouping (D35, D36):
- Lone header segments wait in staged_segments until a second segment of
  their file arrives, which drops the per-article junk binaries.
- Counter grouping joins random-subject uploads by poster, [i/M] counter,
  part count and day.

Search and maintenance (D33, D34):
- A trigram-indexed search_text column, smaller indexes, estimated stats.
- Passes that clean up nonsense names; weekly index maintenance; GC budgets
  per kind.
- Web UI: grey out stale results while a search runs and cancel superseded
  requests. Count only up to 1,000 matches for the page, which made filter
  clicks take seconds (Newznab keeps the 10,000 cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:59:23 +02:00
228 changed files with 8635 additions and 11932 deletions

No files matched your search

+1 -5
View File
@@ -5,10 +5,6 @@
/// <reference path="../../adonisrc.ts" /> /// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/auth.ts" /> /// <reference path="../../config/auth.ts" />
/// <reference path="../../config/gc.ts" /> /// <reference path="../../config/coordinator.ts" />
/// <reference path="../../config/hash.ts" /> /// <reference path="../../config/hash.ts" />
/// <reference path="../../config/indexer.ts" />
/// <reference path="../../config/logger.ts" /> /// <reference path="../../config/logger.ts" />
/// <reference path="../../config/nntp.ts" />
/// <reference path="../../config/queue.ts" />
/// <reference path="../../config/redis.ts" />
+48
View File
@@ -30,6 +30,54 @@ const routes = {
tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}], tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],
types: placeholder as Registry['profile.access_tokens.destroy']['types'], types: placeholder as Registry['profile.access_tokens.destroy']['types'],
}, },
'releases.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/releases',
tokens: [{"old":"/api/v1/releases","type":0,"val":"api","end":""},{"old":"/api/v1/releases","type":0,"val":"v1","end":""},{"old":"/api/v1/releases","type":0,"val":"releases","end":""}],
types: placeholder as Registry['releases.index']['types'],
},
'releases.nzb': {
methods: ["GET","HEAD"],
pattern: '/api/v1/releases/:id/nzb',
tokens: [{"old":"/api/v1/releases/:id/nzb","type":0,"val":"api","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"v1","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"releases","end":""},{"old":"/api/v1/releases/:id/nzb","type":1,"val":"id","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"nzb","end":""}],
types: placeholder as Registry['releases.nzb']['types'],
},
'node.node.lease': {
methods: ["POST"],
pattern: '/api/v1/node/lease',
tokens: [{"old":"/api/v1/node/lease","type":0,"val":"api","end":""},{"old":"/api/v1/node/lease","type":0,"val":"v1","end":""},{"old":"/api/v1/node/lease","type":0,"val":"node","end":""},{"old":"/api/v1/node/lease","type":0,"val":"lease","end":""}],
types: placeholder as Registry['node.node.lease']['types'],
},
'node.node.stream': {
methods: ["GET","HEAD"],
pattern: '/api/v1/node/ranges/:id/stream',
tokens: [{"old":"/api/v1/node/ranges/:id/stream","type":0,"val":"api","end":""},{"old":"/api/v1/node/ranges/:id/stream","type":0,"val":"v1","end":""},{"old":"/api/v1/node/ranges/:id/stream","type":0,"val":"node","end":""},{"old":"/api/v1/node/ranges/:id/stream","type":0,"val":"ranges","end":""},{"old":"/api/v1/node/ranges/:id/stream","type":1,"val":"id","end":""},{"old":"/api/v1/node/ranges/:id/stream","type":0,"val":"stream","end":""}],
types: placeholder as Registry['node.node.stream']['types'],
},
'node.node.batch': {
methods: ["POST"],
pattern: '/api/v1/node/ranges/:id/batches',
tokens: [{"old":"/api/v1/node/ranges/:id/batches","type":0,"val":"api","end":""},{"old":"/api/v1/node/ranges/:id/batches","type":0,"val":"v1","end":""},{"old":"/api/v1/node/ranges/:id/batches","type":0,"val":"node","end":""},{"old":"/api/v1/node/ranges/:id/batches","type":0,"val":"ranges","end":""},{"old":"/api/v1/node/ranges/:id/batches","type":1,"val":"id","end":""},{"old":"/api/v1/node/ranges/:id/batches","type":0,"val":"batches","end":""}],
types: placeholder as Registry['node.node.batch']['types'],
},
'node.node.heartbeat': {
methods: ["POST"],
pattern: '/api/v1/node/ranges/:id/heartbeat',
tokens: [{"old":"/api/v1/node/ranges/:id/heartbeat","type":0,"val":"api","end":""},{"old":"/api/v1/node/ranges/:id/heartbeat","type":0,"val":"v1","end":""},{"old":"/api/v1/node/ranges/:id/heartbeat","type":0,"val":"node","end":""},{"old":"/api/v1/node/ranges/:id/heartbeat","type":0,"val":"ranges","end":""},{"old":"/api/v1/node/ranges/:id/heartbeat","type":1,"val":"id","end":""},{"old":"/api/v1/node/ranges/:id/heartbeat","type":0,"val":"heartbeat","end":""}],
types: placeholder as Registry['node.node.heartbeat']['types'],
},
'node.node.finish': {
methods: ["POST"],
pattern: '/api/v1/node/ranges/:id/finish',
tokens: [{"old":"/api/v1/node/ranges/:id/finish","type":0,"val":"api","end":""},{"old":"/api/v1/node/ranges/:id/finish","type":0,"val":"v1","end":""},{"old":"/api/v1/node/ranges/:id/finish","type":0,"val":"node","end":""},{"old":"/api/v1/node/ranges/:id/finish","type":0,"val":"ranges","end":""},{"old":"/api/v1/node/ranges/:id/finish","type":1,"val":"id","end":""},{"old":"/api/v1/node/ranges/:id/finish","type":0,"val":"finish","end":""}],
types: placeholder as Registry['node.node.finish']['types'],
},
'node.node.release': {
methods: ["POST"],
pattern: '/api/v1/node/ranges/:id/release',
tokens: [{"old":"/api/v1/node/ranges/:id/release","type":0,"val":"api","end":""},{"old":"/api/v1/node/ranges/:id/release","type":0,"val":"v1","end":""},{"old":"/api/v1/node/ranges/:id/release","type":0,"val":"node","end":""},{"old":"/api/v1/node/ranges/:id/release","type":0,"val":"ranges","end":""},{"old":"/api/v1/node/ranges/:id/release","type":1,"val":"id","end":""},{"old":"/api/v1/node/ranges/:id/release","type":0,"val":"release","end":""}],
types: placeholder as Registry['node.node.release']['types'],
},
} as const satisfies Record<string, AdonisEndpoint> } as const satisfies Record<string, AdonisEndpoint>
export { routes } export { routes }
+96
View File
@@ -55,4 +55,100 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>> errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
} }
} }
'releases.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/releases'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['index']>>>
}
}
'releases.nzb': {
methods: ["GET","HEAD"]
pattern: '/api/v1/releases/:id/nzb'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['nzb']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['nzb']>>>
}
}
'node.node.lease': {
methods: ["POST"]
pattern: '/api/v1/node/lease'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/node_controller').default['lease']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/node_controller').default['lease']>>>
}
}
'node.node.stream': {
methods: ["GET","HEAD"]
pattern: '/api/v1/node/ranges/:id/stream'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/node_controller').default['stream']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/node_controller').default['stream']>>>
}
}
'node.node.batch': {
methods: ["POST"]
pattern: '/api/v1/node/ranges/:id/batches'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/node_controller').default['batch']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/node_controller').default['batch']>>>
}
}
'node.node.heartbeat': {
methods: ["POST"]
pattern: '/api/v1/node/ranges/:id/heartbeat'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/node_controller').default['heartbeat']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/node_controller').default['heartbeat']>>>
}
}
'node.node.finish': {
methods: ["POST"]
pattern: '/api/v1/node/ranges/:id/finish'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/node_controller').default['finish']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/node_controller').default['finish']>>>
}
}
'node.node.release': {
methods: ["POST"]
pattern: '/api/v1/node/ranges/:id/release'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/node_controller').default['release']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/node_controller').default['release']>>>
}
}
} }
+14
View File
@@ -18,4 +18,18 @@ export interface ApiDefinition {
destroy: typeof routes['profile.access_tokens.destroy'] destroy: typeof routes['profile.access_tokens.destroy']
} }
} }
releases: {
index: typeof routes['releases.index']
nzb: typeof routes['releases.nzb']
}
node: {
node: {
lease: typeof routes['node.node.lease']
stream: typeof routes['node.node.stream']
batch: typeof routes['node.node.batch']
heartbeat: typeof routes['node.node.heartbeat']
finish: typeof routes['node.node.finish']
release: typeof routes['node.node.release']
}
}
} }
+1 -1
View File
@@ -6,7 +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'),
Profile: () => import('#controllers/profile_controller'), Profile: () => import('#controllers/profile_controller'),
Releases: () => import('#controllers/releases_controller'), Releases: () => import('#controllers/releases_controller'),
} }
+19 -12
View File
@@ -4,33 +4,40 @@ type ParamValue = string | number | bigint | boolean
export type ScannedRoutes = { export type ScannedRoutes = {
ALL: { ALL: {
'releases.index': { paramsTuple?: []; params?: {} }
'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.stats': { 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.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.lease': { paramsTuple?: []; params?: {} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.batch': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.heartbeat': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.finish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.release': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
GET: { GET: {
'releases.index': { paramsTuple?: []; params?: {} }
'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.stats': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
HEAD: { HEAD: {
'releases.index': { paramsTuple?: []; params?: {} }
'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'releases.stats': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
POST: { POST: {
'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.access_tokens.destroy': { paramsTuple?: []; params?: {} } 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
'node.node.lease': { paramsTuple?: []; params?: {} }
'node.node.batch': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.heartbeat': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.finish': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.release': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
} }
} }
declare module '@adonisjs/core/types/http' { declare module '@adonisjs/core/types/http' {
-1
View File
@@ -1 +0,0 @@
{"root":[{"domain":"root","pattern":"/api/v1/releases","matchers":{},"tokens":[{"old":"/api/v1/releases","type":0,"val":"api","end":""},{"old":"/api/v1/releases","type":0,"val":"v1","end":""},{"old":"/api/v1/releases","type":0,"val":"releases","end":""}],"meta":{"params":[]},"name":"releases.index","handler":{"method":"index","reference":[null,"index"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/releases/:id","matchers":{"id":{"match":{}}},"tokens":[{"old":"/api/v1/releases/:id","type":0,"val":"api","end":""},{"old":"/api/v1/releases/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/releases/:id","type":0,"val":"releases","end":""},{"old":"/api/v1/releases/:id","type":1,"val":"id","end":"","matcher":{}}],"meta":{"params":["id"]},"name":"releases.show","handler":{"method":"show","reference":[null,"show"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/releases/:id/nzb","matchers":{"id":{"match":{}}},"tokens":[{"old":"/api/v1/releases/:id/nzb","type":0,"val":"api","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"v1","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"releases","end":""},{"old":"/api/v1/releases/:id/nzb","type":1,"val":"id","end":"","matcher":{}},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"nzb","end":""}],"meta":{"params":["id"]},"name":"releases.nzb","handler":{"method":"nzb","reference":[null,"nzb"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/stats","matchers":{},"tokens":[{"old":"/api/v1/stats","type":0,"val":"api","end":""},{"old":"/api/v1/stats","type":0,"val":"v1","end":""},{"old":"/api/v1/stats","type":0,"val":"stats","end":""}],"meta":{"params":[]},"name":"releases.stats","handler":{"method":"stats","reference":[null,"stats"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/auth/signup","matchers":{},"tokens":[{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}],"meta":{"params":[]},"name":"auth.new_account.store","handler":{"method":"store","reference":[null,"store"],"importExpression":"()=>import('#controllers/new_account_controller')","name":"NewAccount"},"methods":["POST"],"middleware":{}},{"domain":"root","pattern":"/api/v1/auth/login","matchers":{},"tokens":[{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}],"meta":{"params":[]},"name":"auth.access_tokens.store","handler":{"method":"store","reference":[null,"store"],"importExpression":"()=>import('#controllers/access_tokens_controller')","name":"AccessTokens"},"methods":["POST"],"middleware":{}},{"domain":"root","pattern":"/api/v1/account/profile","matchers":{},"tokens":[{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}],"meta":{"params":[]},"name":"profile.profile.show","handler":{"method":"show","reference":[null,"show"],"importExpression":"()=>import('#controllers/profile_controller')","name":"Profile"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/account/logout","matchers":{},"tokens":[{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],"meta":{"params":[]},"name":"profile.access_tokens.destroy","handler":{"method":"destroy","reference":[null,"destroy"],"importExpression":"()=>import('#controllers/access_tokens_controller')","name":"AccessTokens"},"methods":["POST"],"middleware":{}}]}
-11
View File
@@ -1,11 +0,0 @@
node_modules
build
tmp
debug
.git
.idea
.vscode
.env
.env.*
!.env.example
*.log
+8 -3
View File
@@ -12,9 +12,14 @@ APP_URL=http://${HOST}:${PORT}
# Session # Session
SESSION_DRIVER=cookie SESSION_DRIVER=cookie
# Database (the rebuild's own)
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=usenet_indexer
DB_PASSWORD=
DB_DATABASE=usenet_rebuild
#-------------------------------------------------------------------- #--------------------------------------------------------------------
# CORS (configure allowed origins for API access) # CORS (configure allowed origins for API access)
#-------------------------------------------------------------------- #--------------------------------------------------------------------
# CORS_ORIGIN=http://localhost:5173,http://localhost:3000 # CORS_ORIGIN=http://localhost:5173,http://localhost:3000
# Newznab API key (/api?apikey=...); leave empty to allow access without a key
NEWZNAB_API_KEY=
+1
View File
@@ -1 +1,2 @@
SESSION_DRIVER=memory SESSION_DRIVER=memory
DB_DATABASE=usenet_rebuild_test
+81
View File
@@ -0,0 +1,81 @@
# Tests every push; a push to master that passes is deployed to homelab.
#
# 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),
# which fetches the commit from this repo, builds the image, runs the
# migrations and recreates the app services (never postgres or redis).
name: Test and deploy
on:
push:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: indexer
POSTGRES_PASSWORD: indexer
POSTGRES_DB: usenet_indexer
options: >-
--health-cmd "pg_isready -U indexer -d usenet_indexer"
--health-interval 5s
--health-retries 20
redis:
image: redis:8
env:
TZ: UTC
PORT: 3333
HOST: 0.0.0.0
NODE_ENV: test
LOG_LEVEL: warn
APP_KEY: ci-only-app-key-not-a-secret-000000
APP_URL: http://localhost:3333
SESSION_DRIVER: memory
DB_CONNECTION: pg
DB_HOST: postgres
DB_PORT: 5432
DB_USER: indexer
DB_PASSWORD: indexer
DB_DATABASE: usenet_indexer
REDIS_HOST: redis
REDIS_PORT: 6379
# Tests never connect to a news server.
NNTP_HOST: localhost
NNTP_PORT: 563
NNTP_USER: ci
NNTP_PASSWORD: ci
NNTP_SECURE: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Migrate
run: node ace migration:run --force
- name: Test
run: node ace test
deploy:
needs: test
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Deploy to homelab
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes \
daan@10.206.0.175 "deploy ${{ github.sha }}"
-29
View File
@@ -1,29 +0,0 @@
# syntax=docker/dockerfile:1
# Node is pinned: on 24.21, jsonschema 1.5.0 (used by Ace to validate commands)
# throws "Invalid URL" on $ref, so no ace command starts. See LOG.md.
ARG NODE_VERSION=24.16
# Build stage: dev dependencies, TypeScript compile, native addons.
# The full node image ships python3/make/g++, needed to compile yencode.
FROM node:${NODE_VERSION}-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN node ace build \
&& cd build \
&& npm ci --omit=dev
# Runtime stage: compiled app with production dependencies only.
FROM node:${NODE_VERSION}-bookworm-slim
ENV NODE_ENV=production
# bsdtar (libarchive) unpacks compressed RAR entries, e.g. NZBs posted inside RARs.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libarchive-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/build ./
USER node
# Every service picks its own command, e.g. `node ace worker:fetch`.
CMD ["node", "ace", "list"]
+203
View File
@@ -0,0 +1,203 @@
# Rebuild: header-first indexer (spec, draft for approval)
Branch `rebuild`. Phase 1 finds releases from headers and makes their NZBs.
Everything learned from articles (names from yEnc headers, par2, archive
listings, encryption, spots) is phase 2 and adds to what phase 1 stored; it
never rewrites it. Sources: CONCEPT.md, LOG.md 2026-10-08 (13)–(14).
## Status (2026-10-09)
Built on this branch (a fresh AdonisJS 7 app; phase 1 code imported from
the current app by pipeline stage): coordinator (planner, NNTP pool,
prefetch spool, leases, rule sets, fragment merger, settling), the node API
(lease, framed stream, batches, finish/release/heartbeat), the edge worker
(`edge:node`, `--processes`), the catalog and chunk store, NZBs and a name
search. Measured on the workstation (4 cores, 100 Mbit, Eweka):
- Edge worker, one core: ~210–215 k headers/s on recent a.b.boneless,
~330–340 k on a 2013 a.b.ath range; ~0.7–0.8 GB peak per process.
(Start: 73–103 k/s, 1.0–1.2 GB.) What's left is spread out (the
per-file key ~12%, GC ~10%, zlib ~10%, the scan ~6%).
- NNTP: Eweka takes ~1.5–2 s per XOVER command; ~22–30 k lines/s per
connection in chunks of 100 k when the line allows; connections don't
slow each other.
- Bandwidth: compressed XOVER is ~69 B per article on recent boneless
(100 k-article chunks of ~7 MB). The workstation's 100 Mbit line carries
~140–150 k articles/s, which is where its end-to-end runs now stop (10
connections 90–99% busy, ~89 Mbit/s).
- End to end at that rate (one 4-core machine): workers about half busy
each; server side ~17% (coordinator) + ~8% (web) + ~3% (Postgres) of a
core. (Before this round's fixes Postgres was at 32%: the merger's
neighbour query read every range per file.)
- On homelab (12 cores, 1 Gbit; 2026-10-09, separate containers and
database, `/mnt/bulk/usenet-rebuild`): 20 connections fetched ~630 k
articles/s (~31 k per connection, ~3 s per 100 k chunk, ~250 Mbit/s at
~50 B per article on older boneless) and 8 worker processes indexed all of
it, waiting for the spool (~366 M headers in ~12 minutes). Server side at
that rate: coordinator 0.4–0.5 cores (1.3–1.7 before it stopped
inflating replies to check them), web 0.3–0.45, Postgres (on the HDD)
under 0.15. Worker per core there: ~195 k headers/s (boneless 2026),
~275 k (ath 2013).
- Storage: chunks 17.9–20.3 B per segment (format v2); a release ~1.1 KB.
- For the whole target (528 B overview lines): ~36 TB of compressed XOVER
to download. At 50 connections (~1–1.25 M lines/s, ~0.6–0.7 Gbit/s)
~5–6 days, ~5–6 worker cores, two coordinator shards (one process
handles ~60 MB/s), under a core for web and Postgres. The line is the
limit below ~0.7 Gbit/s: at 1 Gbit, ~3.5 days at best.
Where workers run: total speed is set by the account's NNTP connections
(~30 k articles/s each), not by worker CPU, so remote workers don't make
it faster; they take CPU off homelab (at 1.25 M/s ~7 worker cores) at the
cost of relaying the compressed XOVER (≤ ~0.7 Gbit/s up, homelab has 1
Gbit/s). Plan: coordinator, web, Postgres and a few workers on homelab,
most workers on batcave, a few on poweredge-kantoor with
`edge:node --max-mbit` (e.g. 50 of its 160 Mbit/s ≈ 100–125 k articles/s;
the cap is shared by the node's processes).
Not built yet: re-running ranges for a retracted rule (the counts and rule
versions are recorded), deployment (Dockerfile, compose), phase 2.
## Target
7,000 days (all of Eweka's retention, back to 2008-08) of the 1000 largest
`alt.binaries.*` groups. Estimated: 528 B overview lines; 435 B in their
main group; 140 B kept segments in ~1.25 B files and ~115 M releases after
dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
≥ 25 B overview lines/day (~50 connections), so ~3 weeks.
## Parts
1. **Coordinator** (server). Plans each group's history as ranges of 2 M
articles, leases them to workers, tracks progress and retries. The
group list (owner rule) and the garbage signature table go with every
lease. Fetches XOVER for the workers (relay: workers have no NNTP
account; the gzip stream is passed on as is).
**Prefetch**: the coordinator fetches the next ranges ahead of the
workers, in chunks of 100 k lines and in order, into a spool on disk
(~106 MB per 2 M range at ~53 B per line; on the HDD, written and read
sequentially). It keeps about two ranges ahead per active lease, capped
by size (default 10 GB), and fetches with all its header connections all
the time, independent of how fast workers are. A lease goes to a range
that is already (partly) spooled, else to the next planned one (leased
ranges are fetched first); a worker reads its chunks from the spool,
waiting only for chunks still being fetched. A worker gets its
whole range in one streaming request (`GET /ranges/:id/stream`): the
spooled chunks in order, waiting for those still being fetched. It still
parses chunk by chunk (2 M lines are ~560 MB of text) and posts results
per batch, moving the range's resume point. Chunks matter on the NNTP
side: a compressed XOVER reply can't be resumed, old ranges answer
slowly, and chunks of one range can be fetched in parallel. Spooled chunks are
deleted when their range's results are stored, and fetched again if a
range is leased again later.
Order: oldest era first (2008–2018 holds 63% of the readable files on
17% of the lines), then forward.
One coordinator process handles ~60 MB/s of compressed XOVER (its event
loop was 16.5% busy at ~10 MB/s); beyond that, prefetching is split over
processes with `coordinator:run --shard=i/n` (ranges with id % n = i − 1,
each with its own NNTP_CONNECTIONS; only shard 1 plans, merges and
settles).
2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`,
2026-10-09, after two rounds of work): ~200–215 k headers/s per core on
recent a.b.boneless, ~320–340 k on a 2013 a.b.ath range, ~0.7 GB peak
per process. **Rust** (`tools/edge-bench-rs`, the same RangeIndexer path
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
CPU-s per 2 M headers, ath 2.15 vs 7.5) and a third of the memory
(0.20–0.24 GB). Its gains: no GC, hand-written matching instead of the
regexes, mimalloc, libdeflate (half of zlib's inflate time) and SIMD line
scanning. At the full target (~1.25 M/s) that is ~1.5–2 worker cores
instead of ~6–7; the speed is still set by the NNTP connections. The
cost: a second implementation of lib/headers, lib/release, lib/rules and
the chunk format, kept identical (check.ts) for as long as both exist.
Per lease: compressed chunks of 10–50 k lines, in order →
parse subjects → skip crossposts outside their owner group (of the
indexed groups in the article's Xref, the one with the lowest priority
number; see the group list) → group articles into files → files
into releases (`lib/release` rules) → drop garbage (random name +
dropped signature or tool) → set aside per-article random subjects and
obfuscated subjects (counted only; phase 2). Returns per batch: release
chunks, edge fragments, probe candidates, counters. Holds no state beyond
its lease.
**Probe candidates**: per release, the articles phase 2 should read, as
(release key, kind, message-id, file in its chunk): the first article of
the first archive volume (`.part01.rar`, `.rar`, `.7z.001`), of the par2
index (smallest `.par2`), and of a lone random-named file; for a 7z set
also its last volume (the listing is in the tail). At most a few per
release (~115 M releases → ~150–250 M articles), not one per file. Per-
article and obfuscated subjects get none (they need every article's body).
3. **Chunk store** (bulk, append-only). One record per release per batch:
its files' names, sizes and segment lists, compactly encoded (template
codec for message-ids, zstd). Written once, never updated. A batch is
stored in one transaction and only when it starts at its range's resume
point, so a batch sent twice or late is refused and the catalog's sums
stay right (no per-file dedup); a recovery run for a retracted rule only
adds what that rule had dropped. ~3 TB.
4. **Release catalog** (hot, Postgres on NVMe). One row per release: key,
name, poster, groups, posted_at, file count, size, segments, expected
file count (`[i/N]`), has-par2, readable-name flag, state. Updated with
aggregates once per batch (one upsert per release per batch), plus a
small name index for search. ~115 M rows, ~50–100 GB.
5. **Rules and recovery**. What a worker skips isn't stored (message-ids
are the bulk: garbage alone would be ~2.5 TB); a skipped range is read
again instead (headers are cheap: ~53 B per overview line compressed).
- *Per range, per rule and signature*: how many articles and files the
rule dropped (garbage per (tool, group), per-article random subjects,
obfuscated subjects, and crossposts per owner group). A few rows per
range.
- *Rule versions*: every range records the version of the rule set it ran
with. Retracting or changing a rule makes the ranges where it matched
stale; the coordinator leases them again for that rule only, and the
worker returns just what the rule had dropped.
- *Sample*: ~1% of what each rule drops (by a hash of the release key) is
kept with full file details and probe candidates, so a rule can be
checked, and its losses measured in phase 2, before it is retracted.
6. **Probe queue**. The candidates, in the catalog's database: one small row
each (release, kind, message-id, chunk reference), priority by the
release's state (settled first) and kind (random names before readable
ones). Phase 2 takes them from here; phase 1 only fills it.
7. **Fragments**. Files crossing a range edge: kept by range and article
number; merged when the neighbouring range is done, then written as a
chunk. Never staged per segment.
8. **Settling**. A release is settled when the ranges covering its posting
span are done and no chunk arrived for a day: completeness is computed
then (expected vs found files and segments). Phase 2 picks its work from
settled releases.
9. **NZB and API**. NZB built from a release's chunks; a minimal Newznab
search (names) and get. Web UI later.
## Group list
Editable at any time: an added group gets its ranges planned, a removed one
no new leases. Each group gets a priority number when it is added (the first
list by size, later additions after it); a crosspost belongs to the indexed
group in its Xref with the lowest number. So adding a group never takes
articles from groups already done, and removing one re-reads only the ranges
of other groups whose counts show crossposts skipped for it (part 5). Start:
the 1000 largest `alt.binaries.*` groups by LIST ACTIVE (~266 k ranges of
2 M articles; boneless alone 80 k).
## Not in phase 1
Inspection and archive walks, par2/SFV naming, spots, NZB imports, the
after-probe garbage deletes, live indexing (the current indexer keeps
running and does those meanwhile).
## Decisions
a. Chunk payload: a write-once `bytea` table in Postgres, partitioned by
month, on the HDD; files on disk only if writes still limit. **Accepted.**
b. A separate database on the same server. **Accepted.**
c. Group list: editable, owner by priority number (above). **Accepted.**
d. Reposts: separate releases in phase 1, merged in phase 2. **Accepted.**
e. Headers through the coordinator's relay, with prefetch (part 1).
**Accepted.**
+2 -6
View File
@@ -1,4 +1,4 @@
import { indexEntities } from '@adonisjs/core' import { indexEntities } from '@adonisjs/core/generators'
import { defineConfig } from '@adonisjs/core/app' import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks' import { generateRegistry } from '@tuyau/core/hooks'
@@ -53,7 +53,6 @@ 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')
], ],
/* /*
@@ -104,10 +103,7 @@ export default defineConfig({
| the production build. | the production build.
| |
*/ */
metaFiles: [{ metaFiles: [],
pattern: 'public/**',
reloadServer: false,
}],
hooks: { hooks: {
init: [ init: [
+1 -1
View File
@@ -17,7 +17,7 @@ export default class AccessTokensController {
} }
async destroy({ auth }: HttpContext) { async destroy({ auth }: HttpContext) {
const user = auth.getUserOrFail() const user = auth.use('api').getUserOrFail()
if (user.currentAccessToken) { if (user.currentAccessToken) {
await User.accessTokens.delete(user, user.currentAccessToken.identifier) await User.accessTokens.delete(user, user.currentAccessToken.identifier)
} }
-121
View File
@@ -1,121 +0,0 @@
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
import SearchService from '#services/SearchService'
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 (a Secret from .env, or a plain string set at runtime). */
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 (CONCEPT.md D23) for Prowlarr, Sonarr, Radarr and the like:
* GET /api?t=caps|search|tvsearch|movie|music|book|get&apikey=…
*/
export default class NewznabController {
async handle({ request, response }: HttpContext) {
const qs = request.qs()
const t = String(qs.t ?? '')
const server = this.server(request)
const xml = (body: string, type = 'application/xml') => {
response.header('Content-Type', `${type}; charset=utf-8`)
return response.send(body)
}
const apiKey = configuredApiKey()
if (apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials'))
if (t === 'caps') return xml(capsXml(server, LIMITS))
if (t === 'get') {
const id = int(qs.id)
if (id === undefined) return xml(errorXml(200, 'Missing parameter (id)'))
const nzb = await SearchService.nzb(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'})`))
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, …) are not supported: caps says so, but
// answer with 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
? { total: 0, releases: [] }
: await SearchService.search({
q,
categories,
readable: true,
notIncomplete: true,
hideContainers: true,
maxAgeDays: 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: any) => {
const tv = /(?:^|[._ -])S(\d{1,2})E(\d{1,3})/i.exec(r.name)
return {
id: r.id,
title: r.name,
category: r.category ?? 8010,
size: r.size,
files: r.fileCount,
postedAt: r.postedAt ? new Date(r.postedAt) : null,
poster: r.poster,
groups: r.groups ?? [],
password: Boolean(r.encrypted),
season: tv ? `S${tv[1].padStart(2, '0')}` : undefined,
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
}
})
return xml(resultsXml(server, items, offset, result.total), 'application/rss+xml')
}
private server(request: HttpContext['request']): NewznabServer {
const proto = request.header('x-forwarded-proto') ?? 'http'
const host = request.header('x-forwarded-host') ?? request.header('host') ?? 'localhost'
return {
title: 'Usenet Indexer',
baseUrl: `${proto}://${host}`,
apiKey: configuredApiKey(),
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Readable } from 'node:stream'
import { setTimeout as sleep } from 'node:timers/promises'
import Leases, { LeaseLost } from '#services/leases'
import BatchStore, { StaleBatch } from '#services/batch_store'
import Spool from '#services/spool'
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
/** 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
/**
* The edge workers' API (REBUILD.md, parts 1–2): a lease, the range's
* chunks as one stream, results per batch, and the end of the range.
*/
export default class NodeController {
private node(ctx: HttpContext) {
return Number(ctx.auth.use('node').getUserOrFail().id)
}
async lease(ctx: HttpContext) {
const lease = await Leases.lease(this.node(ctx))
if (!lease) return ctx.response.noContent()
return lease
}
/** The range's chunks from its resume point, waiting for those still being fetched. */
async stream(ctx: HttpContext) {
const nodeId = this.node(ctx)
const rangeId = Number(ctx.params.id)
const range = await this.leased(ctx, rangeId, nodeId)
if (!range) return
const spans = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }, Number(range.next_id))
async function* frames() {
for (const span of spans) {
const waitUntil = Date.now() + CHUNK_WAIT_MS
let quietSince = Date.now()
while (!(await Spool.has(rangeId, span))) {
if (Date.now() > waitUntil) throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} wasn't spooled in time`)
if (Date.now() - quietSince >= KEEPALIVE_MS) {
quietSince = Date.now()
yield encodeFrame(KEEPALIVE)
await Leases.heartbeat(rangeId, nodeId)
}
await sleep(500)
}
yield encodeFrame({ ...span, data: await Spool.read(rangeId, span) })
// Streaming keeps the lease alive.
await Leases.heartbeat(rangeId, nodeId)
}
}
ctx.response.header('Content-Type', 'application/octet-stream')
ctx.response.stream(Readable.from(frames()))
}
async batch(ctx: HttpContext) {
const nodeId = this.node(ctx)
const rangeId = Number(ctx.params.id)
try {
return await BatchStore.store(rangeId, nodeId, fromWire(ctx.request.body() as WireBatch))
} catch (error) {
if (error instanceof StaleBatch) return ctx.response.conflict({ error: error.message })
throw error
}
}
async heartbeat(ctx: HttpContext) {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.heartbeat(rangeId, nodeId))
}
async finish(ctx: HttpContext) {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.finish(rangeId, nodeId))
}
/** POST /ranges/:id/corrupt { from }: a chunk that doesn't inflate; the range goes back. */
async corrupt(ctx: HttpContext) {
const from = Number(ctx.request.input('from'))
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.corrupt(rangeId, nodeId, from))
}
async release(ctx: HttpContext) {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.release(rangeId, nodeId))
}
private async lifecycle(ctx: HttpContext, action: (rangeId: number, nodeId: number) => Promise<void>) {
try {
await action(Number(ctx.params.id), this.node(ctx))
return { ok: true }
} catch (error) {
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
throw error
}
}
private async leased(ctx: HttpContext, rangeId: number, nodeId: number) {
try {
return await Leases.leased(rangeId, nodeId)
} catch (error) {
if (error instanceof LeaseLost) {
ctx.response.conflict({ error: error.message })
return null
}
throw error
}
}
}
+1 -1
View File
@@ -3,6 +3,6 @@ import type { HttpContext } from '@adonisjs/core/http'
export default class ProfileController { export default class ProfileController {
async show({ auth, serialize }: HttpContext) { async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.getUserOrFail())) return serialize(UserTransformer.transform(auth.use('api').getUserOrFail()))
} }
} }
+17 -34
View File
@@ -1,44 +1,27 @@
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import SearchService, { type SortOrder } from '#services/SearchService' import db from '@adonisjs/lucid/services/db'
import NzbService from '#services/nzb_service'
const flag = (value: unknown) => value === '1' || value === 'true' || value === true
/** The catalog (REBUILD.md, part 9): a name search and a release's NZB. */
export default class ReleasesController { export default class ReleasesController {
/** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&source=&poster=&sort=&page=&per_page= */ /** Releases whose name has every word of `q` (dots, dashes, underscores and spaces alike), newest first. */
async index({ request }: HttpContext) { async index({ request }: HttpContext) {
const qs = request.qs() const q = String(request.input('q', '')).trim()
return SearchService.search({ const limit = Math.min(Number(request.input('limit', 50)) || 50, 200)
q: typeof qs.q === 'string' ? qs.q : undefined, const query = db
readable: flag(qs.readable), .from('releases')
complete: flag(qs.complete), .select('id', 'name', 'poster', 'groups', 'posted_at', 'files', 'file_total', 'bytes', 'complete', 'readable')
hideEncrypted: flag(qs.hide_encrypted), .orderBy('posted_at', 'desc')
includePar2Only: flag(qs.par2_only), .limit(limit)
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined, for (const word of q.split(/[\s._-]+/).filter(Boolean)) query.whereILike('name', `%${word.replace(/[\\%_]/g, '\\$&')}%`)
poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined, return { releases: await query }
sort: qs.sort as SortOrder | undefined,
page: Number(qs.page) || 1,
perPage: Number(qs.per_page) || 50,
})
} }
/** GET /api/v1/releases/:id */
async show({ params, response }: HttpContext) {
const release = await SearchService.release(Number(params.id))
return release ?? response.notFound({ error: 'Release not found' })
}
/** GET /api/v1/releases/:id/nzb */
async nzb({ params, response }: HttpContext) { async nzb({ params, response }: HttpContext) {
const nzb = await SearchService.nzb(Number(params.id)) const nzb = await NzbService.build(Number(params.id))
if (!nzb) return response.notFound({ error: 'Release not found' }) if (!nzb) return response.notFound({ error: 'No such release' })
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="${filename}"`) response.header('Content-Disposition', `attachment; filename="${nzb.name.replace(/["\\/]/g, '_')}.nzb"`)
return response.send(nzb.xml) return nzb.xml
}
/** GET /api/v1/stats */
async stats() {
return SearchService.stats()
} }
} }
-4
View File
@@ -1,4 +0,0 @@
import { BaseModel as AdonisBaseModel } from '@adonisjs/lucid/orm'
export default class BaseModel extends AdonisBaseModel {
}
-35
View File
@@ -1,35 +0,0 @@
import { DateTime } from 'luxon'
import { column } from '@adonisjs/lucid/orm'
import BaseModel from '#models/base_model'
export default class File extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column({ columnName: 'file_key' })
declare fileKey: string | null
@column()
declare filename: string
@column()
declare poster: string
@column()
declare date: number
@column()
declare parts: number
@column({ columnName: 'message_ids' })
declare messageIds: Record<string, any>
@column()
declare groups: string[]
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
-32
View File
@@ -1,32 +0,0 @@
import { DateTime } from 'luxon'
import { column } from '@adonisjs/lucid/orm'
import BaseModel from '#models/base_model'
export default class Group extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare active: boolean
/** Every article up to here was fetched successfully (see FetchRangeService). */
@column({ columnName: 'last_indexed_id' })
declare lastIndexedId: bigint
/** Download bodies of obfuscated articles to identify them (bandwidth-heavy). */
@column({ columnName: 'fetch_bodies' })
declare fetchBodies: boolean
/** How far the scheduler has queued fetch jobs. */
@column({ columnName: 'last_queued_id' })
declare lastQueuedId: bigint
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
+8
View File
@@ -0,0 +1,8 @@
import { NodeSchema } from '#database/schema'
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
/** An edge worker (REBUILD.md, part 2); it authenticates with an access token (the `node` guard). */
export default class Node extends NodeSchema {
static accessTokens = DbAccessTokensProvider.forModel(Node, { table: 'node_access_tokens' })
declare currentAccessToken?: AccessToken
}
-160
View File
@@ -1,160 +0,0 @@
import { execFile } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import IngestService from '#services/IngestService'
import { storedEntryData, type RarEntry, type RarInfo } from '#lib/rar/index'
import { fileStem, rarReleaseIdentity } from '#lib/release/index'
/** Stored per file and release; a listing of thousands of files is not useful. */
const MAX_ENTRIES = 500
export interface ArchiveResult {
info: RarInfo
releaseId?: number
releaseRenamed?: string
grouped?: boolean
}
/**
* Records what a RAR volume's headers say: the listing on the file, the
* release it belongs to (from the shared directory or continued entry, when
* the file has none yet), and for first volumes the contents and password
* flag on the release. See CONCEPT.md (D19, D21).
*/
class ArchiveService {
async record(file: any, binary: any, info: RarInfo): Promise<ArchiveResult> {
const stored = { ...info, entries: info.entries.slice(0, MAX_ENTRIES).map(({ stored: _s, dataOffset: _d, ...entry }) => entry) }
await db.from('files').where('id', file.id).update({ archive: JSON.stringify(stored) })
let releaseId = file.release_id ?? binary?.release_id ?? null
let grouped = false
const identity = rarReleaseIdentity(info.entries)
if (!releaseId && identity) {
const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), [
{
release: identity,
poster: binary?.poster ?? file.poster,
weakIdentity: true,
groups: binary?.groups ?? file.groups ?? [],
postedAt: binary?.posted_at ? new Date(binary.posted_at) : undefined,
},
])
releaseId = ids.get(identity.key)!
grouped = true
if (binary) await db.from('binaries').where('id', binary.id).update({ release_id: releaseId })
await db.from('files').where('id', file.id).update({ release_id: releaseId })
}
if (!releaseId) return { info }
const release = await db.from('releases').where('id', releaseId).first()
const update: Record<string, unknown> = {}
if (info.firstVolume !== false) {
update.encrypted = info.headersEncrypted || info.entries.some((e) => e.encrypted)
if (info.entries.length > 0) update.contents = JSON.stringify(stored.entries)
} else if (info.headersEncrypted) {
update.encrypted = true
}
const better = releaseNameFromContents(info)
const renamed = release && better && (looksRandom(release.name) || release.source === 'post') ? better : undefined
if (renamed) update.name = renamed
if (Object.keys(update).length > 0) await db.from('releases').where('id', releaseId).update(update)
if (renamed) logger.info(`Release ${releaseId} renamed "${release.name}" → "${renamed}" from its archive.`)
return { info, releaseId, releaseRenamed: renamed, grouped }
}
}
const run = promisify(execFile)
/** Tools that print one archive entry to stdout, tried in order. */
const EXTRACTORS: [string, (archive: string, entry: string) => string[]][] = [
['bsdtar', (archive, entry) => ['-xOf', archive, entry]],
['unrar', (archive, entry) => ['p', '-inul', '-p-', '--', archive, entry]],
]
/**
* The unpacked data of one entry of a complete (single-volume) archive:
* copied out when stored, else unpacked with bsdtar (libarchive, in the
* Docker image) or unrar. Null when that is not possible.
*/
export async function extractEntry(archive: Buffer, entry: RarEntry, maxBytes = 64 * 1024 * 1024): Promise<Buffer | null> {
const stored = storedEntryData(archive, entry)
if (stored) return stored
if (entry.encrypted || entry.directory || entry.splitBefore || entry.splitAfter) return null
if (entry.size !== null && entry.size > maxBytes) return null
const dir = await mkdtemp(path.join(tmpdir(), 'rar-'))
try {
const file = path.join(dir, 'archive.rar')
await writeFile(file, archive)
for (const [tool, args] of EXTRACTORS) {
try {
const { stdout } = await run(tool, args(file, entry.name), { encoding: 'buffer', maxBuffer: maxBytes, timeout: 60_000 })
if (stdout.length > 0) return stdout
} catch (error: any) {
if (error.code !== 'ENOENT') logger.warn(`${tool} could not extract "${entry.name}": ${error.message}`)
}
}
return null
} finally {
await rm(dir, { recursive: true, force: true })
}
}
/**
* A release name from an archive listing: the top-level directory shared by
* all entries when it reads like a release name, otherwise the stem of the
* largest entry with a readable name (e.g. an archive holding just
* `Joker.Folie.a.Deux.2024.2160p….nzb`). Samples are skipped.
*/
export function releaseNameFromContents(info: Pick<RarInfo, 'entries'>): string | undefined {
const entries = info.entries.filter((e) => !e.directory)
const tops = new Set(info.entries.map((e) => e.name.split(/[\\/]/)[0]))
if (tops.size === 1 && info.entries.some((e) => /[\\/]/.test(e.name))) {
const [top] = tops
if (/[A-Za-z]{2,}.*[._-].*[A-Za-z0-9]{2,}/.test(top) && !looksRandom(top)) return top
}
const candidates = entries
.map((e) => ({ stem: fileStem(e.name.split(/[\\/]/).pop() ?? e.name), size: e.size ?? 0 }))
.filter(({ stem }) => !/(^|[._ -])sample([._ -]|$)/i.test(stem))
.filter(({ stem }) => /[A-Za-z]{2,}.*[._ -].*[A-Za-z0-9]{2,}/.test(stem) && !looksRandom(stem))
.sort((a, b) => b.size - a.size)
return candidates[0]?.stem
}
/**
* Renames random-named releases from their stored archive listing (no
* downloads). Used by `release:rename`.
*/
export async function renameFromStoredContents(): Promise<number> {
const releases = await db
.from('releases')
.whereNotNull('contents')
.whereRaw("jsonb_array_length(contents) > 0")
.select('id', 'name', 'source', 'contents')
let renamed = 0
for (const release of releases) {
if (!looksRandom(release.name) && release.source !== 'post') continue
const better = releaseNameFromContents({ entries: release.contents })
if (!better || better === release.name) continue
await db.from('releases').where('id', release.id).update({ name: better })
renamed++
}
return renamed
}
/** Obfuscated names: one long token of mixed letters and digits, no separators. */
export function looksRandom(name: string): boolean {
// An unknown short extension (e.g. ".bin") says nothing about the name.
name = name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
if (/^[0-9a-f]{16,}$/i.test(name)) return true
if (!/^[A-Za-z0-9]{16,}$/.test(name) || !/[a-z]/.test(name) || !/[A-Z]/.test(name)) return false
// Digits, or many capitals without separators (e.g. RmISakytFwXSCEzjVEpaKk).
return /\d/.test(name) || (name.match(/[A-Z]/g) ?? []).length >= 6
}
export default new ArchiveService()
-187
View File
@@ -1,187 +0,0 @@
import { createHash } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import NntpService from '#services/NntpService'
import Par2Service, { type Par2Result } from '#services/Par2Service'
import ArchiveService, { extractEntry, looksRandom, type ArchiveResult } from '#services/ArchiveService'
import NameService from '#services/NameService'
import { decodeSegment, YencCrcError, YencError, YencFile } from '#lib/yenc/index'
import NzbImportService, { type NzbImportResult } from '#services/NzbImportService'
import { sniffContentType, type ContentType } from '#lib/content/index'
import { parseRarHeaders, type RarEntry, type RarInfo } from '#lib/rar/index'
import { fileStem } from '#lib/release/index'
import { looksLikeFilename } from '#lib/subject/index'
const FIRST_CHUNK = 16 * 1024
/**
* MD5 of the first 16 KiB of a file, as par2 records it, from the body of
* the file's first part. Undefined when that part is shorter than 16 KiB but
* the file is not (the chunk would span two parts).
*/
export function firstChunkMd5(body: Buffer, fileSize: number): string | undefined {
return chunkMd5(decodeSegment(body).data, fileSize)
}
function chunkMd5(data: Buffer, fileSize: number): string | undefined {
if (data.length < Math.min(FIRST_CHUNK, fileSize)) return undefined
return createHash('md5').update(data.subarray(0, FIRST_CHUNK)).digest('hex')
}
export interface ContentResult {
par2?: Par2Result
archive?: ArchiveResult
named: number
contentType: ContentType | null
nzb?: NzbImportResult
/** NZBs read from inside an archive. */
archiveNzbs?: NzbImportResult[]
}
/**
* Takes names and groupings from file contents (CONCEPT.md D21). For a
* completed file with a random name, one article (the first) is enough:
* - its fingerprint (size + first-16-KiB MD5) matches it to a par2 entry,
* - par2 files are recognised by content and name every file of their set,
* - RAR headers give the release grouping, name and contents.
*/
class ContentService {
/** Random or missing names, and named first RAR volumes (for contents and password flag). */
isCandidate(name: string) {
if (!looksLikeFilename(name) || looksRandom(fileStem(name))) return true
if (/\.nzb$/i.test(name)) return true
return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name))
}
/** Downloads a whole (small) file whose first segment is already decoded. */
private async readWhole(first: Buffer, messageIds: Record<string, { id: string; size: number }>): Promise<Buffer | null> {
const parts = Object.entries(messageIds).sort(([a], [b]) => Number(a) - Number(b))
const bytes = parts.reduce((sum, [, p]) => sum + (p.size ?? 0), 0)
if (parts.length > WHOLE_MAX_PARTS || bytes > WHOLE_MAX_BYTES) return null
if (parts.length === 1) return first
let assembled: YencFile | undefined
const conn = await NntpService.acquire()
let failed = false
try {
for (const [, part] of parts) {
const segment = decodeSegment(await NntpService.fetchBody(conn, part.id), { lenientSize: true })
assembled ??= YencFile.fromSegment(segment)
assembled.add(segment)
}
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
return assembled!.getBuffer()
}
/**
* Downloads a small archive that lists NZBs and imports each of them
* (CONCEPT.md D28): NZBs are posted inside RARs too, e.g. for obfuscated
* uploads by other posters.
*/
private async importArchiveNzbs(file: any, first: Buffer): Promise<NzbImportResult[]> {
const archive = await this.readWhole(first, file.message_ids)
const info = archive && parseRarHeaders(archive)
if (!archive || !info) return []
const entries = info.entries.filter(isNzbEntry)
const results: NzbImportResult[] = []
for (const entry of entries) {
const xml = await extractEntry(archive, entry)
if (!xml) continue
const imported = await NzbImportService.import(file, xml.toString('utf8'), {
entryName: entry.name,
moveContainer: entries.length === 1,
})
if (imported) results.push(imported)
}
return results
}
async inspect(fileId: number): Promise<ContentResult | null> {
const file = await db.from('files').where('id', fileId).first()
const binary = await db.from('binaries').where('file_id', fileId).first()
if (!file || !this.isCandidate(file.filename)) return null
const first = file.message_ids?.['1']
if (!first?.id) return null
const conn = await NntpService.acquire()
let failed = false
let body: Buffer
try {
body = await NntpService.fetchBody(conn, first.id)
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
// Some tools declare a size that leaves out a header (nzbe); the CRC still has to match.
let segment
try {
segment = decodeSegment(body)
} catch (error) {
if (!(error instanceof YencError) || error instanceof YencCrcError) throw error
segment = decodeSegment(body, { lenientSize: true })
}
const size = segment.meta.begin.size
const contentType = sniffContentType(segment.data)
await db.from('files').where('id', fileId).update({ content_type: contentType })
// Fingerprint, so par2 sets (now or later) can name this file exactly.
if (binary) {
await db
.from('binaries')
.where('id', binary.id)
.update({
size: db.raw('coalesce(size, ?)', [size]),
md5_16k: db.raw('coalesce(md5_16k, ?)', [chunkMd5(segment.data, size) ?? null]),
})
}
const result: ContentResult = { named: 0, contentType }
if (contentType === 'nzbe' || contentType === 'rclone') {
// An encrypted NZB, or rclone crypt (names and data encrypted): nothing
// to read, and useless without the key.
const releaseId = file.release_id ?? binary?.release_id
if (releaseId) {
await db.from('releases').where('id', releaseId).update({ container: contentType, encrypted: true })
}
return result
}
if (contentType === 'nzb') {
const xml = await this.readWhole(segment.data, file.message_ids)
if (xml) result.nzb = (await NzbImportService.import(file, xml.toString('utf8'))) ?? undefined
return result
}
const set = await Par2Service.read(segment.data, file.message_ids)
if (set && binary) {
result.par2 = await Par2Service.attach(binary, set)
} else {
const info = parseRarHeaders(segment.data)
if (info) {
result.archive = await ArchiveService.record(file, binary, info)
if (hasNzbEntries(info)) result.archiveNzbs = await this.importArchiveNzbs(file, segment.data)
}
}
if (binary) result.named = await NameService.resolve([binary.id])
return result
}
}
/** NZBs that can be read from a single, unencrypted archive. */
function hasNzbEntries(info: RarInfo): boolean {
return !info.volume && !info.headersEncrypted && info.entries.some(isNzbEntry)
}
function isNzbEntry(entry: RarEntry): boolean {
return /\.nzb$/i.test(entry.name) && !entry.encrypted && !entry.directory
}
/** Small posted files (NZBs) that are read completely. */
const WHOLE_MAX_PARTS = 40
const WHOLE_MAX_BYTES = 25 * 1024 * 1024
export default new ContentService()
-264
View File
@@ -1,264 +0,0 @@
import { createHash } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import indexerConfig from '#config/indexer'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import { type OverviewHeader } from '#services/IngestService'
import { decodeSegment, inferTotalParts, parseYencMeta } from '#lib/yenc/index'
import { sniffContentType } from '#lib/content/index'
import { parseRarHeaders } from '#lib/rar/index'
/** Expanding re-reads the file's article range with XOVER; beyond this it is too costly. */
const EXPAND_MAX_RANGE = 200_000
/** A claimed row whose worker died is taken again after this long. */
const RECLAIM_AFTER = '1 hour'
export interface DeferredArticle {
articleNumber: number
header: OverviewHeader
}
/** File name from a deferred uploader's subject: `[i/M] "name" yEnc`, no part counter. */
export function deferredName(subject: string | undefined): string | null {
return /"([^"]+)"/.exec(subject ?? '')?.[1]?.trim() || null
}
export function isDeferred(header: OverviewHeader): boolean {
const id = header['message-id'] ?? ''
return indexerConfig.deferBodyMessageIds.some((re) => re.test(id))
}
/** A recorded range never spans more articles than this (one XOVER to expand it). */
const RANGE_MAX_SPAN = 50_000
export function bodyJob(header: OverviewHeader, group: string, articleNumber: number | bigint) {
return {
name: 'process-body',
data: { header, group, articleNumber: Number(articleNumber) },
// By message-id: an article crossposted to several indexed groups is downloaded once.
opts: { jobId: `body-${createHash('sha1').update(header['message-id'] ?? '').digest('hex')}` },
}
}
/**
* Deferred uploads (CONCEPT.md D28). Their articles skip the body queue and
* are recorded here per file, from the headers alone. `worker:deferred`
* later downloads one body per file for its size, part count and content
* type, and sends small files (NZBs in RARs, par2 index files) to the body
* queue completely.
*/
class DeferredService {
/**
* Records one group's deferred articles: per file when the subject names
* the file, otherwise only as article ranges. Returns the number of files.
*/
async record(groupName: string, articles: DeferredArticle[]): Promise<number> {
const named = articles.filter((a) => deferredName(a.header.subject) !== null)
await this.recordRanges(groupName, articles.filter((a) => deferredName(a.header.subject) === null))
return this.recordFiles(groupName, named)
}
private async recordRanges(groupName: string, articles: DeferredArticle[]) {
if (articles.length === 0) return
const sorted = [...articles].sort((a, b) => Number(a.articleNumber) - Number(b.articleNumber))
const now = new Date()
const rows: Record<string, unknown>[] = []
for (const article of sorted) {
const n = Number(article.articleNumber)
const bytes = Number.parseInt(article.header[':bytes'] ?? '0', 10) || 0
const last = rows.at(-1)
if (last && n - (last.first_article as number) <= RANGE_MAX_SPAN) {
last.last_article = n
;(last.article_count as number)++
last.bytes = (last.bytes as number) + bytes
} else {
rows.push({ group_name: groupName, first_article: n, last_article: n, article_count: 1, bytes, created_at: now })
}
}
await db.table('deferred_ranges').multiInsert(rows)
}
private async recordFiles(groupName: string, articles: DeferredArticle[]): Promise<number> {
const files = new Map<string, {
name: string; subject: string; poster: string; first: number; firstId: string; last: number
count: number; bytes: number; postedAt: Date | null
}>()
for (const { articleNumber, header } of articles) {
const name = deferredName(header.subject)!
const n = Number(articleNumber)
const bytes = Number.parseInt(header[':bytes'] ?? '0', 10) || 0
const date = header.date ? new Date(header.date) : null
const postedAt = date && !Number.isNaN(date.getTime()) ? date : null
const file = files.get(name)
if (!file) {
files.set(name, {
name, subject: header.subject ?? '', poster: header.from ?? '', first: n,
firstId: (header['message-id'] ?? '').replace(/^<|>$/g, ''), last: n, count: 1, bytes, postedAt,
})
continue
}
if (n < file.first) {
file.first = n
file.firstId = (header['message-id'] ?? '').replace(/^<|>$/g, '')
}
file.last = Math.max(file.last, n)
file.count++
file.bytes += bytes
if (postedAt && (!file.postedAt || postedAt < file.postedAt)) file.postedAt = postedAt
}
if (files.size === 0) return 0
const now = new Date()
const rows = [...files.values()].map((f) => ({
file_key: createHash('sha1').update(`deferred\n${f.name}`).digest('hex'),
name: f.name,
subject: f.subject,
poster: f.poster,
group_name: groupName,
first_article: f.first,
first_message_id: f.firstId,
last_article: f.last,
article_count: f.count,
bytes: f.bytes,
posted_at: f.postedAt,
first_seen_at: now,
last_seen_at: now,
}))
// Counts and the article range only add up within the group the file was first seen in.
await db.rawQuery(
`INSERT INTO deferred_files AS d (${Object.keys(rows[0]).join(', ')})
SELECT * FROM jsonb_to_recordset(?::jsonb) AS r(
file_key varchar, name text, subject text, poster text, group_name text, first_article bigint,
first_message_id text, last_article bigint, article_count int, bytes bigint, posted_at timestamptz,
first_seen_at timestamptz, last_seen_at timestamptz)
ON CONFLICT (file_key) DO UPDATE SET
last_seen_at = EXCLUDED.last_seen_at,
first_message_id = CASE WHEN d.group_name = EXCLUDED.group_name AND EXCLUDED.first_article < d.first_article
THEN EXCLUDED.first_message_id ELSE d.first_message_id END,
first_article = CASE WHEN d.group_name = EXCLUDED.group_name THEN LEAST(d.first_article, EXCLUDED.first_article) ELSE d.first_article END,
last_article = CASE WHEN d.group_name = EXCLUDED.group_name THEN GREATEST(d.last_article, EXCLUDED.last_article) ELSE d.last_article END,
article_count = d.article_count + CASE WHEN d.group_name = EXCLUDED.group_name THEN EXCLUDED.article_count ELSE 0 END,
bytes = d.bytes + CASE WHEN d.group_name = EXCLUDED.group_name THEN EXCLUDED.bytes ELSE 0 END,
posted_at = LEAST(d.posted_at, EXCLUDED.posted_at)`,
[JSON.stringify(rows)]
)
return files.size
}
/** Takes up to `limit` files that stopped growing; they are marked `processing`. */
async claim(limit: number): Promise<any[]> {
const settled = new Date(Date.now() - indexerConfig.deferredSettleMs)
const { rows } = await db.rawQuery(
`UPDATE deferred_files SET status = 'processing', processed_at = now()
WHERE id IN (
SELECT id FROM deferred_files
WHERE (status = 'pending' AND last_seen_at < ?)
OR (status = 'processing' AND processed_at < now() - interval '${RECLAIM_AFTER}')
ORDER BY id LIMIT ? FOR UPDATE SKIP LOCKED)
RETURNING *`,
[settled, limit]
)
return rows
}
/**
* Queues the bodies of up to `limit` recorded ranges, oldest first: the
* articles are found again with XOVER. Returns ranges and articles queued.
*/
async expandRanges(limit: number): Promise<{ ranges: number; articles: number }> {
const ranges = await db.from('deferred_ranges').whereNull('expanded_at').orderBy('id').limit(limit)
let articles = 0
for (const range of ranges) {
const conn = await NntpService.acquire()
let failed = false
let overviews: [number, OverviewHeader][]
try {
await conn.group(range.group_name)
overviews = (await conn.xover(String(range.first_article), String(range.last_article))).overviews
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
const jobs = overviews
.filter(([, header]) => header && isDeferred(header) && deferredName(header.subject) === null)
.map(([articleNumber, header]) => bodyJob(header, range.group_name, articleNumber))
for (let i = 0; i < jobs.length; i += 5000) await QueueService.bodyQueue.addBulk(jobs.slice(i, i + 5000))
await db.from('deferred_ranges').where('id', range.id).update({ expanded_at: new Date() })
articles += jobs.length
}
return { ranges: ranges.length, articles }
}
/** Reads one body of the file; expands small files into the body queue. Returns the new status. */
async process(file: any): Promise<string> {
const update: Record<string, unknown> = { processed_at: new Date(), error: null }
try {
const conn = await NntpService.acquire()
let failed = false
let body: Buffer
try {
body = await NntpService.fetchBody(conn, file.first_message_id)
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
const meta = parseYencMeta(body)
const totalParts = inferTotalParts(meta)
Object.assign(update, {
yenc_name: meta.begin.name,
size: meta.begin.size,
total_parts: totalParts,
sampled_part: meta.begin.part ?? 1,
})
if ((meta.begin.part ?? 1) === 1) {
const data = decodeSegment(body, { lenientSize: true, verifyCrc: false }).data
update.content_type = sniffContentType(data)
const rar = parseRarHeaders(data)
if (rar) update.encrypted = rar.headersEncrypted || rar.entries.some((e) => e.encrypted)
}
update.status = 'sampled'
if (totalParts <= indexerConfig.deferredExpandMaxParts && update.encrypted !== true) {
const queued = await this.expand(file)
if (queued > 0) update.status = 'expanded'
}
} catch (error: any) {
update.status = 'failed'
update.error = String(error.message).slice(0, 500)
}
await db.from('deferred_files').where('id', file.id).update(update)
return update.status as string
}
/** Finds the file's articles again (XOVER over its range) and queues their bodies. */
private async expand(file: any): Promise<number> {
const first = Number(file.first_article)
const last = Number(file.last_article)
if (last - first > EXPAND_MAX_RANGE) return 0
const conn = await NntpService.acquire()
let failed = false
let overviews: [number, OverviewHeader][]
try {
await conn.group(file.group_name)
overviews = (await conn.xover(String(first), String(last))).overviews
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
const jobs = overviews
.filter(([, header]) => header && deferredName(header.subject) === file.name)
.map(([articleNumber, header]) => bodyJob(header, file.group_name, articleNumber))
if (jobs.length > 0) await QueueService.bodyQueue.addBulk(jobs)
return jobs.length
}
}
export default new DeferredService()
-115
View File
@@ -1,115 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import QueueService from '#services/QueueService'
/** A failed range is retried after this long... */
const REQUEUE_AFTER_MS = 5 * 60 * 1000
/** ...at most this many times; after that it stays visible as a gap. */
const MAX_REQUEUES = 5
/** A queued range without a job in Redis (e.g. Redis was flushed) is re-queued after this long. */
const LOST_AFTER_MS = 60 * 60 * 1000
interface Group {
id: number
name: string
}
/**
* Tracks the article ranges given to worker:fetch. A range only counts as
* indexed once the worker reports it done, so failed or lost fetch jobs are
* retried instead of silently skipped. See CONCEPT.md (D18).
*/
class FetchRangeService {
async queue(group: Group, startId: bigint, endId: bigint) {
const now = new Date()
const [row] = await db
.table('fetch_ranges')
.insert({ group_id: group.id, start_id: startId.toString(), end_id: endId.toString(), created_at: now, updated_at: now })
.returning(['id', 'requeues'])
await this.addJob(group.name, Number(row.id), 0, startId, endId)
}
private async addJob(groupName: string, rangeId: number, requeues: number, startId: bigint, endId: bigint) {
await QueueService.nntpFetchQueue.add(
'fetch-headers',
{ groupName, startId: startId.toString(), endId: endId.toString(), rangeId },
// The job id changes per attempt: BullMQ ignores an add() for an id it still knows.
{ jobId: this.jobId(rangeId, requeues) }
)
}
private jobId(rangeId: number, requeues: number) {
return `range-${rangeId}-${requeues}`
}
async markDone(rangeId: number) {
await db.from('fetch_ranges').where('id', rangeId).update({ status: 'done', error: null, updated_at: new Date() })
}
async markFailed(rangeId: number, error: string) {
await db.from('fetch_ranges').where('id', rangeId).update({ status: 'failed', error, updated_at: new Date() })
}
/** Re-queues failed ranges, and queued ranges whose job no longer exists. */
async requeue(group: Group): Promise<number> {
const now = Date.now()
const candidates = await db
.from('fetch_ranges')
.where('group_id', group.id)
.where('requeues', '<', MAX_REQUEUES)
.where((q) =>
q
.where((f) => f.where('status', 'failed').where('updated_at', '<', new Date(now - REQUEUE_AFTER_MS)))
.orWhere((l) => l.where('status', 'queued').where('updated_at', '<', new Date(now - LOST_AFTER_MS)))
)
.orderBy('start_id')
let requeued = 0
for (const range of candidates) {
if (range.status === 'queued') {
const job = await QueueService.nntpFetchQueue.getJob(this.jobId(Number(range.id), range.requeues))
const state = job ? await job.getState() : 'missing'
if (state !== 'missing' && state !== 'failed' && state !== 'unknown') continue
}
await db
.from('fetch_ranges')
.where('id', range.id)
.update({ status: 'queued', requeues: range.requeues + 1, updated_at: new Date() })
await this.addJob(group.name, Number(range.id), range.requeues + 1, BigInt(range.start_id), BigInt(range.end_id))
requeued++
logger.warn(
`Re-queued ${group.name} ${range.start_id}-${range.end_id} (attempt ${range.requeues + 2}): ${range.error ?? 'job lost'}`
)
}
return requeued
}
/**
* Moves `groups.last_indexed_id` up to just below the first range that is
* not done yet, and forgets done ranges. Returns the new watermark.
*/
async advanceWatermark(group: Group): Promise<bigint> {
const { rows } = await db.rawQuery(
`UPDATE groups g SET last_indexed_id = greatest(g.last_indexed_id, coalesce(
(SELECT min(start_id) - 1 FROM fetch_ranges WHERE group_id = g.id AND status <> 'done'),
g.last_queued_id))
WHERE g.id = ? RETURNING last_indexed_id`,
[group.id]
)
await db.from('fetch_ranges').where('group_id', group.id).where('status', 'done').delete()
return BigInt(rows[0].last_indexed_id)
}
/** Ranges that failed for good (for monitoring). */
async abandoned(group: Group) {
return db
.from('fetch_ranges')
.where('group_id', group.id)
.where('status', 'failed')
.where('requeues', '>=', MAX_REQUEUES)
.count('* as count')
.first()
}
}
export default new FetchRangeService()
-471
View File
@@ -1,471 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import type { QueryClientContract } from '@adonisjs/lucid/types/database'
import type { Knex } from 'knex'
import QueueService from '#services/QueueService'
import NameService from '#services/NameService'
import { parseXrefGroups } from '#lib/nntp/xref'
import { fileKey, parseSubject, weakFileKey } from '#lib/subject/index'
import { releaseIdentity, type ReleaseIdentity } from '#lib/release/index'
import { inferTotalParts, type YencMeta } from '#lib/yenc/index'
/** One segment, with the identity of the file it belongs to already resolved. */
export interface IngestSegment {
fileKey: string
poster: string
/** Subject without segment counter. */
subject: string
name: string
totalParts: number
number: number
/** Without angle brackets. */
messageId: string
bytes: number
articleNumber?: bigint | number
postedAt?: Date
groups: string[]
/**
* The identity comes from the yEnc name only (obfuscated posts). Such names
* can be shared by several equal-sized files of one post, so a second
* article for the same part number marks the binary as conflicted.
*/
weakIdentity?: boolean
/** Release (download) this file belongs to, when it can be determined. */
release?: ReleaseIdentity | null
/** Exact file size from =ybegin (body-derived segments only). */
size?: number
/** yEnc CRCs (body-derived segments only), to verify split volumes. */
pcrc32?: string
partSize?: number
fileCrc32?: string
/** Part 1 only: MD5 of the first 16 KiB of the file, as par2 records it. */
md5_16k?: string
}
export interface CompletedBinary {
id: number
fileKey: string
}
const BINARY_CHUNK = 1000
/** last_seen_at is only rewritten when it is older than this (it drives GC at 48 h). */
const TOUCH_MS = 10 * 60 * 1000
type Merge = 'least' | 'coalesce' | 'touch'
/**
* Inserts rows keyed by `key` and returns their ids. Existing rows are not
* locked unless something in them changes (`least`: an earlier value,
* `coalesce`: a value where there was none, `touch`: a timestamp more than
* TOUCH_MS newer). Parallel fetch jobs see mostly the same rows, and
* `INSERT … ON CONFLICT DO UPDATE` made them wait on each other for every one.
*/
async function upsertKeyed(
knex: Knex,
table: 'binaries' | 'releases',
key: string,
rows: Record<string, unknown>[],
merge: Record<string, [Merge, string]>
): Promise<Map<string, number>> {
const ids = new Map<string, number>()
if (rows.length === 0) return ids
const inserted: Record<string, any>[] = await knex(table).insert(rows).onConflict(key).ignore().returning(['id', key])
for (const row of inserted) ids.set(row[key], Number(row.id))
const incoming = new Map(rows.filter((r) => !ids.has(r[key] as string)).map((r) => [r[key] as string, r]))
if (incoming.size === 0) return ids
const columns = Object.keys(merge)
const existing: Record<string, any>[] = await knex(table).whereIn(key, [...incoming.keys()]).select('id', key, ...columns)
const changes: Record<string, unknown>[] = []
for (const row of existing) {
ids.set(row[key], Number(row.id))
const next = incoming.get(row[key])!
const changed = columns.some((column) => {
const [kind] = merge[column]
const current = row[column]
const value = next[column]
if (value === null || value === undefined) return false
if (kind === 'coalesce') return current === null
if (kind === 'least') return current === null || value < current
return current === null || +(value as Date) - +current > TOUCH_MS
})
if (changed) changes.push({ id: Number(row.id), ...Object.fromEntries(columns.map((c) => [c, next[c] ?? null])) })
}
if (changes.length === 0) return ids
const set = columns
.map((c) => {
const [kind] = merge[c]
const fn = kind === 'least' ? 'least' : kind === 'touch' ? 'greatest' : 'coalesce'
return `${c} = ${fn}(t.${c}, v.${c})`
})
.join(', ')
const types = columns.map((c) => `${c} ${merge[c][1]}`).join(', ')
changes.sort((a, b) => (a.id as number) - (b.id as number))
for (let attempt = 1; ; attempt++) {
try {
await knex.raw(
`UPDATE ${table} t SET ${set}
FROM jsonb_to_recordset(?::jsonb) AS v(id bigint, ${types})
WHERE t.id = v.id`,
[JSON.stringify(changes)]
)
return ids
} catch (error: any) {
// Rare now, but two jobs can still update overlapping rows in a different order.
if (error.code !== '40P01' || attempt >= 3) throw error
}
}
}
/** Size-only matching below this is too ambiguous (nfo, sfv, small volumes). */
export const PAR2_SIZE_ONLY_MIN = 1024 * 1024
/** A par2 set and its files are posted together; don't match across days. */
export const PAR2_MATCH_WINDOW_SECONDS = 12 * 60 * 60
const PART_CHUNK = 2000
function chunks<T>(items: T[], size: number): T[][] {
const result: T[][] = []
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
return result
}
function validDate(date?: Date) {
return date && !Number.isNaN(date.getTime()) ? date : null
}
/** An XOVER overview entry as returned by nntp-js. */
export type OverviewHeader = Record<string, string>
function common(header: OverviewHeader, groupName: string, articleNumber?: bigint | number) {
const xrefGroups = parseXrefGroups(header.xref)
return {
poster: header.from ?? '',
messageId: (header['message-id'] ?? '').replace(/^<|>$/g, ''),
bytes: Number.parseInt(header[':bytes'] ?? '0', 10) || 0,
articleNumber,
postedAt: header.date ? new Date(header.date) : undefined,
groups: xrefGroups.length > 0 ? xrefGroups : [groupName],
}
}
/** Builds a segment from the subject line; null when the subject is obfuscated. */
export function segmentFromHeader(
header: OverviewHeader,
groupName: string,
articleNumber?: bigint | number
): IngestSegment | null {
const parsed = header.subject ? parseSubject(header.subject) : null
if (!parsed || !header['message-id']) return null
const base = common(header, groupName, articleNumber)
return {
...base,
fileKey: fileKey(base.poster, parsed.base, parsed.totalSegments),
subject: parsed.base,
name: parsed.name,
totalParts: parsed.totalSegments,
number: parsed.segment,
release: releaseIdentity({ poster: base.poster, subject: parsed.base, name: parsed.name, weak: false }),
}
}
/**
* Builds a segment for an obfuscated post from the yEnc header in its body.
* Subject and poster can be random per article, so the identity comes from
* the yEnc header only (see weakFileKey).
*/
export function segmentFromYenc(
header: OverviewHeader,
groupName: string,
meta: Pick<YencMeta, 'begin' | 'part'> & Partial<Pick<YencMeta, 'end'>>,
articleNumber?: bigint | number
): IngestSegment | null {
const base = common(header, groupName, articleNumber)
const totalParts = inferTotalParts(meta)
const number = meta.begin.part ?? 1
if (!base.messageId || number < 1 || number > totalParts) return null
const subject = `"${meta.begin.name}" yEnc`
return {
...base,
fileKey: weakFileKey(meta.begin.name, meta.begin.size, totalParts),
subject,
name: meta.begin.name,
totalParts,
number,
weakIdentity: true,
release: releaseIdentity({ poster: base.poster, subject, name: meta.begin.name, weak: true }),
size: meta.begin.size,
// Single-part posts only carry crc32, which is then also the part's CRC.
pcrc32: meta.end?.pcrc32 ?? (meta.part ? undefined : meta.end?.crc32),
partSize: meta.part ? meta.part.end - meta.part.begin + 1 : meta.begin.size,
fileCrc32: meta.end?.crc32,
}
}
/**
* Stores segments in `binaries`/`parts` and queues every binary that became
* complete exactly once. Idempotent: ingesting the same segments again is a
* no-op. See CONCEPT.md (D12).
*/
class IngestService {
async ingest(segments: IngestSegment[]): Promise<CompletedBinary[]> {
if (segments.length === 0) return []
// Short statements, no enclosing transaction: every step is idempotent, and
// row locks held for a whole batch made parallel fetch jobs wait on each other.
const binaryIds = await (async () => {
const knex = db.connection().getWriteClient()
const now = new Date()
const releaseIds = await this.upsertReleases(knex, segments, now)
// One row per file_key (ON CONFLICT cannot touch a row twice per statement),
// sorted so concurrent workers lock rows in the same order and cannot deadlock.
const binaries = new Map<string, Record<string, unknown>>()
for (const segment of segments) {
const postedAt = validDate(segment.postedAt)
const existing = binaries.get(segment.fileKey)
if (existing) {
if (postedAt && (!existing.posted_at || postedAt < (existing.posted_at as Date))) {
existing.posted_at = postedAt
}
continue
}
binaries.set(segment.fileKey, {
file_key: segment.fileKey,
poster: segment.poster,
subject: segment.subject,
name: segment.name,
total_parts: segment.totalParts,
groups: JSON.stringify(segment.groups),
posted_at: postedAt,
first_seen_at: now,
last_seen_at: now,
release_id: segment.release ? releaseIds.get(segment.release.key) : null,
size: segment.size ?? null,
md5_16k: segment.md5_16k ?? null,
})
}
const binaryRows = [...binaries.values()].sort((a, b) =>
(a.file_key as string).localeCompare(b.file_key as string)
)
const idsByKey = new Map<string, number>()
for (const chunk of chunks(binaryRows, BINARY_CHUNK)) {
const ids = await upsertKeyed(knex, 'binaries', 'file_key', chunk, {
last_seen_at: ['touch', 'timestamptz'],
posted_at: ['least', 'timestamptz'],
release_id: ['coalesce', 'bigint'],
md5_16k: ['coalesce', 'varchar'],
})
for (const [key, id] of ids) idsByKey.set(key, id)
}
const partRows = segments
.map((segment) => ({
binary_id: idsByKey.get(segment.fileKey)!,
number: segment.number,
message_id: segment.messageId,
bytes: segment.bytes,
article_number: segment.articleNumber?.toString() ?? null,
pcrc32: segment.pcrc32 ?? null,
part_size: segment.partSize ?? null,
file_crc32: segment.fileCrc32 ?? null,
md5_16k: segment.md5_16k ?? null,
}))
const weakRows = partRows.filter((_, i) => segments[i].weakIdentity)
partRows.sort((a, b) => a.binary_id - b.binary_id || a.number - b.number)
for (const chunk of chunks(partRows, PART_CHUNK)) {
await knex('parts').insert(chunk).onConflict(['binary_id', 'number']).ignore()
}
if (weakRows.length > 0) await this.flagConflicts(db.connection(), weakRows)
return [...idsByKey.values()]
})()
// par2 naming needs a size, which only body-derived segments have; for
// header batches these queries were most of Postgres' work.
const sized = [...new Set(segments.filter((s) => s.size != null).map((s) => s.fileKey))]
if (sized.length > 0) {
const sizedIds = (await db.from('binaries').whereIn('file_key', sized).select('id')).map((r) => Number(r.id))
await this.matchPar2Releases(sizedIds)
await NameService.resolve(sizedIds)
}
// After commit, so the last writer of a binary always sees all of its parts.
const completed = await this.claimCompleted(binaryIds)
await this.enqueue(completed)
return completed
}
/**
* Creates or touches the releases referenced by `segments` and returns
* their ids by release key. Also used by `release:rebuild`.
*/
async upsertReleases(
knex: Knex,
segments: Pick<IngestSegment, 'release' | 'poster' | 'groups' | 'postedAt' | 'weakIdentity'>[],
now = new Date()
): Promise<Map<string, number>> {
const releases = new Map<string, Record<string, unknown>>()
for (const segment of segments) {
const release = segment.release
if (!release) continue
const postedAt = validDate(segment.postedAt)
const existing = releases.get(release.key)
if (existing) {
if (postedAt && (!existing.posted_at || postedAt < (existing.posted_at as Date))) {
existing.posted_at = postedAt
}
// Not every file carries the [i/M] counter (e.g. a par2 index): take it from any that does.
if (existing.file_total === null && release.fileTotal) existing.file_total = release.fileTotal
continue
}
releases.set(release.key, {
release_key: release.key,
name: release.name,
// Obfuscating posters randomise the poster, so it says nothing about the release.
poster: segment.weakIdentity ? null : segment.poster,
file_total: release.fileTotal ?? null,
source: release.source,
groups: JSON.stringify(segment.groups),
posted_at: postedAt,
first_seen_at: now,
last_seen_at: now,
})
}
const ids = new Map<string, number>()
const rows = [...releases.values()].sort((a, b) =>
(a.release_key as string).localeCompare(b.release_key as string)
)
for (const chunk of chunks(rows, BINARY_CHUNK)) {
const result = await upsertKeyed(knex, 'releases', 'release_key', chunk, {
last_seen_at: ['touch', 'timestamptz'],
posted_at: ['least', 'timestamptz'],
file_total: ['coalesce', 'integer'],
source: ['coalesce', 'varchar'],
})
for (const [key, id] of result) ids.set(key, id)
}
return ids
}
/**
* Assigns body-derived binaries without a release and without a
* fingerprint to a known par2 set by size: only when that size is listed
* once in the set, the file is large, and exactly one set nearby lists it.
* Binaries with a fingerprint are matched exactly by NameService instead.
*/
async matchPar2Releases(binaryIds: number[]) {
if (binaryIds.length === 0) return
await db.rawQuery(
`UPDATE binaries b SET release_id = m.release_id
FROM (
SELECT b2.id, min(rf.release_id) AS release_id
FROM binaries b2
JOIN release_files rf ON rf.size = b2.size
JOIN releases r ON r.id = rf.release_id
WHERE b2.id = ANY(?) AND b2.release_id IS NULL AND b2.md5_16k IS NULL AND b2.size >= ?
AND abs(extract(epoch FROM (r.posted_at - b2.posted_at))) < ?
AND (SELECT count(*) FROM release_files x WHERE x.release_id = rf.release_id AND x.size = rf.size) = 1
GROUP BY b2.id
HAVING count(DISTINCT rf.release_id) = 1
) m
WHERE b.id = m.id`,
[binaryIds, PAR2_SIZE_ONLY_MIN, PAR2_MATCH_WINDOW_SECONDS]
)
}
/**
* Flags binaries where a stored part has a different message-id and a
* different part CRC than the incoming one: two files were merged under
* one identity. Identical reposts (same CRC) are ignored.
*/
private async flagConflicts(
trx: QueryClientContract,
rows: {
binary_id: number
number: number
message_id: string
bytes: number
article_number: string | null
pcrc32: string | null
part_size: number | null
file_crc32: string | null
md5_16k: string | null
}[]
) {
const bindings = [
rows.map((r) => r.binary_id),
rows.map((r) => r.number),
rows.map((r) => r.message_id),
rows.map((r) => r.bytes),
rows.map((r) => r.article_number),
rows.map((r) => r.pcrc32),
rows.map((r) => r.part_size),
rows.map((r) => r.file_crc32),
rows.map((r) => r.md5_16k),
]
// Keep the extra articles: SplitService separates the volumes from them.
const conflicts = await trx.rawQuery(
`INSERT INTO part_conflicts (binary_id, number, message_id, bytes, article_number, pcrc32, part_size, file_crc32, md5_16k)
SELECT v.binary_id, v.number, v.message_id, v.bytes, v.article_number, v.pcrc32, v.part_size, v.file_crc32, v.md5_16k
FROM unnest(?::bigint[], ?::int[], ?::text[], ?::int[], ?::bigint[], ?::text[], ?::int[], ?::text[], ?::text[])
AS v(binary_id, number, message_id, bytes, article_number, pcrc32, part_size, file_crc32, md5_16k)
JOIN parts p ON p.binary_id = v.binary_id AND p.number = v.number
WHERE p.message_id <> v.message_id
-- Same part CRC: the same data posted again (a repost), not another file.
AND (p.pcrc32 IS NULL OR v.pcrc32 IS NULL OR p.pcrc32 <> v.pcrc32)
ON CONFLICT DO NOTHING
RETURNING binary_id`,
bindings as any
)
const binaryIds: number[] = [
...new Set<number>(conflicts.rows.map((r: { binary_id: string }) => Number(r.binary_id))),
]
if (binaryIds.length === 0) return
const result = await trx.rawQuery(
`UPDATE binaries SET conflicted_at = now()
WHERE conflicted_at IS NULL AND id = ANY(?) RETURNING name`,
[binaryIds]
)
for (const row of result.rows) {
logger.warn(`Binary "${row.name}" has conflicting parts; it will be split into volumes.`)
}
}
/**
* Marks complete binaries among `binaryIds` as claimed. The single UPDATE
* locks each row, so concurrent callers never claim the same binary twice.
*/
async claimCompleted(binaryIds: number[]): Promise<CompletedBinary[]> {
if (binaryIds.length === 0) return []
const result = await db.rawQuery(
`UPDATE binaries b SET completed_at = now()
WHERE b.id = ANY(?) AND b.completed_at IS NULL AND b.conflicted_at IS NULL
AND (SELECT count(*) FROM parts p WHERE p.binary_id = b.id) >= b.total_parts
RETURNING b.id, b.file_key`,
[binaryIds]
)
return result.rows.map((row: { id: string; file_key: string }) => ({
id: Number(row.id),
fileKey: row.file_key,
}))
}
async enqueue(completed: CompletedBinary[]) {
if (completed.length === 0) return
// jobId = file_key: BullMQ ignores an add() for an id that already exists.
await QueueService.fileQueue.addBulk(
completed.map(({ id, fileKey }) => ({
name: 'process-file',
data: { binaryId: id },
opts: { jobId: fileKey },
}))
)
}
}
export default new IngestService()
-128
View File
@@ -1,128 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import { looksRandom } from '#services/ArchiveService'
/**
* A release that is nothing more than one randomly named file (its own
* filename stem). Such a file moves into the par2 set that names it exactly;
* real groupings (par2, RAR, NZB, spot, subject, post id) are left alone.
*/
export const WEAK_RELEASE_SQL = (column: string) =>
`EXISTS (SELECT 1 FROM releases wr WHERE wr.id = ${column} AND wr.source IN ('stem', 'weak-stem')
AND wr.file_count <= 1 AND (wr.name !~ '[._ -]' OR NOT is_readable_name(wr.name)))`
/**
* Whether `candidate` (e.g. from a par2 listing) should replace `current`:
* the current name is random, or a single token without separators
* (mOzyyxCyYUj, F8lv3w5agMDI3; too short for looksRandom), while the
* candidate reads like a release name.
*/
export function isBetterName(current: string, candidate: string): boolean {
if (looksRandom(candidate) || !/[._ -]/.test(candidate)) return false
return looksRandom(current) || !/[._ -]/.test(current)
}
/** Smaller files (nfo, sfv) collide too easily on size alone. */
const MIN_SIZE_ONLY = 64 * 1024
/**
* Gives binaries (and their files) the real filename that a par2 set lists
* for them. See CONCEPT.md (D21).
* - Exact: size and MD5 of the first 16 KiB both match a par2 entry. This
* also puts the binary in that par2 set's release.
* - Size only, when the fingerprint is unknown: within the binary's own
* release, only when exactly one listed file has that size.
*/
class NameService {
async resolve(binaryIds: number[]): Promise<number> {
if (binaryIds.length === 0) return 0
const exact = await db.rawQuery(
`UPDATE binaries b SET real_name = m.name,
release_id = CASE WHEN b.release_id IS NULL OR ${WEAK_RELEASE_SQL('b.release_id')} THEN m.release_id ELSE b.release_id END
FROM (
SELECT DISTINCT ON (b2.id) b2.id, rf.name, rf.release_id
FROM binaries b2
JOIN release_files rf ON rf.size = b2.size AND rf.md5_16k = b2.md5_16k
WHERE b2.id = ANY(?)
ORDER BY b2.id, rf.release_id DESC
) m
WHERE b.id = m.id AND (b.real_name IS DISTINCT FROM m.name OR b.release_id IS NULL
OR (b.release_id <> m.release_id AND ${WEAK_RELEASE_SQL('b.release_id')}))`,
[binaryIds]
)
const bySize = await db.rawQuery(
`UPDATE binaries b SET real_name = m.name
FROM (
SELECT b2.id, min(rf.name) AS name
FROM binaries b2
JOIN release_files rf ON rf.release_id = b2.release_id AND rf.size = b2.size
WHERE b2.id = ANY(?) AND b2.real_name IS NULL AND b2.md5_16k IS NULL AND b2.size >= ?
GROUP BY b2.id
HAVING count(DISTINCT rf.name) = 1
) m
WHERE b.id = m.id`,
[binaryIds, MIN_SIZE_ONLY]
)
await db.rawQuery(
`UPDATE files f SET real_name = b.real_name,
release_id = CASE WHEN f.release_id IS NULL OR ${WEAK_RELEASE_SQL('f.release_id')} THEN coalesce(b.release_id, f.release_id) ELSE f.release_id END
FROM binaries b
WHERE b.id = ANY(?) AND b.file_id = f.id
AND (f.real_name IS DISTINCT FROM b.real_name OR (f.release_id IS NULL AND b.release_id IS NOT NULL)
OR (f.release_id <> b.release_id AND ${WEAK_RELEASE_SQL('f.release_id')}))`,
[binaryIds]
)
return (exact.rowCount ?? 0) + (bySize.rowCount ?? 0)
}
/**
* When a par2 set arrives: the binaries it can name, i.e. those matching
* one of its entries exactly (size + first-16-KiB MD5) and those in its
* release. Selecting every binary of the listed sizes instead picked up
* hundreds of thousands of equal-sized volumes per set (LOG.md 2026-09-28 (18)).
*/
async resolveForRelease(releaseId: number): Promise<number> {
const { rows } = await db.rawQuery(
`SELECT b.id FROM release_files rf
JOIN binaries b ON b.md5_16k = rf.md5_16k AND b.size = rf.size
WHERE rf.release_id = ?
UNION
SELECT id FROM binaries WHERE release_id = ? AND size IS NOT NULL`,
[releaseId, releaseId]
)
return this.resolve(rows.map((r: { id: string }) => Number(r.id)))
}
}
/**
* Repair for data from before files moved into the par2 set that names them
* (used by `release:rename`): regroups exact matches out of random-named
* single-file releases, then renames par2 releases that still carry a
* random name after their listing. No downloads.
*/
export async function regroupAndRenameFromPar2(): Promise<{ regrouped: number; renamed: number }> {
const { rows } = await db.rawQuery(
`SELECT DISTINCT b.id FROM binaries b
JOIN release_files rf ON rf.md5_16k = b.md5_16k AND rf.size = b.size
WHERE b.release_id IS DISTINCT FROM rf.release_id
AND (b.release_id IS NULL OR ${WEAK_RELEASE_SQL('b.release_id')})`
)
const ids = rows.map((r: { id: string }) => Number(r.id))
for (let i = 0; i < ids.length; i += 1000) await nameService.resolve(ids.slice(i, i + 1000))
const { par2ReleaseName } = await import('#lib/release/index')
const listings = await db.rawQuery(
`SELECT r.id, r.name, array_agg(rf.name) AS names FROM releases r
JOIN release_files rf ON rf.release_id = r.id GROUP BY r.id, r.name`
)
let renamed = 0
for (const release of listings.rows) {
const better = par2ReleaseName(release.names)
if (!better || better === release.name || !isBetterName(release.name, better)) continue
await db.from('releases').where('id', release.id).update({ name: better })
renamed++
}
return { regrouped: ids.length, renamed }
}
const nameService = new NameService()
export default nameService
-119
View File
@@ -1,119 +0,0 @@
import nntpConfig from '#config/nntp'
import { NNTP } from "nntp-js";
import { parseGroupResponse, type GroupInfo } from '#lib/nntp/group'
class NntpService {
private readonly poolSize: number;
private allConnections: Set<any>;
private idleConnections: any[];
private waiters: ((conn: any) => void)[];
private createdCount: number;
constructor(poolSize = 10) {
this.poolSize = poolSize;
this.allConnections = new Set();
this.idleConnections = [];
this.waiters = [];
this.createdCount = 0;
}
private async _createConnection() {
const conn = new NNTP(nntpConfig.host, nntpConfig.port);
try {
await conn.connect();
if (nntpConfig.user) {
await conn.login(nntpConfig.user, nntpConfig.password?.release());
}
} catch (error) {
// Free the slot, otherwise a failed connect shrinks the pool for good.
this.createdCount--;
this._destroy(conn);
throw error;
}
this.allConnections.add(conn);
return conn;
}
public async acquire() {
if (this.idleConnections.length > 0) {
return this.idleConnections.pop();
}
if (this.createdCount < this.poolSize) {
this.createdCount++;
console.log(`Creating new connection (${this.createdCount}/${this.poolSize}).`);
return this._createConnection();
}
return new Promise(resolve => this.waiters.push(resolve));
}
/**
* Returns a connection to the pool. Pass `failed` when a command on it
* threw: the connection may be dead (e.g. closed by the server while idle),
* so it is discarded instead of being handed to the next job.
*/
public release(conn: any, failed = false) {
if (failed) {
this.discard(conn);
return;
}
const resolve = this.waiters.shift();
if (resolve) {
resolve(conn);
} else {
this.idleConnections.push(conn);
}
}
/** Closes a broken connection and frees its slot for a new one. */
public discard(conn: any) {
this.allConnections.delete(conn);
this.createdCount--;
this._destroy(conn);
// Someone is waiting for a slot: open a fresh connection for them.
const resolve = this.waiters.shift();
if (resolve) {
this.createdCount++;
this._createConnection().then(resolve, () => this.waiters.unshift(resolve));
}
}
private _destroy(conn: any) {
try {
conn.sock?.destroy();
} catch {
// already closed
}
}
/** Selects a group and returns its correctly parsed article range. */
public async selectGroup(conn: any, name: string): Promise<GroupInfo> {
const { resp } = await conn.group(name)
return parseGroupResponse(resp)
}
/** Fetches a raw (still dot-stuffed) article body as a Buffer. */
public async fetchBody(conn: any, messageId: string): Promise<Buffer> {
const id = messageId.startsWith('<') ? messageId : `<${messageId}>`
const { data } = await conn.body(id)
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
}
public async shutdown() {
console.log('Shutting down all connections in the pool.');
// A connection the server already closed may never answer QUIT.
const quit = (conn: any) =>
Promise.race([conn.quit(), new Promise((resolve) => setTimeout(resolve, 2000).unref())])
.catch(() => {})
.finally(() => this._destroy(conn));
await Promise.all([...this.allConnections].map(quit));
this.allConnections.clear();
this.idleConnections.length = 0;
this.waiters.length = 0;
this.createdCount = 0;
}
}
export default new NntpService(nntpConfig.poolSize);
-168
View File
@@ -1,168 +0,0 @@
import { createHash } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import IngestService from '#services/IngestService'
import { type ReleaseIdentity } from '#lib/release/index'
import { parseNzb, type ParsedNzb } from '#lib/nzb/index'
import { parseSubject } from '#lib/subject/index'
import { fileStem } from '#lib/release/index'
import { looksRandom } from '#services/ArchiveService'
export interface NzbImportResult {
releaseId: number
releaseName: string
listed: number
known: number
added: number
password: boolean
}
/** Filename of an NZB entry, from its subject (tolerating odd quoting). */
export function nzbFileName(subject: string): string {
const parsed = parseSubject(subject)
const name = parsed?.name ?? /"([^"]+)"/.exec(subject)?.[1] ?? subject
return name.trim().replace(/^"+|"+$/g, '').trim() || subject.trim()
}
export interface NzbImportOptions {
/**
* For NZBs inside an archive: the entry's name. It keeps several NZBs of
* one archive apart and names their release when the NZB has no name.
*/
entryName?: string
/** Move the posted file into the release the NZB describes (default true). */
moveContainer?: boolean
/** A name given with the NZB (e.g. a Spotnet title); wins over the NZB's own. */
name?: string
/** Release source for new releases (default `nzb`). */
source?: ReleaseIdentity['source']
}
/**
* Reads an NZB that was posted to Usenet into the index (CONCEPT.md D26).
* Files it lists that are already known (by first segment) keep their
* release, which the NZB then joins; the others are added as files, so posts
* spread over groups we do not index still become complete.
*/
class NzbImportService {
async import(nzbFile: any, xml: string, options: NzbImportOptions = {}): Promise<NzbImportResult | null> {
const nzb = parseNzb(xml)
if (!nzb || nzb.files.length === 0) return null
const entries = nzb.files.map((f) => ({
file: f,
name: nzbFileName(f.subject),
firstId: f.segments[0].id,
}))
return db.transaction(async (trx) => {
const known = await trx
.from('files')
.whereRaw(`(message_ids->'1'->>'id') = ANY(?)`, [entries.map((e) => e.firstId)])
.select('id', 'release_id', `message_ids`)
const knownIds = new Set(known.map((f) => f.message_ids?.['1']?.id))
// Join the release the listed files are already in, else the posted NZB's own, else a new one.
const counts = new Map<number, number>()
for (const f of known) if (f.release_id) counts.set(Number(f.release_id), (counts.get(Number(f.release_id)) ?? 0) + 1)
let releaseId: number | undefined = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0]
const title = options.name?.trim() || this.title(nzb, nzbFile, entries.map((e) => e.name), options.entryName)
if (!releaseId) {
const origin = `nzb\n${nzbFile.message_ids?.['1']?.id ?? nzbFile.id}`
const key = createHash('sha1').update(options.entryName ? `${origin}\n${options.entryName}` : origin).digest('hex')
const ids = await IngestService.upsertReleases(trx.knexClient, [
{
release: { key, name: title, source: options.source ?? 'nzb' },
poster: nzbFile.poster,
groups: nzbFile.groups ?? [],
postedAt: nzbFile.date ? new Date(Number(nzbFile.date)) : undefined,
},
])
releaseId = ids.get(key)!
}
const now = new Date()
const missing = entries.filter((e) => !knownIds.has(e.firstId))
for (let i = 0; i < missing.length; i += 500) {
await trx
.knexClient('files')
.insert(
missing.slice(i, i + 500).map(({ file, name, firstId }) => ({
file_key: createHash('sha1').update(`nzb-file\n${firstId}`).digest('hex'),
filename: name,
poster: file.poster || nzbFile.poster,
date: (file.date || Math.floor(Number(nzbFile.date) / 1000)) * 1000,
parts: file.segments.length,
size: file.segments.reduce((sum, s) => sum + s.bytes, 0),
message_ids: JSON.stringify(Object.fromEntries(file.segments.map((s) => [s.number, { id: s.id, size: s.bytes }]))),
groups: JSON.stringify(file.groups),
release_id: releaseId,
created_at: now,
updated_at: now,
}))
)
.onConflict('file_key')
.ignore()
}
// The poster's NZB says which files form the post: known files join this
// release, also when name rules had split them over several.
const previous = [...new Set(known.map((f) => f.release_id).filter((id) => id && Number(id) !== releaseId))]
await trx
.from('files')
.whereIn('id', known.filter((f) => Number(f.release_id) !== releaseId).map((f) => f.id))
.update({ release_id: releaseId })
if (previous.length) {
await trx.rawQuery(
`UPDATE binaries b SET release_id = ? FROM files f WHERE b.file_id = f.id AND f.release_id = ? AND b.release_id IS DISTINCT FROM ?`,
[releaseId, releaseId, releaseId]
)
await trx.from('releases').whereIn('id', previous).where('file_count', 0).delete()
}
// The NZB file joins the release it describes, unless it is part of a real release already.
const own = nzbFile.release_id ? Number(nzbFile.release_id) : null
if (own !== releaseId && options.moveContainer !== false) {
const ownRelease = own ? await trx.from('releases').where('id', own).first() : null
if (!ownRelease || ownRelease.file_count <= 1) {
await trx.from('files').where('id', nzbFile.id).update({ release_id: releaseId })
if (own) await trx.from('releases').where('id', own).where('file_count', 0).delete()
}
}
const release = await trx.from('releases').where('id', releaseId).first()
const update: Record<string, unknown> = {}
if (nzb.meta.password) update.password = nzb.meta.password
if (!release.file_total) update.file_total = entries.length
if ((looksRandom(release.name) || release.source === 'post') && !looksRandom(title)) update.name = title
if (Object.keys(update).length) await trx.from('releases').where('id', releaseId).update(update)
const result = {
releaseId,
releaseName: (update.name as string) ?? release.name,
listed: entries.length,
known: known.length,
added: missing.length,
password: Boolean(nzb.meta.password),
}
logger.info(
`NZB "${options.entryName ? `${nzbFile.filename}:${options.entryName}` : nzbFile.filename}" → release ${releaseId} "${result.releaseName}": ${result.listed} files listed, ` +
`${result.known} known, ${result.added} added${result.password ? ', with password' : ''}.`
)
return result
})
}
/** Release name: the NZB's meta name, else the NZB's (entry or file) name, else its largest file's stem. */
private title(nzb: ParsedNzb, nzbFile: any, names: string[], entryName?: string): string {
if (nzb.meta.name) return nzb.meta.name
const entry = entryName ? fileStem(entryName.split(/[\\/]/).pop() ?? entryName) : ''
if (entry && !looksRandom(entry)) return entry
const own = fileStem(String(nzbFile.real_name ?? nzbFile.filename ?? ''))
if (own && !looksRandom(own)) return own
const largest = nzb.files
.map((f, i) => ({ name: names[i], bytes: f.segments.reduce((s, x) => s + x.bytes, 0) }))
.sort((a, b) => b.bytes - a.bytes)[0]
return fileStem(largest?.name ?? own)
}
}
export default new NzbImportService()
-165
View File
@@ -1,165 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import NntpService from '#services/NntpService'
import IngestService, { PAR2_MATCH_WINDOW_SECONDS } from '#services/IngestService'
import { decodeSegment, YencFile } from '#lib/yenc/index'
import { isPar2, parsePar2, type Par2Set } from '#lib/par2/index'
import { par2ReleaseIdentity, par2ReleaseName } from '#lib/release/index'
import NameService, { WEAK_RELEASE_SQL, isBetterName } from '#services/NameService'
/** Par2 index files are small; recovery volumes can be large and are not needed. */
const MAX_PARTS = 20
/** Size-only matching below this is too ambiguous. */
const SIZE_ONLY_MIN = 1024 * 1024
function countBy<T>(values: T[]): Map<T, number> {
const counts = new Map<T, number>()
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
return counts
}
export interface Par2Result {
releaseId: number
releaseName: string
listed: number
matched: number
}
/**
* Recognises releases of obfuscated posts from their par2 files: the par2
* lists the real name and exact size of every file in the set, and
* body-derived binaries carry their exact size. See CONCEPT.md (D16).
*/
class Par2Service {
/**
* Reads a par2 file whose first segment is already decoded, downloading the
* remaining parts if there are any. Returns null when it is not par2.
*/
async read(first: Buffer, messageIds: Record<string, { id: string }>): Promise<Par2Set | null> {
if (!isPar2(first)) return null
const ids = Object.entries(messageIds)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, part]) => part.id)
if (ids.length > MAX_PARTS) return null
if (ids.length === 1) return parsePar2(first)
let assembled: YencFile | undefined
const conn = await NntpService.acquire()
let failed = false
try {
for (const id of ids) {
const segment = decodeSegment(await NntpService.fetchBody(conn, id))
assembled ??= YencFile.fromSegment(segment)
assembled.add(segment)
}
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
return parsePar2(assembled!.getBuffer())
}
async attach(binary: any, set: Par2Set): Promise<Par2Result> {
const result = await db.transaction(async (trx) => {
// Exact: size and first-16-KiB MD5 of a listed file. Only these may pull
// the set into a release the files already belong to; size alone is
// ambiguous across posts (standard volume sizes recur).
const exact = await trx
.from('binaries')
.whereIn(['size', 'md5_16k'] as any, set.files.map((f) => [f.length, f.md5_16k]) as any)
.select('id', 'release_id')
// Size only, for files without a fingerprint: the size must be listed
// once in this set, be large, and belong to exactly one binary nearby.
const listedOnce = [...countBy(set.files.map((f) => f.length))]
.filter(([size, n]) => n === 1 && size >= SIZE_ONLY_MIN)
.map(([size]) => size)
const bySize = listedOnce.length
? await trx
.from('binaries')
.whereIn('size', listedOnce)
.whereNull('md5_16k')
.whereRaw('abs(extract(epoch FROM (posted_at - ?::timestamptz))) < ?', [
binary.posted_at,
PAR2_MATCH_WINDOW_SECONDS,
])
.select('id', 'release_id', 'size')
: []
const sizeCounts = countBy(bySize.map((b) => Number(b.size)))
const fuzzy = bySize.filter((b) => sizeCounts.get(Number(b.size)) === 1)
const candidates = [...exact, ...fuzzy]
// Prefer a release the exactly matched files are already in (e.g.
// obfuscated volumes grouped by their stem); otherwise the set's own.
const counts = countBy(exact.filter((c) => c.release_id).map((c) => Number(c.release_id)))
let releaseId = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0]
const identity = par2ReleaseIdentity(
set.recoverySetId,
set.files.map((f) => f.name)
)
if (!releaseId) {
const ids = await IngestService.upsertReleases(trx.knexClient, [
{
release: identity,
poster: binary.poster,
weakIdentity: true,
groups: binary.groups,
postedAt: binary.posted_at ? new Date(binary.posted_at) : undefined,
},
])
releaseId = ids.get(identity.key)!
}
await trx
.knexClient('release_files')
.insert(
set.files.map((f) => ({
release_id: releaseId,
par2_file_id: f.fileId,
name: f.name,
size: f.length,
md5_16k: f.md5_16k,
md5: f.md5,
set_id: set.recoverySetId,
}))
)
.onConflict(['release_id', 'par2_file_id'])
.ignore()
const matchIds = candidates.filter((c) => !c.release_id).map((c) => Number(c.id))
await trx.from('binaries').whereIn('id', [binary.id, ...matchIds]).whereNull('release_id').update({ release_id: releaseId })
// Exact matches sitting alone in a random-named release (one file of a
// season, each posted under its own random name) join the set's release.
await trx.rawQuery(
`UPDATE binaries b SET release_id = ? WHERE b.id = ANY(?) AND b.release_id <> ? AND ${WEAK_RELEASE_SQL('b.release_id')}`,
[releaseId, exact.map((c) => Number(c.id)), releaseId]
)
await trx.rawQuery(
`UPDATE files f SET release_id = b.release_id FROM binaries b
WHERE b.file_id = f.id AND b.release_id = ? AND f.release_id IS DISTINCT FROM b.release_id`,
[releaseId]
)
// The set may have joined a release named after one random filename.
let releaseName = (await trx.from('releases').where('id', releaseId).first())?.name
const better = par2ReleaseName(set.files.map((f) => f.name))
if (releaseName && better && isBetterName(releaseName, better)) {
await trx.from('releases').where('id', releaseId).update({ name: better })
releaseName = better
}
logger.info(
`Par2 set ${set.recoverySetId.slice(0, 8)} → release ${releaseId} "${releaseName}": ` +
`${set.files.length} files listed, ${matchIds.length} binaries matched by size.`
)
return { releaseId, releaseName, listed: set.files.length, matched: matchIds.length }
})
// Real names for every known file of the set (exact fingerprint, or size within the release).
await NameService.resolveForRelease(result.releaseId)
return result
}
}
export default new Par2Service()
-61
View File
@@ -1,61 +0,0 @@
import { Queue, type DefaultJobOptions } from 'bullmq'
import queueConfig from '#config/queue'
import gcConfig from '#config/gc'
const defaultJobOptions: DefaultJobOptions = {
// Transient NNTP errors (e.g. a pooled connection the server closed) are retried.
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: {
age: gcConfig.jobs.completedAgeSeconds,
count: gcConfig.jobs.completedMaxCount,
},
removeOnFail: { age: gcConfig.jobs.failedAgeSeconds },
}
/**
* Queues are created (and connect to Redis) on first use. Ace imports every
* command at boot, so eager queues would keep one-off commands from exiting.
*/
class QueueService {
private readonly queues = new Map<string, Queue>()
private queue(name: string): Queue {
let queue = this.queues.get(name)
if (!queue) {
queue = new Queue(name, { connection: queueConfig.connection, defaultJobOptions })
this.queues.set(name, queue)
}
return queue
}
get nntpFetchQueue() {
return this.queue('nntp-fetch-queue')
}
get fileQueue() {
return this.queue('file-queue')
}
get bodyQueue() {
return this.queue('body-queue')
}
get collectionQueue() {
return this.queue('collection-queue')
}
/** Queues no longer used by the pipeline, kept here so GC can empty them. */
get legacy(): Queue[] {
return [this.queue('header-queue')]
}
get all(): Queue[] {
return [this.nntpFetchQueue, this.fileQueue, this.bodyQueue, this.collectionQueue]
}
/** Closes the queues that were opened. */
async closeAll() {
await Promise.all([...this.queues.values()].map((queue) => queue.close()))
this.queues.clear()
}
}
export default new QueueService()
-25
View File
@@ -1,25 +0,0 @@
import { createRequire } from 'module'
import redisConfig from '#config/redis'
const require = createRequire(import.meta.url)
const IORedis = require('ioredis')
/**
* Connects on first use. Ace imports every command at boot, so an eager
* connection would keep even `node ace list` from exiting.
*/
class RedisService {
private instance?: any
public get client(): any {
this.instance ??= new IORedis(redisConfig.connections.main)
return this.instance
}
async close() {
if (this.instance) await this.instance.quit()
this.instance = undefined
}
}
export default new RedisService()
-284
View File
@@ -1,284 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import { buildNzb } from '#lib/nzb/index'
import SpotService from '#services/SpotService'
export type SortOrder = 'newest' | 'oldest' | 'largest' | 'files' | 'name'
export interface SearchParams {
q?: string
/** Only releases whose name is readable (not an obfuscated token). */
readable?: boolean
/** Only releases that have all their files. */
complete?: boolean
/** Hide password-protected releases. */
hideEncrypted?: boolean
source?: string
poster?: string
sort?: SortOrder
page?: number
perPage?: number
/** Newznab: exact category ids (already expanded from parents). */
categories?: number[]
/** Newznab: leave out releases known to be missing files. */
notIncomplete?: boolean
maxAgeDays?: number
/** TV: season and episode numbers, matched as S01E02 or 1x02 in the name. */
season?: number
episode?: number
/** Overrides `page` (Newznab uses offsets). */
offset?: number
/** Leave out releases that are only an encrypted container (nzbe). */
hideContainers?: boolean
/** Include incomplete releases that consist of par2 files only (hidden by default). */
includePar2Only?: boolean
}
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.size DESC, r.id DESC',
files: 'r.file_count DESC, r.id DESC',
name: 'r.name ASC, r.id ASC',
}
/**
* Shown in search: releases with files, and Spotnet listings whose NZB is
* fetched on first download (CONCEPT.md D29). Matches the partial indexes.
*/
const VISIBLE_SQL = `(r.file_count > 0 OR r.source = 'spot')`
/** Incomplete, and every file is a par2 file: nothing to download but repair data. */
const PAR2_ONLY_SQL = `(r.file_count > 0 AND NOT r.complete AND NOT EXISTS (
SELECT 1 FROM files f WHERE f.release_id = r.id
AND f.content_type IS DISTINCT FROM 'par2' AND f.filename !~* '\\.par2$'))`
/** A search word as an ILIKE pattern; dots, underscores, dashes and spaces are interchangeable. */
function pattern(word: string): string {
const escaped = word.replace(/[\\%]/g, (c) => `\\${c}`)
return `%${escaped.replace(/[._\- ]+/g, '_')}%`
}
/**
* Searching and browsing releases for the web interface (CONCEPT.md D22).
*/
class SearchService {
async search(params: SearchParams) {
const where: string[] = [
VISIBLE_SQL,
// Retracted by its poster or a Spotnet moderator (CONCEPT.md D29).
`NOT EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id AND s.status = 'removed')`,
]
const bindings: unknown[] = []
const words = (params.q ?? '').trim().split(/\s+/).filter(Boolean).slice(0, 8)
for (const word of words) {
const p = pattern(word)
// Every word must match the release name, a file name, or the archive contents.
// One trigram-indexed lookup per table, not a subquery per release.
where.push(`(r.name ILIKE ? OR r.contents::text ILIKE ?
OR r.id IN (SELECT f.release_id FROM files f WHERE f.filename ILIKE ? OR f.real_name ILIKE ?))`)
bindings.push(p, p, p, p)
}
if (params.readable) where.push('is_readable_name(r.name)')
if (params.hideEncrypted) where.push('r.encrypted IS NOT TRUE')
if (params.hideContainers) where.push('r.container IS NULL')
// par2 files whose data never arrived (e.g. uploads spread over groups we can't fetch).
if (!params.includePar2Only) where.push(`(${PAR2_ONLY_SQL}) IS NOT TRUE`)
// Kept current by triggers (migration …860).
if (params.complete) where.push('r.complete')
if (params.source) {
where.push('r.source = ?')
bindings.push(params.source)
}
if (params.poster) {
where.push('r.poster ILIKE ?')
bindings.push(pattern(params.poster))
}
if (params.categories?.length) {
where.push('r.category = ANY(?)')
bindings.push(params.categories)
}
if (params.notIncomplete) {
where.push(`(r.complete OR (r.file_total IS NULL AND NOT EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = r.id)))`)
}
if (params.maxAgeDays) {
where.push(`r.posted_at >= now() - make_interval(days => ?)`)
bindings.push(params.maxAgeDays)
}
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 [{ rows }, { rows: countRows }] = await Promise.all([
db.rawQuery(
`SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.file_count, r.category, r.container,
r.size, r.encrypted, is_readable_name(r.name) AS readable,
(SELECT count(*) FROM release_files rf WHERE rf.release_id = r.id) AS listed_files,
(SELECT count(*) FROM release_files rf WHERE rf.release_id = r.id AND EXISTS (
SELECT 1 FROM files f JOIN binaries b ON b.file_id = f.id
WHERE f.release_id = r.id AND (b.size = rf.size OR f.real_name = rf.name))) AS listed_present,
jsonb_array_length(coalesce(r.contents, '[]'::jsonb)) AS archive_entries,
r.complete, ${PAR2_ONLY_SQL} AS par2_only
FROM releases r WHERE ${whereSql}
ORDER BY ${order} LIMIT ? OFFSET ?`,
[...bindings, perPage, offset]
),
db.rawQuery(`SELECT count(*) AS total FROM releases r WHERE ${whereSql}`, bindings),
])
return {
page,
perPage,
offset,
total: Number(countRows[0].total),
releases: rows.map((r: any) => ({
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: r.file_count,
size: Number(r.size),
encrypted: r.encrypted,
readable: r.readable,
listedFiles: Number(r.listed_files),
listedPresent: Number(r.listed_present),
archiveEntries: Number(r.archive_entries),
complete: r.complete,
category: r.category,
container: r.container,
par2Only: r.par2_only,
})),
}
}
async release(id: number) {
const release = await db.from('releases').where('id', id).first()
if (!release) return null
const [files, listed, pending] = await Promise.all([
db
.from('files as f')
.leftJoin('binaries as b', 'b.file_id', 'f.id')
.where('f.release_id', id)
.orderByRaw('coalesce(f.real_name, f.filename)')
.select('f.id', 'f.filename', 'f.real_name', 'f.size', 'f.parts', 'f.date', 'f.poster', 'b.size as exact_size'),
db.rawQuery(
`SELECT rf.name, rf.size,
EXISTS (SELECT 1 FROM files f JOIN binaries b ON b.file_id = f.id
WHERE f.release_id = rf.release_id AND (f.real_name = rf.name OR b.size = rf.size)) AS present
FROM release_files rf WHERE rf.release_id = ? ORDER BY rf.name`,
[id]
),
db.rawQuery(
`SELECT count(*) AS binaries, coalesce(sum(b.total_parts), 0) AS parts_total,
coalesce(sum((SELECT count(*) FROM parts p WHERE p.binary_id = b.id)), 0) AS parts_have
FROM binaries b WHERE b.release_id = ? AND b.completed_at IS NULL AND b.conflicted_at IS NULL`,
[id]
),
])
const spot = release.source === 'spot'
? await db.from('spots').where('release_id', id).select('tag', 'poster', 'category', 'website').first()
: null
return {
id: Number(release.id),
name: release.name,
source: release.source,
spot: spot ? { tag: spot.tag, poster: spot.poster, category: spot.category, website: spot.website } : null,
listingOnly: release.source === 'spot' && Number(release.file_count) === 0,
poster: release.poster,
groups: release.groups,
postedAt: release.posted_at,
fileTotal: release.file_total,
fileCount: release.file_count,
size: Number(release.size),
encrypted: release.encrypted,
container: release.container,
contents: release.contents ?? [],
files: files.map((f) => ({
id: Number(f.id),
name: f.real_name ?? f.filename,
postedName: f.filename,
realName: f.real_name,
size: Number(f.exact_size ?? f.size),
parts: f.parts,
postedAt: f.date ? new Date(Number(f.date)) : null,
poster: f.poster,
})),
par2Files: listed.rows.map((r: any) => ({ name: r.name, size: Number(r.size), present: r.present })),
pending: {
binaries: Number(pending.rows[0].binaries),
partsHave: Number(pending.rows[0].parts_have),
partsTotal: Number(pending.rows[0].parts_total),
},
}
}
async nzb(id: number): Promise<{ name: string; xml: string } | null> {
let release = await db.from('releases').where('id', id).first()
if (!release) return null
if (release.source === 'spot' && Number(release.file_count) === 0) {
// A Spotnet listing: read its NZB from Usenet now (the import may join known files elsewhere).
const imported = await SpotService.importListing(id)
if (!imported) return null
id = imported
release = await db.from('releases').where('id', id).first()
}
const files = await db
.from('files')
.where('release_id', id)
.orderByRaw('coalesce(real_name, filename)')
.select('filename', 'real_name', 'poster', 'date', 'groups', 'message_ids')
const xml = buildNzb(
files.map((f) => ({
name: f.real_name ?? f.filename,
poster: f.poster,
date: Number(f.date),
groups: f.groups?.length ? f.groups : ['alt.binaries.test'],
segments: f.message_ids,
})),
{ title: release.name, password: release.password ?? undefined }
)
return { name: release.name, xml }
}
async stats() {
const { rows } = await db.rawQuery(`
SELECT (SELECT count(*) FROM releases r WHERE ${VISIBLE_SQL}) AS releases,
(SELECT count(*) FROM releases r WHERE ${VISIBLE_SQL} AND is_readable_name(name)) AS readable_releases,
(SELECT count(*) FROM files) AS files,
(SELECT coalesce(sum(size), 0) FROM files) AS size,
(SELECT count(*) FROM releases WHERE encrypted) AS encrypted_releases,
(SELECT max(updated_at) FROM groups) AS last_indexed_at,
(SELECT json_agg(json_build_object('source', source, 'releases', n) ORDER BY n DESC)
FROM (SELECT coalesce(source, 'unknown') AS source, count(*) AS n FROM releases r WHERE ${VISIBLE_SQL} GROUP BY 1) s) AS sources`)
const r = rows[0]
return {
releases: Number(r.releases),
readableReleases: Number(r.readable_releases),
files: Number(r.files),
size: Number(r.size),
encryptedReleases: Number(r.encrypted_releases),
lastIndexedAt: r.last_indexed_at,
sources: r.sources ?? [],
}
}
}
export default new SearchService()
-45
View File
@@ -1,45 +0,0 @@
import { setTimeout as sleep } from 'node:timers/promises'
/** After a signal, give cleanup this long before exiting anyway. */
const FORCE_EXIT_MS = 25_000
/**
* Graceful shutdown for the long-running commands. SIGTERM (docker stop) and
* SIGINT wake up `wait()` and `sleep()`, so workers can finish their active
* jobs and close their connections before the process exits.
*/
class Shutdown {
private readonly controller = new AbortController()
constructor() {
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
this.controller.abort()
setTimeout(() => process.exit(0), FORCE_EXIT_MS).unref()
})
}
}
get requested(): boolean {
return this.controller.signal.aborted
}
/** Resolves when a shutdown signal arrives. */
wait(): Promise<void> {
if (this.requested) return Promise.resolve()
return new Promise((resolve) =>
this.controller.signal.addEventListener('abort', () => resolve(), { once: true })
)
}
/** Sleeps, but returns early on shutdown. */
async sleep(ms: number): Promise<void> {
try {
await sleep(ms, undefined, { signal: this.controller.signal })
} catch {
// aborted by a shutdown signal
}
}
}
export default new Shutdown()
-146
View File
@@ -1,146 +0,0 @@
import { createHash } from 'node:crypto'
import db from '@adonisjs/lucid/services/db'
import IngestService from '#services/IngestService'
import NameService from '#services/NameService'
import { splitVerified } from '#lib/split/index'
/** Conflicted binaries handled per run; the rest follow on the next tick. */
const BATCH = 200
export interface SplitResult {
parents: number
volumes: number
completed: number
}
/**
* Splits conflicted obfuscated binaries (several equal-sized files under one
* size+parts identity) into one binary per volume. Only volumes whose
* combined part CRCs match the file CRC of their last part are created, so a
* split can never produce a corrupt file; the rest waits for more articles.
* See CONCEPT.md (D20).
*/
class SplitService {
async splitPending(): Promise<SplitResult> {
const parents = await db
.from('binaries')
.whereNotNull('conflicted_at')
.whereNull('parent_binary_id')
// split_at = 'infinity' marks binaries that cannot be split (see migration …810).
.where((q) => q.whereNull('split_at').orWhereRaw('last_seen_at > split_at'))
// Most recently active first: those are the posts still coming in.
.orderBy('last_seen_at', 'desc')
.limit(BATCH)
const volumeIds: number[] = []
for (const parent of parents) {
volumeIds.push(...(await this.split(parent)))
}
// Former conflicts that turned out to be reposts (unflagged, keep their
// split_at): no new article may arrive to trigger their completion.
const unflagged = await db
.from('binaries')
.whereNull('conflicted_at')
.whereNull('completed_at')
.whereNull('parent_binary_id')
.whereNotNull('split_at')
.whereRaw("split_at <> 'infinity'")
.limit(BATCH)
.select('id')
volumeIds.push(...unflagged.map((b) => Number(b.id)))
await IngestService.matchPar2Releases(volumeIds)
await NameService.resolve(volumeIds)
const completed = await IngestService.claimCompleted(volumeIds)
await IngestService.enqueue(completed)
return { parents: parents.length, volumes: volumeIds.length, completed: completed.length }
}
/** Creates the newly verified volumes of one conflicted binary; returns their ids. */
private async split(parent: any): Promise<number[]> {
return db.transaction(async (trx) => {
// Unverified volumes from before verification existed can never complete.
await trx.from('binaries').where('parent_binary_id', parent.id).whereNull('completed_at').delete()
const { rows } = await trx.rawQuery(
`WITH taken AS (
SELECT p.message_id FROM parts p JOIN binaries v ON v.id = p.binary_id
WHERE v.parent_binary_id = ?
), candidates AS (
SELECT number, message_id, bytes, article_number, pcrc32, part_size, file_crc32 FROM parts WHERE binary_id = ?
UNION ALL
SELECT number, message_id, bytes, article_number, pcrc32, part_size, file_crc32 FROM part_conflicts WHERE binary_id = ?
)
SELECT * FROM candidates WHERE message_id NOT IN (SELECT message_id FROM taken)`,
[parent.id, parent.id, parent.id]
)
const volumes = splitVerified(
rows.map((r: any) => ({
number: r.number,
messageId: r.message_id,
bytes: r.bytes,
articleNumber: r.article_number === null ? null : Number(r.article_number),
pcrc32: r.pcrc32,
partSize: r.part_size,
fileCrc32: r.file_crc32,
})),
{ totalParts: parent.total_parts }
)
const now = new Date()
const ids: number[] = []
for (const articles of volumes) {
// A verified volume is fixed, so its first article identifies it.
const fileKey = createHash('sha1').update(`${parent.file_key}#${articles[0].messageId}`).digest('hex')
const inserted = await trx
.knexClient('binaries')
.insert({
file_key: fileKey,
poster: parent.poster,
subject: parent.subject,
name: parent.name,
total_parts: parent.total_parts,
groups: JSON.stringify(parent.groups),
posted_at: parent.posted_at,
size: parent.size,
release_id: parent.release_id,
parent_binary_id: parent.id,
first_seen_at: now,
last_seen_at: now,
})
.onConflict('file_key')
.ignore()
.returning('id')
if (inserted.length === 0) continue
const id = Number(inserted[0].id)
await trx.knexClient('parts').insert(
articles.map((a) => ({
binary_id: id,
number: a.number,
message_id: a.messageId,
bytes: a.bytes,
article_number: a.articleNumber,
pcrc32: a.pcrc32,
part_size: a.partSize,
file_crc32: a.fileCrc32,
}))
)
// The fingerprint of a volume is that of its part 1 article.
await trx.rawQuery(
`UPDATE binaries SET md5_16k = (
SELECT md5_16k FROM part_conflicts WHERE binary_id = ? AND message_id = ?
UNION ALL SELECT md5_16k FROM parts WHERE binary_id = ? AND message_id = ? LIMIT 1)
WHERE id = ?`,
[parent.id, articles[0].messageId, parent.id, articles[0].messageId, id]
)
ids.push(id)
}
await trx.from('binaries').where('id', parent.id).update({ split_at: now })
return ids
})
}
}
export default new SplitService()
-172
View File
@@ -1,172 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import indexerConfig from '#config/indexer'
import NntpService from '#services/NntpService'
import NzbImportService from '#services/NzbImportService'
import { type OverviewHeader } from '#services/IngestService'
import { parseNzb } from '#lib/nzb/index'
import {
moderationTarget,
spotterId,
decodeSpotBinary,
parseSpotArticleHeaders,
parseSpotHeader,
parseSpotXml,
verifySpotHeader,
type SpotHeader,
} from '#lib/spotnet/index'
/** Spots whose NZB is larger than this are skipped (Spotweb skips >50 KB of XML too). */
const MAX_NZB_SEGMENTS = 20
/** A personal dispose only counts within this long after the spot (Spotweb: 5 days). */
const DISPOSE_WINDOW_SECONDS = 5 * 24 * 60 * 60
/** A claimed spot whose worker died is taken again after this long. */
const RECLAIM_AFTER = '1 hour'
export function isSpotGroup(name: string): boolean {
return indexerConfig.spotGroups.includes(name)
}
/**
* Spotnet (CONCEPT.md D29). Spots are signed posts in free.pt; their full
* header holds XML with the message ids of a compressed NZB, which is
* imported like a posted NZB (D26). Spot NZBs name many obfuscated posts
* (nyuu, ngPost, the skipped multi-group uploader).
*/
class SpotService {
/** Queues the spots among a batch of one spot group's headers. Returns how many. */
async recordHeaders(groupName: string, overviews: [number, OverviewHeader][]): Promise<number> {
const now = new Date()
const rows = []
for (const [articleNumber, header] of overviews) {
if (!header?.from || !header['message-id']) continue
const spot = parseSpotHeader(header.from, header.subject ?? '', header['message-id'], header.date)
if (!spot?.title) continue
rows.push({
message_id: spot.messageId,
group_name: groupName,
article_number: Number(articleNumber),
title: spot.title,
tag: spot.tag || null,
poster: spot.poster,
category: spot.category,
subcats: JSON.stringify(spot.subcats),
size: spot.size || null,
posted_at: spot.stamp ? new Date(spot.stamp * 1000) : null,
key_id: spot.keyId,
spotter_id: spotterId(spot.selfSignedPubKey) || null,
created_at: now,
})
}
if (rows.length === 0) return 0
await db.knexQuery().table('spots').insert(rows).onConflict('message_id').ignore()
return rows.length
}
/** Takes up to `limit` pending spots, oldest first; they are marked `processing`. */
async claim(limit: number): Promise<any[]> {
const { rows } = await db.rawQuery(
`UPDATE spots SET status = 'processing', processed_at = now()
WHERE message_id IN (
SELECT message_id FROM spots
WHERE status = 'pending' OR (status = 'processing' AND processed_at < now() - interval '${RECLAIM_AFTER}')
ORDER BY created_at, article_number LIMIT ? FOR UPDATE SKIP LOCKED)
RETURNING *`,
[limit]
)
return rows
}
/** Verifies the spot on its raw header, then reads and imports its NZB. Returns the new status. */
async process(spot: any): Promise<string> {
const update: Record<string, unknown> = { processed_at: new Date(), error: null }
try {
const raw = await this.fetch(`HEAD <${spot.message_id}>`)
const { headers, xml } = parseSpotArticleHeaders(raw.toString('latin1'))
// The signature covers the ISO-8859-1 bytes, which the overview (read as UTF-8) may have mangled.
const header = parseSpotHeader(headers.from ?? '', headers.subject ?? '', spot.message_id, headers.date)
if (!header || !verifySpotHeader(header)) {
update.status = 'unverified'
} else if (moderationTarget(header)) {
update.status = 'moderation'
await this.moderate(header, moderationTarget(header)!)
} else {
const parsed = parseSpotXml(xml)
update.title = header.title
update.poster = header.poster
update.spotter_id = spotterId(header.selfSignedPubKey) || null
if (parsed?.website) update.website = parsed.website
const segments = parsed?.nzbSegments ?? []
if (segments.length === 0 || segments.length > MAX_NZB_SEGMENTS) {
update.status = 'no-nzb'
} else {
const bodies = []
for (const id of segments) bodies.push(await this.fetch(`BODY <${id.replace(/^<|>$/g, '')}>`))
const nzbXml = decodeSpotBinary(bodies).toString('utf8')
const nzb = parseNzb(nzbXml)
if (!nzb || nzb.files.length === 0) {
update.status = 'no-nzb'
} else {
const groups = [...new Set(nzb.files.flatMap((f) => f.groups))]
const result = await NzbImportService.import(
{
id: null,
filename: `${header.title}.nzb`,
poster: header.poster,
groups,
date: header.stamp * 1000,
message_ids: { '1': { id: spot.message_id } },
},
nzbXml,
{ name: header.title, source: 'spot', moveContainer: false }
)
update.status = result ? 'imported' : 'no-nzb'
if (result) update.release_id = result.releaseId
}
}
}
} catch (error: any) {
update.status = 'failed'
update.error = String(error.message).slice(0, 500)
logger.warn(`Spot ${spot.message_id} "${spot.title}": ${error.message}`)
}
await db.from('spots').where('message_id', spot.message_id).update(update)
return update.status as string
}
/**
* Applies a delete/dispose/remove: the target spot is marked `removed`
* (its release is then hidden from search). As in Spotweb, a personal
* dispose must come from the same spotter within 5 days; a moderator's
* (signed with Spotnet's key, no personal key) always counts.
*/
private async moderate(header: SpotHeader, targetId: string) {
const target = await db.from('spots').where('message_id', targetId).first()
if (!target) return
const personal = header.selfSignedPubKey.length > 50 ? spotterId(header.selfSignedPubKey) : ''
if (personal) {
const age = header.stamp - (target.posted_at ? new Date(target.posted_at).getTime() / 1000 : 0)
if (target.spotter_id !== personal || age > DISPOSE_WINDOW_SECONDS) return
}
await db.from('spots').where('message_id', targetId).update({ status: 'removed' })
logger.info(`Spot "${target.title}" removed by ${personal ? 'its poster' : 'a moderator'}.`)
}
/** One HEAD or BODY, as raw bytes. */
private async fetch(command: string): Promise<Buffer> {
const conn = await NntpService.acquire()
let failed = false
try {
const { data } = await conn._artcmd(command)
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
} catch (error: any) {
// 430 (no such article) is an answer, not a broken connection.
failed = !/^4\d\d/.test(String(error.message))
throw error
} finally {
NntpService.release(conn, failed)
}
}
}
export default new SpotService()
+194
View File
@@ -0,0 +1,194 @@
import db from '@adonisjs/lucid/services/db'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import type { EdgeBatch, ProbeCandidate, ReleaseChunk } from '#lib/edge/index'
import ChunkPartitions from '#services/chunk_partitions'
export class StaleBatch extends Error {}
export interface StoredBatch {
releases: number
newReleases: number
candidates: number
fragments: number
}
/**
* Stores one batch of a leased range (REBUILD.md, parts 3–7), in one
* transaction: release aggregates in the catalog, one chunk per release,
* probe candidates, rule counts and samples, edge fragments, and the
* range's resume point. A batch that doesn't start at the resume point was
* sent twice or too late, and is refused (StaleBatch), so the sums stay right.
*/
class BatchStore {
async store(rangeId: number, nodeId: number, batch: EdgeBatch): Promise<StoredBatch> {
await ChunkPartitions.ensure()
return db.transaction(async (trx) => {
const range = await trx.from('ranges').where('id', rangeId).forUpdate().first()
if (!range || range.status !== 'leased' || Number(range.node_id) !== nodeId)
throw new StaleBatch(`Range ${rangeId} is not leased to this node`)
if (Number(range.next_id) !== batch.from)
throw new StaleBatch(
`Range ${rangeId} is at ${range.next_id}, the batch starts at ${batch.from}`
)
const result: StoredBatch = {
releases: batch.releases.length,
newReleases: 0,
candidates: 0,
fragments: batch.fragments.length,
}
const written = await this.writeReleases(
trx,
rangeId,
batch.from,
batch.releases,
batch.candidates
)
result.newReleases = written.newReleases
result.candidates = written.candidates
if (batch.counts.length > 0) {
await trx.rawQuery(
`INSERT INTO rule_counts (range_id, rule, key, articles, files)
SELECT ?, v.rule, v.key, v.articles, v.files FROM jsonb_to_recordset(?::jsonb) AS v(rule text, key text, articles bigint, files bigint)
ON CONFLICT (range_id, rule, key) DO UPDATE SET articles = rule_counts.articles + excluded.articles, files = rule_counts.files + excluded.files`,
[rangeId, JSON.stringify(batch.counts)]
)
}
for (const s of batch.samples) {
await trx.insertQuery().table('rule_samples').insert({
range_id: rangeId,
rule: s.rule,
rule_key: s.ruleKey,
release_key: s.key,
name: s.name,
files: s.files,
segments: s.segments,
payload: s.payload,
})
}
for (let i = 0; i < batch.fragments.length; i += 2000) {
const rows = batch.fragments.slice(i, i + 2000).map((f) => ({
range_id: rangeId,
file_key: f.fileKey,
article_number: Math.min(...f.segments.map((s) => s[3])),
segments: f.segments.length,
fragment: JSON.stringify(f),
}))
await trx.insertQuery().table('fragments').multiInsert(rows)
}
await trx
.from('ranges')
.where('id', rangeId)
.update({
next_id: batch.next,
headers: db.raw('headers + ?', [batch.headers]),
heartbeat_at: new Date(),
})
return result
})
}
/**
* Release aggregates in the catalog, one write-once chunk per release, and
* 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.
*/
async writeReleases(
trx: TransactionClientContract,
rangeId: number,
batchFrom: number,
releases: ReleaseChunk[],
candidates: ProbeCandidate[]
) {
const out = { newReleases: 0, candidates: 0, ids: new Map<string, number>() }
const ids = out.ids
// Releases, sorted by key so concurrent batches lock them in the same order.
const sorted = [...releases].sort((a, b) => a.key.localeCompare(b.key))
for (let i = 0; i < sorted.length; i += 1000) {
const chunk = sorted.slice(i, i + 1000)
const { rows } = await trx.rawQuery(
`INSERT INTO releases (release_key, name, source, poster, groups, posted_at, file_total, files, segments, bytes, chunks, first_article, last_article, readable, has_par2)
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
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)
ON CONFLICT (release_key) DO UPDATE SET
files = releases.files + excluded.files,
segments = releases.segments + excluded.segments,
bytes = releases.bytes + excluded.bytes,
chunks = releases.chunks + 1,
posted_at = least(releases.posted_at, excluded.posted_at),
file_total = coalesce(releases.file_total, excluded.file_total),
first_article = least(releases.first_article, excluded.first_article),
last_article = greatest(releases.last_article, excluded.last_article),
readable = releases.readable OR excluded.readable,
has_par2 = releases.has_par2 OR excluded.has_par2,
last_seen_at = now()
RETURNING id, release_key, (xmax = 0) AS inserted`,
[
JSON.stringify(
chunk.map((r) => ({
key: r.key,
name: r.name,
source: r.source,
poster: r.poster,
groups: r.groups,
posted_at: r.postedAt,
file_total: r.fileTotal,
files: r.files,
segments: r.segments,
bytes: r.bytes,
first_article: r.firstArticle,
last_article: r.lastArticle,
readable: r.readable,
has_par2: r.hasPar2,
}))
),
]
)
for (const row of rows) {
ids.set(row.release_key, Number(row.id))
if (row.inserted) out.newReleases++
}
}
// One write-once chunk per release.
for (let i = 0; i < sorted.length; i += 500) {
const rows = sorted.slice(i, i + 500).map((r) => ({
release_id: ids.get(r.key)!,
range_id: rangeId,
batch_from: batchFrom,
files: r.files,
segments: r.segments,
payload: r.payload,
}))
if (rows.length > 0) await trx.insertQuery().table('release_chunks').multiInsert(rows)
}
const kept = candidates.filter((c) => ids.has(c.releaseKey))
for (let i = 0; i < kept.length; i += 2000) {
const rows = kept
.slice(i, i + 2000)
.map((c) => ({
release_id: ids.get(c.releaseKey)!,
kind: c.kind,
message_id: c.messageId,
file: c.file,
}))
const added = await trx
.knexQuery()
.table('probe_candidates')
.insert(rows)
.onConflict(['release_id', 'kind', 'message_id'])
.ignore()
.returning('id')
out.candidates += added.length
}
return out
}
}
export default new BatchStore()
+30
View File
@@ -0,0 +1,30 @@
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
/** Months ahead that always have a release_chunks partition. */
const AHEAD = 2
/**
* release_chunks is partitioned by month of writing (REBUILD.md, decision a).
* Inserts fail without a partition for now(), so the current and the next
* months' partitions are created before they are needed.
*/
class ChunkPartitions {
async ensure(now = new Date()) {
// Checked each time, not remembered: a rolled-back transaction can take a created partition along.
const current = `release_chunks_${now.toISOString().slice(0, 7).replace('-', '')}`
const { rows } = await db.rawQuery(`SELECT to_regclass(?) IS NOT NULL AS present`, [current])
if (rows[0].present) return
const tablespace = env.get('CHUNK_TABLESPACE')
for (let i = 0; i <= AHEAD; i++) {
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1))
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i + 1, 1))
const name = `release_chunks_${from.toISOString().slice(0, 7).replace('-', '')}`
await db.rawQuery(
`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF release_chunks FOR VALUES FROM ('${from.toISOString()}') TO ('${to.toISOString()}')${tablespace ? ` TABLESPACE ${tablespace}` : ''}`
)
}
}
}
export default new ChunkPartitions()
+151
View File
@@ -0,0 +1,151 @@
import db from '@adonisjs/lucid/services/db'
import BatchStore from '#services/batch_store'
import ChunkPartitions from '#services/chunk_partitions'
import RuleSets from '#services/rule_sets'
import { RuleSet } from '#lib/rules/index'
import { candidatesOf, chunkOf, mergeFragments, releaseKeyOf, type Fragment, type ProbeCandidate, type ReleaseChunk } from '#lib/edge/index'
import type { AggregatedFile } from '#lib/files/index'
import type { EdgeSegment } from '#lib/edge/index'
export interface MergeResult {
files: number
completed: number
incomplete: number
waiting: number
/** This round reached the end of the file keys (the next one starts over). */
wrapped: boolean
}
/** File keys per round. */
const BATCH = 20_000
/**
* Files cut by a range's edge (REBUILD.md, fragments), put together once
* the ranges around them are done: a complete file is judged by the rules
* like any other and written as a release chunk (with probe candidates); a
* file still missing parts while every neighbouring range is done is
* incomplete, and dropped (counted on its ranges). Others wait.
*/
class FragmentMerger {
/** Where the next round goes on: waiting files must not take every round's places. */
private cursor = ''
async run(): Promise<MergeResult> {
const result: MergeResult = { files: 0, completed: 0, incomplete: 0, waiting: 0, wrapped: false }
const { rows: keys } = await db.rawQuery(
`SELECT DISTINCT f.file_key FROM fragments f JOIN ranges r ON r.id = f.range_id
WHERE r.status = 'done' AND f.file_key > ? ORDER BY f.file_key LIMIT ?`,
[this.cursor, BATCH]
)
// At the end of the keys: the next round starts over.
result.wrapped = keys.length < BATCH
this.cursor = result.wrapped ? '' : keys.at(-1).file_key
if (keys.length === 0) return result
const rules = new RuleSet(await RuleSets.current())
const rows = await db
.from('fragments as f')
.join('ranges as r', 'r.id', 'f.range_id')
.whereIn('f.file_key', keys.map((k: { file_key: string }) => k.file_key))
.select('f.id', 'f.file_key', 'f.range_id', 'f.fragment', 'r.group_id', 'r.start_id', 'r.end_id', 'r.status')
const byKey = new Map<string, typeof rows>()
for (const row of rows) {
const list = byKey.get(row.file_key)
if (list) list.push(row)
else byKey.set(row.file_key, [row])
}
const completed: { file: AggregatedFile<EdgeSegment>; rangeId: number; ids: number[] }[] = []
const incomplete: { rangeId: number; ids: number[]; segments: number }[] = []
const neighbours = await this.neighbourStatus([...byKey.values()])
for (const list of byKey.values()) {
result.files++
const file = mergeFragments(list.map((r) => r.fragment as Fragment))
const ids = list.map((r) => Number(r.id))
const rangeId = Math.max(...list.map((r) => Number(r.range_id)))
if (file) {
completed.push({ file, rangeId, ids })
continue
}
if (list.every((r) => r.status === 'done') && neighbours(list)) {
incomplete.push({ rangeId, ids, segments: list.reduce((n, r) => n + (r.fragment as Fragment).segments.length, 0) })
} else {
result.waiting++
}
}
// Completed files, grouped into their releases, judged and written like a batch's.
const byRelease = new Map<string, { files: AggregatedFile<EdgeSegment>[]; rangeId: number }>()
for (const { file, rangeId } of completed) {
const key = releaseKeyOf(file.segments[0])
const entry = byRelease.get(key)
if (entry) entry.files.push(file)
else byRelease.set(key, { files: [file], rangeId })
}
const releases: { rangeId: number; chunk: ReleaseChunk; candidates: ProbeCandidate[] }[] = []
for (const [key, { files, rangeId }] of byRelease) {
const verdict = rules.judge(key, files.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId })))
if (verdict.rule !== null) continue
releases.push({ rangeId, chunk: chunkOf(key, files), candidates: candidatesOf(key, files) })
}
await ChunkPartitions.ensure()
await db.transaction(async (trx) => {
for (const { rangeId, chunk, candidates } of releases) await BatchStore.writeReleases(trx, rangeId, 0, [chunk], candidates)
const done = [...completed.flatMap((c) => c.ids), ...incomplete.flatMap((c) => c.ids)]
for (let i = 0; i < done.length; i += 5000) await trx.from('fragments').whereIn('id', done.slice(i, i + 5000)).delete()
for (const { rangeId, segments } of incomplete) {
await trx.rawQuery(
`INSERT INTO rule_counts (range_id, rule, key, articles, files) VALUES (?, 'incomplete', 'edge', ?, 1)
ON CONFLICT (range_id, rule, key) DO UPDATE SET articles = rule_counts.articles + excluded.articles, files = rule_counts.files + 1`,
[rangeId, segments]
)
}
})
result.completed = completed.length
result.incomplete = incomplete.length
return result
}
/**
* For each file's fragments: whether the ranges right before and after
* their ranges (where a missing part could be) are done, read in one
* query for the whole round (per file it was three, ~2,000 files a
* minute). A neighbour not planned yet counts as not done (newer articles
* come later), except before the group's oldest article.
*/
private async neighbourStatus(lists: { group_id: number; start_id: number | string; end_id: number | string }[][]) {
const span = (list: (typeof lists)[number]) => ({
group: Number(list[0].group_id),
first: Math.min(...list.map((r) => Number(r.start_id))),
last: Math.max(...list.map((r) => Number(r.end_id))),
})
const spans = lists.map(span)
const groups = [...new Set(spans.map((s) => s.group))]
// The range holding the article before and the one after, each by the (group_id, start_id) index:
// "end_id = ? OR start_id = ?" read every range of the group per file (5.5 s a round; LOG.md 2026-10-09).
const { rows } = await db.rawQuery(
`SELECT s.group_id, s.before_end, s.after_start, b.status AS before, a.status AS after
FROM unnest(?::int[], ?::bigint[], ?::bigint[]) AS s(group_id, before_end, after_start)
LEFT JOIN LATERAL (SELECT r.status FROM ranges r WHERE r.group_id = s.group_id AND r.start_id <= s.before_end AND r.end_id >= s.before_end
ORDER BY r.start_id DESC LIMIT 1) b ON true
LEFT JOIN LATERAL (SELECT r.status FROM ranges r WHERE r.group_id = s.group_id AND r.start_id <= s.after_start AND r.end_id >= s.after_start
ORDER BY r.start_id DESC LIMIT 1) a ON true`,
[spans.map((s) => s.group), spans.map((s) => s.first - 1), spans.map((s) => s.last + 1)]
)
const byEnd = new Map<string, string>()
const byStart = new Map<string, string>()
for (const r of rows) {
if (r.before !== null) byEnd.set(`${r.group_id}:${r.before_end}`, r.before)
if (r.after !== null) byStart.set(`${r.group_id}:${r.after_start}`, r.after)
}
const lows = new Map((await db.from('groups').whereIn('id', groups).select('id', 'low')).map((g) => [Number(g.id), g.low === null ? null : Number(g.low)]))
return (list: (typeof lists)[number]) => {
const { group, first, last } = span(list)
const before = byEnd.get(`${group}:${first - 1}`)
const low = lows.get(group)
const beforeDone = before !== undefined ? before === 'done' : low === null || low === undefined || first <= low
return beforeDone && byStart.get(`${group}:${last + 1}`) === 'done'
}
}
}
export default new FragmentMerger()
+80
View File
@@ -0,0 +1,80 @@
import db from '@adonisjs/lucid/services/db'
import coordinatorConfig from '#config/coordinator'
import RuleSets from '#services/rule_sets'
import Spool from '#services/spool'
import type { Lease } from '#lib/edge/index'
export class LeaseLost extends Error {}
/**
* Ranges for workers (REBUILD.md, coordinator): the next range with chunks
* spooled, by priority (oldest era first), else the next planned one (the
* prefetcher fetches leased ranges first; the worker's stream waits). A
* worker never waits for prefetching to choose its range: refused leases
* left workers idle for 30 s, so fewer ranges were leased and fewer
* prefetched (LOG.md 2026-10-09). A lease whose worker went quiet for
* LEASE_TTL_SECONDS is taken over and resumes at its resume point.
*/
class Leases {
async lease(nodeId: number): Promise<Lease | null> {
const rules = await RuleSets.current()
const take = (spooled: boolean) =>
db.rawQuery(
`UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ?
WHERE r.id = (
SELECT id FROM ranges
WHERE ${spooled ? 'spooled_chunks > 0' : 'true'} AND next_id <= end_id
AND (status = 'planned' OR (status = 'leased' AND heartbeat_at < now() - make_interval(secs => ?)))
ORDER BY priority, id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING r.id, r.group_id, r.start_id, r.end_id, r.next_id`,
[nodeId, rules.version, coordinatorConfig.leaseTtlSeconds]
)
let { rows } = await take(true)
if (rows.length === 0) ({ rows } = await take(false))
const row = rows[0]
if (!row) return null
await db.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
const group = await db.from('groups').where('id', row.group_id).select('name').firstOrFail()
return { range: { id: Number(row.id), group: group.name, start: Number(row.start_id), end: Number(row.end_id), next: Number(row.next_id) }, rules }
}
async leased(rangeId: number, nodeId: number) {
const range = await db.from('ranges').where('id', rangeId).first()
if (!range || range.status !== 'leased' || Number(range.node_id) !== nodeId) throw new LeaseLost(`Range ${rangeId} is not leased to this node`)
return range
}
async heartbeat(rangeId: number, nodeId: number) {
await this.leased(rangeId, nodeId)
await db.from('ranges').where('id', rangeId).update({ heartbeat_at: new Date() })
await db.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
}
/** The worker reached the range's end: done, and its spooled chunks go. */
async finish(rangeId: number, nodeId: number) {
const range = await this.leased(rangeId, nodeId)
if (Number(range.next_id) <= Number(range.end_id)) throw new LeaseLost(`Range ${rangeId} is at ${range.next_id}, not past its end ${range.end_id}`)
await db.from('ranges').where('id', rangeId).update({ status: 'done', done_at: new Date(), heartbeat_at: new Date() })
await Spool.remove(rangeId)
}
/**
* A worker got a chunk that doesn't inflate: the spooled chunk goes and
* the range back to the queue (the prefetcher fetches leased ranges'
* missing chunks again; the next worker resumes at the range's resume point).
*/
async corrupt(rangeId: number, nodeId: number, from: number) {
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)
if (span) await Spool.removeChunk(rangeId, span)
await db.from('ranges').where('id', rangeId).update({ status: 'planned', node_id: null })
}
/** A stopping worker gives its range back; the next one resumes it. */
async release(rangeId: number, nodeId: number) {
await this.leased(rangeId, nodeId)
await db.from('ranges').where('id', rangeId).update({ status: 'planned', node_id: null })
}
}
export default new Leases()
+85
View File
@@ -0,0 +1,85 @@
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import coordinatorConfig from '#config/coordinator'
import { XoverClient } from '#lib/nntp/index'
/**
* The coordinator's header connections (REBUILD.md, decision e: workers
* have no NNTP account). At most NNTP_CONNECTIONS, reused; a connection
* that failed is closed and replaced on the next use.
*/
class NntpPool {
private idle: XoverClient[] = []
private busy = new Set<XoverClient>()
private open = 0
private waiting: ((client: XoverClient) => void)[] = []
get size() {
return coordinatorConfig.nntpConnections
}
async use<T>(work: (client: XoverClient) => Promise<T>): Promise<T> {
const client = await this.acquire()
this.busy.add(client)
let broken = false
try {
return await work(client)
} catch (error) {
broken = true
throw error
} finally {
this.busy.delete(client)
this.release(client, broken)
}
}
private async acquire(): Promise<XoverClient> {
const ready = this.idle.pop()
if (ready) return ready
if (this.open < this.size) {
this.open++
try {
return await XoverClient.connect({
host: env.get('NNTP_HOST')!,
port: env.get('NNTP_PORT', 563),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', true),
compress: true,
timeoutMs: coordinatorConfig.nntpTimeoutMs,
})
} catch (error) {
this.open--
throw error
}
}
return new Promise((resolve) => this.waiting.push(resolve))
}
private release(client: XoverClient, broken: boolean) {
if (broken) {
this.open--
try {
client.close()
} catch (error: any) {
logger.debug(`Closing a failed NNTP connection: ${error.message}`)
}
// Someone waiting gets a fresh connection.
const next = this.waiting.shift()
if (next) this.acquire().then(next, () => this.waiting.unshift(next))
return
}
const next = this.waiting.shift()
if (next) next(client)
else this.idle.push(client)
}
/** Closes every connection, also those in use: their commands fail. */
async close() {
for (const client of [...this.idle.splice(0), ...this.busy]) client.close()
this.busy.clear()
this.open = 0
}
}
export default new NntpPool()
+35
View File
@@ -0,0 +1,35 @@
import db from '@adonisjs/lucid/services/db'
import { decodeChunk } from '#lib/chunks/index'
import { buildNzb } from '#lib/nzb/index'
/**
* A release's NZB (REBUILD.md, part 9), from its chunks in the chunk store:
* every chunk decoded, its files in name order. A file stored twice (the same
* name and first article, e.g. a range read again) is taken once.
*/
class NzbService {
async build(releaseId: number): Promise<{ name: string; xml: string } | null> {
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 files = new Map<string, ReturnType<typeof decodeChunk>[number]>()
for (const chunk of chunks) {
for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file)
}
const xml = buildNzb(
[...files.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map((f) => ({
name: f.name,
poster: f.poster,
date: f.postedAt ?? 0,
groups: f.groups,
segments: f.segments.map(([number, id, size]) => ({ number, id, size })),
})),
{ title: release.name }
)
return { name: release.name, xml }
}
}
export default new NzbService()
+72
View File
@@ -0,0 +1,72 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import coordinatorConfig from '#config/coordinator'
import NntpPool from '#services/nntp_pool'
/**
* The indexed groups and their ranges (REBUILD.md, group list, coordinator).
* New groups get priorities after the existing ones (owner rule). A group's
* span from GROUP is cut into ranges of RANGE_ARTICLES; a range's priority is
* its position in its group's history (0 oldest … 1000 newest), so all groups
* start with their oldest era. Planning again only adds ranges for new articles.
*/
class Planner {
async addGroups(names: string[]): Promise<number> {
let added = 0
for (const name of names) {
const { rows } = await db.rawQuery(
`INSERT INTO groups (name, priority) SELECT ?, coalesce(max(priority), 0) + 1 FROM groups ON CONFLICT (name) DO NOTHING RETURNING id`,
[name]
)
added += rows.length
}
return added
}
/** The `top` groups matching `pattern` by article span, largest first (LIST ACTIVE). */
async largestGroups(pattern: string, top: number): Promise<string[]> {
const groups = await NntpPool.use((client) => client.listActive(pattern))
return groups
.filter((g) => g.high > g.low)
.sort((a, b) => b.high - b.low - (a.high - a.low))
.slice(0, top)
.map((g) => g.name)
}
async planGroup(group: { id: number; name: string }): Promise<number> {
const info = await NntpPool.use((client) => client.group(group.name))
const size = coordinatorConfig.rangeArticles
const last = await db.from('ranges').where('group_id', group.id).max('end_id as end').first()
const from = Math.max(info.low, Number(last?.end ?? 0) + 1)
const span = Math.max(1, info.high - info.low)
const rows = []
for (let start = from; start <= info.high; start += size) {
rows.push({
group_id: group.id,
start_id: start,
end_id: Math.min(start + size - 1, info.high),
next_id: start,
priority: Math.floor((1000 * (start - info.low)) / span),
})
}
for (let i = 0; i < rows.length; i += 2000) await db.table('ranges').multiInsert(rows.slice(i, i + 2000))
await db.from('groups').where('id', group.id).update({ low: info.low, high: info.high, planned_at: new Date() })
return rows.length
}
async planAll(): Promise<{ groups: number; ranges: number }> {
const groups = await db.from('groups').where('active', true).orderBy('priority').select('id', 'name')
let ranges = 0
for (const group of groups) {
// One group failing (e.g. gone from the server) doesn't stop the others.
try {
ranges += await this.planGroup({ id: Number(group.id), name: group.name })
} catch (error: any) {
logger.warn(`Planning ${group.name}: ${error.message}`)
}
}
return { groups: groups.length, ranges }
}
}
export default new Planner()
+161
View File
@@ -0,0 +1,161 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import coordinatorConfig from '#config/coordinator'
import NntpPool from '#services/nntp_pool'
import Spool, { type ChunkSpan } from '#services/spool'
interface Target {
id: number
group: string
/** Chunks not spooled yet, in order. */
missing: ChunkSpan[]
/** All of the range's chunks, from its start. */
total: number
}
/** How often the targets are read again from the database. */
const REFRESH_MS = 2000
/** How often the spool's size is measured (a stat per file). */
const SIZE_MS = 30_000
/**
* Keeps the news server busy ahead of the workers (REBUILD.md, prefetch).
* Every connection takes the next missing chunk: the leased ranges' first
* (a worker waits for them; all connections work on the same range, in
* order), then the next planned ranges, about SPOOL_AHEAD_PER_LEASE per
* 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
* kept its worker waiting (LOG.md 2026-10-09).
*/
export class Prefetcher {
/**
* This process's share of the ranges, `id % count = index` (`coordinator:run --shard`): one
* process's event loop handles ~60 MB/s of compressed XOVER (16.5% of a core at ~10 MB/s;
* LOG.md 2026-10-09), less than 50 connections bring.
*/
constructor(private shard = { index: 0, count: 1 }) {}
private targets: Target[] = []
private claimed = new Set<string>()
private running = 0
private refreshedAt = 0
private stopped = false
private spoolBytes = 0
private sizedAt = 0
/** Fetched since the last report: chunks, articles, ms spent fetching. */
private stats = { chunks: 0, articles: 0, fetchMs: 0, since: Date.now() }
/** One round: refreshes the targets now and then, and puts idle connections to work. */
async tick() {
if (Date.now() - this.refreshedAt >= REFRESH_MS) {
this.refreshedAt = Date.now()
this.targets = await this.readTargets()
}
this.report()
while (!this.stopped && this.running < NntpPool.size && this.peek()) {
this.running++
this.work()
.catch((error) => logger.warn(`Prefetching: ${error.message}`))
.finally(() => this.running--)
}
}
stop() {
this.stopped = true
}
get active() {
return this.running
}
/** One connection's loop: chunks until there is nothing left to fetch. */
private async work() {
while (!this.stopped) {
const next = this.take()
if (!next) return
const { target, span } = next
try {
const fetchedAt = Date.now()
const data = await NntpPool.use(async (client) => {
await client.group(target.group)
return client.xoverRaw(span.from, span.to)
})
this.stats.chunks++
this.stats.articles += span.to - span.from + 1
this.stats.fetchMs += Date.now() - fetchedAt
const existed = await Spool.has(target.id, span)
await Spool.write(target.id, span, data)
if (existed) continue
this.spoolBytes += data.length
const { rows } = await db.rawQuery(
`UPDATE ranges SET spooled_chunks = spooled_chunks + 1 WHERE id = ? RETURNING spooled_chunks`,
[target.id]
)
if (Number(rows[0]?.spooled_chunks) >= target.total) await db.from('ranges').where('id', target.id).update({ spooled_at: new Date() })
} finally {
this.claimed.delete(`${target.id}:${span.from}`)
}
}
}
/** A line a minute: what the connections fetched, and how long a chunk took. */
private report() {
const seconds = (Date.now() - this.stats.since) / 1000
if (seconds < 60) return
const { chunks, articles, fetchMs } = this.stats
if (chunks > 0) {
logger.info(
`Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` +
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk, connections busy ${Math.round((fetchMs / (seconds * 1000 * NntpPool.size)) * 100)}%.`
)
}
this.stats = { chunks: 0, articles: 0, fetchMs: 0, since: Date.now() }
}
private peek(): boolean {
return this.targets.some((t) => t.missing.some((s) => !this.claimed.has(`${t.id}:${s.from}`)))
}
/** The first unclaimed missing chunk, in target order. */
private take(): { target: Target; span: ChunkSpan } | null {
for (const target of this.targets) {
const index = target.missing.findIndex((s) => !this.claimed.has(`${target.id}:${s.from}`))
if (index === -1) continue
const [span] = target.missing.splice(index, 1)
this.claimed.add(`${target.id}:${span.from}`)
return { target, span }
}
return null
}
private async readTargets(): Promise<Target[]> {
if (Date.now() - this.sizedAt >= SIZE_MS) {
this.sizedAt = Date.now()
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 rows = (q: any) => mine(q.from('ranges as r').join('groups as g', 'g.id', 'r.group_id').select('r.id', 'g.name as group', 'r.start_id', 'r.end_id', 'r.next_id'))
// Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them.
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.
const ahead = Math.max(coordinatorConfig.spool.minAhead, coordinatorConfig.spool.aheadPerLease * leased.length)
const started = await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', '>', 0).whereNull('r.spooled_at').orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
const [{ 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 fresh = room && Number(ready) < ahead
? await rows(db.query()).where('r.status', 'planned').where('r.spooled_chunks', 0).whereRaw('r.next_id <= r.end_id').orderBy([{ column: 'r.priority' }, { column: 'r.id' }]).limit(ahead - Number(ready))
: []
const targets: Target[] = []
for (const row of [...leased, ...(room ? started : []), ...fresh]) {
const start = Number(row.start_id)
const end = Number(row.end_id)
const total = Spool.chunksOf({ start, end }).length
const missing: ChunkSpan[] = []
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 (missing.length > 0) targets.push({ id: Number(row.id), group: row.group, missing, total })
}
return targets
}
}
+39
View File
@@ -0,0 +1,39 @@
import db from '@adonisjs/lucid/services/db'
import { DEFAULT_RULES, type Rules } from '#lib/rules/index'
type StoredRules = Omit<Rules, 'version' | 'priorities'>
/**
* The rule set workers get with a lease (REBUILD.md, rules and recovery):
* the newest version, plus the indexed groups' priorities (owner rule).
* Version 1 (DEFAULT_RULES, no dropped signatures) is created when there is none.
*/
class RuleSets {
async current(): Promise<Rules> {
let row = await db.from('rule_sets').orderBy('version', 'desc').first()
if (!row) {
const { version: _version, ...rules } = DEFAULT_RULES
await db.table('rule_sets').insert({ version: 1, rules: JSON.stringify({ ...rules, droppedSignatures: [] }), note: 'default' }).onConflict('version').ignore()
row = await db.from('rule_sets').orderBy('version', 'desc').first()
}
const groups = await db.from('groups').where('active', true).select('name', 'priority')
const { version: _v, ...defaults } = DEFAULT_RULES
return {
// A field newer than the stored set takes today's default.
...defaults,
...(row.rules as StoredRules),
version: Number(row.version),
priorities: Object.fromEntries(groups.map((g) => [g.name, Number(g.priority)])),
}
}
/** A new version: what changes is recorded on the ranges that run with it. */
async publish(rules: StoredRules, note?: string): Promise<number> {
const latest = await db.from('rule_sets').max('version as v').first()
const version = Number(latest?.v ?? 0) + 1
await db.table('rule_sets').insert({ version, rules: JSON.stringify(rules), note: note ?? null })
return version
}
}
export default new RuleSets()
+28
View File
@@ -0,0 +1,28 @@
import db from '@adonisjs/lucid/services/db'
/** Releases unchanged for this long are settled (ranges are read in order; the merger adds within minutes). */
const QUIET_HOURS = 6
const BATCH = 50_000
/**
* Settles releases (REBUILD.md, part 8): once nothing has been added for
* QUIET_HOURS, its completeness is decided: complete when every file the
* [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.
*/
class Settler {
async run(): Promise<number> {
let settled = 0
while (true) {
const { rowCount } = await db.rawQuery(
`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(hours => ?) LIMIT ?)`,
[QUIET_HOURS, BATCH]
)
settled += rowCount ?? 0
if ((rowCount ?? 0) < BATCH) return settled
}
}
}
export default new Settler()
+83
View File
@@ -0,0 +1,83 @@
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import coordinatorConfig from '#config/coordinator'
export interface ChunkSpan {
from: number
to: number
}
/**
* Prefetched XOVER chunks on disk (REBUILD.md, prefetch): `<dir>/<range id>/<from>-<to>.gz`,
* each as the news server sent it. Chunks follow a fixed grid from the
* range's start, so a resume point (always at a batch boundary) is a chunk's start.
*/
class Spool {
get dir() {
return coordinatorConfig.spool.dir
}
/** The chunks of a range from `from` (its resume point) to its end. */
chunksOf(range: { start: number; end: number }, from = range.start): ChunkSpan[] {
const size = coordinatorConfig.chunkArticles
const spans: ChunkSpan[] = []
for (let at = range.start; at <= range.end; at += size) {
const span = { from: at, to: Math.min(at + size - 1, range.end) }
if (span.to >= from) spans.push(span)
}
return spans
}
private path(rangeId: number, span: ChunkSpan) {
return join(this.dir, String(rangeId), `${span.from}-${span.to}.gz`)
}
async has(rangeId: number, span: ChunkSpan) {
try {
await stat(this.path(rangeId, span))
return true
} catch {
return false
}
}
async write(rangeId: number, span: ChunkSpan, data: Buffer) {
const file = this.path(rangeId, span)
await mkdir(join(this.dir, String(rangeId)), { recursive: true })
// Written under another name first: a reader never sees half a chunk.
await writeFile(`${file}.part`, data)
await rename(`${file}.part`, file)
}
read(rangeId: number, span: ChunkSpan) {
return readFile(this.path(rangeId, span))
}
/** One chunk, e.g. one that doesn't inflate (a false end of reply; see XoverClient.compressedRaw). */
async removeChunk(rangeId: number, span: ChunkSpan) {
await rm(this.path(rangeId, span), { force: true })
}
async remove(rangeId: number) {
await rm(join(this.dir, String(rangeId)), { recursive: true, force: true })
}
/** Bytes in the spool. */
async size(): Promise<number> {
let total = 0
let ranges: string[] = []
try {
ranges = await readdir(this.dir)
} catch {
return 0
}
for (const range of ranges) {
for (const file of await readdir(join(this.dir, range)).catch(() => [])) {
total += (await stat(join(this.dir, range, file)).catch(() => ({ size: 0 }))).size
}
}
return total
}
}
export default new Spool()
+14 -1
View File
@@ -11,8 +11,21 @@
| |
*/ */
/**
* Edge workers (edge:node) have no database or web server: what start/env
* requires for those gets a placeholder, so a node's .env needs only the
* coordinator's URL and its token.
*/
if (process.argv[2] === 'edge:node') {
const placeholders: Record<string, string> = {
NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1', LOG_LEVEL: 'info', APP_KEY: 'edge-node-has-no-app-key-0000000000', APP_URL: 'http://127.0.0.1',
SESSION_DRIVER: 'memory', DB_HOST: '127.0.0.1', DB_PORT: '5432', DB_USER: 'none', DB_DATABASE: 'none',
}
for (const [key, value] of Object.entries(placeholders)) process.env[key] ??= value
}
await import('reflect-metadata') await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core') const { Ignitor, prettyPrintError } = await import('@adonisjs/core/ignitor')
/** /**
* URL to the application root. AdonisJS need it to resolve * URL to the application root. AdonisJS need it to resolve
+1 -1
View File
@@ -10,7 +10,7 @@
*/ */
await import('reflect-metadata') await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core') const { Ignitor, prettyPrintError } = await import('@adonisjs/core/ignitor')
/** /**
* URL to the application root. AdonisJS need it to resolve * URL to the application root. AdonisJS need it to resolve
+1 -1
View File
@@ -13,7 +13,7 @@
process.env.NODE_ENV = 'test' process.env.NODE_ENV = 'test'
import 'reflect-metadata' import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core' import { Ignitor, prettyPrintError } from '@adonisjs/core/ignitor'
import { configure, processCLIArgs, run } from '@japa/runner' import { configure, processCLIArgs, run } from '@japa/runner'
/** /**
-97
View File
@@ -1,97 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import indexerConfig from '#config/indexer'
import QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
import DeferredService, { isDeferred } from '#services/DeferredService'
const BATCH = 2000
/** Rebuilds the wait list (KEYS[1]) without the ids in the set KEYS[2], in one atomic pass. */
const FILTER_LIST = `
local ids = redis.call('LRANGE', KEYS[1], 0, -1)
local keep = {}
for _, id in ipairs(ids) do
if redis.call('SISMEMBER', KEYS[2], id) == 0 then keep[#keep + 1] = id end
end
redis.call('DEL', KEYS[1])
for i = 1, #keep, 5000 do
redis.call('RPUSH', KEYS[1], unpack(keep, i, math.min(i + 4999, #keep)))
end
return #ids - #keep`
/**
* Removes waiting body jobs whose message-id is on the skip or defer list,
* e.g. after adding a pattern (CONCEPT.md D24, D28). Deferred ones are
* recorded in deferred_files first, so they can still be processed later.
*/
export default class BodyQueuePrune extends BaseCommand {
public static commandName = 'body-queue:prune'
public static description = 'Drops waiting body jobs that are now skipped or deferred.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Only count' })
declare dryRun: boolean
@flags.boolean({ description: 'Drop deferred jobs without recording them (already recorded by an earlier run)' })
declare skipRecord: boolean
public async run() {
const redis = RedisService.client
const queue = QueueService.bodyQueue
const waitKey = queue.toKey('wait')
let skipped = 0
let deferred = 0
let scanned = 0
const dropped: string[] = []
try {
const ids: string[] = await redis.lrange(waitKey, 0, -1)
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH)
const data: [Error | null, string | null][] = await batch
.reduce((pipeline, id) => pipeline.hget(queue.toKey(id), 'data'), redis.pipeline())
.exec()
const drop: string[] = []
const toDefer = new Map<string, { articleNumber: number; header: Record<string, string> }[]>()
batch.forEach((id, j) => {
const job = data[j][1] ? JSON.parse(data[j][1]!) : null
if (!job?.header) return
const messageId = job.header['message-id'] ?? ''
if (isDeferred(job.header)) {
const list = toDefer.get(job.group) ?? []
list.push({ articleNumber: Number(job.articleNumber), header: job.header })
toDefer.set(job.group, list)
drop.push(id)
deferred++
} else if (indexerConfig.skipBodyMessageIds.some((re) => re.test(messageId))) {
drop.push(id)
skipped++
}
})
scanned += batch.length
if (this.dryRun || drop.length === 0) continue
if (!this.skipRecord) for (const [group, articles] of toDefer) await DeferredService.record(group, articles)
dropped.push(...drop)
}
if (dropped.length > 0) {
const dropKey = queue.toKey('prune-ids')
await redis.del(dropKey)
for (let i = 0; i < dropped.length; i += BATCH) await redis.sadd(dropKey, ...dropped.slice(i, i + BATCH))
await redis.eval(FILTER_LIST, 2, waitKey, dropKey)
await redis.del(dropKey)
for (let i = 0; i < dropped.length; i += BATCH) {
await redis.unlink(...dropped.slice(i, i + BATCH).map((id) => queue.toKey(id)))
}
}
this.logger.info(
`Scanned ${scanned} waiting body jobs: ${this.dryRun ? 'would drop' : 'dropped'} ${deferred} deferred and ${skipped} skipped.`
)
} finally {
await QueueService.closeAll()
await RedisService.close()
}
}
}
-68
View File
@@ -1,68 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import QueueService from '#services/QueueService'
import ContentService from '#services/ContentService'
const BATCH = 1000
/**
* Queues completed files for content inspection (names and groupings from
* par2 and RAR headers, CONCEPT.md D21) that were completed before it
* existed. Costs one article download per file, done by worker:collection.
*/
export default class ContentBackfill extends BaseCommand {
public static commandName = 'content:backfill'
public static description = 'Queues existing files with random names for content inspection.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Queue at most this many files' })
declare limit?: number
@flags.boolean({ description: 'Also files that were inspected before (after release:rebuild --reset-content)' })
declare all: boolean
@flags.boolean({ description: 'Only archives whose listing contains NZBs, to import those (CONCEPT.md D28)' })
declare archiveNzbs: boolean
public async run() {
let lastId = 0
let queued = 0
try {
while (!this.limit || queued < this.limit) {
// Files never inspected: no fingerprint on their binary and no archive listing.
const query = db
.from('files as f')
.join('binaries as b', 'b.file_id', 'f.id')
.where('f.id', '>', lastId)
if (this.archiveNzbs) query.whereRaw(`f.archive::text ~* '\\.nzb"'`)
else if (!this.all) query.whereNull('b.md5_16k').whereNull('f.archive')
const files = await query
.orderBy('f.id')
.limit(BATCH)
.select('f.id', 'f.filename')
if (files.length === 0) break
lastId = Number(files.at(-1).id)
const jobs = files
.filter((f) => ContentService.isCandidate(f.filename))
.slice(0, this.limit ? this.limit - queued : undefined)
.map((f) => ({
name: 'process-collection',
data: { fileId: Number(f.id) },
// A new id per run: BullMQ ignores an add() for an id it still knows.
// Targeted backfills go ahead of whatever is waiting.
opts: { jobId: `content-${f.id}-${Date.now()}`, lifo: this.archiveNzbs },
}))
if (jobs.length > 0) await QueueService.collectionQueue.addBulk(jobs)
queued += jobs.length
}
this.logger.info(`Queued ${queued} files for content inspection.`)
} finally {
await QueueService.closeAll()
}
}
}
-32
View File
@@ -1,32 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import DeferredService from '#services/DeferredService'
/**
* Sends recorded deferred ranges (fully obfuscated ngPost articles, CONCEPT.md
* D28) to the body queue, oldest first. Every article in them is downloaded:
* their headers can't be grouped into files.
*/
export default class DeferredExpandRanges extends BaseCommand {
public static commandName = 'deferred:expand-ranges'
public static description = 'Queues the bodies of deferred article ranges (costly: every article is downloaded).'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Ranges to expand', default: 10 })
declare limit: number
public async run() {
try {
const { ranges, articles } = await DeferredService.expandRanges(this.limit)
this.logger.info(`Expanded ${ranges} ranges: ${articles} articles sent to the body queue.`)
} finally {
await NntpService.shutdown()
await QueueService.closeAll()
}
}
}
-282
View File
@@ -1,282 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import type { Queue } from 'bullmq'
import db from '@adonisjs/lucid/services/db'
import gcConfig from '#config/gc'
import QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
import Shutdown from '#services/Shutdown'
const CLEAN_BATCH = 1000
const SCAN_COUNT = 1000
export default class GcScheduler extends BaseCommand {
public static commandName = 'gc:scheduler'
public static description = 'Periodically removes old BullMQ jobs and stale binaries.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({ description: 'Run one sweep and exit' })
declare once: boolean
@flags.boolean({ description: 'Report what would be removed without changing anything' })
declare dryRun: boolean
public async run() {
this.logger.info(
`GC started (interval ${gcConfig.intervalMs / 1000}s${this.dryRun ? ', dry run' : ''}).`
)
try {
do {
try {
await this.sweep()
} catch (err: any) {
this.logger.error(`GC sweep failed: ${err.message}`)
}
if (!this.once) await Shutdown.sleep(gcConfig.intervalMs)
} while (!this.once && !Shutdown.requested)
} finally {
await QueueService.closeAll()
await RedisService.close()
}
}
private async sweep() {
const { completedAgeSeconds, failedAgeSeconds } = gcConfig.jobs
for (const queue of [...QueueService.all, ...QueueService.legacy]) {
const completed = await this.cleanJobs(queue, 'completed', completedAgeSeconds)
const failed = await this.cleanJobs(queue, 'failed', failedAgeSeconds)
if (completed || failed) {
this.logger.info(
`${queue.name}: ${this.verb('remove', 'removed')} ${completed} completed and ${failed} failed jobs.`
)
}
}
const trimmed = await this.trimBodyQueue()
if (trimmed) this.logger.info(`Body queue: ${this.verb('drop', 'dropped')} ${trimmed} oldest waiting jobs.`)
const binaries = await this.deleteStaleBinaries()
this.logger.info(`Binaries: ${this.verb('delete', 'deleted')} ${binaries} stale.`)
if (!this.dryRun) {
const budget = Date.now() + gcConfig.deleteBudgetMs
const parts = await this.dropCopiedParts(budget)
const single = await this.deleteSingleArticleBinaries(budget)
const releases = await this.deleteEmptyReleases(budget)
if (parts || single || releases) {
this.logger.info(
`Space: ${parts} copied parts, ${single} single-article binaries, ${releases} empty releases deleted.`
)
}
}
const deferred = await this.deleteOldDeferredFiles()
if (deferred) this.logger.info(`Deferred files: ${this.verb('delete', 'deleted')} ${deferred} past retention.`)
const { expired, scanned } = await this.expireLegacyPendingFiles()
if (scanned > 0) {
this.logger.info(
`Legacy pending files: scanned ${scanned}, ${this.verb('set a TTL on', 'set a TTL on')} ${expired} without expiry.`
)
}
}
/**
* Drops the oldest waiting body jobs above gcConfig.bodyQueueMax. BullMQ
* workers take jobs from the right end of the wait list, so the oldest are
* there.
*/
private async trimBodyQueue(): Promise<number> {
const redis = RedisService.client
const key = QueueService.bodyQueue.toKey('wait')
const excess = (await redis.llen(key)) - gcConfig.bodyQueueMax
if (excess <= 0 || this.dryRun) return Math.max(excess, 0)
let dropped = 0
while (dropped < excess) {
const ids: string[] = (await redis.rpop(key, Math.min(5000, excess - dropped))) ?? []
if (ids.length === 0) break
await redis.unlink(...ids.map((id) => QueueService.bodyQueue.toKey(id)))
dropped += ids.length
}
return dropped
}
/**
* Deletes binaries (and, by cascade, their parts) that received no segment
* for `staleAfterSeconds`: incomplete ones are given up on; completed ones
* already live on in `files`; conflicted ones will never become a file.
* Completed ones still waiting for worker:file are kept.
*/
private async deleteStaleBinaries(): Promise<number> {
const query = db
.from('binaries')
.where('last_seen_at', '<', new Date(Date.now() - gcConfig.binaries.staleAfterSeconds * 1000))
.where((q) =>
q.whereNull('completed_at').orWhereNotNull('file_id').orWhereNotNull('conflicted_at')
)
if (this.dryRun) {
const [{ count }] = await query.count('* as count')
return Number(count)
}
return Number(await query.delete())
}
private async deleteOldDeferredFiles(): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.deferredRetentionDays * 24 * 60 * 60 * 1000)
let total = 0
for (const [table, column] of [['deferred_files', 'first_seen_at'], ['deferred_ranges', 'created_at']]) {
const query = db.from(table).where(column, '<', cutoff)
if (this.dryRun) {
const [{ count }] = await query.count('* as count')
total += Number(count)
} else {
total += Number(await query.delete())
}
}
return total
}
// Keyset cursors over the tables, kept between sweeps so one pass over
// millions of rows is spread over several sweeps.
private singleCursor = 0
private releaseCursor = 0
/** Parts of header-derived binaries that are a file already (worker:file now drops them itself). */
private async dropCopiedParts(budget: number): Promise<number> {
let total = 0
while (Date.now() < budget) {
const result = await db.rawQuery(
`DELETE FROM parts WHERE binary_id IN (
SELECT b.id FROM binaries b
WHERE b.file_id IS NOT NULL AND b.size IS NULL
AND EXISTS (SELECT 1 FROM parts p WHERE p.binary_id = b.id)
LIMIT ?)`,
[500]
)
total += result.rowCount ?? 0
if (!result.rowCount) break
}
return total
}
/** Header-derived binaries that never got a second article (gcConfig.binaries.singleArticleAfterSeconds). */
private async deleteSingleArticleBinaries(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.binaries.singleArticleAfterSeconds * 1000)
let total = 0
while (Date.now() < budget) {
const { rows } = await db.rawQuery(
`WITH batch AS (
SELECT id FROM binaries WHERE id > ? ORDER BY id LIMIT ?
), gone AS (
DELETE FROM binaries b USING batch
WHERE b.id = batch.id AND b.completed_at IS NULL AND b.size IS NULL AND b.total_parts > 1
AND b.last_seen_at < ? AND (SELECT count(*) FROM parts p WHERE p.binary_id = b.id) <= 1
RETURNING 1)
SELECT (SELECT max(id) FROM batch) AS last, (SELECT count(*) FROM gone) AS deleted`,
[this.singleCursor, gcConfig.deleteBatch, cutoff]
)
total += Number(rows[0].deleted)
if (rows[0].last === null) {
this.singleCursor = 0
break
}
this.singleCursor = Number(rows[0].last)
}
return total
}
/** Releases nothing refers to any more: no files, binaries, par2 listing or spot. */
private async deleteEmptyReleases(budget: number): Promise<number> {
const cutoff = new Date(Date.now() - gcConfig.emptyReleaseAfterSeconds * 1000)
let total = 0
while (Date.now() < budget) {
const { rows } = await db.rawQuery(
`WITH batch AS (
SELECT id FROM releases WHERE id > ? ORDER BY id LIMIT ?
), gone AS (
DELETE FROM releases r USING batch
WHERE r.id = batch.id AND r.file_count = 0 AND r.first_seen_at < ?
AND NOT EXISTS (SELECT 1 FROM binaries b WHERE b.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id)
RETURNING 1)
SELECT (SELECT max(id) FROM batch) AS last, (SELECT count(*) FROM gone) AS deleted`,
[this.releaseCursor, gcConfig.deleteBatch, cutoff]
)
total += Number(rows[0].deleted)
if (rows[0].last === null) {
this.releaseCursor = 0
break
}
this.releaseCursor = Number(rows[0].last)
}
return total
}
/** Removes jobs in `state` that finished more than `ageSeconds` ago. */
private async cleanJobs(queue: Queue, state: 'completed' | 'failed', ageSeconds: number) {
const graceMs = ageSeconds * 1000
if (this.dryRun) {
const cutoff = Date.now() - graceMs
// Finished-job sets are sorted by finishedOn, so a score range counts them cheaply.
return RedisService.client.zcount(queue.toKey(state), '-inf', cutoff)
}
let total = 0
let removed: string[]
do {
removed = await queue.clean(graceMs, CLEAN_BATCH, state)
total += removed.length
} while (removed.length === CLEAN_BATCH)
return total
}
/**
* Pending-file hashes of the old header worker (and the pre-AdonisJS app)
* never expire. Give them a TTL rather than deleting them outright.
*/
private async expireLegacyPendingFiles() {
const redis = RedisService.client
const { prefix, ttlSeconds } = gcConfig.legacyPendingFiles
let cursor = '0'
let scanned = 0
let expired = 0
do {
const [next, keys]: [string, string[]] = await redis.scan(
cursor,
'MATCH',
`${prefix}*`,
'COUNT',
SCAN_COUNT
)
cursor = next
if (keys.length === 0) continue
scanned += keys.length
const ttls: [Error | null, number][] = await keys
.reduce((pipeline, key) => pipeline.ttl(key), redis.pipeline())
.exec()
const orphaned = keys.filter((_, i) => ttls[i][1] === -1)
expired += orphaned.length
if (!this.dryRun && orphaned.length > 0) {
await orphaned
.reduce((pipeline, key) => pipeline.expire(key, ttlSeconds, 'NX'), redis.pipeline())
.exec()
}
} while (cursor !== '0')
return { scanned, expired }
}
private verb(present: string, past: string) {
return this.dryRun ? `would ${present}` : past
}
}
-72
View File
@@ -1,72 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import Group from '#models/group'
import NntpService from '#services/NntpService'
import { parseGroupResponse } from '#lib/nntp/group'
/**
* Adds newsgroups to index (or reactivates them). The scheduler picks them
* up on its next tick, starting INDEX_INITIAL_BACKFILL articles back.
*/
export default class GroupAdd extends BaseCommand {
public static commandName = 'group:add'
public static description = 'Adds newsgroups to index.'
public static options: CommandOptions = {
startApp: true,
}
@args.spread({ description: 'Group names, e.g. alt.binaries.hdtv' })
declare names: string[]
@flags.boolean({ description: 'Deactivate the groups instead' })
declare disable: boolean
@flags.boolean({
description: 'Also download bodies to identify obfuscated posts (one body per article; bandwidth-heavy)',
})
declare bodies: boolean
@flags.number({ description: 'Start this many articles back instead of INDEX_INITIAL_BACKFILL (new groups only)' })
declare backfill?: number
@flags.boolean({ description: 'With --backfill: also for groups indexed before, skipping what was missed since' })
declare reset: boolean
public async run() {
for (const name of this.names) {
if (!/^[a-z0-9][a-z0-9.+_-]*$/i.test(name)) {
this.logger.error(`Not a valid group name: ${name}`)
continue
}
const group = await Group.firstOrCreate({ name }, { name, active: !this.disable, fetchBodies: this.bodies })
group.active = !this.disable
if (this.bodies) group.fetchBodies = true
if (this.backfill !== undefined && (this.reset || BigInt(group.lastQueuedId ?? 0) === 0n)) {
// The scheduler continues after last_queued_id.
const last = await this.lastArticle(name)
group.lastQueuedId = (last > BigInt(this.backfill) ? last - BigInt(this.backfill) : 0n)
group.lastIndexedId = group.lastQueuedId
}
await group.save()
this.logger.info(
`${name}: ${group.active ? 'active' : 'inactive'}, bodies ${group.fetchBodies ? 'on' : 'off'}` +
(this.backfill !== undefined ? `, starting after article ${group.lastQueuedId}` : '')
)
}
if (this.backfill !== undefined) await NntpService.shutdown()
}
private async lastArticle(name: string): Promise<bigint> {
const conn = await NntpService.acquire()
let failed = false
try {
return parseGroupResponse((await conn.group(name)).resp).last
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
}
}
-176
View File
@@ -1,176 +0,0 @@
import { writeFile } from 'node:fs/promises'
import { setTimeout as sleep } from 'node:timers/promises'
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import NntpService from '#services/NntpService'
import indexerConfig from '#config/indexer'
import { parseGroupResponse } from '#lib/nntp/group'
import { parseSubject } from '#lib/subject/index'
interface GroupRate {
name: string
perMinute: number
sampled?: number
normal?: number
obfuscated?: number
skipped?: number
avgArticleBytes?: number
avgHeaderBytes?: number
}
/**
* Lists the busiest newsgroups (by articles posted per minute, from two
* LIST ACTIVE snapshots) and optionally samples their newest headers to
* estimate what indexing them would cost. See LOG.md (2026-09-28 (14)).
*
* node ace groups:top --minutes 5 --limit 1000 --sample 300 --out /data/top-groups.txt
*/
export default class GroupsTop extends BaseCommand {
public static commandName = 'groups:top'
public static description = 'Lists the busiest binary groups and estimates the bandwidth to index them.'
public static options: CommandOptions = {
startApp: true,
}
@flags.string({ description: 'Group pattern for LIST ACTIVE', default: 'alt.binaries.*' })
declare pattern: string
@flags.number({ description: 'Minutes between the two snapshots', default: 5 })
declare minutes: number
@flags.number({ description: 'Number of groups to list', default: 1000 })
declare limit: number
@flags.number({ description: 'Headers to sample per listed group (0 = no sampling)', default: 0 })
declare sample: number
@flags.string({ description: 'Write the list to this file (default: print only)' })
declare out?: string
public async run() {
const conn = await NntpService.acquire()
let failed = false
try {
this.logger.info(`Snapshot 1 of ${this.pattern}…`)
const first = await this.snapshot(conn)
this.logger.info(`${first.size} groups. Waiting ${this.minutes} min for snapshot 2…`)
const started = Date.now()
await sleep(this.minutes * 60_000)
const second = await this.snapshot(conn)
const minutes = (Date.now() - started) / 60_000
const groups: GroupRate[] = []
for (const [name, high] of second) {
const before = first.get(name)
if (before === undefined || high <= before) continue
groups.push({ name, perMinute: Number(high - before) / minutes })
}
groups.sort((a, b) => b.perMinute - a.perMinute)
const top = groups.slice(0, this.limit)
this.logger.info(`${groups.length} active groups; listing ${top.length}.`)
if (this.sample > 0) {
for (const [i, group] of top.entries()) {
try {
Object.assign(group, await this.sampleGroup(conn, group.name))
} catch (error: any) {
this.logger.warning(`${group.name}: ${error.message}`)
}
if ((i + 1) % 50 === 0) this.logger.info(`Sampled ${i + 1}/${top.length} groups…`)
}
}
const text = this.format(top, minutes)
if (this.out) {
await writeFile(this.out, text)
this.logger.info(`Written to ${this.out}`)
} else {
console.log(text)
}
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
await NntpService.shutdown()
}
}
/** Group name → highest article number, from LIST ACTIVE. */
private async snapshot(conn: any): Promise<Map<string, bigint>> {
const { data } = await conn.list(this.pattern)
const result = new Map<string, bigint>()
for (const line of Buffer.from(data).toString('latin1').split('\r\n')) {
const [name, high] = line.split(' ')
if (name && /^\d+$/.test(high ?? '')) result.set(name, BigInt(high))
}
return result
}
/** Classifies the newest headers the way worker:fetch does. */
private async sampleGroup(conn: any, name: string) {
const { last } = parseGroupResponse((await conn.group(name)).resp)
const start = last - BigInt(this.sample) + 1n
const { overviews } = await conn.xover((start > 0n ? start : 1n).toString(), last.toString())
let normal = 0
let skipped = 0
let obfuscated = 0
let obfuscatedBytes = 0
let headerBytes = 0
for (const [, header] of overviews as [number, Record<string, string>][]) {
if (!header) continue
headerBytes += Object.values(header).reduce((sum, v) => sum + String(v).length + 1, 0)
if (header.subject && parseSubject(header.subject)) normal++
else if (indexerConfig.skipBodyMessageIds.some((re) => re.test(header['message-id'] ?? ''))) skipped++
else {
obfuscated++
obfuscatedBytes += Number(header[':bytes']) || 0
}
}
const sampled = normal + skipped + obfuscated
return {
sampled,
normal,
skipped,
obfuscated,
avgArticleBytes: obfuscated ? obfuscatedBytes / obfuscated : 0,
avgHeaderBytes: sampled ? headerBytes / sampled : 0,
}
}
private format(groups: GroupRate[], minutes: number): string {
const sampled = groups.some((g) => g.sampled)
const lines = [
`# Busiest ${this.pattern} groups by articles/min, measured over ${minutes.toFixed(1)} min on ${new Date().toISOString()}`,
sampled
? '# rank\tgroup\tarticles/min\tobfuscated%\tskipped%\tbody Mbit/s\theader Mbit/s'
: '# rank\tgroup\tarticles/min',
]
let bodyTotal = 0
let headerTotal = 0
let articlesTotal = 0
for (const [i, g] of groups.entries()) {
articlesTotal += g.perMinute
if (!g.sampled) {
lines.push(`${i + 1}\t${g.name}\t${Math.round(g.perMinute)}`)
continue
}
const obfuscatedShare = g.obfuscated! / g.sampled
// Bodies: every obfuscated article is downloaded once. Headers: every article.
const bodyMbit = (g.perMinute * obfuscatedShare * g.avgArticleBytes! * 8) / 60 / 1e6
const headerMbit = (g.perMinute * g.avgHeaderBytes! * 8) / 60 / 1e6
bodyTotal += bodyMbit
headerTotal += headerMbit
lines.push(
[i + 1, g.name, Math.round(g.perMinute), (100 * obfuscatedShare).toFixed(1), ((100 * g.skipped!) / g.sampled).toFixed(1), bodyMbit.toFixed(1), headerMbit.toFixed(2)].join('\t')
)
}
lines.push(`# total: ${Math.round(articlesTotal)} articles/min`)
if (sampled) {
lines.push(`# bandwidth to index like alt.binaries.test (headers + obfuscated bodies): ${(bodyTotal + headerTotal).toFixed(0)} Mbit/s`)
lines.push(`# of which bodies ${bodyTotal.toFixed(0)} Mbit/s, headers ${headerTotal.toFixed(1)} Mbit/s (skip list applied)`)
}
return lines.join('\n') + '\n'
}
}
-119
View File
@@ -1,119 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import Group from '#models/group'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import indexerConfig from '#config/indexer'
import Shutdown from '#services/Shutdown'
import FetchRangeService from '#services/FetchRangeService'
import SplitService from '#services/SplitService'
export default class IndexScheduler extends BaseCommand {
public static commandName = 'index:scheduler'
public static description = 'Periodically checks for new articles and schedules them for fetching.'
public static options: CommandOptions = {
startApp: true,
}
private pool = NntpService
public async run() {
this.logger.info('Scheduler started. Awaiting tasks...')
const schedule = async () => {
this.logger.info('Checking for new headers...')
const groups = await Group.query().where('active', true)
if (groups.length === 0) {
this.logger.info('No active groups to index. Add some via `node ace db:seed` or manually.')
return
}
let conn
let failed = false
try {
conn = await this.pool.acquire()
for (const group of groups) {
try {
const { first: firstArticle, last: lastArticle } = await this.pool.selectGroup(
conn,
group.name
)
await FetchRangeService.requeue(group)
// Postgres returns bigint columns as strings.
const lastQueued = BigInt(group.lastQueuedId ?? 0)
const { batchSize, maxBatchesPerTick, initialBackfill } = indexerConfig
let startId =
lastQueued === 0n ? lastArticle - initialBackfill + 1n : lastQueued + 1n
if (startId < firstArticle) startId = firstArticle
if (startId > lastArticle) {
await FetchRangeService.advanceWatermark(group)
this.logger.info(`No new headers for group ${group.name}.`)
continue
}
let batches = 0
let endId = startId - 1n
while (endId < lastArticle && batches < maxBatchesPerTick) {
const batchStart = endId + 1n
endId = batchStart + batchSize - 1n < lastArticle ? batchStart + batchSize - 1n : lastArticle
await FetchRangeService.queue(group, batchStart, endId)
batches++
}
this.logger.info(
`Queued ${batches} fetch job(s) for ${group.name}: articles ${startId} to ${endId}` +
(endId < lastArticle ? ` (${lastArticle - endId} left for next tick)` : '')
)
group.lastQueuedId = endId
await group.save()
const watermark = await FetchRangeService.advanceWatermark(group)
if (endId - watermark > batchSize * BigInt(maxBatchesPerTick) * 3n) {
this.logger.warning(
`${group.name}: indexed up to ${watermark}, ${endId - watermark} articles behind the queue.`
)
}
} catch (err: any) {
failed = true
this.logger.error(`Error processing group ${group.name}: ${err.message}`)
}
}
} catch (err: any) {
failed = true
this.logger.error(`Error in scheduler main loop: ${err.message}`)
} finally {
if (conn) {
this.pool.release(conn, failed)
}
}
}
const split = async () => {
try {
const result = await SplitService.splitPending()
if (result.parents > 0) {
this.logger.info(
`Split ${result.parents} conflicted binaries: ${result.volumes} verified volumes, ${result.completed} completed.`
)
}
} catch (err: any) {
this.logger.error(`Error splitting conflicted binaries: ${err.message}`)
}
}
// Run immediately, then wait a full interval after each run so runs never overlap.
while (!Shutdown.requested) {
await schedule()
await split()
await Shutdown.sleep(indexerConfig.intervalMs)
}
await this.pool.shutdown()
await QueueService.closeAll()
}
}
-163
View File
@@ -1,163 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import IngestService from '#services/IngestService'
import NameService from '#services/NameService'
import QueueService from '#services/QueueService'
import { rarReleaseIdentity, releaseIdentity } from '#lib/release/index'
const BATCH = 5000
/**
* Recomputes the release of every binary (and its file) with the current
* rules in lib/release. Used after changing those rules.
*/
export default class ReleaseRebuild extends BaseCommand {
public static commandName = 'release:rebuild'
public static description = 'Reassigns all binaries and files to releases using the current grouping rules.'
public static options: CommandOptions = {
startApp: true,
}
@flags.boolean({
description:
'Also drop everything derived from file contents (par2 lists, par2/RAR groupings, real names), ' +
'to rebuild it with content:backfill --all',
})
declare resetContent: boolean
public async run() {
if (this.resetContent) await this.dropContentDerived()
let lastId = 0
let assigned = 0
let unassigned = 0
try {
while (true) {
const binaries = await db
.from('binaries')
.where('id', '>', lastId)
.orderBy('id')
.limit(BATCH)
.select('id', 'poster', 'subject', 'name', 'groups', 'posted_at')
if (binaries.length === 0) break
lastId = Number(binaries.at(-1).id)
const segments = binaries.map((binary) => {
// Body-derived binaries store a synthetic subject, see segmentFromYenc.
const weak = /^"[^"]*" yEnc$/.test(binary.subject)
return {
id: Number(binary.id),
poster: binary.poster,
groups: binary.groups,
postedAt: binary.posted_at ? new Date(binary.posted_at) : undefined,
weakIdentity: weak,
release: releaseIdentity({ poster: binary.poster, subject: binary.subject, name: binary.name, weak }),
}
})
await db.transaction(async (trx) => {
const releaseIds = await IngestService.upsertReleases(trx.knexClient, segments)
const ids = segments.map((s) => s.id)
const releaseIdsPerBinary = segments.map((s) =>
s.release ? releaseIds.get(s.release.key)! : null
)
await trx.rawQuery(
`UPDATE binaries b SET release_id = v.release_id
FROM unnest(?::bigint[], ?::bigint[]) AS v(id, release_id)
WHERE b.id = v.id AND b.release_id IS DISTINCT FROM v.release_id
AND NOT (v.release_id IS NULL
AND b.release_id IN (SELECT id FROM releases WHERE source IN ('par2', 'rar')))
-- A posted NZB decided this grouping (NzbImportService); name rules don't override it.
AND NOT EXISTS (SELECT 1 FROM files n WHERE n.release_id = b.release_id AND n.content_type = 'nzb')`,
[ids, releaseIdsPerBinary] as any
)
})
assigned += segments.filter((s) => s.release).length
unassigned += segments.filter((s) => !s.release).length
}
// Files grouped by their RAR headers: regroup from the stored listing (no downloads).
let rarGrouped = 0
let rarCursor = 0
while (true) {
const rows = await db
.from('files as f')
.join('binaries as b', 'b.file_id', 'f.id')
.whereNotNull('f.archive')
.whereNull('b.release_id')
.where('f.id', '>', rarCursor)
.orderBy('f.id')
.limit(BATCH)
.select('f.id as file_id', 'f.archive', 'b.id as binary_id', 'b.poster', 'b.groups', 'b.posted_at')
if (rows.length === 0) break
rarCursor = Number(rows.at(-1).file_id)
const segments = rows.map((r) => ({
fileId: Number(r.file_id),
binaryId: Number(r.binary_id),
poster: r.poster,
groups: r.groups,
postedAt: r.posted_at ? new Date(r.posted_at) : undefined,
weakIdentity: true,
release: rarReleaseIdentity(r.archive?.entries ?? []),
}))
const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), segments)
for (const s of segments.filter((x) => x.release)) {
const releaseId = ids.get(s.release!.key)
await db.from('binaries').where('id', s.binaryId).update({ release_id: releaseId })
await db.from('files').where('id', s.fileId).update({ release_id: releaseId })
rarGrouped++
}
}
// Real names from par2 for binaries with a known size (no downloads).
let named = 0
let nameCursor = 0
while (true) {
const rows = await db.from('binaries').whereNotNull('size').where('id', '>', nameCursor).orderBy('id').limit(BATCH).select('id')
if (rows.length === 0) break
nameCursor = Number(rows.at(-1).id)
named += await NameService.resolve(rows.map((r) => Number(r.id)))
}
const files = await db.rawQuery(
`UPDATE files f SET release_id = b.release_id FROM binaries b
WHERE b.file_id = f.id AND f.release_id IS DISTINCT FROM b.release_id`
)
const orphans = await db.rawQuery(
`DELETE FROM releases r WHERE NOT EXISTS (SELECT 1 FROM binaries b WHERE b.release_id = r.id)
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.release_id = r.id)`
)
if (this.resetContent) {
this.logger.info('Content-derived data dropped; run `node ace content:backfill --all` to rebuild it.')
}
this.logger.info(
`Binaries: ${assigned} in a release, ${unassigned} without, ${rarGrouped} regrouped from RAR headers, ` +
`${named} named from par2. ` +
`Files updated: ${files.rowCount}. Empty releases removed: ${orphans.rowCount}.`
)
} finally {
await QueueService.closeAll()
}
}
/** par2 lists, par2/RAR release assignments and recovered names. */
private async dropContentDerived() {
await db.transaction(async (trx) => {
await trx.rawQuery('DELETE FROM release_files')
await trx.rawQuery(`UPDATE binaries SET real_name = NULL WHERE real_name IS NOT NULL`)
await trx.rawQuery(`UPDATE files SET real_name = NULL, archive = NULL WHERE real_name IS NOT NULL OR archive IS NOT NULL`)
await trx.rawQuery(`UPDATE releases SET contents = NULL, encrypted = NULL WHERE contents IS NOT NULL OR encrypted IS NOT NULL`)
// Binaries whose name rules find no release were grouped from contents (or wrongly by size).
await trx.rawQuery(`
UPDATE binaries SET release_id = NULL
WHERE subject ~ '^"[^"]*" yEnc$' AND release_id IS NOT NULL
AND (name !~ '\\.[A-Za-z0-9]{1,5}$' OR release_id IN (SELECT id FROM releases WHERE source IN ('par2', 'rar')))`)
await trx.rawQuery(`
UPDATE files f SET release_id = b.release_id FROM binaries b
WHERE b.file_id = f.id AND f.release_id IS DISTINCT FROM b.release_id`)
})
this.logger.info('Dropped par2 lists, content-derived groupings and recovered names.')
}
}
-20
View File
@@ -1,20 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { renameFromStoredContents } from '#services/ArchiveService'
import { regroupAndRenameFromPar2 } from '#services/NameService'
/** Renames random-named releases from their stored archive listings (no downloads). */
export default class ReleaseRename extends BaseCommand {
public static commandName = 'release:rename'
public static description = 'Renames random-named releases from their par2 listings and archive contents.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const par2 = await regroupAndRenameFromPar2()
this.logger.info(`par2: ${par2.regrouped} files moved into the set that names them, ${par2.renamed} releases renamed.`)
this.logger.info(`Renamed ${await renameFromStoredContents()} releases from their archive contents.`)
}
}
-57
View File
@@ -1,57 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
/** Prints how well files are being grouped into releases. */
export default class ReleaseStats extends BaseCommand {
public static commandName = 'release:stats'
public static description = 'Shows release recognition statistics.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
const { rows } = await db.rawQuery(`
WITH r AS (
SELECT r.id, r.file_total, r.poster IS NULL AS weak, r.source,
count(b.id) AS binaries,
count(b.id) FILTER (WHERE b.completed_at IS NOT NULL) AS completed
FROM releases r LEFT JOIN binaries b ON b.release_id = r.id
GROUP BY r.id
)
SELECT
count(*) AS releases,
count(*) FILTER (WHERE completed > 0) AS with_files,
count(*) FILTER (WHERE completed >= 2) AS multi_file,
count(*) FILTER (WHERE file_total IS NOT NULL AND completed >= file_total) AS complete_by_counter,
count(*) FILTER (WHERE weak) AS from_obfuscated,
count(*) FILTER (WHERE weak AND completed > 0) AS from_obfuscated_with_files
FROM r`)
const { rows: coverage } = await db.rawQuery(`
SELECT count(*) AS completed_files,
count(*) FILTER (WHERE release_id IS NOT NULL) AS in_release
FROM binaries WHERE completed_at IS NOT NULL`)
const { rows: bySource } = await db.rawQuery(`
SELECT coalesce(r.source, 'unknown') AS source, count(DISTINCT r.id) AS releases,
count(b.id) FILTER (WHERE b.completed_at IS NOT NULL) AS completed_files
FROM releases r JOIN binaries b ON b.release_id = r.id GROUP BY 1 ORDER BY 2 DESC`)
// Readable names: descriptive as posted, or recovered from contents.
const { rows: naming } = await db.rawQuery(`
WITH f AS (
SELECT real_name IS NOT NULL AS recovered,
is_readable_name(coalesce(real_name, filename)) AS readable_name,
regexp_replace(coalesce(real_name, filename),
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z(\\.\\d{3})?|zip|\\d{3}|nfo|sfv|nzb|tar|zst|mkv|mp4|avi|flac|mp3|jpg))+$', '', 'i') AS stem,
coalesce(real_name, filename) AS name
FROM files
)
SELECT count(*) AS files,
count(*) FILTER (WHERE recovered) AS names_recovered,
count(*) FILTER (WHERE name ~ '\\.[A-Za-z0-9]{1,5}$' AND readable_name) AS readable
FROM f`)
this.logger.info(JSON.stringify({ ...rows[0], ...coverage[0] }))
this.logger.info(`naming: ${JSON.stringify(naming[0])}`)
this.logger.info(`by source: ${JSON.stringify(bySource)}`)
}
}
+103
View File
@@ -0,0 +1,103 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { setTimeout as sleep } from 'node:timers/promises'
import { Prefetcher } from '#services/prefetcher'
import Planner from '#services/planner'
import FragmentMerger from '#services/fragment_merger'
import Settler from '#services/settler'
import NntpPool from '#services/nntp_pool'
const TICK_MS = 2000
const PLAN_EVERY_MS = 60 * 60 * 1000
const MERGE_EVERY_MS = 60 * 1000
const MERGE_BUDGET_MS = 30 * 1000
const STOP_WAIT_MS = 15 * 1000
/**
* The coordinator's background work (REBUILD.md, part 1): the prefetcher,
* merging fragments of finished ranges every minute, and every hour
* planning ranges for new articles and settling quiet releases. The API (leases, stream,
* batches) runs in the web server.
*/
export default class CoordinatorRun extends BaseCommand {
static commandName = 'coordinator:run'
static description = 'Prefetches ranges into the spool and plans new ones'
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.string({ description: 'This process\'s share of the prefetching, "i/n" (ranges with id % n = i - 1); only 1/n plans, merges and settles' })
declare shard?: string
async run() {
const [index, count] = (this.shard ?? '1/1').split('/').map(Number)
if (!(count >= 1 && index >= 1 && index <= count)) throw new Error(`--shard: "i/n" with 1 <= i <= n, not "${this.shard}"`)
const prefetcher = new Prefetcher({ index: index - 1, count })
const main = index === 1
let stopping = false
let stopped: () => void
const done = new Promise<void>((resolve) => (stopped = resolve))
// bin/console turns SIGTERM into app.terminate: wait for the spooling in flight first.
this.app.terminating(async () => {
this.logger.info('Stopping: waiting for the chunks in flight.')
stopping = true
prefetcher.stop()
await done
})
this.logger.info(count > 1 ? `Coordinator started (shard ${index}/${count}${main ? '' : ', prefetching only'}).` : 'Coordinator started.')
// Two loops: the prefetcher's ticks don't wait for merging or planning (a merge round
// of up to MERGE_BUDGET_MS left the connections idle once their targets were fetched).
const prefetching = (async () => {
while (!stopping) {
try {
await prefetcher.tick()
} catch (error: any) {
this.logger.error(`Prefetch round failed: ${error.message}`)
}
await sleep(TICK_MS)
}
})()
let plannedAt = 0
let mergedAt = 0
while (!stopping) {
try {
if (main && Date.now() - plannedAt >= PLAN_EVERY_MS) {
plannedAt = Date.now()
const { groups, ranges } = await Planner.planAll()
if (ranges > 0) this.logger.info(`Planned ${ranges} ranges over ${groups} groups.`)
const settled = await Settler.run()
if (settled > 0) this.logger.info(`Settled ${settled} releases.`)
}
if (main && Date.now() - mergedAt >= MERGE_EVERY_MS) {
mergedAt = Date.now()
// Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS.
// (Going on while only waiting files were left kept Postgres busy all the time.)
const totals = { completed: 0, incomplete: 0, waiting: 0 }
let decidedThisPass = 0
while (!stopping && Date.now() - mergedAt < MERGE_BUDGET_MS) {
const merged = await FragmentMerger.run()
totals.completed += merged.completed
totals.incomplete += merged.incomplete
totals.waiting = merged.waiting
decidedThisPass += merged.completed + merged.incomplete
if (merged.wrapped) {
if (decidedThisPass === 0) break
decidedThisPass = 0
}
}
if (totals.completed || totals.incomplete) this.logger.info(`Fragments: ${totals.completed} files completed, ${totals.incomplete} incomplete, ${totals.waiting} waiting.`)
}
} catch (error: any) {
this.logger.error(`Coordinator round failed: ${error.message}`)
}
await sleep(TICK_MS)
}
await prefetching
// The chunks in flight, for at most STOP_WAIT_MS (a connection the server holds back can take minutes); then closed.
const waitUntil = Date.now() + STOP_WAIT_MS
while (prefetcher.active > 0 && Date.now() < waitUntil) await sleep(200)
if (prefetcher.active > 0) this.logger.info(`Closing ${prefetcher.active} connections still fetching.`)
await NntpPool.close()
this.logger.info('Coordinator stopped.')
stopped!()
await this.terminate()
}
}
+179
View File
@@ -0,0 +1,179 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { unzipSync } from 'node:zlib'
import env from '#start/env'
import { XoverClient } from '#lib/nntp/index'
import { RangeIndexer } from '#lib/edge/index'
import { DEFAULT_RULES, type Rules } from '#lib/rules/index'
import { decodeChunk, encodeChunk } from '#lib/chunks/index'
import db from '@adonisjs/lucid/services/db'
import BatchStore from '#services/batch_store'
/**
* Measures the edge worker on a real range (REBUILD.md: TypeScript or Rust):
* --capture saves a range's compressed XOVER chunks to a directory once;
* without it the saved chunks are replayed through the range indexer, timing
* inflate, parse and indexing per chunk, with memory and output sizes.
*/
export default class EdgeBench extends BaseCommand {
static commandName = 'edge:bench'
static description = 'Measures the edge worker on a captured range'
static options: CommandOptions = { startApp: true }
@flags.string({ description: 'Directory of the captured chunks', required: true })
declare dir: string
@flags.boolean({ description: 'Capture the range first (needs NNTP_* in the environment)' })
declare capture: boolean
@flags.string({ description: 'Group to capture' })
declare group?: string
@flags.number({ description: 'First article to capture' })
declare start?: number
@flags.number({ description: 'Articles to capture', default: 2_000_000 })
declare articles: number
@flags.number({ description: 'Articles per XOVER', default: 50_000 })
declare chunk: number
@flags.number({ description: 'Chunks per batch (results taken every N chunks)', default: 10 })
declare batchChunks: number
@flags.number({ description: 'Chunks without a new part before an open file is given up', default: 5 })
declare idle: number
@flags.boolean({ description: 'Decode every release chunk and encode it again: the same bytes' })
declare verify: boolean
@flags.boolean({ description: 'Also store the batches (BatchStore, the configured database), timed' })
declare store: boolean
@flags.string({ description: 'Indexed groups in priority order, comma-separated (owner rule)' })
declare priorities?: string
async run() {
if (this.capture) await this.captureRange()
await this.replay()
}
private async captureRange() {
if (!this.group || this.start === undefined) throw new Error('--group and --start are needed to capture')
await mkdir(this.dir, { recursive: true })
const client = 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: true,
timeoutMs: 300_000,
})
await client.group(this.group)
const end = this.start + this.articles - 1
let index = 0
for (let from = this.start; from <= end; from += this.chunk, index++) {
const to = Math.min(from + this.chunk - 1, end)
const started = Date.now()
const raw = await client.xoverRaw(from, to)
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`)
}
await writeFile(join(this.dir, 'range.json'), JSON.stringify({ group: this.group, start: this.start, end }))
client.close()
}
private async replay() {
const { group, start, end } = JSON.parse(await readFile(join(this.dir, 'range.json'), 'utf8')) as { group: string; start: number; end: number }
const priorities = Object.fromEntries((this.priorities ?? group).split(',').map((g, i) => [g.trim(), i + 1]))
if (!(group in priorities)) priorities[group] = Object.keys(priorities).length + 1
const rules: Rules = { ...DEFAULT_RULES, priorities, droppedSignatures: [] }
const files = (await readdir(this.dir)).filter((f) => f.endsWith('.gz')).sort()
const indexer = new RangeIndexer({ group, start, end, rules, idleChunks: this.idle })
const t = { inflate: 0, parse: 0, index: 0 }
let compressed = 0
let text = 0
let peakRss = 0
let peakDetail = ''
const out = { headers: 0, releases: 0, files: 0, segments: 0, payload: 0, candidates: 0, fragments: 0, samples: 0 }
const counts = new Map<string, number>()
const keys = new Set<string>()
let batches = 0
let storeMs = 0
let rangeId = 0
let nodeId = 0
if (this.store) {
// Inactive: the coordinator neither plans nor leases it.
const [g] = await db.table('groups').insert({ name: `bench:${group}:${Date.now()}`, priority: 1_000_000 + (Date.now() % 1_000_000), active: false }).returning('id')
const [n] = await db.table('nodes').insert({ name: `bench-${Date.now()}` }).returning('id')
nodeId = Number(n.id)
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: start, end_id: end, next_id: start, status: 'leased', node_id: nodeId }).returning('id')
rangeId = Number(r.id)
}
const cpuStart = process.cpuUsage()
const wall = Date.now()
for (const file of files) {
const raw = await readFile(join(this.dir, file))
compressed += raw.length
let at = performance.now()
const data = unzipSync(raw).toString('utf8')
t.inflate += performance.now() - at
text += data.length
at = performance.now()
const next = Number(file.split('-')[2].replace('.gz', '')) + 1
// The worker's path: the reply's text straight into the indexer (parsing is part of "index").
indexer.addText(data, next)
const last = file === files.at(-1)
if (last) indexer.finish()
t.index += performance.now() - at
if (!last && (files.indexOf(file) + 1) % this.batchChunks !== 0) continue
at = performance.now()
const batch = indexer.take()
t.index += performance.now() - at
if (this.store) {
const storedAt = performance.now()
await BatchStore.store(rangeId, nodeId, batch)
storeMs += performance.now() - storedAt
}
out.headers += batch.headers
out.releases += batch.releases.length
batches++
for (const r of batch.releases) {
if (this.verify) {
const decoded = decodeChunk(r.payload)
if (!encodeChunk(decoded).equals(r.payload) || decoded.reduce((n, f) => n + f.segments.length, 0) !== r.segments) {
throw new Error(`Release ${r.key}: its chunk doesn't decode to itself`)
}
}
keys.add(r.key)
out.files += r.files
out.segments += r.segments
out.payload += r.payload.length
}
out.candidates += batch.candidates.length
out.fragments += batch.fragments.length
out.samples += batch.samples.length
for (const c of batch.counts) counts.set(`${c.rule} ${c.key}`.trim(), (counts.get(`${c.rule} ${c.key}`.trim()) ?? 0) + c.articles)
const m = process.memoryUsage()
if (m.rss > peakRss) {
peakRss = m.rss
peakDetail = `heap ${(m.heapUsed / 1e6).toFixed(0)}/${(m.heapTotal / 1e6).toFixed(0)} MB, external ${(m.external / 1e6).toFixed(0)} MB, array buffers ${(m.arrayBuffers / 1e6).toFixed(0)} MB`
}
}
// Done, so nothing takes it for a range to work on (a leased one was set back to planned once).
if (this.store) await db.from('ranges').where('id', rangeId).update({ status: 'done', done_at: new Date() })
const cpu = process.cpuUsage(cpuStart)
const cpuS = (cpu.user + cpu.system) / 1e6
const total = t.inflate + t.parse + t.index
this.logger.info(`${group} ${start}–${end}: ${files.length} chunks, ${(compressed / 1e6).toFixed(0)} MB compressed, ${(text / 1e6).toFixed(0)} MB text`)
this.logger.info(`${out.headers.toLocaleString('en')} headers in ${(total / 1000).toFixed(1)} s (${Math.round(out.headers / (total / 1000)).toLocaleString('en')}/s on one core; CPU ${cpuS.toFixed(1)} s, wall ${((Date.now() - wall) / 1000).toFixed(1)} s)`)
this.logger.info(` inflate ${(t.inflate / 1000).toFixed(1)} s, parse ${(t.parse / 1000).toFixed(1)} s, index ${(t.index / 1000).toFixed(1)} s; peak RSS ${(peakRss / 1e6).toFixed(0)} MB (${peakDetail})`)
this.logger.info(` ${batches} batches of ${this.batchChunks} chunks: ${out.releases} release chunks for ${keys.size} releases (${(out.releases / Math.max(1, keys.size)).toFixed(2)} chunks per release)`)
this.logger.info(` kept: ${out.releases} release chunks, ${out.files} files, ${out.segments} segments, payload ${(out.payload / 1e6).toFixed(1)} MB (${(out.payload / Math.max(1, out.segments)).toFixed(1)} B/segment); ${out.candidates} candidates, ${out.fragments} fragments, ${out.samples} samples`)
for (const [k, v] of [...counts].sort((a, b) => b[1] - a[1]).slice(0, 8)) this.logger.info(` dropped ${k}: ${v.toLocaleString('en')} articles`)
if (this.store) this.logger.info(` stored ${batches} batches in ${(storeMs / 1000).toFixed(1)} s (${(storeMs / Math.max(1, out.releases)).toFixed(2)} ms per release chunk)`)
}
}
+268
View File
@@ -0,0 +1,268 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { setTimeout as sleep } from 'node:timers/promises'
import { spawn, type ChildProcess } from 'node:child_process'
import { availableParallelism } from 'node:os'
import { promisify } from 'node:util'
import { gzip, unzip } from 'node:zlib'
import { RangeIndexer, decodeFrames, isKeepalive, throttle, toWire, type EdgeBatch, type Lease } from '#lib/edge/index'
const IDLE_MS = 30_000
const unzipAsync = promisify(unzip)
const gzipAsync = promisify(gzip)
const RETRIES = 8
class LeaseLost extends Error {}
/** A spooled chunk that doesn't inflate (reported; the coordinator fetches it again). */
class CorruptChunk extends Error {}
/**
* An edge worker (REBUILD.md, part 2): leases a range from the coordinator,
* streams its chunks, indexes them, posts a batch every --batch-articles
* articles (each moves the range's resume point), and finishes the range.
* Needs COORDINATOR_URL and NODE_TOKEN only. On SIGTERM it gives its range
* back after the current chunk; the next worker resumes from the last batch.
*/
export default class EdgeNode extends BaseCommand {
static commandName = 'edge:node'
static description = 'Indexes ranges for the coordinator'
static options: CommandOptions = { startApp: true, staysAlive: true }
@flags.number({ description: 'Articles per posted batch (500k: ~4% of releases get a second chunk; LOG.md 2026-10-09)', default: 500_000 })
declare batchArticles: number
@flags.number({ description: 'Stop after this many ranges (default: keep going)' })
declare ranges?: number
@flags.number({ description: 'Worker processes (one core each; default: EDGE_PROCESSES or 1, 0 = one per core)' })
declare processes?: number
@flags.number({ description: 'Download cap for the whole node in Mbit/s, shared by its processes (default: EDGE_MAX_MBIT or none)' })
declare maxMbit?: number
private stopping = false
async run() {
const url = process.env.COORDINATOR_URL
const token = process.env.NODE_TOKEN
if (!url || !token) throw new Error('COORDINATOR_URL and NODE_TOKEN are needed')
const requested = this.processes ?? Number(process.env.EDGE_PROCESSES ?? 1)
const processes = requested === 0 ? availableParallelism() : requested
if (processes > 1 && !process.env.EDGE_CHILD) return this.supervise(processes)
let stopped: () => void
const done = new Promise<void>((resolve) => (stopped = resolve))
this.app.terminating(async () => {
this.stopping = true
await done
})
const api = new Api(url.replace(/\/$/, ''), token)
let ranges = 0
try {
while (!this.stopping && (!this.ranges || ranges < this.ranges)) {
let lease: Lease | null = null
try {
lease = await api.lease()
} catch (error: any) {
this.logger.warning(`Lease failed: ${error.message}`)
}
if (!lease) {
if (this.ranges) break
await sleep(IDLE_MS)
continue
}
await this.runRange(api, lease)
ranges++
}
} finally {
stopped!()
}
await this.terminate()
}
/**
* One worker per process (a worker is single-threaded): starts `count`
* children, restarts one that exits unexpectedly, and passes SIGTERM on
* (each gives its range back).
*/
private async supervise(count: number) {
const cap = this.cap()
this.logger.info(`Starting ${count} worker processes${cap ? ` (download cap ${cap} Mbit/s, ${(cap / count).toFixed(1)} each)` : ''}.`)
const children = new Set<ChildProcess>()
let stopping = false
let stopped: () => void
const done = new Promise<void>((resolve) => (stopped = resolve))
const start = (slot: number) => {
const args = [
process.argv[1],
'edge:node',
`--batch-articles=${this.batchArticles}`,
...(this.ranges ? [`--ranges=${this.ranges}`] : []),
// Each child gets its share of the node's cap.
...(cap ? [`--max-mbit=${cap / count}`] : []),
]
const child = spawn(process.execPath, [...process.execArgv, ...args], { env: { ...process.env, EDGE_CHILD: String(slot) }, stdio: 'inherit' })
children.add(child)
child.on('exit', (code) => {
children.delete(child)
if (stopping || (this.ranges && code === 0)) {
if (children.size === 0) stopped()
return
}
this.logger.warning(`Worker ${slot} exited (${code}); starting it again in 10 s.`)
setTimeout(() => !stopping && start(slot), 10_000)
})
}
for (let slot = 1; slot <= count; slot++) start(slot)
this.app.terminating(async () => {
stopping = true
for (const child of children) child.kill('SIGTERM')
await done
})
await done
await this.terminate()
}
/** The node's download cap in Mbit/s, or null. */
private cap(): number | null {
const mbit = this.maxMbit ?? (process.env.EDGE_MAX_MBIT ? Number(process.env.EDGE_MAX_MBIT) : null)
return mbit && mbit > 0 ? mbit : null
}
private async runRange(api: Api, lease: Lease) {
const { range, rules } = lease
const started = Date.now()
const indexer = new RangeIndexer({ group: range.group, start: range.start, end: range.end, resumeAt: range.next, rules })
const totals = { headers: 0, releases: 0, candidates: 0, bytes: 0, postMs: 0 }
let batchFrom = range.next
let reachedEnd = false
// Posts go in order, but the worker indexes on meanwhile (a post took 1–16 s; LOG.md 2026-10-09).
let posting: Promise<void> = Promise.resolve()
const post = (batch: EdgeBatch) => {
const previous = posting
posting = (async () => {
await previous
const at = Date.now()
const stored = await api.batch(range.id, batch)
totals.postMs += Date.now() - at
totals.bytes += stored.bytes
totals.headers += batch.headers
totals.releases += stored.releases
totals.candidates += stored.candidates
})()
// A failed post stops the range at the next chunk (a lost lease: nothing more will be stored).
posting.catch((error) => (postError ??= error))
}
let postError: Error | null = null
const handle = async (chunk: { from: number; to: number; text: Promise<Buffer> }) => {
if (postError) throw postError
let text: Buffer
try {
text = await chunk.text
} catch (error: any) {
await posting.catch(() => {})
await api.corrupt(range.id, chunk.from)
throw new CorruptChunk(`chunk ${chunk.from}–${chunk.to} doesn't inflate (${error.message}); reported, the range goes back`)
}
indexer.addText(text.toString('utf8'), chunk.to + 1)
reachedEnd = chunk.to >= range.end
if (reachedEnd) indexer.finish()
if (reachedEnd || chunk.to + 1 - batchFrom >= this.batchArticles) {
post(indexer.take())
batchFrom = chunk.to + 1
}
}
try {
// Inflating runs on a thread-pool thread: the next chunk inflates while this one is indexed.
let pending: { from: number; to: number; text: Promise<Buffer> } | null = null
const cap = this.cap()
const stream = await api.stream(range.id)
for await (const frame of decodeFrames(cap ? throttle(stream, (cap * 1e6) / 8) : stream)) {
if (this.stopping) break
if (isKeepalive(frame)) continue
const next = { from: frame.from, to: frame.to, text: unzipAsync(frame.data) }
next.text.catch(() => {})
if (pending) await handle(pending)
pending = next
}
if (pending && !this.stopping) await handle(pending)
await posting
if (!reachedEnd) {
// Stopping: back to the queue; the next worker resumes at the last stored batch.
this.logger.info(`Range ${range.id}: giving it back.`)
await api.call(range.id, 'release')
return
}
await api.call(range.id, 'finish')
const seconds = (Date.now() - started) / 1000
this.logger.info(
`${range.group} ${range.start}–${range.end}: ${totals.headers.toLocaleString('en')} headers in ${seconds.toFixed(0)} s ` +
`(${Math.round(totals.headers / seconds).toLocaleString('en')}/s), ${totals.releases} release chunks, ${totals.candidates} probe candidates; ` +
`posted ${(totals.bytes / 1e6).toFixed(1)} MB in ${(totals.postMs / 1000).toFixed(1)} s.`
)
} catch (error: any) {
await posting.catch(() => {})
if (error instanceof LeaseLost || error instanceof CorruptChunk) {
this.logger.warning(`Range ${range.id}: ${error.message}`)
return
}
this.logger.error(`Range ${range.id} failed: ${error.message}; giving it back.`)
await api.call(range.id, 'release').catch(() => {})
}
}
}
/** The coordinator's node API, with retries for network trouble and 5xx. */
class Api {
constructor(
private base: string,
private token: string
) {}
private async request(path: string, init: RequestInit = {}): Promise<Response> {
for (let attempt = 1; ; attempt++) {
try {
const response = await fetch(`${this.base}/api/v1/node/${path}`, {
...init,
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', ...init.headers },
})
if (response.status === 409) throw new LeaseLost(((await response.json().catch(() => ({}))) as { error?: string }).error ?? 'lease lost')
if (response.status < 500) return response
if (attempt >= RETRIES) throw new Error(`${path}: HTTP ${response.status}`)
} catch (error) {
if (error instanceof LeaseLost || attempt >= RETRIES) throw error
}
await sleep(Math.min(60_000, 1000 * 2 ** attempt))
}
}
async lease(): Promise<Lease | null> {
const response = await this.request('lease', { method: 'POST', body: '{}' })
if (response.status === 204) return null
if (!response.ok) throw new Error(`lease: HTTP ${response.status}`)
return response.json() as Promise<Lease>
}
async stream(rangeId: number): Promise<AsyncIterable<Uint8Array>> {
const response = await this.request(`ranges/${rangeId}/stream`, { headers: { Accept: 'application/octet-stream' } })
if (!response.ok || !response.body) throw new Error(`stream: HTTP ${response.status}`)
return response.body as unknown as AsyncIterable<Uint8Array>
}
async batch(rangeId: number, batch: EdgeBatch): Promise<{ releases: number; candidates: number; bytes: number }> {
// Gzipped (on a thread-pool thread): chunk payloads are compressed already, the rest compresses well.
const body = await gzipAsync(JSON.stringify(toWire(batch)), { level: 1 })
const response = await this.request(`ranges/${rangeId}/batches`, { method: 'POST', body, headers: { 'Content-Encoding': 'gzip' } })
if (!response.ok) throw new Error(`batch: HTTP ${response.status}`)
return { ...((await response.json()) as { releases: number; candidates: number }), bytes: body.length }
}
async corrupt(rangeId: number, from: number) {
const response = await this.request(`ranges/${rangeId}/corrupt`, { method: 'POST', body: JSON.stringify({ from }) })
if (!response.ok) throw new Error(`corrupt: HTTP ${response.status}`)
}
async call(rangeId: number, action: 'finish' | 'release' | 'heartbeat') {
const response = await this.request(`ranges/${rangeId}/${action}`, { method: 'POST', body: '{}' })
if (!response.ok) throw new Error(`${action}: HTTP ${response.status}`)
}
}
+30
View File
@@ -0,0 +1,30 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import Planner from '#services/planner'
import NntpPool from '#services/nntp_pool'
/** Adds groups to index, after the existing ones in priority (owner rule). */
export default class GroupsAdd extends BaseCommand {
static commandName = 'groups:add'
static description = 'Adds groups to index (by name, or the largest by LIST ACTIVE)'
static options: CommandOptions = { startApp: true }
@args.spread({ description: 'Group names', required: false })
declare names?: string[]
@flags.number({ description: 'The largest N groups matching --pattern' })
declare top?: number
@flags.string({ description: 'LIST ACTIVE pattern for --top', default: 'alt.binaries.*' })
declare pattern: string
async run() {
try {
const names = this.top ? await Planner.largestGroups(this.pattern, this.top) : (this.names ?? [])
const added = await Planner.addGroups(names)
this.logger.info(`${added} of ${names.length} groups added.`)
} finally {
await NntpPool.close()
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { BaseCommand, args } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import Node from '#models/node'
/** Registers an edge worker and prints its token (shown once). */
export default class NodesCreate extends BaseCommand {
static commandName = 'nodes:create'
static description = 'Registers an edge worker and prints its token'
static options: CommandOptions = { startApp: true }
@args.string({ description: 'Node name' })
declare name: string
async run() {
const node = await Node.firstOrCreate({ name: this.name }, { name: this.name })
const token = await Node.accessTokens.create(node)
this.logger.info(`Node ${node.name} (#${node.id}). Token: ${token.value!.release()}`)
}
}
+20
View File
@@ -0,0 +1,20 @@
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import Planner from '#services/planner'
import NntpPool from '#services/nntp_pool'
/** Cuts every active group's history (and new articles since) into ranges. */
export default class RangesPlan extends BaseCommand {
static commandName = 'ranges:plan'
static description = "Plans ranges for the indexed groups' articles"
static options: CommandOptions = { startApp: true }
async run() {
try {
const { groups, ranges } = await Planner.planAll()
this.logger.info(`${ranges} ranges planned over ${groups} groups.`)
} finally {
await NntpPool.close()
}
}
}
-97
View File
@@ -1,97 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import fs from 'node:fs/promises'
import path from 'node:path'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import env from '#start/env'
import NntpService from '#services/NntpService'
import IngestService, { segmentFromYenc } from '#services/IngestService'
import { parseYencMeta, YencError } from '#lib/yenc/index'
import { firstChunkMd5 } from '#services/ContentService'
export default class BodyWorker extends BaseCommand {
public static commandName = 'worker:body'
public static description = 'Starts a worker to process article bodies for yEnc metadata.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting body worker...')
const pool = NntpService
const worker = new Worker('body-queue', async (job) => {
const { header, group, articleNumber } = job.data
const messageId = header['message-id']
this.logger.debug(`Processing header with unparsable subject: ${header.subject}`)
let conn
let failed = false
try {
conn = await pool.acquire()
const bodyBuffer = await pool.fetchBody(conn, messageId)
let meta
try {
meta = parseYencMeta(bodyBuffer)
} catch (parseError: any) {
if (parseError instanceof YencError && bodyBuffer.indexOf('=ybegin ') === -1) {
this.logger.debug(`Not a yEnc post, skipping: ${messageId}`)
return
}
this.logger.error(`Failed to parse yEnc data for message ID ${messageId}. Dumping buffer.`)
const debugDir = path.join(this.app.appRoot.pathname, 'debug')
await fs.mkdir(debugDir, { recursive: true })
const timestamp = new Date().toISOString().replace(/:/g, '-')
const dumpFile = path.join(debugDir, `body-error-${timestamp}-${messageId.replace(/[<>]/g, '')}.bin`)
await fs.writeFile(dumpFile, bodyBuffer)
this.logger.error(`Problematic body buffer saved to: ${dumpFile}`)
throw parseError
}
pool.release(conn)
conn = undefined
const segment = segmentFromYenc(header, group, meta, articleNumber)
if (segment?.number === 1) segment.md5_16k = firstChunkMd5(bodyBuffer, meta.begin.size)
if (!segment) {
this.logger.warning(`Inconsistent yEnc part numbering in ${messageId}, skipping.`)
return
}
const completed = await IngestService.ingest([segment])
this.logger.debug(
`Stored part ${segment.number}/${segment.totalParts} of "${segment.name}"` +
(completed.length > 0 ? ' (file complete)' : '')
)
} catch (error: any) {
failed = true
this.logger.error(`Error in body worker for message ID ${messageId}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(conn, failed)
}
}
}, {
connection: queueConfig.connection,
// Each job holds one NNTP connection while downloading, so keep this <= the pool size.
concurrency: env.get('BODY_CONCURRENCY', 1),
})
worker.on('failed', (job, err) => {
this.logger.error(`Body job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Body worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
-76
View File
@@ -1,76 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
import ContentService from '#services/ContentService'
import File from '#models/file'
export default class CollectionWorker extends BaseCommand {
public static commandName = 'worker:collection'
public static description = 'Starts a worker to process file collections (e.g., RAR archives).'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting collection worker...')
const worker = new Worker('collection-queue', async (job) => {
const { fileId } = job.data
this.logger.debug(`Processing file ID ${fileId} for collection.`)
const file = await File.find(fileId)
if (!file) {
this.logger.error(`File with ID ${fileId} not found in the database.`)
return
}
try {
const content = await ContentService.inspect(fileId)
if (content?.par2) {
const { par2 } = content
this.logger.info(`Par2 "${file.filename}": release "${par2.releaseName}", ${par2.listed} files listed.`)
} else if (content?.archive) {
const { info, releaseRenamed, grouped } = content.archive
const flags = [info.headersEncrypted && 'encrypted headers', info.entries.some((e) => e.encrypted) && 'encrypted files']
.filter(Boolean)
.join(', ')
this.logger.info(
`Archive "${file.filename}": ${info.format}, ${info.entries.length} entries${flags ? ` (${flags})` : ''}` +
(grouped ? ', grouped into a release' : '') +
(releaseRenamed ? `, release renamed to "${releaseRenamed}"` : '')
)
}
if (content?.named) this.logger.info(`"${file.filename}" got its real name from par2.`)
if (content?.contentType === 'nzbe') this.logger.info(`"${file.filename}" is an encrypted NZB container.`)
if (content?.contentType === 'rclone') this.logger.info(`"${file.filename}" is rclone-encrypted.`)
if (content?.nzb) {
const n = content.nzb
this.logger.info(`NZB "${file.filename}": ${n.listed} files (${n.known} known, ${n.added} added) → "${n.releaseName}".`)
}
} catch (error: any) {
this.logger.warning(`Content inspection of "${file.filename}" failed: ${error.message}`)
}
}, {
connection: queueConfig.connection,
// Each job holds one NNTP connection while downloading; keep <= the pool size.
concurrency: env.get('COLLECTION_CONCURRENCY', 1),
})
worker.on('failed', (job, err) => {
this.logger.error(`Collection job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Collection worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
-57
View File
@@ -1,57 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
import DeferredService from '#services/DeferredService'
const IDLE_MS = 60_000
/**
* Works through `deferred_files` (CONCEPT.md D28): one body per file, and
* small files completely. Not started by default (compose profile
* `deferred`); run it when there is bandwidth to spare.
*/
export default class DeferredWorker extends BaseCommand {
public static commandName = 'worker:deferred'
public static description = 'Samples deferred files (e.g. ngPost uploads): one article body per file.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Stop after this many files (default: keep running)' })
declare limit?: number
public async run() {
const concurrency = env.get('DEFERRED_CONCURRENCY', 4)
this.logger.info(`Deferred worker started (concurrency ${concurrency}).`)
const counts: Record<string, number> = {}
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
const batch = await DeferredService.claim(Math.min(concurrency * 4, this.limit ? this.limit - done : Infinity))
if (batch.length === 0) {
if (this.limit) break
await Shutdown.sleep(IDLE_MS)
continue
}
let next = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (next < batch.length) {
const status = await DeferredService.process(batch[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})
)
done += batch.length
this.logger.info(`${done} deferred files processed: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}.`)
}
} finally {
await NntpService.shutdown()
await QueueService.closeAll()
}
}
}
-110
View File
@@ -1,110 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import env from '#start/env'
import db from '@adonisjs/lucid/services/db'
import indexerConfig from '#config/indexer'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
import FetchRangeService from '#services/FetchRangeService'
import IngestService, { segmentFromHeader, type IngestSegment } from '#services/IngestService'
import DeferredService, { bodyJob, isDeferred, type DeferredArticle } from '#services/DeferredService'
import SpotService, { isSpotGroup } from '#services/SpotService'
export default class FetchWorker extends BaseCommand {
public static commandName = 'worker:fetch'
public static description = 'Starts a worker that fetches headers (XOVER) and stores them as binaries/parts.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting fetch worker...')
const pool = NntpService
const bodyQueue = QueueService.bodyQueue
const worker = new Worker('nntp-fetch-queue', async (job) => {
const { groupName, startId, endId } = job.data
this.logger.info(`Processing fetch job for ${groupName}, articles ${startId}-${endId}`)
let overviews: [number, Record<string, string>][]
let conn
let failed = false
try {
conn = await pool.acquire()
await conn.group(groupName)
overviews = (await conn.xover(startId, endId)).overviews
} catch (error: any) {
failed = true
this.logger.error(`Error fetching headers for ${groupName}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(conn, failed)
}
}
if (isSpotGroup(groupName)) {
const spots = await SpotService.recordHeaders(groupName, overviews)
if (job.data.rangeId) await FetchRangeService.markDone(job.data.rangeId)
this.logger.info(`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${spots} spots queued.`)
return
}
const group = await db.from('groups').where('name', groupName).first()
const fetchBodies = Boolean(group?.fetch_bodies)
const segments: IngestSegment[] = []
const obfuscated: { articleNumber: number; header: Record<string, string> }[] = []
const deferred: DeferredArticle[] = []
let skipped = 0
for (const [articleNumber, header] of overviews) {
if (!header) continue
const segment = segmentFromHeader(header, groupName, articleNumber)
if (segment) segments.push(segment)
// Recorded per file (or as article ranges) in every group, bodies or not; processed later if at all.
else if (isDeferred(header)) deferred.push({ articleNumber, header })
else if (!fetchBodies || indexerConfig.skipBodyMessageIds.some((re) => re.test(header['message-id'] ?? ''))) skipped++
else obfuscated.push({ articleNumber, header })
}
const completed = await IngestService.ingest(segments)
const deferredFiles = await DeferredService.record(groupName, deferred)
if (obfuscated.length > 0) {
await bodyQueue.addBulk(obfuscated.map(({ articleNumber, header }) => bodyJob(header, groupName, articleNumber)))
}
if (job.data.rangeId) await FetchRangeService.markDone(job.data.rangeId)
this.logger.info(
`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${segments.length} stored, ` +
`${obfuscated.length} sent to body-queue${skipped ? ` (${skipped} skipped)` : ''}` +
`${deferred.length ? `, ${deferred.length} deferred (${deferredFiles} files)` : ''}, ${completed.length} files completed.`
)
}, {
connection: queueConfig.connection,
// XOVER is quick; most of a job is storing. Each concurrent job uses one DB connection (DB_POOL_MAX).
concurrency: env.get('FETCH_CONCURRENCY', 5),
})
worker.on('failed', async (job, err) => {
this.logger.error(`Fetch job ${job?.id} failed for group ${job?.data.groupName}: ${err.message}`)
// Only after the last attempt; the scheduler re-queues the range later.
if (job?.data.rangeId && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await FetchRangeService.markFailed(job.data.rangeId, err.message).catch(() => {})
}
})
this.logger.info('Fetch worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await NntpService.shutdown()
await QueueService.closeAll()
}
}
-117
View File
@@ -1,117 +0,0 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import db from '@adonisjs/lucid/services/db'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
export default class FileWorker extends BaseCommand {
public static commandName = 'worker:file'
public static description = 'Starts a worker that turns completed binaries into files.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting file worker...')
const collectionQueue = QueueService.collectionQueue
const worker = new Worker('file-queue', async (job) => {
const { binaryId } = job.data
const binary = await db.from('binaries').where('id', binaryId).first()
if (!binary) {
this.logger.warning(`Binary ${binaryId} no longer exists (garbage collected?).`)
return
}
if (binary.conflicted_at) {
this.logger.warning(`Binary ${binaryId} ("${binary.name}") has conflicting parts, skipping.`)
return
}
const parts = await db
.from('parts')
.where('binary_id', binaryId)
.orderBy('number')
.select('number', 'message_id', 'bytes')
if (parts.length < binary.total_parts) {
throw new Error(`Binary ${binaryId} has ${parts.length}/${binary.total_parts} parts.`)
}
const messageIds = Object.fromEntries(
parts.map((part) => [part.number, { id: part.message_id, size: part.bytes }])
)
// Already known as a file, e.g. added from a posted NZB before its own articles completed.
const existing = await db
.from('files')
.whereRaw(`(message_ids->'1'->>'id') = ?`, [messageIds['1']?.id ?? ''])
.first()
if (existing) {
await db.from('binaries').where('id', binaryId).update({ file_id: existing.id })
await dropCopiedParts(binary)
this.logger.debug(`File "${binary.name}" already exists as ID ${existing.id} (same first segment).`)
return
}
// file_key is unique: a second completion of the same upload inserts nothing.
const knex = db.connection().getWriteClient()
const now = new Date()
const [inserted] = await knex('files')
.insert({
file_key: binary.file_key,
filename: binary.name,
poster: binary.poster,
date: binary.posted_at ? new Date(binary.posted_at).getTime() : now.getTime(),
parts: parts.length,
message_ids: JSON.stringify(messageIds),
groups: JSON.stringify(binary.groups),
release_id: binary.release_id,
real_name: binary.real_name,
size: parts.reduce((sum, part) => sum + Number(part.bytes), 0),
created_at: now,
updated_at: now,
})
.onConflict('file_key')
.ignore()
.returning('id')
const fileId =
inserted?.id ?? (await db.from('files').where('file_key', binary.file_key).first())?.id
await db.from('binaries').where('id', binaryId).update({ file_id: fileId })
await dropCopiedParts(binary)
if (!inserted) {
this.logger.debug(`File "${binary.name}" (${binary.file_key}) already exists as ID ${fileId}.`)
return
}
this.logger.info(`Saved file "${binary.name}" (${parts.length} parts) as ID ${fileId}.`)
await collectionQueue.add('process-collection', { fileId }, { jobId: `file-${fileId}` })
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
this.logger.error(`File job ${job?.id} failed: ${err.message}`)
})
this.logger.info('File worker started and listening for jobs.')
await Shutdown.wait()
this.logger.info('Shutting down: finishing active jobs...')
await worker.close()
await QueueService.closeAll()
}
}
/**
* The file holds every message id now. Binaries identified by their subject
* (no size: header-derived) don't need their parts any more; body-derived
* ones keep them, so articles of an equal-sized next volume are recognised
* as a conflict and split off (CONCEPT.md D20). LOG.md 2026-09-28 (17).
*/
async function dropCopiedParts(binary: { id: number; size: number | null }) {
if (binary.size === null) await db.from('parts').where('binary_id', binary.id).delete()
}
-54
View File
@@ -1,54 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import env from '#start/env'
import Shutdown from '#services/Shutdown'
import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
const IDLE_MS = 30_000
/**
* Works through queued Spotnet spots (CONCEPT.md D29): verifies each one,
* downloads its compressed NZB (a few KB to a few MB) and imports it.
*/
export default class SpotsWorker extends BaseCommand {
public static commandName = 'worker:spots'
public static description = 'Reads Spotnet spots: verifies them and imports their NZBs.'
public static options: CommandOptions = {
startApp: true,
}
@flags.number({ description: 'Stop after this many spots (default: keep running)' })
declare limit?: number
public async run() {
const concurrency = env.get('SPOT_CONCURRENCY', 4)
this.logger.info(`Spot worker started (concurrency ${concurrency}).`)
const counts: Record<string, number> = {}
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
const batch = await SpotService.claim(Math.min(concurrency * 4, this.limit ? this.limit - done : Infinity))
if (batch.length === 0) {
if (this.limit) break
await Shutdown.sleep(IDLE_MS)
continue
}
let next = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (next < batch.length) {
const status = await SpotService.process(batch[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})
)
done += batch.length
this.logger.info(`${done} spots: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}.`)
}
} finally {
await NntpService.shutdown()
}
}
}
+4
View File
@@ -32,6 +32,10 @@ export const http = defineConfig({
*/ */
useAsyncLocalStorage: false, useAsyncLocalStorage: false,
router: {
matcher: 'tree',
},
/** /**
* Redirect configuration controls the behavior of * Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding. * response.redirect().back() and query string forwarding.
+10
View File
@@ -20,6 +20,16 @@ const authConfig = defineConfig({
}), }),
}), }),
/**
* Edge workers (REBUILD.md, part 2): a token per node.
*/
node: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/node'),
}),
}),
/** /**
* Session-based guard for browser authentication. * Session-based guard for browser authentication.
*/ */
+5 -4
View File
@@ -27,10 +27,11 @@ const bodyParserConfig = defineConfig({
* Config for the JSON parser. * Config for the JSON parser.
*/ */
json: { json: {
/** // Edge worker batches carry their releases' chunks (base64) and edge fragments; the limit is on the inflated body.
* Normalize empty string values to null. limit: '256mb',
*/
convertEmptyStringsToNull: true, // Workers send '' on purpose (e.g. a rule count's key); null would break NOT NULL columns.
convertEmptyStringsToNull: false,
/** /**
* Content types handled by the JSON parser. * Content types handled by the JSON parser.
+29
View File
@@ -0,0 +1,29 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
/** The coordinator (REBUILD.md, part 1). */
export default {
/** Articles per range. */
rangeArticles: env.get('RANGE_ARTICLES', 2_000_000),
/**
* Articles per XOVER chunk (spool files, stream frames). Eweka takes
* ~1.5–2 s per command whatever its size: one connection read 9–15k
* lines/s in chunks of 25k, ~20k in 50k, ~30k in 100k and 200k (LOG.md
* 2026-10-09).
*/
chunkArticles: env.get('CHUNK_ARTICLES', 100_000),
/** A leased range without a heartbeat for this long goes back to the queue. */
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
/** NNTP connections the coordinator fetches headers with. */
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),
spool: {
dir: env.get('SPOOL_DIR', app.tmpPath('spool')),
/** Ranges kept fetched ahead, per active lease. */
aheadPerLease: env.get('SPOOL_AHEAD_PER_LEASE', 2),
/** At least this many ranges ahead, so the first leases start at once. */
minAhead: env.get('SPOOL_MIN_AHEAD', 2),
maxBytes: env.get('SPOOL_MAX_GB', 10) * 1e9,
},
}
+12 -115
View File
@@ -1,137 +1,34 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app' import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid' import { defineConfig } from '@adonisjs/lucid'
import env from '#start/env'
/**
* The rebuild's own database (REBUILD.md, decision b), on the same server
* as the current indexer's.
*/
const dbConfig = defineConfig({ const dbConfig = defineConfig({
/** connection: 'pg',
* Default connection used for all queries.
*/
connection: env.get('DB_CONNECTION', 'sqlite'),
connections: { connections: {
/**
* SQLite connection (default).
*/
sqlite: {
client: 'better-sqlite3',
connection: {
filename: app.tmpPath('db.sqlite3'),
},
/**
* Required by Knex for SQLite defaults.
*/
useNullAsDefault: true,
migrations: {
/**
* Sort migration files naturally by filename.
*/
naturalSort: true,
/**
* Paths containing migration files.
*/
paths: ['database/migrations'],
},
schemaGeneration: {
/**
* Enable schema generation from Lucid models.
*/
enabled: true,
/**
* Custom schema rules file paths.
*/
rulesPaths: ['./database/schema_rules.js'],
},
},
/**
* PostgreSQL connection.
* Install package to switch: npm install pg
*/
pg: { pg: {
client: 'pg', client: 'pg',
connection: { connection: {
host: env.get('DB_HOST'), host: env.get('DB_HOST'),
port: env.get('DB_PORT'), port: env.get('DB_PORT'),
user: env.get('DB_USER'), user: env.get('DB_USER'),
password: env.get('DB_PASSWORD')?.release(), password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'), database: env.get('DB_DATABASE'),
// For idempotent bulk writers (worker:fetch): don't wait for the WAL
// flush on every statement. A crash loses the last moments of writes,
// which the retried job writes again.
...(env.get('DB_SYNCHRONOUS_COMMIT') === 'off' ? { options: '-c synchronous_commit=off' } : {}),
}, },
// One connection per concurrent job; knex defaults to 10.
pool: { min: 0, max: env.get('DB_POOL_MAX', 10) }, pool: { min: 0, max: env.get('DB_POOL_MAX', 10) },
migrations: { migrations: {
naturalSort: true, naturalSort: true,
paths: ['database/migrations'], paths: ['database/migrations'],
}, },
debug: app.inDev, schemaGeneration: {
enabled: true,
rulesPaths: ['./database/schema_rules.js'],
},
debug: app.inDev && env.get('DB_DEBUG', false),
}, },
/**
* MySQL / MariaDB connection.
* Install package to switch: npm install mysql2
*/
// mysql: {
// client: 'mysql2',
// connection: {
// host: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* Microsoft SQL Server connection.
* Install package to switch: npm install tedious
*/
// mssql: {
// client: 'mssql',
// connection: {
// server: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* libSQL (Turso) connection.
* Install package to switch: npm install @libsql/client
*/
// libsql: {
// client: 'libsql',
// connection: {
// url: env.get('LIBSQL_URL'),
// authToken: env.get('LIBSQL_AUTH_TOKEN'),
// },
// useNullAsDefault: true,
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
}, },
}) })
-57
View File
@@ -1,57 +0,0 @@
import env from '#start/env'
/**
* Retention for transient pipeline state in Redis. See CONCEPT.md (D11).
*/
export default {
/** How often `gc:scheduler` sweeps. */
intervalMs: env.get('GC_INTERVAL_MS', 10 * 60 * 1000),
jobs: {
/** Completed jobs are only kept for inspection; keep them briefly. */
completedAgeSeconds: env.get('GC_COMPLETED_AGE_SECONDS', 60 * 60),
completedMaxCount: 1000,
/** Failed jobs are kept longer so they can be inspected and retried. */
failedAgeSeconds: env.get('GC_FAILED_AGE_SECONDS', 7 * 24 * 60 * 60),
},
/**
* Upper bound on waiting body jobs. Obfuscated traffic can exceed what the
* line downloads for hours; beyond this, the oldest waiting jobs are
* dropped so Redis stays bounded (~1 KB per job).
*/
bodyQueueMax: env.get('GC_BODY_QUEUE_MAX', 500_000),
binaries: {
/**
* A binary that gets no new segments for this long is deleted: incomplete
* ones are given up on, completed ones have been copied into `files`.
*/
staleAfterSeconds: env.get('GC_BINARY_STALE_SECONDS', 48 * 60 * 60),
/**
* Header-derived binaries that still have one article after this long:
* uploads that give every article its own random name (and poster) never
* complete from headers. A real file gets its next articles in minutes.
*/
singleArticleAfterSeconds: env.get('GC_SINGLE_ARTICLE_SECONDS', 2 * 60 * 60),
},
/** Releases without files or binaries (their binaries were collected) are deleted after this long. */
emptyReleaseAfterSeconds: env.get('GC_EMPTY_RELEASE_SECONDS', 2 * 60 * 60),
/** Bulk deletes run in batches of this size, for at most this long per sweep. */
deleteBatch: 20_000,
deleteBudgetMs: env.get('GC_DELETE_BUDGET_MS', 3 * 60 * 1000),
/**
* Deferred files (CONCEPT.md D28) are deleted this long after they were
* first seen, processed or not. The articles stay on the server.
*/
deferredRetentionDays: env.get('GC_DEFERRED_RETENTION_DAYS', 30),
/** Redis hashes from the old header worker (before D12); they only need to expire. */
legacyPendingFiles: {
prefix: 'file:',
ttlSeconds: 48 * 60 * 60,
},
}
-47
View File
@@ -1,47 +0,0 @@
import env from '#start/env'
export default {
/** How often `index:scheduler` checks the groups for new articles. */
intervalMs: env.get('INDEX_INTERVAL_MS', 60 * 1000),
/** Articles per fetch job (one XOVER call). */
batchSize: BigInt(env.get('INDEX_BATCH_SIZE', 20_000)),
/** Upper bound on fetch jobs queued per group per tick, so catching up is spread out. */
maxBatchesPerTick: env.get('INDEX_MAX_BATCHES_PER_TICK', 5),
/**
* Where a group that was never indexed starts: this many articles before
* the newest one. The server's low-water mark can be years back.
*/
initialBackfill: BigInt(env.get('INDEX_INITIAL_BACKFILL', 50_000)),
/**
* Obfuscated articles whose message-id matches one of these are not sent
* to the body queue: their uploader spreads every file over many groups,
* so identifying them would take far more bandwidth than there is
* (see LOG.md, 2026-09-28 (9)). Normal-subject articles are unaffected.
*/
skipBodyMessageIds: [
// hex7$hex7$hex7@hex10.hex5, ~60k articles/min over 10+ groups
/^<?[0-9a-f]{7}\$[0-9a-f]{7}\$[0-9a-f]{7}@[0-9a-f]{10}\.[0-9a-f]{5}>?$/,
],
/**
* Obfuscated articles whose message-id matches one of these don't go to
* the body queue. Instead they're recorded per file in `deferred_files`
* (their subject keeps the file name, only the part counter is missing).
* `worker:deferred` later fetches one body per file (CONCEPT.md D28).
*/
deferBodyMessageIds: [
// ngPost: <hex32@ngPost>. Mostly RAR5 with encrypted headers (LOG.md 2026-09-28 (14)).
/^<?[0-9a-f]{32}@ngPost>?$/,
],
/**
* Spotnet header groups (CONCEPT.md D29): their headers are spots, not
* binaries. Add one with `group:add free.pt --backfill 5000`.
*/
spotGroups: ['free.pt'],
/** worker:deferred leaves a file alone until it has had no new articles for this long. */
deferredSettleMs: env.get('DEFERRED_SETTLE_MS', 15 * 60 * 1000),
/** Files of at most this many parts are downloaded completely (small archives, NZBs, par2 index files). */
deferredExpandMaxParts: env.get('DEFERRED_EXPAND_MAX_PARTS', 8),
}
-11
View File
@@ -1,11 +0,0 @@
import env from '#start/env'
export default {
host: env.get('NNTP_HOST'),
port: env.get('NNTP_PORT', 119),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', false),
/** Connections per process. Mind the provider's limit across all processes. */
poolSize: env.get('NNTP_POOL_SIZE', 10),
}
-8
View File
@@ -1,8 +0,0 @@
import env from '#start/env'
export default {
connection: {
host: env.get('REDIS_HOST', '127.0.0.1'),
port: env.get('REDIS_PORT', 6379),
},
}
-12
View File
@@ -1,12 +0,0 @@
import env from '#start/env'
export default {
connection: 'main',
connections: {
main: {
host: env.get('REDIS_HOST', '127.0.0.1'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD')?.release(),
},
},
}
-17
View File
@@ -1,17 +0,0 @@
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
@@ -1,21 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'groups'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable().unique()
table.boolean('active').notNullable().defaultTo(true)
table.bigInteger('last_indexed_id').defaultTo(0)
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,24 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'files'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string('filename').notNullable()
table.string('poster').notNullable()
table.bigInteger('date').notNullable()
table.integer('parts').notNullable()
table.jsonb('message_ids').notNullable()
table.jsonb('groups')
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,32 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Files that are still being collected from XOVER. One row per uploaded file,
* identified by file_key (see lib/subject/file_key.ts).
*/
export default class extends BaseSchema {
protected tableName = 'binaries'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.bigIncrements('id')
table.string('file_key', 40).notNullable().unique()
table.text('poster').notNullable()
table.text('subject').notNullable()
table.text('name').notNullable()
table.integer('total_parts').notNullable()
table.jsonb('groups').notNullable()
table.timestamp('posted_at', { useTz: true })
table.timestamp('first_seen_at', { useTz: true }).notNullable()
table.timestamp('last_seen_at', { useTz: true }).notNullable().index()
table.timestamp('completed_at', { useTz: true })
// Set when two different articles claim the same part number (see IngestService).
table.timestamp('conflicted_at', { useTz: true })
table.integer('file_id').unsigned().references('files.id').onDelete('SET NULL')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,24 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Segments (articles) of a binary. The primary key makes re-ingesting the
* same range a no-op.
*/
export default class extends BaseSchema {
protected tableName = 'parts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.bigInteger('binary_id').notNullable().references('binaries.id').onDelete('CASCADE')
table.integer('number').notNullable()
table.text('message_id').notNullable()
table.integer('bytes').notNullable()
table.bigInteger('article_number')
table.primary(['binary_id', 'number'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,17 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'files'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('file_key', 40).unique()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('file_key')
})
}
}
@@ -1,38 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* A release is one download: the files of one post (archive volumes, par2
* set, nfo). See lib/release/release_key.ts and CONCEPT.md (D16).
*/
export default class extends BaseSchema {
protected tableName = 'releases'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.bigIncrements('id')
table.string('release_key', 40).notNullable().unique()
table.text('name').notNullable()
// Null for obfuscated posts, whose poster is random per article.
table.text('poster')
// Number of files announced by the [i/M] subject counter, if any.
table.integer('file_total')
table.jsonb('groups').notNullable()
table.timestamp('posted_at', { useTz: true })
table.timestamp('first_seen_at', { useTz: true }).notNullable()
table.timestamp('last_seen_at', { useTz: true }).notNullable()
})
this.schema.alterTable('binaries', (table) => {
table.bigInteger('release_id').references('releases.id').onDelete('SET NULL').index()
})
this.schema.alterTable('files', (table) => {
table.bigInteger('release_id').references('releases.id').onDelete('SET NULL').index()
})
}
async down() {
this.schema.alterTable('files', (table) => table.dropColumn('release_id'))
this.schema.alterTable('binaries', (table) => table.dropColumn('release_id'))
this.schema.dropTable(this.tableName)
}
}
@@ -1,33 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Par2-based grouping of obfuscated posts (CONCEPT.md D16): the par2 file
* list of a release, and the exact file size of body-derived binaries to
* match against it.
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('binaries', (table) => {
// Exact file size from =ybegin; only known for body-derived binaries.
table.bigInteger('size').index()
})
this.schema.alterTable('releases', (table) => {
// How the release was recognised: subject, stem, weak-stem or par2.
table.string('source', 16)
})
this.schema.createTable('release_files', (table) => {
table.bigInteger('release_id').notNullable().references('releases.id').onDelete('CASCADE')
table.string('par2_file_id', 32).notNullable()
table.text('name').notNullable()
table.bigInteger('size').notNullable().index()
table.string('md5_16k', 32).notNullable()
table.primary(['release_id', 'par2_file_id'])
})
}
async down() {
this.schema.dropTable('release_files')
this.schema.alterTable('releases', (table) => table.dropColumn('source'))
this.schema.alterTable('binaries', (table) => table.dropColumn('size'))
}
}
@@ -1,37 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Article ranges handed to worker:fetch, so a range only counts as indexed
* once it was fetched successfully (CONCEPT.md D18).
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('groups', (table) => {
// How far the scheduler has queued; last_indexed_id becomes the
// watermark below which every article was fetched.
table.bigInteger('last_queued_id').notNullable().defaultTo(0)
})
this.defer(async (db) => {
await db.rawQuery('UPDATE groups SET last_queued_id = last_indexed_id')
})
this.schema.createTable('fetch_ranges', (table) => {
table.bigIncrements('id')
table.integer('group_id').notNullable().references('groups.id').onDelete('CASCADE')
table.bigInteger('start_id').notNullable()
table.bigInteger('end_id').notNullable()
// queued | done | failed
table.string('status', 8).notNullable().defaultTo('queued')
table.integer('requeues').notNullable().defaultTo(0)
table.text('error')
table.timestamp('created_at', { useTz: true }).notNullable()
table.timestamp('updated_at', { useTz: true }).notNullable()
table.index(['group_id', 'status'])
})
}
async down() {
this.schema.dropTable('fetch_ranges')
this.schema.alterTable('groups', (table) => table.dropColumn('last_queued_id'))
}
}
@@ -1,25 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** Archive listings from the first RAR volume (CONCEPT.md D19). */
export default class extends BaseSchema {
async up() {
this.schema.alterTable('files', (table) => {
// RarInfo of a first volume: format, flags and entries.
table.jsonb('archive')
})
this.schema.alterTable('releases', (table) => {
// Password protected: encrypted file data or encrypted headers.
table.boolean('encrypted')
// Entries of the release's first archive volume.
table.jsonb('contents')
})
}
async down() {
this.schema.alterTable('releases', (table) => {
table.dropColumn('contents')
table.dropColumn('encrypted')
})
this.schema.alterTable('files', (table) => table.dropColumn('archive'))
}
}
@@ -1,32 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Splitting equal-sized obfuscated volumes (CONCEPT.md D20): the extra
* articles of conflicted binaries are kept, and the split-off volumes point
* to the conflicted binary they came from.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('part_conflicts', (table) => {
table.bigInteger('binary_id').notNullable().references('binaries.id').onDelete('CASCADE')
table.integer('number').notNullable()
table.text('message_id').notNullable()
table.integer('bytes').notNullable()
table.bigInteger('article_number')
table.primary(['binary_id', 'number', 'message_id'])
})
this.schema.alterTable('binaries', (table) => {
table.bigInteger('parent_binary_id').references('binaries.id').onDelete('SET NULL').index()
// Last time the splitter processed this conflicted binary.
table.timestamp('split_at', { useTz: true })
})
}
async down() {
this.schema.alterTable('binaries', (table) => {
table.dropColumn('split_at')
table.dropColumn('parent_binary_id')
})
this.schema.dropTable('part_conflicts')
}
}
@@ -1,16 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** Full-file MD5 from par2, to verify assembled (e.g. split) volumes. */
export default class extends BaseSchema {
protected tableName = 'release_files'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('md5', 32)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => table.dropColumn('md5'))
}
}
@@ -1,36 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Binaries that were flagged conflicted before part_conflicts existed lost
* their extra articles, so splitting them only reshuffles the first article
* per part number, a mix of different volumes. Mark them as never to be
* split, and remove the volumes and files the first splitter run made from
* them (see LOG.md, 2026-09-28).
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
await db.rawQuery(`
WITH boundary AS (
SELECT migration_time FROM adonis_schema WHERE name LIKE '%1768620764790%'
), legacy AS (
SELECT id FROM binaries
WHERE conflicted_at IS NOT NULL AND parent_binary_id IS NULL
AND conflicted_at < (SELECT migration_time FROM boundary)
), instances AS (
SELECT id, file_id FROM binaries WHERE parent_binary_id IN (SELECT id FROM legacy)
), deleted_files AS (
DELETE FROM files WHERE id IN (SELECT file_id FROM instances WHERE file_id IS NOT NULL)
), deleted_instances AS (
DELETE FROM binaries WHERE id IN (SELECT id FROM instances)
)
-- 'infinity': last_seen_at > split_at is never true, so SplitService skips them.
UPDATE binaries SET split_at = 'infinity' WHERE id IN (SELECT id FROM legacy)
`)
})
}
async down() {
// The removed volumes were unverifiable; nothing to restore.
}
}
@@ -1,30 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* yEnc CRCs of body-derived articles, so split volumes can be verified
* without downloading them (CONCEPT.md D20): the combined part CRCs of a
* volume must equal the whole-file crc32 in its last part.
*/
export default class extends BaseSchema {
async up() {
for (const tableName of ['parts', 'part_conflicts']) {
this.schema.alterTable(tableName, (table) => {
table.string('pcrc32', 8)
// Decoded length of the part (=ypart end - begin + 1).
table.integer('part_size')
// Whole-file CRC32, when the poster included it (usually in the last part).
table.string('file_crc32', 8)
})
}
}
async down() {
for (const tableName of ['parts', 'part_conflicts']) {
this.schema.alterTable(tableName, (table) => {
table.dropColumn('pcrc32')
table.dropColumn('part_size')
table.dropColumn('file_crc32')
})
}
}
}
@@ -1,40 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Names and groupings from file contents (CONCEPT.md D21): a fingerprint
* (exact size + MD5 of the first 16 KiB, as par2 records it) and the real
* filename recovered from par2.
*/
export default class extends BaseSchema {
async up() {
this.schema.alterTable('binaries', (table) => {
table.string('md5_16k', 32).index()
// Filename recovered from file contents (par2); `name` stays as posted.
table.text('real_name')
})
this.schema.alterTable('files', (table) => {
table.text('real_name')
})
for (const tableName of ['parts', 'part_conflicts']) {
this.schema.alterTable(tableName, (table) => {
// Only for part 1: MD5 of the first 16 KiB of the file.
table.string('md5_16k', 32)
})
}
this.schema.alterTable('release_files', (table) => {
table.index(['md5_16k'])
})
}
async down() {
this.schema.alterTable('release_files', (table) => table.dropIndex(['md5_16k']))
for (const tableName of ['parts', 'part_conflicts']) {
this.schema.alterTable(tableName, (table) => table.dropColumn('md5_16k'))
}
this.schema.alterTable('files', (table) => table.dropColumn('real_name'))
this.schema.alterTable('binaries', (table) => {
table.dropColumn('real_name')
table.dropColumn('md5_16k')
})
}
}
@@ -1,43 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Web interface (CONCEPT.md D22): trigram indexes for substring search, and
* SQL versions of the "readable name" rule used for filtering.
*/
export default class extends BaseSchema {
async up() {
this.defer(async (db) => {
await db.rawQuery('CREATE EXTENSION IF NOT EXISTS pg_trgm')
// Per-file suffixes stripped, as in lib/release fileStem.
await db.rawQuery(`
CREATE OR REPLACE FUNCTION name_stem(name text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
SELECT regexp_replace(name,
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z(\\.\\d{3})?|zip|\\d{3}|nfo|sfv|nzb|tar|zst|gz|xz|mkv|mp4|avi|m4v|ts|iso|img|flac|mp3|epub|pdf|jpg|png))+$',
'', 'i')
$$`)
// Not an obfuscated token (as in ArchiveService.looksRandom).
await db.rawQuery(`
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
SELECT name IS NOT NULL AND NOT (
(name_stem(name) ~ '^[A-Za-z0-9]{16,}$' AND name_stem(name) ~ '\\d'
AND name_stem(name) ~ '[a-z]' AND name_stem(name) ~ '[A-Z]')
OR name_stem(name) ~* '^[0-9a-f]{16,}$')
$$`)
await db.rawQuery('CREATE INDEX IF NOT EXISTS releases_name_trgm ON releases USING gin (name gin_trgm_ops)')
await db.rawQuery('CREATE INDEX IF NOT EXISTS files_filename_trgm ON files USING gin (filename gin_trgm_ops)')
await db.rawQuery('CREATE INDEX IF NOT EXISTS files_real_name_trgm ON files USING gin (real_name gin_trgm_ops)')
await db.rawQuery('CREATE INDEX IF NOT EXISTS releases_posted_at_idx ON releases (posted_at DESC NULLS LAST)')
})
}
async down() {
this.defer(async (db) => {
await db.rawQuery('DROP INDEX IF EXISTS releases_posted_at_idx')
await db.rawQuery('DROP INDEX IF EXISTS files_real_name_trgm')
await db.rawQuery('DROP INDEX IF EXISTS files_filename_trgm')
await db.rawQuery('DROP INDEX IF EXISTS releases_name_trgm')
await db.rawQuery('DROP FUNCTION IF EXISTS is_readable_name(text)')
await db.rawQuery('DROP FUNCTION IF EXISTS name_stem(text)')
})
}
}
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more