Compare commits
49
Commits
cf51db047f
...
rebuild
No files matched your search
@@ -59,35 +59,28 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Rust toolchain
|
||||
run: |
|
||||
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
# The edge worker (edge/, Rust): built first, since the e2e suite runs edge/target/release/edge-node against
|
||||
# the node API; then its own tests, and the Rust and TypeScript RangeIndexers compared on the committed
|
||||
# captures (edge/fixtures).
|
||||
- name: Build the edge worker
|
||||
run: cargo build --release --manifest-path edge/Cargo.toml --bin edge-node
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
- name: Migrate
|
||||
run: node ace migration:run --force
|
||||
- name: Test
|
||||
run: node ace test
|
||||
|
||||
# The edge worker (edge/, Rust): its tests, and the Rust and TypeScript
|
||||
# RangeIndexers compared on the committed captures (edge/fixtures).
|
||||
edge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Rust toolchain
|
||||
run: |
|
||||
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: cargo test
|
||||
run: cargo test --release --manifest-path edge/Cargo.toml
|
||||
- name: The Rust indexer over the fixtures
|
||||
run: edge/bench-fixtures.sh
|
||||
|
||||
deploy:
|
||||
needs: [test, edge]
|
||||
needs: [test]
|
||||
if: github.ref == 'refs/heads/rebuild'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+11
-9
@@ -8,6 +8,15 @@
|
||||
# validate commands) throws "Invalid URL" on $ref, so no ace command starts.
|
||||
ARG NODE_VERSION=24.16
|
||||
|
||||
# The Rust worker (before the app: its image carries edge-bench). Registry and target dir are build caches, so a rebuild only compiles what changed.
|
||||
FROM rust:1-bookworm AS edge-build
|
||||
WORKDIR /src
|
||||
COPY edge ./
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/src/target \
|
||||
cargo build --release --locked --bin edge-node --bin edge-bench \
|
||||
&& cp target/release/edge-node target/release/edge-bench /usr/local/bin/
|
||||
|
||||
# The app, compiled, with production dependencies only.
|
||||
FROM node:${NODE_VERSION}-bookworm AS app-build
|
||||
WORKDIR /src
|
||||
@@ -22,19 +31,12 @@ FROM node:${NODE_VERSION}-bookworm-slim AS app
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=app-build /src/build ./
|
||||
# The Rust indexer's bench: `node ace replay:corpus` runs a group's corpus through it (the replay harness).
|
||||
COPY --from=edge-build /usr/local/bin/edge-bench /usr/local/bin/edge-bench
|
||||
USER node
|
||||
# Every service picks its own command, e.g. `node ace coordinator:run`.
|
||||
CMD ["node", "ace", "list"]
|
||||
|
||||
# The Rust worker. Registry and target dir are build caches, so a rebuild only compiles what changed.
|
||||
FROM rust:1-bookworm AS edge-build
|
||||
WORKDIR /src
|
||||
COPY edge ./
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/src/target \
|
||||
cargo build --release --locked --bin edge-node --bin edge-bench \
|
||||
&& cp target/release/edge-node target/release/edge-bench /usr/local/bin/
|
||||
|
||||
FROM debian:bookworm-slim AS edge
|
||||
COPY --from=edge-build /usr/local/bin/edge-node /usr/local/bin/edge-node
|
||||
# edge-bench replays the corpus for the grouping sweeps (deploy/corpus_sweep.py).
|
||||
|
||||
+388
-38
@@ -60,9 +60,21 @@ most workers on batcave, a few on poweredge-kantoor with
|
||||
`edge:node --max-mbit` (e.g. 50 of its 160 Mbit/s ≈ 100–125 k articles/s;
|
||||
the cap is shared by the node's processes).
|
||||
|
||||
Every group is tiled from article 1 (2026-10-28): one "ancient" range,
|
||||
status `ancient`, covers 1 … low watermark − 1, what had expired on the
|
||||
server before the group was planned. Never leased, fetched or filled —
|
||||
nothing selects it — and the status panel reports it apart ("Ancient: …
|
||||
articles below the low watermark"); migration 6700 added it for the
|
||||
groups planned before, `Planner.historyRows` for groups planned since.
|
||||
|
||||
Also built (2026-10-09): the live tail (new articles planned every 2
|
||||
minutes from one LIST ACTIVE per name prefix, at priority −1: leased and
|
||||
fetched before history); the catalog API, Newznab (`/api`) and the web
|
||||
fetched before history; since 2026-10-27 a round grows the group's open
|
||||
tail range, a planned one at the tail's priority nothing spooled or
|
||||
took, up to RANGE_ARTICLES before it adds rows, as a tail nothing leased
|
||||
had become a sliver a group a round, 127k rows of a median 14k articles;
|
||||
`ranges:merge-tail` packs what such rounds left); the catalog API,
|
||||
Newznab (`/api`) and the web
|
||||
interface, from voyager 1 (the current build), with categories from the
|
||||
name; voyager 1's 29 learned garbage signatures (`rules:signatures`,
|
||||
`database/data/voyager1_signatures.tsv`).
|
||||
@@ -139,7 +151,9 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
|
||||
indexed groups in the article's Xref, the one with the lowest priority
|
||||
number; see the group list) → group articles into files → files
|
||||
into releases (`lib/release` rules) → drop garbage (random name +
|
||||
dropped signature or tool) → set aside per-article random subjects and
|
||||
dropped signature or tool), bare executables, dropped posters, and the
|
||||
single-archive fakes (stub archives and nonsense pieces, below) → set
|
||||
aside per-article random subjects and
|
||||
obfuscated subjects (counted only; phase 2). Returns per batch: release
|
||||
chunks, edge fragments, probe candidates, counters. Holds no state beyond
|
||||
its lease.
|
||||
@@ -327,8 +341,13 @@ or music name whose content is a program: archive entries all
|
||||
executables, or the file itself one by name or by its first bytes, sniffed
|
||||
`exe`), *repeated* (the same first 16 KiB and size in 20+ releases),
|
||||
*locked* (passworded, no password from a spot, its NZB or the title),
|
||||
*unusable* (random names, no inner name, no par2 or inner-name merge, no
|
||||
spot, a day after settling). Verdicts (`release_verdicts`, with the rule
|
||||
*tiny* (one file of one article, 2–100 kB, under a readable release-like
|
||||
name outside the music, e-book and comic groups, without an e-book,
|
||||
subtitle, crack or sidecar word: the catalog's side of the edge's
|
||||
tiny-archive rule, 1.5M rows of the 2015–17 flood that the edge indexed
|
||||
before the rule; LOG.md (131), (135)), *unusable* (random names, no inner
|
||||
name, no par2 or inner-name merge, no spot, a day after settling).
|
||||
Verdicts (`release_verdicts`, with the rule
|
||||
set's version) hide releases from search (`judged` shows them); a verdict
|
||||
whose rule no longer matches is taken back. Nothing is dropped yet.
|
||||
|
||||
@@ -390,13 +409,50 @@ little in a backfill. That churn is most of the WAL (0.6 TB a day) and the
|
||||
releases, a third of them one poster's image dump (27.7 M rows, LOG.md
|
||||
(102)).
|
||||
|
||||
1. **Blocks.** A group's history is still fetched as ranges of 2 M
|
||||
articles in chunks of 100 k (the spool, the corpus, the prefetcher stay),
|
||||
but it is *leased* as blocks: consecutive ranges covering at least 30
|
||||
days of posting time (`ranges.posted_low/high`), so the window below is
|
||||
small against a block (≤ 3% of a block's releases touch an edge). A
|
||||
small group is one block; boneless has a block a week or so. Blocks of
|
||||
one group run in parallel on different workers, in any order.
|
||||
1. **One cursor a group (amended 2026-10-26; was: blocks).** A group's
|
||||
history is still fetched as ranges of 2 M articles in chunks of 100 k
|
||||
(the spool, the corpus, the prefetcher stay) and leased as ranges, but
|
||||
a group's ranges are indexed in article order by one worker at a time,
|
||||
and the window below is *carried* from range to range: the next range
|
||||
finishes what the previous one left open. The first design leased
|
||||
blocks of 30 days of posting time, ran a group's blocks in parallel and
|
||||
joined their edges afterwards (a block merger). The user asked why
|
||||
(LOG.md 2026-10-25 (110)): downloading stays parallel through the
|
||||
prefetcher and a Rust thread parses faster than a connection delivers,
|
||||
so one worker a group loses nothing but the end of a reset, when the
|
||||
last big group runs alone. The merger, the block cutting and the
|
||||
`blocks` lease go. Costs: one seam a group where the history meets the
|
||||
tail (the releases in flight then end as two incomplete halves; history
|
||||
runs forward in one pass a group, priority only between groups), and
|
||||
the carried state must survive a restart (5 below). Measured on six
|
||||
consecutive ranges of a.b.ath (2016): one range is 15 hours of posting,
|
||||
so without the carry no release would finish inside a range (LOG.md
|
||||
2026-10-26 (111)).
|
||||
**The group lease (2026-10-26, the user's question in LOG.md (117)):**
|
||||
a worker thread leases a *group*, not a range, and asks the coordinator
|
||||
for the group's next range each time it finished one; the coordinator
|
||||
keeps one cursor a group (`group_leases`: the holder, its heartbeat,
|
||||
the next range in article order, the snapshot it stands at). The
|
||||
answer is the range (id, span, resume point), the rules version and the
|
||||
stream URL — nothing about where the chunks are: the stream serves a
|
||||
stored range's chunks from the corpus file and a live one's from the
|
||||
spool, so a worker needs no mount and no corpus layout, and a remote
|
||||
worker is the same binary with another URL. The overhead is a copy
|
||||
through the node API: the corpus is ~30 B an article compressed (2016;
|
||||
16–18 for 2021 and 2025), eight local workers at the bench's rate are
|
||||
~170 MB/s, which the HDD under the corpus bounds either way; a remote
|
||||
worker is bounded by its link (100 Mbit ≈ 400k articles/s). History
|
||||
therefore no longer waits for the corpus fill: a group's stored ranges
|
||||
are read from disk, an unstored one is fetched live and captured into
|
||||
the corpus on the way, and the prefetcher runs ahead of each held
|
||||
group's cursor (plus the capture work left). A group with no planned
|
||||
range left is the tail: the worker keeps the group and asks again
|
||||
every minute, the planner's tail ranges fall into its order. A lease
|
||||
whose holder went quiet for LEASE_TTL_SECONDS is taken over: the new
|
||||
holder downloads the group's snapshot (5) and redoes the ranges since
|
||||
it; the batch store's keys make the redo idempotent. The replay
|
||||
harness (8) stays for offline runs; the driver on homelab goes once the
|
||||
worker runs.
|
||||
|
||||
2. **The window.** The worker reads a block in article order and keeps its
|
||||
open files and releases in memory (counters, names, sizes; the segment
|
||||
@@ -412,15 +468,23 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
|
||||
decides later for those without a counter), never updated by the header
|
||||
pass again. Incomplete releases are kept (user, 2026-10-24). A fill
|
||||
posted after the window is a release of its own (the judge's `repeated`
|
||||
rule already knows reposts). At the block's end the releases the window
|
||||
hasn't closed on are written **provisionally** (`settled_at` null) and
|
||||
flagged `edge`, as are the finished releases posted within 24 h of the
|
||||
block's first frontier; the block merger (replaces the fragment merger)
|
||||
joins the edge releases of adjacent blocks of a group by identity key
|
||||
once both are done, finishes them and clears the flag. The settler goes:
|
||||
`settled_at` is the finish. The **tail** needs no path of its own: a
|
||||
tail lease's open releases at its end are such provisional edges, the
|
||||
next lease's continuation joins them.
|
||||
rule already knows reposts). At a lease's end (the tail's poll, a worker
|
||||
stopping; amended 2026-10-26, was: the block's end) the releases the
|
||||
window hasn't closed on are written **provisionally** (`settled_at`
|
||||
null, `finished: false`, flagged `edge`): a release being uploaded
|
||||
shows in the catalog, and the carried window finishes the same row
|
||||
later, since the release key is the identity, the group and the first
|
||||
article; the store's finish path adds the files not sent before. No
|
||||
merger: the carry does the joining. The settler goes: `settled_at` is
|
||||
the finish. The **tail** needs no path of its own: its lease ends leave
|
||||
such provisional releases, the next lease's continuation finishes them.
|
||||
One exception (2026-10-27): a provisional release that is complete
|
||||
(every file its counter announced found) and quiet for six hours is
|
||||
settled by the coordinator (`Settler.settleComplete`, every five
|
||||
minutes), as the finish would: no counted file can still come, and the
|
||||
continuation may be hours away or never (the first run's range leases
|
||||
left 109k provisional rows at their blocks' end edges, 606 of them
|
||||
complete, shown as still collecting; the user, release 10697).
|
||||
|
||||
3. **Albums.** Files of one poster in one owner group whose subjects differ
|
||||
only by a number sequence, or share a directory-like prefix, posted
|
||||
@@ -428,11 +492,35 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
|
||||
shared template (or the prefix), its files kept by name so each stays
|
||||
findable; at least two files, else the single stays a release. This is
|
||||
for subjects that name no release (today's `post`/`stem` singles); a
|
||||
poster's ten different cracks in a day stay ten releases.
|
||||
poster's ten different cracks in a day stay ten releases. The stake,
|
||||
from voyager 2's final analysis (LOG.md (112)): 77% of its 83.7 M
|
||||
releases had one file, 50% one segment (0.3% of the segments);
|
||||
a.b.pictures.erotica.porn alone was 27.7 M single-file releases of one
|
||||
poster. A counter total shared by many stems of one poster within the
|
||||
window is a batch counter, not a file count (Yenc-PP-A&A's [n/68067]):
|
||||
`file_total` null for them, not 68,067 incomplete singles.
|
||||
**Built 2026-10-26** (edge/src/indexer.rs, LOG.md (113)): a release the
|
||||
window closed on with one file named by its stem or post, the name with
|
||||
a number and no archive, volume, sidecar or random name, waits in the
|
||||
album of its poster and template (the name's digit runs as `#`, the
|
||||
extension apart: `#_#.jpg.data`) posted within WINDOW_MS of the album's
|
||||
first file, at most ALBUM_MAX_FILES (1,000) a album; the album closes a
|
||||
window after that span and is written as one release (`source: album`,
|
||||
no file total) named by the members' shared numbers (`1481439079025_#
|
||||
.jpg.data`), a lone member as the release it was. The batch store writes
|
||||
the members' names as `release_texts` kind 6 (`member`, the first 20).
|
||||
The directory-like prefix variant is not built (no case seen in the
|
||||
corpus samples). The batch counter: a total ≥ 100 under which one poster
|
||||
started 20+ releases within 100 batches gives none of them a file
|
||||
total. On six a.b.ath ranges of 2016: 46 albums of 790 files among
|
||||
2,099 chunks, most of them a subject parser miss (`[n/60] Shogun I
|
||||
(1980) Shogun I (1980).avi.012`: the counter text taken into the name,
|
||||
a volume each) that the album rule glues back together.
|
||||
|
||||
4. **A slimmer catalog.** `releases` loses its text copies: `poster_id` →
|
||||
`posters(id)` (the table gets an id; 33 B → 4), `group_set_id` →
|
||||
`group_sets` (the ~2,600 distinct group combinations; 85 B jsonb → 4),
|
||||
`group_sets` (the distinct group combinations: 139,927 in voyager 2's
|
||||
83.7 M releases, LOG.md (112); 85 B jsonb → 4),
|
||||
`release_key` and `identity_key` as 20-byte `bytea` (41 B each → 20),
|
||||
`source` a smallint. ~150 of ~430 B a row; the poster and key indexes
|
||||
shrink with them (~12 GB of 50 today); `releases_category_newest_idx`
|
||||
@@ -445,7 +533,94 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
|
||||
a day; at a file per ~50 articles that is ~1 M open files of ~100 B in
|
||||
memory per block, and their segment lists (~25 B each) in the block's
|
||||
spool file, read back when a release is finished. Per worker thread a
|
||||
few hundred MB at worst.
|
||||
few hundred MB at worst. The carried window (amended 2026-10-26) has to
|
||||
survive a restart (deploys restart the workers daily): its counters and
|
||||
names are small (kilobytes for most groups, ~100 MB for the busiest),
|
||||
the open files' segment lists are the bulk (~1.25 GB for a day of the
|
||||
busiest group). A snapshot at every 2 M range would be ~600 B/article
|
||||
of writes, so the state is snapshotted at a lease's end and on a timer,
|
||||
to a file on bulk, and a crash redoes the ranges since the last snapshot
|
||||
from the corpus or the prefetch cache: the deterministic keys make the
|
||||
redo idempotent. **The snapshot goes through the API** (2026-10-26):
|
||||
`PUT /node/groups/:id/snapshot` with the serialized window (the
|
||||
frontier, the open files and releases, the open albums, the upload
|
||||
numbers, the batch-counter table; gzip) and the article it stands at
|
||||
(the last batch's `next`), kept by the coordinator on bulk as
|
||||
`<snapshot dir>/<group id>`; `GET` gives it back to the next holder. A
|
||||
worker snapshots when it gives a group back (a stop) and every
|
||||
SNAPSHOT_MINUTES while it holds one, always right after a batch was
|
||||
stored, so the snapshot's article is a resume point. The replay
|
||||
harness (8) keeps a group's window in one process without one.
|
||||
**Built 2026-10-26** (LOG.md (117)): migration 6500 `group_leases`;
|
||||
`app/services/group_leases.ts` (lease by the most urgent planned
|
||||
range within LEASE_MIN_PRIORITY..LEASE_MAX_PRIORITY, the longest free
|
||||
first; takeover after LEASE_TTL_SECONDS, the ranges from the snapshot's
|
||||
article on planned again; next; heartbeat; release; the snapshot
|
||||
streamed to `SNAPSHOT_DIR/<group id>.gz`); the node API's
|
||||
`groups/lease`, `groups/:id/next|heartbeat|release|snapshot`; the
|
||||
range stream serving a stored range's chunks from the corpus; the
|
||||
prefetcher ahead of held groups' cursors, skipping chunks the corpus
|
||||
has; `RangeIndexer::for_group/begin_range/snapshot/restore` (bincode,
|
||||
the files a table, SNAPSHOT_VERSION 1); `edge-node` holding groups by
|
||||
default (`--ranges` keeps the old path). Tests: the lease order,
|
||||
takeover and redo, the endpoints with a corpus-served stream and the
|
||||
snapshot through the API, the snapshot restoring a window between two
|
||||
ranges (the two-range release one, the album joined). Deployed and
|
||||
started 2026-10-26 22:37 (LOG.md (118)). A chunk that doesn't inflate
|
||||
(a live reply cut short; the spool doesn't check) is reported after
|
||||
the batch before it went out and the range asked for again from that
|
||||
chunk: the window stays (the first run lost it, and the open batch).
|
||||
The lease bounds alone don't select a window: the history planner's
|
||||
oldest buckets are priority 0–2 too, so the first run also took 2026
|
||||
history at 0 (25k ranges in 40 groups, fetched live) while 1,739 of
|
||||
the 2016 corpus ranges sat at voyager 2's leftover priorities (done
|
||||
when the window was marked, planned again by the reset): hence
|
||||
`LEASE_CAPTURE_ONLY` (the lease, next and the prefetcher's run-ahead
|
||||
take capture ranges only) and the one-off repair of the priorities.
|
||||
The stream polls the corpus as well as the spool while it waits for a
|
||||
chunk: the corpus fill claims chunks of the same range ahead of the
|
||||
leased run (the prefetcher's claims are per range and chunk), stores
|
||||
them in the corpus only, and the run leaves them there; a stream that
|
||||
looked at the corpus once, seconds before that store, waited its whole
|
||||
hour on the spool and the worker lost the group and its open batch
|
||||
(three groups on the first morning). A failed stream now posts the
|
||||
open batch before the group goes back, like a stop does, so the
|
||||
window's snapshot survives it; only a batch the coordinator refused
|
||||
(lease lost, too large) still loses the window.
|
||||
**Two lanes a group (2026-10-27; migration 6600, LOG.md (119)).** A
|
||||
group's ranges in article order through one window meant a thread
|
||||
that leased a group for its tail range first waded through the
|
||||
group's history (older articles come first), and a held history
|
||||
group's tail waited behind days of its ranges; and the bounds that ran
|
||||
the 2016 window alone (0..0, capture only) shut the tail out entirely.
|
||||
Now `group_leases` has a row per group and *lane*: the `history` lane
|
||||
works the ranges the bounds hand out (the windows, the history), the
|
||||
`tail` lane the tail planner's (priority at or below the tail's,
|
||||
whatever the bounds), each with its own holder, window, snapshot
|
||||
(`<group>-tail.gz`) and takeover redo (the lane's ranges only, and
|
||||
only those the window did after the snapshot: leased, done since it,
|
||||
or read past it). The edge puts `--tail-threads` of its threads on the
|
||||
tail lane (EDGE_TAIL_THREADS; 1 of 2 or more by default, which is
|
||||
what homelab's 16 run with), the rest on history; the prefetcher runs ahead of every held
|
||||
cursor. The seam a group is the same one as before, once: where the
|
||||
history lane reaches the first range the tail lane did, it is caught
|
||||
up. **The lease order** within a lane and priority is a mix (the
|
||||
user, 2026-10-27): one lease starts from the group with the most
|
||||
planned articles, the next from the longest unheld one (never held
|
||||
first), alternating on a sequence (`group_lease_turns`), so a big
|
||||
group snapshotted at a stop doesn't wait behind hundreds of small
|
||||
never-held groups and a quiet group's tail is visited all the same.
|
||||
**`coordinator:status`** (`app/services/status.ts`, `--json`) prints
|
||||
the catalog's room against `CATALOG_MAX_NVME_GB`, the ranges by band
|
||||
(tail, raised, each capture window, the history by thousand), the
|
||||
last hour's indexed and stored articles, the spool, the tail backlog,
|
||||
the held groups with their snapshots' age and size, the snapshot
|
||||
files, and the phase-2 backlogs (provisional releases, probe
|
||||
candidates, the pending ladder, the judge's dirty marks). **End to
|
||||
end** (`tests/e2e`, suite `e2e`, CI builds the binary first): the Rust
|
||||
worker against the real node API, database, spool and corpus — the
|
||||
corpus-fill race, a failed stream and the restart that restores the
|
||||
snapshot, a restart whose snapshot file is gone, a takeover.
|
||||
|
||||
6. **The corpus stays as it is.** Measured on 300 unfiltered chunks
|
||||
(30 M lines, LOG.md (102)): 43% of lines lack a "(k/N)" counter, and of
|
||||
@@ -504,6 +679,17 @@ releases; the inspector matches them again), `nntp_shares`, `nodes`,
|
||||
`release_texts`. The category trigger reads the groups from
|
||||
`group_sets`; the judge's dirty trigger watches `poster_id` instead of
|
||||
`poster`.
|
||||
- `pending_releases` (migrations 6300 and 6400, the HDD): the unnamed
|
||||
releases outside the catalog (decision f): `release_key` (primary),
|
||||
`identity_key`, `group_id`, `name` (the random stem), `poster` text, the
|
||||
posting span, `first_article`, `last_article`, counts, `file_total`,
|
||||
`partial`, `finished`, `tool`, `candidates` jsonb (the probe targets),
|
||||
`inspections` jsonb (what each probe showed), `named`, `status` (0
|
||||
waiting, 1 named and in the catalog, 2 discarded, 3 exhausted, 4 named
|
||||
awaiting the move, 9 taken), `tries`, `created_at`, `probed_at`; partial
|
||||
indexes on the waiting (by `created_at`) and the named (by `probed_at`).
|
||||
- `pending_signatures` (6400): per (tool, group, poster; poster '' for the
|
||||
tool and group together) `probed`, `named`, `dropped`, `updated_at`.
|
||||
- `release_texts(release_id, kind smallint, text varchar(255))` (6200),
|
||||
primary key on all three, one trigram index (fastupdate): every string
|
||||
search matches against, appended by whoever learns one and never an
|
||||
@@ -531,10 +717,13 @@ releases; the inspector matches them again), `nntp_shares`, `nodes`,
|
||||
- Gone: `fragments`, `range_blocks`, `release_final()` (final = finished:
|
||||
`settled_at IS NOT NULL`), the settler, the fragment merger,
|
||||
`releases_category_newest_idx`.
|
||||
- `release_inspections` and `probe_candidates` on `CHUNK_TABLESPACE` (the
|
||||
HDD) with their indexes. Note LOG.md 2026-10-19 (81): the judge's fake
|
||||
rule read all of `release_inspections` every round, 17 min on the HDD;
|
||||
the judge must read it by release or keep what it needs on the NVMe.
|
||||
- `release_inspections` and `probe_candidates` were on `CHUNK_TABLESPACE`
|
||||
(the HDD) with their indexes; both are back on the NVMe (migrations 4800
|
||||
and 7300). Note LOG.md 2026-10-19 (81): the judge's fake rule read all of
|
||||
`release_inspections` every round, 17 min on the HDD; and 2026-11-03
|
||||
(136): the prober's catalog claim updates `probe_candidates` from random
|
||||
points every minute, 2–3 min on the HDD beside the pending ladder's sweep.
|
||||
What a worker reads or updates by random key every minute stays on the NVMe.
|
||||
|
||||
### Batch store (built 2026-10-25)
|
||||
|
||||
@@ -595,7 +784,49 @@ is before frontier − `WINDOW_MS` are judged and written `finished`, as an
|
||||
it, else `finished: false, edge: true`. A file short of parts stays in
|
||||
its release and sets `partial` (the store: `complete = false`). A lone
|
||||
article of a multi-part random file is still counted `per-article`, not
|
||||
written. Gone: fragments and the cut-off, `uploads_of` (online now),
|
||||
written; before the block's end a scrambled name counts as random too
|
||||
(`looks_scrambled`: a random subject and poster per article, 160k of
|
||||
alt.binaries.storage's releases; LOG.md (122)).
|
||||
|
||||
**Single-archive rules (built 2026-10-31; LOG.md (123)-(127)).** The judge
|
||||
(`names::judge`, `RuleSet.judge`) gets each file's articles seen and their
|
||||
bytes, and whether the release is *settled* (the window closed on it: a
|
||||
par2 can still join an open one; the start edge doesn't count, since a par2
|
||||
follows its rar and 7% of the catalog's releases are start edges). A settled
|
||||
release of one unsplit zip, rar or 7z and nothing else is dropped as a
|
||||
**stub archive** (`stub-archive`, key: the legs tripped) when posted to
|
||||
2+ groups, under 100 MB (the fakes stop at ~90 MB; hash-named uploads,
|
||||
real albums and passworded videos above it don't), and (A) its articles
|
||||
are ≤ 256 KiB with the release ≥ 1 MB (Power-Post's 250 kB default; a
|
||||
one-article release always has small articles, which exempts e-books and
|
||||
sidecars), or (X) posted to 4+ groups, or (W) by a webmail poster. A
|
||||
ROT13 volume name (".cnegN.rar" is ".partN.rar": a.b.erotica's passworded
|
||||
videos, LOG.md (128)) is a volume, not a single archive. A release
|
||||
touching a music, e-book, comics, tabs or fonts group is exempt: those
|
||||
communities post real single small archives the same way (karaoke zips to
|
||||
a.b.sounds.karaoke + a.b.test with Power-Post's articles; 16 of 32,308
|
||||
known fakes lost, LOG.md (127)). These are the
|
||||
exe-stub and recycled-installer floods under film and software names:
|
||||
55% of the catalog on 2026-10-30, 99.3% of the known fakes, 0 known
|
||||
content in the 217k sample, verified on 1,500 decoded articles. A settled
|
||||
single archive of one article and 100 kB–1 MB under a release-like,
|
||||
unscrambled name without spaces and without an e-book format in it is
|
||||
dropped as a **nonsense piece** (`nonsense-piece`, key: the upload tool):
|
||||
the newznzb.* pieces (scene-styled or random with a "-XX" suffix), 9% of
|
||||
the catalog; under 100 kB are cue/log sidecars and adverts, and the names
|
||||
with spaces are books and albums. Below that, a settled single archive of
|
||||
one article and 2–100 kB under a release-like name (spaces allowed), outside
|
||||
the exempt groups and without an e-book, subtitle, crack/keygen/trainer,
|
||||
cover or sidecar word, is dropped as a **tiny archive** (`tiny-archive`,
|
||||
key: the upload tool; LOG.md (131), (133)): the 2015–17 flood's RARs
|
||||
holding "<title>.exe" at 2–60 kB, 1.66M catalog rows from a dozen posters
|
||||
(12 of 12 fetched fake); the word exemptions keep the real tiny archives
|
||||
(subtitles, cracks, trainers) at the price of ~50k fakes named "Incl
|
||||
Keygen" that the prober judges later. The rules are switched by the
|
||||
rule set (`dropStubArchives`, `dropNonsensePieces`, `dropTinyArchives`),
|
||||
sampled like every drop, and mirrored in `lib/rules/rule_set.ts` (the TypeScript indexer
|
||||
judges as each batch completes files, so it passes settled without the
|
||||
window's knowledge). Gone: fragments and the cut-off, `uploads_of` (online now),
|
||||
`check.ts` and the fixture comparison (the parity with the TypeScript
|
||||
indexer; CI runs `edge/bench-fixtures.sh` instead), the `incomplete` count
|
||||
(incomplete releases are kept). The wire carries `finished`, `edge` and
|
||||
@@ -603,11 +834,54 @@ indexer; CI runs `edge/bench-fixtures.sh` instead), the `incomplete` count
|
||||
window holds a day of a group's segments in memory (the busiest group's
|
||||
~50 M); the spool file per block of the design is not built yet.
|
||||
|
||||
Next: albums in the edge (design 3), block leases and the planner (then
|
||||
the store keys by the leased block and the lease columns leave `ranges`;
|
||||
until then every range end leaves provisional edges), the block merger
|
||||
(joins and finishes the edges), the prober writing `release_texts` kind 2,
|
||||
the corpus replay harness.
|
||||
### The replay harness (built 2026-10-26)
|
||||
|
||||
`node ace replay:corpus --group <name> [--dir CORPUS_DIR] [--from article]
|
||||
[--to article] [--limit ranges] [--batch-chunks 5] [--no-finish]` (commands/
|
||||
replay_corpus.ts; design 8): a group's complete corpus ranges in article
|
||||
order through one window in the Rust edge (`edge-bench --group <dir>
|
||||
--rules rules.json --wire -`: the window carried from range to range, the
|
||||
end finished after the last range; `edge/src/lease.rs` has the lease JSON
|
||||
the worker and the bench share) and the batch store into a scratch
|
||||
catalog, as the worker would store them: each range leased to a node named
|
||||
`replay`, its batches stored at its resume point (pulled from the bench's
|
||||
stdout as the store keeps up), the range finished when its last batch is
|
||||
in. It refuses a database not named `*_replay` or `*_scratch`.
|
||||
The rules are the scratch catalog's current set and group priorities:
|
||||
copy `groups` and `rule_sets` from the catalog for a faithful replay. The
|
||||
report: releases finished, provisional, edges; complete, incomplete,
|
||||
undecided; readable, with par2; files, segments as a share of the
|
||||
headers; bytes per header of the NVMe tables (releases, release_texts,
|
||||
posters, group_sets, rule counts and samples) and of those on
|
||||
CHUNK_TABLESPACE (release_chunks, probe_candidates, release_inspections).
|
||||
The app image carries `edge-bench` for it (Dockerfile); on homelab the
|
||||
scratch catalog is a second database (`usenet_replay`, default tablespace
|
||||
`nvme` like the catalog, the chunk tables on `pg_default`; migrated with
|
||||
the voyager-3 image built from the deploy cache's repo, `groups` and
|
||||
`rule_sets` copied over), the corpus mounted read-only.
|
||||
`deploy/replay-window.sh` runs a window there: each group whose window
|
||||
ranges are all stored is replayed once with the window's bounds as
|
||||
`--from`/`--to` (the corpus holds other windows of the same group), 4
|
||||
groups at a time, logs and `.done` markers in `.cache/replay/`, looping
|
||||
until the window is done (LOG.md 2026-10-26 (111) has the first local
|
||||
numbers, (112) the homelab run). **Deployed 2026-10-26** (LOG.md (116)):
|
||||
voyager 3 runs on homelab without its edge worker (`.cache/no-edge`:
|
||||
deploy.sh and the nightly resume leave it stopped, since range by range
|
||||
it would split releases at every edge with no merger left), and history
|
||||
comes from the corpus through this driver into the catalog itself
|
||||
(`DB=usenet_rebuild OUT=.cache/replay-catalog`, the harness allowed by
|
||||
`REPLAY_INTO_CATALOG=1`), the prober's ladder, the merger and the judge
|
||||
working on it as it fills; the scratch run's numbers (63 groups) were
|
||||
read before (LOG.md (115)). The tail waits for the window worker.
|
||||
|
||||
Next: the replay on homelab over the 2016 window (the NVMe bytes per
|
||||
header for real), the window worker (design 1 and 5: group leases, the
|
||||
stream serving stored chunks, the snapshot through the API; being built,
|
||||
LOG.md (117)), the prober writing `release_texts` kind 2, the spot/NZB
|
||||
match on pending keys (decision f), the tail's provisional writes at a
|
||||
range end (the window's open releases shown while they upload: the files
|
||||
sent provisionally marked, the finish sending the rest). Gone from the list: block
|
||||
leases, the planner and the block merger (design 1, amended).
|
||||
|
||||
## Decisions
|
||||
|
||||
@@ -618,11 +892,87 @@ c. Group list: editable, owner by priority number (above). **Accepted.**
|
||||
d. Reposts: separate releases in phase 1, merged in phase 2. **Accepted.**
|
||||
e. Headers through the coordinator's relay, with prefetch (part 1).
|
||||
**Accepted.**
|
||||
f. Random-named releases (no file with a readable name) keep no chunk in
|
||||
the header pass: their file keys and probe candidates only, until
|
||||
phase 2 names them (bodies) and fetches their segments again. The full
|
||||
history is ~3 TB with them, ~1.2–1.4 TB without (725 M of the 1.25 B
|
||||
kept files; LOG.md 2026-10-09). **Accepted** 2026-10-09, not built yet.
|
||||
f. **Nothing enters the catalog without a usable name** (amended
|
||||
2026-10-26, the user's direction; before: random-named releases keep
|
||||
no chunk but their rows). A release the window closed on whose files
|
||||
all have random names (`isUnreadableName`, `names::is_unreadable_name`:
|
||||
a name read token by token, so separators, spaces and random
|
||||
"extensions" don't hide a scramble; since 2026-11-02, LOG.md (129),
|
||||
when 15% of the catalog's "readable" names were "Zx5AjNaK-mh7CFg6Hf8uu"
|
||||
and "Description - Lt5nHjlo3RiuBb3C") is no release yet: it goes to `pending_releases`
|
||||
(migration 6300, the HDD) with its keys, counts, article span, upload
|
||||
tool and probe targets, no row in `releases`, `release_texts` or
|
||||
`posters`, no chunk. The pending ladder works them **from least to most
|
||||
effort**: (0) no fetch: a signature verdict (tool and group, poster)
|
||||
learned from earlier probes, a spot or NZB that names the message-ids;
|
||||
(1) a par2 main file's first article (it names the files); (2) the first
|
||||
article of the first file (the yEnc name, an archive's listing in its
|
||||
first 16 KiB); (3) a 7z's tail; then nothing more. A probe that yields
|
||||
a usable name moves the release into the catalog, its segments read
|
||||
again from the corpus (the range stored) or the server; one that
|
||||
yields none counts against its signature, and a signature with
|
||||
≥100 probes and ≤0.5% named (voyager 2's garbage learner, LOG.md
|
||||
2026-10-08 (5)) is **discarded** from then on, a sample of it still
|
||||
probed to notice a change. **By class** (2026-11-02, LOG.md (130)): a
|
||||
pending release's class is its tool, group, size bucket, name shape and
|
||||
partial flag (`lib/release/classes.ts`, `names::pending_class`, the SQL
|
||||
`pending_class()`; `pending_releases.klass`), and `pending_classes` keeps
|
||||
each class's climbs and names; the claim takes the classes with the best
|
||||
record first (`classScore`, a prior for the unexplored, at most a quarter
|
||||
of a claim from one class), the cheapest target article first within one
|
||||
(`target_bytes`), and the oldest of the rest; a class with the learner's
|
||||
thresholds is dropped like a signature, and the rule set's
|
||||
`droppedClasses` keep its releases out of the pending store at the edge,
|
||||
the explored sample excepted. A probed file whose first 16 KiB and size
|
||||
match a named catalog release's (`file_hashes`) takes that name without
|
||||
another fetch (`app/services/hash_twins.ts`), on the ladder and for
|
||||
catalog releases without one. The reason, in the 2016 replay: one poster's
|
||||
hash-named pieces ("Bob", 11 aliases, 3.2 M releases in five small
|
||||
groups) were 76% of the rows and three quarters of the NVMe bytes for
|
||||
11% of the segments and nothing anyone can find; the full history has
|
||||
725 M random-named files (LOG.md 2026-10-09). Built 2026-10-26: the
|
||||
edge sends an unnamed release with an empty payload and its tool, the
|
||||
batch store writes `pending_releases` (LOG.md (114)); a finished release
|
||||
the edge found no target for (the edge's `candidates_of`: par2, archive
|
||||
start, 7z tail, else the first random-named file as the lone target;
|
||||
LOG.md (140)) is written exhausted at once; the ladder in the
|
||||
prober (`app/services/pending_ladder.ts`, LOG.md (115)): a claim takes
|
||||
the oldest waiting releases' next target (par2, archive, lone, 7z-tail
|
||||
in that order), at least PENDING_SHARE (25%) of each claim next to the
|
||||
catalog's own candidates, answered from `probe_cache` when the article
|
||||
was read before; `usableName` (lib/probe/inspect.ts) takes the largest
|
||||
named file of a par2 set or a listing, or a lone file's yEnc name, that
|
||||
is neither packaging nor random; a name sets status 4, no name the next
|
||||
target or status 3 (exhausted); each finished climb counts on
|
||||
`pending_signatures` for (tool, group, poster) and (tool, group), and a
|
||||
signature with PENDING_MIN_PROBES (100) climbs and at most
|
||||
PENDING_MAX_NAMED (0.5%) named is dropped: a sweep beside the lanes
|
||||
(every five minutes, in its own loop) discards the waiting releases
|
||||
of each dropped signature and class once (`swept_at`; the edge drops
|
||||
their new releases itself), the rules' `samplePercent` of them left to
|
||||
climb, each by an index (`pending_releases_signature_idx` for a
|
||||
poster's rows: tested on the rows, a signature was a scan of the whole
|
||||
table; LOG.md (133), (134)). The batch store, the record, the claim
|
||||
and the sweep lock pending rows in one order, by key and the counters
|
||||
after (`lockOrder` for the multi-row statements; the record and the
|
||||
sweep lock their rows with `SELECT … ORDER BY release_key FOR UPDATE`
|
||||
first, since an UPDATE's join or `= ANY(subquery)` locks in plan
|
||||
order), and a flush that deadlocks anyway keeps its outcomes for the
|
||||
next (LOG.md (134), (137)). The namer
|
||||
(`app/services/pending_namer.ts`, in the prober's process): a named
|
||||
release's article span read again from the corpus (the stored range's
|
||||
chunks) or by XOVER on a connection of its own, a chunk of articles
|
||||
at a time (one string of a wide span topped V8's limit; spans over 3M
|
||||
articles are given up), through the TypeScript
|
||||
indexer with `partialFiles` (a file given up short of parts stays a
|
||||
partial file of its release, as the Rust window writes it; without it
|
||||
the range path's fragment rule left nothing to find: by 2026-10-29
|
||||
about 52k of 210k named releases failed, all partial), written as a release under the pending key with `source`
|
||||
weak-stem, the name as `inner_name`, its chunk and its candidates (the
|
||||
prober answers those from `probe_cache`: inspections, par2 sets, hashes,
|
||||
the password flag). Not built: the spot/NZB match on pending keys (step
|
||||
0 of the ladder beyond signatures), and a signature by poster alone
|
||||
across groups.
|
||||
g. The account's NNTP connections shared by priority (`nntp_shares`): the
|
||||
coordinator (headers) before the prober, each between its minimum and
|
||||
maximum, 5 kept for the spotter, the spot inspector, the plain
|
||||
|
||||
@@ -91,6 +91,12 @@ export default defineConfig({
|
||||
name: 'functional',
|
||||
timeout: 30000,
|
||||
},
|
||||
// The Rust edge worker against the real node API (tests/e2e): needs edge/target/release/edge-node.
|
||||
{
|
||||
files: ['tests/e2e/**/*.spec.{ts,js}'],
|
||||
name: 'e2e',
|
||||
timeout: 120000,
|
||||
},
|
||||
],
|
||||
forceExit: false,
|
||||
},
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import Leases, { LeaseLost } from '#services/leases'
|
||||
import GroupLeases, { LANES } from '#services/group_leases'
|
||||
import BatchStore, { StaleBatch } from '#services/batch_store'
|
||||
import Spool from '#services/spool'
|
||||
import { KEEPALIVE, encodeFrame, fromWire, type WireBatch } from '#lib/edge/index'
|
||||
import Spool, { type ChunkSpan } from '#services/spool'
|
||||
import Corpus from '#services/corpus'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import { KEEPALIVE, encodeFrame, fromWire, type Lane, type WireBatch } from '#lib/edge/index'
|
||||
|
||||
/**
|
||||
* How long the stream waits for a chunk the prefetcher hasn't spooled yet. Long: a chunk that keeps
|
||||
* coming in isn't given up (only a stalled one is), and some groups' chunks take 20+ minutes
|
||||
* (a.b.0day.stuffz, 17 MB in 22 min; LOG.md 2026-10-10 (3)).
|
||||
*/
|
||||
const CHUNK_WAIT_MS = 60 * 60 * 1000
|
||||
/** While waiting, an empty frame this often: an HTTP client gives up on a quiet body (Node's after 5 minutes). */
|
||||
const KEEPALIVE_MS = 20_000
|
||||
|
||||
@@ -30,18 +28,109 @@ export default class NodeController {
|
||||
return lease
|
||||
}
|
||||
|
||||
/** The range's chunks from its resume point, waiting for those still being fetched. */
|
||||
// Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): a worker thread holds a group and takes its
|
||||
// ranges in article order; the window's snapshot goes through here too, so a worker needs no disk of its own.
|
||||
// `?lane=tail` is the group's tail cursor (its own holder, window and snapshot); history by default.
|
||||
|
||||
/** The lane the request names (`?lane=`), or null after a 400 for one that isn't a lane. */
|
||||
private lane(ctx: HttpContext): Lane | null {
|
||||
const lane = ctx.request.qs().lane ?? 'history'
|
||||
if (LANES.includes(lane)) return lane
|
||||
ctx.response.badRequest({ error: `lane: ${LANES.join(' or ')}, not "${lane}"` })
|
||||
return null
|
||||
}
|
||||
|
||||
async leaseGroup(ctx: HttpContext) {
|
||||
const lane = this.lane(ctx)
|
||||
if (!lane) return
|
||||
const lease = await GroupLeases.lease(this.node(ctx), lane)
|
||||
if (!lease) return ctx.response.noContent()
|
||||
return lease
|
||||
}
|
||||
|
||||
async nextRange(ctx: HttpContext) {
|
||||
const lane = this.lane(ctx)
|
||||
if (!lane) return
|
||||
try {
|
||||
const next = await GroupLeases.next(Number(ctx.params.id), this.node(ctx), lane)
|
||||
if (!next) return ctx.response.noContent()
|
||||
return next
|
||||
} catch (error) {
|
||||
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async groupHeartbeat(ctx: HttpContext) {
|
||||
const lane = this.lane(ctx)
|
||||
if (!lane) return
|
||||
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.heartbeat(groupId, nodeId, lane))
|
||||
}
|
||||
|
||||
async releaseGroup(ctx: HttpContext) {
|
||||
const lane = this.lane(ctx)
|
||||
if (!lane) return
|
||||
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.release(groupId, nodeId, lane))
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /groups/:id/snapshot?next=N: the window's serialized state standing at article N, streamed to disk as sent
|
||||
* (gzip; the body parser leaves an octet-stream alone, so the request is still readable here).
|
||||
*/
|
||||
async putSnapshot(ctx: HttpContext) {
|
||||
const lane = this.lane(ctx)
|
||||
if (!lane) return
|
||||
const next = Number(ctx.request.qs().next)
|
||||
if (!Number.isFinite(next) || next <= 0) return ctx.response.badRequest({ error: 'next (the snapshot\'s article) is needed' })
|
||||
const raw = ctx.request.raw()
|
||||
const body = raw !== null ? Readable.from([Buffer.from(raw, 'binary')]) : ctx.request.request
|
||||
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.putSnapshot(groupId, nodeId, next, body, lane))
|
||||
}
|
||||
|
||||
async getSnapshot(ctx: HttpContext) {
|
||||
const lane = this.lane(ctx)
|
||||
if (!lane) return
|
||||
try {
|
||||
const snapshot = await GroupLeases.getSnapshot(Number(ctx.params.id), this.node(ctx), lane)
|
||||
if (!snapshot) return ctx.response.notFound({ error: 'no snapshot' })
|
||||
ctx.response.header('Content-Type', 'application/octet-stream')
|
||||
ctx.response.header('Content-Length', String(snapshot.size))
|
||||
ctx.response.header('X-Snapshot-Next', String(snapshot.next))
|
||||
return ctx.response.stream(snapshot.stream)
|
||||
} catch (error) {
|
||||
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The range's chunks from its resume point: a stored range's from the corpus (a worker needs no mount; REBUILD.md
|
||||
* design 1), else from the spool, waiting for those still being fetched.
|
||||
*/
|
||||
async stream(ctx: HttpContext) {
|
||||
const nodeId = this.node(ctx)
|
||||
const rangeId = Number(ctx.params.id)
|
||||
const range = await this.leased(ctx, rangeId, nodeId)
|
||||
if (!range) return
|
||||
const spans = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }, Number(range.next_id))
|
||||
const group = range.capture ? await db.from('groups').where('id', range.group_id).select('name').first() : null
|
||||
const corpusRange = group ? { group: group.name as string, start: Number(range.start_id), end: Number(range.end_id) } : null
|
||||
// The chunk from the corpus or the spool, whichever has it. Both are polled while waiting: the corpus fill
|
||||
// claims a chunk ahead of the leased run (the prefetcher's claims are per range and chunk), stores it in the
|
||||
// corpus only, and the run then leaves it there — a stream that looked at the corpus once, before that
|
||||
// store, waited the whole hour on the spool (three groups on 2026-10-27, LOG.md (118)).
|
||||
const stored = async (span: ChunkSpan) =>
|
||||
corpusRange && (await Corpus.has(corpusRange, span)) ? Corpus.read(corpusRange, span) : null
|
||||
async function* frames() {
|
||||
for (const span of spans) {
|
||||
const waitUntil = Date.now() + CHUNK_WAIT_MS
|
||||
const waitUntil = Date.now() + coordinatorConfig.streamChunkWaitMs
|
||||
let quietSince = Date.now()
|
||||
while (!(await Spool.has(rangeId, span))) {
|
||||
let data = await stored(span)
|
||||
while (!data) {
|
||||
if (await Spool.has(rangeId, span)) {
|
||||
data = await Spool.read(rangeId, span)
|
||||
break
|
||||
}
|
||||
if (Date.now() > waitUntil) throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} wasn't spooled in time`)
|
||||
if (Date.now() - quietSince >= KEEPALIVE_MS) {
|
||||
quietSince = Date.now()
|
||||
@@ -49,8 +138,9 @@ export default class NodeController {
|
||||
await Leases.heartbeat(rangeId, nodeId)
|
||||
}
|
||||
await sleep(500)
|
||||
data = await stored(span)
|
||||
}
|
||||
yield encodeFrame({ ...span, data: await Spool.read(rangeId, span) })
|
||||
yield encodeFrame({ ...span, data })
|
||||
// Streaming keeps the lease alive.
|
||||
await Leases.heartbeat(rangeId, nodeId)
|
||||
}
|
||||
@@ -75,7 +165,11 @@ export default class NodeController {
|
||||
}
|
||||
|
||||
async finish(ctx: HttpContext) {
|
||||
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.finish(rangeId, nodeId))
|
||||
return this.lifecycle(ctx, async (rangeId, nodeId) => {
|
||||
const range = await Leases.leased(rangeId, nodeId)
|
||||
await Leases.finish(rangeId, nodeId)
|
||||
await GroupLeases.rangeDone(Number(range.group_id), Number(range.headers), GroupLeases.laneOf(Number(range.priority)))
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /ranges/:id/corrupt { from }: a chunk that doesn't inflate; the range goes back. */
|
||||
|
||||
+131
-8
@@ -8,7 +8,9 @@ import { decodeChunk } from '#lib/chunks/index'
|
||||
import { SIDECAR } from '#services/sidecar_folder'
|
||||
import { identityKey, releaseKey } from '#lib/release/keys'
|
||||
import { sourceCode } from '#lib/release/source'
|
||||
import { TEXT_KINDS, TEXT_MAX } from '#lib/release/text_kind'
|
||||
import { uploadTool } from '#lib/rules/index'
|
||||
import { PENDING, byEffort, lockOrder } from '#services/pending_ladder'
|
||||
import { TEXT_KINDS, TEXT_MAX, textsToStore } from '#lib/release/text_kind'
|
||||
|
||||
/**
|
||||
* A small new release (at most 3 files, under 1 MB) whose files are all sidecars (.nfo, .sfv, .nzb, …): the sidecar
|
||||
@@ -30,6 +32,8 @@ export interface StoredBatch {
|
||||
/** Provisional (tail) releases the batch finished. */
|
||||
finalized: number
|
||||
candidates: number
|
||||
/** Unnamed releases written to the pending store instead of the catalog (decision f). */
|
||||
pending: number
|
||||
}
|
||||
|
||||
/** A chunk's slot in its batch: the key and the upload the edge numbered it with (ProbeCandidate points at it). */
|
||||
@@ -73,18 +77,29 @@ class BatchStore {
|
||||
newReleases: 0,
|
||||
finalized: 0,
|
||||
candidates: 0,
|
||||
pending: 0,
|
||||
}
|
||||
|
||||
// Nothing enters the catalog without a usable name (decision f): an unnamed release (no readable file name)
|
||||
// goes to the pending store with its probe targets, the named ones to the catalog.
|
||||
const named = batch.releases.filter((r) => r.readable)
|
||||
const unnamed = batch.releases.filter((r) => !r.readable)
|
||||
const written = await this.writeReleases(
|
||||
trx,
|
||||
{ groupId: Number(range.group_id), blockId },
|
||||
batch.from,
|
||||
batch.releases,
|
||||
named,
|
||||
batch.candidates
|
||||
)
|
||||
result.newReleases = written.newReleases
|
||||
result.finalized = written.finalized
|
||||
result.candidates = written.candidates
|
||||
result.pending = await this.writePending(
|
||||
trx,
|
||||
Number(range.group_id),
|
||||
unnamed,
|
||||
batch.candidates
|
||||
)
|
||||
|
||||
if (batch.counts.length > 0) {
|
||||
await trx.rawQuery(
|
||||
@@ -133,7 +148,7 @@ class BatchStore {
|
||||
}
|
||||
|
||||
/** The range's block; a range without one (no planner yet) is a block of its own, leased as the range is. */
|
||||
private async blockOf(trx: TransactionClientContract, range: any): Promise<number> {
|
||||
async blockOf(trx: TransactionClientContract, range: any): Promise<number> {
|
||||
if (range.block_id) return Number(range.block_id)
|
||||
const [block] = await trx
|
||||
.table('blocks')
|
||||
@@ -153,6 +168,108 @@ class BatchStore {
|
||||
return Number(block.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unnamed releases of a batch into pending_releases (migration 6300, the HDD): keys, counts, the upload
|
||||
* tool and the probe targets, no chunk. The pending ladder (REBUILD.md decision f) probes them cheapest first,
|
||||
* and a name moves one into the catalog; a provisional tail release sent again adds its counts up.
|
||||
*/
|
||||
async writePending(
|
||||
trx: TransactionClientContract,
|
||||
groupId: number,
|
||||
releases: ReleaseChunk[],
|
||||
candidates: ProbeCandidate[]
|
||||
): Promise<number> {
|
||||
if (releases.length === 0) return 0
|
||||
const targets = new Map<
|
||||
string,
|
||||
{ kind: string; messageId: string; file: string; bytes?: number }[]
|
||||
>()
|
||||
for (const c of candidates) {
|
||||
const key = slot(c.releaseKey, c.upload ?? 0)
|
||||
const list = targets.get(key) ?? []
|
||||
list.push({ kind: c.kind, messageId: c.messageId, file: c.file, bytes: c.bytes })
|
||||
targets.set(key, list)
|
||||
}
|
||||
// In the record's lock order (LOG.md (136)): all rows sorted, then all classes sorted, in one transaction. The
|
||||
// upsert locks a finished row too, before its WHERE passes it by; a chunk's classes before the next chunk's rows
|
||||
// crossed the ladder's record(), which holds its rows and then takes its classes.
|
||||
const rows = lockOrder(
|
||||
releases.map((r) => {
|
||||
const probes = targets.get(slot(r.key, r.upload)) ?? []
|
||||
return {
|
||||
release_key: releaseKey(r.key, groupId, r.firstArticle).toString('hex'),
|
||||
identity_key: identityKey(r.key).toString('hex'),
|
||||
name: r.name.slice(0, TEXT_MAX),
|
||||
poster: r.poster || null,
|
||||
first: r.postedAt,
|
||||
last: r.postedLast ?? r.postedAt,
|
||||
first_article: r.firstArticle,
|
||||
last_article: r.lastArticle,
|
||||
files: r.files,
|
||||
segments: r.segments,
|
||||
bytes: r.bytes,
|
||||
partial: r.partial === true,
|
||||
finished: r.finished !== false,
|
||||
file_total: r.fileTotal,
|
||||
tool: r.tool ?? (probes[0] ? uploadTool(probes[0].messageId) : 'unknown'),
|
||||
candidates: probes,
|
||||
// A finished release without a target is through the ladder before it starts: exhausted, not waiting
|
||||
// (1.57M rows waited forever, never claimed, before the edge gave the hash-named pieces one; LOG.md (140)).
|
||||
status: r.finished !== false && probes.length === 0 ? PENDING.exhausted : PENDING.waiting,
|
||||
// The first target's article (by effort): the cheapest probe first within a class (LOG.md (130)).
|
||||
target_bytes: byEffort(probes)[0]?.bytes ?? null,
|
||||
}
|
||||
}),
|
||||
(r) => r.release_key
|
||||
)
|
||||
let written = 0
|
||||
const classes = new Map<string, { tool: string; klass: number; waiting: number }>()
|
||||
for (let i = 0; i < rows.length; i += 1000) {
|
||||
const { rows: done } = await trx.rawQuery(
|
||||
`INSERT INTO pending_releases (release_key, identity_key, group_id, name, poster, posted_at, last_posted_at, first_article, last_article,
|
||||
files, segments, bytes, partial, finished, file_total, tool, candidates, klass, target_bytes, status)
|
||||
SELECT decode(v.release_key, 'hex'), decode(v.identity_key, 'hex'), ?, v.name, v.poster, to_timestamp(v.first / 1000.0), to_timestamp(v.last / 1000.0),
|
||||
v.first_article, v.last_article, v.files, v.segments, v.bytes, v.partial, v.finished, v.file_total, v.tool, v.candidates,
|
||||
pending_class(v.name, v.bytes, v.partial), v.target_bytes, v.status
|
||||
FROM jsonb_to_recordset(?::jsonb) AS v(release_key text, identity_key text, name text, poster text, first bigint, last bigint, first_article bigint, last_article bigint,
|
||||
files int, segments bigint, bytes bigint, partial boolean, finished boolean, file_total int, tool text, candidates jsonb, target_bytes bigint, status int)
|
||||
ON CONFLICT (release_key) DO UPDATE SET
|
||||
files = pending_releases.files + excluded.files, segments = pending_releases.segments + excluded.segments, bytes = pending_releases.bytes + excluded.bytes,
|
||||
last_posted_at = greatest(pending_releases.last_posted_at, excluded.last_posted_at), last_article = greatest(pending_releases.last_article, excluded.last_article),
|
||||
partial = excluded.partial, finished = excluded.finished, file_total = coalesce(excluded.file_total, pending_releases.file_total),
|
||||
candidates = CASE WHEN jsonb_array_length(pending_releases.candidates) = 0 THEN excluded.candidates ELSE pending_releases.candidates END,
|
||||
klass = pending_class(pending_releases.name, pending_releases.bytes + excluded.bytes, excluded.partial),
|
||||
target_bytes = coalesce(pending_releases.target_bytes, excluded.target_bytes),
|
||||
status = CASE WHEN pending_releases.status = ? AND excluded.finished
|
||||
AND jsonb_array_length(pending_releases.candidates) = 0 AND jsonb_array_length(excluded.candidates) = 0
|
||||
THEN ? ELSE pending_releases.status END
|
||||
WHERE NOT pending_releases.finished
|
||||
RETURNING release_key, tool, klass, (xmax = 0) AS inserted, status`,
|
||||
[groupId, JSON.stringify(rows.slice(i, i + 1000)), PENDING.waiting, PENDING.exhausted]
|
||||
)
|
||||
written += done.length
|
||||
// Each class seen gets its row in the ladder's record, so the claim explores it (pending_classes).
|
||||
for (const d of done) {
|
||||
const key = `${d.tool}\t${d.klass}`
|
||||
const entry = classes.get(key) ?? { tool: d.tool, klass: Number(d.klass), waiting: 0 }
|
||||
if (d.inserted && Number(d.status) === PENDING.waiting) entry.waiting++
|
||||
classes.set(key, entry)
|
||||
}
|
||||
}
|
||||
if (classes.size > 0) {
|
||||
await trx.rawQuery(
|
||||
`INSERT INTO pending_classes (tool, group_id, klass, waiting)
|
||||
SELECT v.tool, ?, v.klass, v.waiting FROM jsonb_to_recordset(?::jsonb) AS v(tool text, klass int, waiting int)
|
||||
ON CONFLICT (tool, group_id, klass) DO UPDATE SET waiting = pending_classes.waiting + excluded.waiting`,
|
||||
[
|
||||
groupId,
|
||||
JSON.stringify(lockOrder(classes.values(), (c) => `${c.tool}\t${groupId}\t${c.klass}`)),
|
||||
]
|
||||
)
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
/**
|
||||
* The releases of a batch into the catalog, one chunk each, and their probe candidates. Every chunk is one
|
||||
* release: inserted, or (a provisional tail release sent again) finished.
|
||||
@@ -237,7 +354,7 @@ class BatchStore {
|
||||
]
|
||||
)
|
||||
)
|
||||
const texts: { release_id: number; text: string }[] = []
|
||||
const texts: { release_id: number; kind: number; text: string }[] = []
|
||||
for (const row of rows) {
|
||||
const w = byKey.get(row.release_key)
|
||||
if (!w) continue
|
||||
@@ -248,15 +365,21 @@ class BatchStore {
|
||||
out.finalized++
|
||||
finalized.add(w.id)
|
||||
}
|
||||
texts.push({ release_id: w.id, text: row.name.slice(0, TEXT_MAX) })
|
||||
texts.push({ release_id: w.id, kind: TEXT_KINDS.name, text: row.name.slice(0, TEXT_MAX) })
|
||||
// An album's members stay findable by their own names (the first TEXTS_PER_KIND of them).
|
||||
if (r.source === 'album' && r.files > 0) {
|
||||
for (const text of textsToStore(decodeChunk(r.payload).map((f) => f.name))) {
|
||||
texts.push({ release_id: w.id, kind: TEXT_KINDS.member, text })
|
||||
}
|
||||
// The name, searchable (release_texts; a name sent again is a no-op).
|
||||
}
|
||||
}
|
||||
// The name (and an album's members), searchable (release_texts; a text sent again is a no-op).
|
||||
if (texts.length > 0) {
|
||||
await trx.rawQuery(
|
||||
`INSERT INTO release_texts (release_id, kind, text)
|
||||
SELECT v.release_id, ?, v.text FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, text text) WHERE v.text <> ''
|
||||
SELECT v.release_id, v.kind, v.text FROM jsonb_to_recordset(?::jsonb) AS v(release_id bigint, kind int, text text) WHERE v.text <> ''
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[TEXT_KINDS.name, JSON.stringify(texts)]
|
||||
[JSON.stringify(texts)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,13 +42,13 @@ class Corpus {
|
||||
return coordinatorConfig.corpus.dir
|
||||
}
|
||||
|
||||
private rangeDir(range: CorpusRange) {
|
||||
return join(this.dir!, range.group, `${range.start}-${range.end}`)
|
||||
private rangeDir(range: CorpusRange, dir = this.dir!) {
|
||||
return join(dir, range.group, `${range.start}-${range.end}`)
|
||||
}
|
||||
|
||||
private path(range: CorpusRange, span: ChunkSpan) {
|
||||
private path(range: CorpusRange, span: ChunkSpan, dir = this.dir!) {
|
||||
const index = Math.floor((span.from - range.start) / coordinatorConfig.chunkArticles)
|
||||
return join(this.rangeDir(range), `${String(index).padStart(4, '0')}-${span.from}-${span.to}.gz`)
|
||||
return join(this.rangeDir(range, dir), `${String(index).padStart(4, '0')}-${span.from}-${span.to}.gz`)
|
||||
}
|
||||
|
||||
/** Whether every chunk of the range is stored (its range.json). */
|
||||
@@ -69,10 +69,10 @@ class Corpus {
|
||||
)
|
||||
}
|
||||
|
||||
/** A stored chunk, or null. */
|
||||
async read(range: CorpusRange, span: ChunkSpan): Promise<Buffer | null> {
|
||||
if (!this.dir) return null
|
||||
return readFile(this.path(range, span)).catch(() => null)
|
||||
/** A stored chunk, or null (`dir`: another corpus than CORPUS_DIR's, the namer's tests). */
|
||||
async read(range: CorpusRange, span: ChunkSpan, dir = this.dir): Promise<Buffer | null> {
|
||||
if (!dir) return null
|
||||
return readFile(this.path(range, span, dir)).catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { mkdir, rename, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import RuleSets from '#services/rule_sets'
|
||||
import CatalogSpace from '#services/catalog_space'
|
||||
import { LeaseLost } from '#services/leases'
|
||||
import type { GroupLease, Lane, NextRange } from '#lib/edge/index'
|
||||
|
||||
export const LANES: Lane[] = ['history', 'tail']
|
||||
|
||||
/**
|
||||
* Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): a worker thread holds a group and asks for its
|
||||
* next range each time it finished one, so a group's ranges are indexed in article order through one carried
|
||||
* window. The coordinator keeps one row a group and lane (group_leases): the holder, its heartbeat, the range it
|
||||
* works and the snapshot its window stands at.
|
||||
*
|
||||
* - Two lanes (2026-10-27): the `history` lane works the ranges the bounds hand out (LEASE_MIN/MAX_PRIORITY,
|
||||
* LEASE_CAPTURE_ONLY: the windows, the history), the `tail` lane the tail planner's ranges (priority at or below
|
||||
* the tail's). Each lane has its own holder, window and snapshot, so a thread on the tail never wades through a
|
||||
* group's history first (a group's ranges come in article order, and a held group's history is days of ranges), and
|
||||
* the tail of a group whose history is held isn't stuck behind it. The one seam a group is where the history lane
|
||||
* reaches the tail lane's first range (its ranges are done: the history lane is caught up).
|
||||
* - lease: the free group with the most urgent planned range of the lane (the ranges' priority), and among those a
|
||||
* mix: the group with the most planned articles and the longest unheld one (never held first) take turns, so a
|
||||
* big group snapshotted at a stop doesn't wait behind hundreds of small never-held ones, and a quiet group's tail
|
||||
* is visited all the same. A holder quiet for LEASE_TTL_SECONDS is taken over. Taking a group resets what the
|
||||
* lane's snapshot doesn't cover: the lane's ranges from the snapshot's article on that the window did after it
|
||||
* (leased, done since the snapshot, or read past it) are planned again (the batch store's keys make the redo
|
||||
* idempotent); without a snapshot the window is lost and the group continues at its resume point (one seam).
|
||||
* - next: the group's lowest planned range of the lane (or the one this node already has), leased to the node; none
|
||||
* when the lane is caught up: the worker snapshots and gives the group back, and gets it again when the tail
|
||||
* planned more.
|
||||
* - snapshot: the serialized window, kept as a file on bulk (SNAPSHOT_DIR; `<group>.gz`, the tail's
|
||||
* `<group>-tail.gz`), given to the next holder of the lane.
|
||||
*/
|
||||
class GroupLeases {
|
||||
get dir(): string {
|
||||
return coordinatorConfig.snapshotDir
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's ranges, as a condition on `r`: the tail lane's are at or below the tail's priority, the history lane's
|
||||
* above it — within the lease bounds when `bounds` (what is handed out), or all of them (what a lane's window did).
|
||||
*/
|
||||
private laneFilter(lane: Lane, bounds: boolean): { sql: string; bindings: number[] } {
|
||||
const tail = coordinatorConfig.tail.priority
|
||||
if (lane === 'tail') return { sql: 'r.priority <= ?', bindings: [tail] }
|
||||
if (!bounds) return { sql: 'r.priority > ?', bindings: [tail] }
|
||||
const { leasePriorityMin: min, leasePriorityMax: max, leaseCaptureOnly } = coordinatorConfig
|
||||
return {
|
||||
sql: `r.priority > ? AND r.priority BETWEEN ? AND ?${leaseCaptureOnly ? ' AND r.capture' : ''}`,
|
||||
bindings: [tail, min, max],
|
||||
}
|
||||
}
|
||||
|
||||
async lease(nodeId: number, lane: Lane = 'history'): Promise<GroupLease | null> {
|
||||
// The catalog at its cap on the NVMe: the tail only (CatalogSpace), as the range leases did.
|
||||
if (lane === 'history' && (await CatalogSpace.full())) return null
|
||||
const rules = await RuleSets.current()
|
||||
const ttl = coordinatorConfig.leaseTtlSeconds
|
||||
const todo = this.laneFilter(lane, true)
|
||||
const all = this.laneFilter(lane, false)
|
||||
return db.transaction(async (trx) => {
|
||||
// One lease at a time: the choice reads several tables.
|
||||
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('group-lease', 0))`)
|
||||
// The free groups with planned work in the lane (a range leased by a holder gone quiet counts: a group whose
|
||||
// last range that holder took would never be taken over), by the most urgent range's priority; within a priority the
|
||||
// most planned articles and the longest unheld alternate: one lease starts from the biggest, the next from
|
||||
// the longest unheld (the sequence counts the leases; ranked afresh each time, "biggest first" would always
|
||||
// win).
|
||||
const { rows: turns } = await trx.rawQuery(`SELECT nextval('group_lease_turns') % 2 AS odd`)
|
||||
const [first, second] = Number(turns[0].odd) === 1 ? [1, 0] : [0, 1]
|
||||
const { rows } = await trx.rawQuery(
|
||||
`WITH todo AS (
|
||||
SELECT r.group_id, min(r.priority) AS priority, sum(r.end_id - r.next_id + 1) AS articles
|
||||
FROM ranges r
|
||||
WHERE (r.status = 'planned' OR (r.status = 'leased' AND r.heartbeat_at < now() - make_interval(secs => ?)))
|
||||
AND r.next_id <= r.end_id AND ${todo.sql}
|
||||
GROUP BY r.group_id
|
||||
), free AS (
|
||||
SELECT g.id, g.name, g.priority AS group_priority, l.snapshot_next, l.snapshot_at, l.heartbeat_at,
|
||||
todo.priority, todo.articles
|
||||
FROM todo JOIN groups g ON g.id = todo.group_id AND g.active
|
||||
LEFT JOIN group_leases l ON l.group_id = g.id AND l.lane = ?
|
||||
WHERE l.node_id IS NULL OR l.heartbeat_at < now() - make_interval(secs => ?)
|
||||
), turns AS (
|
||||
SELECT free.*,
|
||||
least(2 * row_number() OVER (PARTITION BY priority ORDER BY articles DESC, group_priority, id) - ?,
|
||||
2 * row_number() OVER (PARTITION BY priority ORDER BY heartbeat_at NULLS FIRST, group_priority, id) - ?) AS turn
|
||||
FROM free
|
||||
)
|
||||
SELECT * FROM turns ORDER BY priority, turn, group_priority, id LIMIT 1`,
|
||||
[ttl, ...todo.bindings, lane, ttl, first, second]
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
const groupId = Number(row.id)
|
||||
// What the snapshot doesn't cover is read again: the lane's ranges from its article on that the window did
|
||||
// after it (a range done before the snapshot was taken — by the old range leases, say — stays done).
|
||||
if (row.snapshot_next !== null) {
|
||||
await trx.rawQuery(
|
||||
`UPDATE ranges r SET status = 'planned', node_id = NULL, next_id = greatest(r.start_id, ?::bigint)
|
||||
WHERE r.group_id = ? AND r.end_id >= ?::bigint AND ${all.sql}
|
||||
AND (r.status = 'leased' OR (r.status = 'done' AND (r.done_at IS NULL OR r.done_at >= ?)) OR (r.status = 'planned' AND r.next_id > ?::bigint))`,
|
||||
[
|
||||
row.snapshot_next,
|
||||
groupId,
|
||||
row.snapshot_next,
|
||||
...all.bindings,
|
||||
row.snapshot_at ?? new Date(0),
|
||||
row.snapshot_next,
|
||||
]
|
||||
)
|
||||
} else {
|
||||
await trx.rawQuery(
|
||||
`UPDATE ranges r SET status = 'planned', node_id = NULL WHERE r.group_id = ? AND r.status = 'leased' AND ${all.sql}`,
|
||||
[groupId, ...all.bindings]
|
||||
)
|
||||
}
|
||||
await trx.rawQuery(
|
||||
`INSERT INTO group_leases (group_id, lane, node_id, leased_at, heartbeat_at, range_id)
|
||||
VALUES (?, ?, ?, now(), now(), NULL)
|
||||
ON CONFLICT (group_id, lane) DO UPDATE SET node_id = excluded.node_id, leased_at = now(), heartbeat_at = now(), range_id = NULL`,
|
||||
[groupId, lane, nodeId]
|
||||
)
|
||||
await trx.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
|
||||
return {
|
||||
group: { id: groupId, name: row.name, priority: Number(row.group_priority) },
|
||||
lane,
|
||||
rules,
|
||||
snapshot: row.snapshot_next !== null ? { next: Number(row.snapshot_next) } : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The group's lease row of the lane, when this node holds it. */
|
||||
async held(groupId: number, nodeId: number, lane: Lane = 'history') {
|
||||
const lease = await db
|
||||
.from('group_leases')
|
||||
.where('group_id', groupId)
|
||||
.where('lane', lane)
|
||||
.first()
|
||||
if (!lease || Number(lease.node_id) !== nodeId)
|
||||
throw new LeaseLost(`Group ${groupId} (${lane}) is not held by this node`)
|
||||
return lease
|
||||
}
|
||||
|
||||
/**
|
||||
* The group's next range of the lane in article order (or the one this node has), leased to the node; null when
|
||||
* the lane is caught up.
|
||||
*/
|
||||
async next(groupId: number, nodeId: number, lane: Lane = 'history'): Promise<NextRange | null> {
|
||||
await this.held(groupId, nodeId, lane)
|
||||
const rules = await RuleSets.current()
|
||||
const todo = this.laneFilter(lane, true)
|
||||
const all = this.laneFilter(lane, false)
|
||||
const mine = (
|
||||
await db.rawQuery(
|
||||
`SELECT r.id, r.start_id, r.end_id, r.next_id FROM ranges r
|
||||
WHERE r.group_id = ? AND r.status = 'leased' AND r.node_id = ? AND ${all.sql} ORDER BY r.start_id LIMIT 1`,
|
||||
[groupId, nodeId, ...all.bindings]
|
||||
)
|
||||
).rows[0]
|
||||
const row =
|
||||
mine ??
|
||||
(
|
||||
await db.rawQuery(
|
||||
`UPDATE ranges r SET status = 'leased', node_id = ?, leased_at = now(), heartbeat_at = now(), attempts = attempts + 1, rule_version = ?
|
||||
WHERE r.id = (
|
||||
SELECT r.id FROM ranges r WHERE r.group_id = ? AND r.status = 'planned' AND r.next_id <= r.end_id AND ${todo.sql}
|
||||
ORDER BY r.start_id LIMIT 1 FOR UPDATE SKIP LOCKED)
|
||||
RETURNING r.id, r.start_id, r.end_id, r.next_id`,
|
||||
[nodeId, rules.version, groupId, ...todo.bindings]
|
||||
)
|
||||
).rows[0]
|
||||
await db
|
||||
.from('group_leases')
|
||||
.where('group_id', groupId)
|
||||
.where('lane', lane)
|
||||
.update({ range_id: row ? Number(row.id) : null, heartbeat_at: new Date() })
|
||||
if (!row) return null
|
||||
return {
|
||||
range: {
|
||||
id: Number(row.id),
|
||||
start: Number(row.start_id),
|
||||
end: Number(row.end_id),
|
||||
next: Number(row.next_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async heartbeat(groupId: number, nodeId: number, lane: Lane = 'history') {
|
||||
await this.held(groupId, nodeId, lane)
|
||||
await db
|
||||
.from('group_leases')
|
||||
.where('group_id', groupId)
|
||||
.where('lane', lane)
|
||||
.update({ heartbeat_at: new Date() })
|
||||
await db.from('nodes').where('id', nodeId).update({ last_seen_at: new Date() })
|
||||
}
|
||||
|
||||
/** The lane a range belongs to, by its priority. */
|
||||
laneOf(priority: number): Lane {
|
||||
return priority <= coordinatorConfig.tail.priority ? 'tail' : 'history'
|
||||
}
|
||||
|
||||
/** A range of the group finished by its holder: counted on the lane's lease (Leases.finish does the range). */
|
||||
async rangeDone(groupId: number, headers: number, lane: Lane = 'history') {
|
||||
await db.rawQuery(
|
||||
`UPDATE group_leases SET ranges_done = ranges_done + 1, headers = headers + ?, range_id = NULL WHERE group_id = ? AND lane = ?`,
|
||||
[headers, groupId, lane]
|
||||
)
|
||||
}
|
||||
|
||||
/** The worker gives the group back (after its snapshot): its range stays at its resume point for the next holder. */
|
||||
async release(groupId: number, nodeId: number, lane: Lane = 'history') {
|
||||
await this.held(groupId, nodeId, lane)
|
||||
const all = this.laneFilter(lane, false)
|
||||
await db.rawQuery(
|
||||
`UPDATE ranges r SET status = 'planned', node_id = NULL
|
||||
WHERE r.group_id = ? AND r.status = 'leased' AND r.node_id = ? AND ${all.sql}`,
|
||||
[groupId, nodeId, ...all.bindings]
|
||||
)
|
||||
await db
|
||||
.from('group_leases')
|
||||
.where('group_id', groupId)
|
||||
.where('lane', lane)
|
||||
.update({ node_id: null, range_id: null })
|
||||
}
|
||||
|
||||
snapshotPath(groupId: number, lane: Lane = 'history') {
|
||||
return join(this.dir, lane === 'tail' ? `${groupId}-tail.gz` : `${groupId}.gz`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's snapshot standing at `next` (the next article to index), streamed to disk (a busy group's is
|
||||
* gigabytes) and written whole before it replaces the old one.
|
||||
*/
|
||||
async putSnapshot(
|
||||
groupId: number,
|
||||
nodeId: number,
|
||||
next: number,
|
||||
body: Readable,
|
||||
lane: Lane = 'history'
|
||||
) {
|
||||
await this.held(groupId, nodeId, lane)
|
||||
await mkdir(this.dir, { recursive: true })
|
||||
const path = this.snapshotPath(groupId, lane)
|
||||
await pipeline(body, createWriteStream(`${path}.part`))
|
||||
await rename(`${path}.part`, path)
|
||||
await db
|
||||
.from('group_leases')
|
||||
.where('group_id', groupId)
|
||||
.where('lane', lane)
|
||||
.update({ snapshot_next: next, snapshot_at: new Date(), heartbeat_at: new Date() })
|
||||
}
|
||||
|
||||
/** The snapshot's stream and size, or null when there is none. */
|
||||
async getSnapshot(
|
||||
groupId: number,
|
||||
nodeId: number,
|
||||
lane: Lane = 'history'
|
||||
): Promise<{ stream: Readable; size: number; next: number } | null> {
|
||||
const lease = await this.held(groupId, nodeId, lane)
|
||||
if (lease.snapshot_next === null) return null
|
||||
const path = this.snapshotPath(groupId, lane)
|
||||
try {
|
||||
const { size } = await stat(path)
|
||||
return { stream: createReadStream(path), size, next: Number(lease.snapshot_next) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GroupLeases()
|
||||
@@ -0,0 +1,36 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
/**
|
||||
* Twins by content (LOG.md (130)): a probed file's first 16 KiB and size (file_hashes, what the par2 merge and the
|
||||
* repeated verdict match) in a catalog release that has a name, readable or found inside. The same film posted
|
||||
* under scrambles by other tools gets that name without another fetch. Releases with a junk verdict don't count.
|
||||
*/
|
||||
/** Twins read per pair: the hottest pairs have 131k. */
|
||||
const TWIN_SCAN = 500
|
||||
|
||||
export async function twinNames(
|
||||
pairs: { hash16k: string; size: number }[],
|
||||
trx: { rawQuery: typeof db.rawQuery } = db
|
||||
): Promise<Map<string, string>> {
|
||||
const out = new Map<string, string>()
|
||||
if (pairs.length === 0) return out
|
||||
// Per pair the first named twin among its lowest TWIN_SCAN release ids. hash16k is typed as the column
|
||||
// (char(32)): as text the join cast the column and scanned all of file_hashes, 4 s a call; and with the index
|
||||
// the hottest pairs (55 with over 500 twins, up to 131k, all readable) made a call walk every twin, 37 s inside
|
||||
// the flush's transaction (LOG.md (138)). One name is all a pair needs.
|
||||
const { rows } = await trx.rawQuery(
|
||||
`SELECT v.hash16k, v.size, t.name
|
||||
FROM jsonb_to_recordset(?::jsonb) AS v(hash16k char(32), size bigint)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT coalesce(r.inner_name, r.name) AS name
|
||||
FROM (SELECT release_id FROM file_hashes h WHERE h.hash16k = v.hash16k AND h.size = v.size ORDER BY release_id LIMIT ?) h
|
||||
JOIN releases r ON r.id = h.release_id AND (r.readable OR r.inner_name IS NOT NULL)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM release_verdicts x WHERE x.release_id = r.id)
|
||||
ORDER BY r.readable DESC, r.id LIMIT 1) t`,
|
||||
[JSON.stringify(pairs), TWIN_SCAN]
|
||||
)
|
||||
for (const r of rows) out.set(`${r.hash16k}\t${r.size}`, r.name)
|
||||
return out
|
||||
}
|
||||
|
||||
export const twinKey = (hash16k: string, size: number) => `${hash16k}\t${size}`
|
||||
@@ -0,0 +1,514 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import { explored } from '#lib/rules/index'
|
||||
import { usableName, type Inspection } from '#lib/probe/index'
|
||||
import RuleSets from '#services/rule_sets'
|
||||
import { twinKey, twinNames } from '#services/hash_twins'
|
||||
|
||||
/** pending_releases.status (migration 6400). */
|
||||
export const PENDING = {
|
||||
waiting: 0,
|
||||
named: 1,
|
||||
discarded: 2,
|
||||
exhausted: 3,
|
||||
naming: 4,
|
||||
taken: 9,
|
||||
} as const
|
||||
|
||||
/** The probe targets of a pending release in the order of effort: a par2 names the set's files outright, an archive's start its entries, a lone file only its yEnc name, a 7z's tail is the costliest find. */
|
||||
export const EFFORT = ['par2', 'archive', 'lone', '7z-tail'] as const
|
||||
|
||||
export interface PendingTarget {
|
||||
kind: string
|
||||
messageId: string
|
||||
file: string
|
||||
/** The target article's bytes (candidates from 2026-11-02 on). */
|
||||
bytes?: number
|
||||
}
|
||||
|
||||
/** One probe the ladder hands the prober: the next target of a pending release. */
|
||||
export interface PendingProbe {
|
||||
/** pending_releases.release_key as hex. */
|
||||
releaseKey: string
|
||||
kind: string
|
||||
messageId: string
|
||||
file: string
|
||||
tool: string
|
||||
groupId: number
|
||||
/** Its class (lib/release/classes.ts; 0 for a row from before the classes). */
|
||||
klass: number
|
||||
poster: string
|
||||
/** Targets probed before this one. */
|
||||
tries: number
|
||||
targets: number
|
||||
}
|
||||
|
||||
export type PendingOutcome = {
|
||||
probe: PendingProbe
|
||||
status: 'done' | 'missing' | 'error' | 'waiting'
|
||||
inspection?: Inspection
|
||||
}
|
||||
|
||||
export function byEffort(targets: PendingTarget[]): PendingTarget[] {
|
||||
const rank = (kind: string) => {
|
||||
const i = (EFFORT as readonly string[]).indexOf(kind)
|
||||
return i < 0 ? EFFORT.length : i
|
||||
}
|
||||
return [...targets].sort((a, b) => rank(a.kind) - rank(b.kind))
|
||||
}
|
||||
|
||||
/** A class's record (pending_classes), for the claim's order. */
|
||||
interface ClassRow {
|
||||
tool: string
|
||||
groupId: number
|
||||
klass: number
|
||||
probed: number
|
||||
named: number
|
||||
waiting: number
|
||||
}
|
||||
|
||||
/** The record is read again this often. */
|
||||
const CLASSES_MS = 60_000
|
||||
/** A class found short of a claim's share is left alone this long. */
|
||||
const EMPTY_MS = 5 * 60_000
|
||||
/**
|
||||
* Rows in one order for a statement that locks many of them (pending_releases by key, pending_classes and
|
||||
* pending_signatures by theirs): the batch store, the record, the claim and the sweep all lock the same way, so two
|
||||
* of them can't deadlock on each other (LOG.md (134)).
|
||||
*/
|
||||
export function lockOrder<T>(rows: Iterable<T>, key: (row: T) => string): T[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const ka = key(a)
|
||||
const kb = key(b)
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0
|
||||
})
|
||||
}
|
||||
|
||||
/** The prior rate of an unexplored class and its weight in probes: the ladder's overall rate on 2026-11-02 (LOG.md (130)). */
|
||||
const PRIOR_RATE = 0.15
|
||||
const PRIOR_WEIGHT = 10
|
||||
/** One class takes at most this share of a claim: the rest explores the classes after it. */
|
||||
const CLASS_SHARE = 0.25
|
||||
|
||||
/** The expected naming rate of a class: its record shrunk towards the prior. */
|
||||
export function classScore(c: { probed: number; named: number }): number {
|
||||
return (c.named + PRIOR_RATE * PRIOR_WEIGHT) / (c.probed + PRIOR_WEIGHT)
|
||||
}
|
||||
|
||||
/** An UPDATE returns its rows in no order: the claim's (the cheapest target, then the oldest) restored. */
|
||||
function claimed(rows: any[]): any[] {
|
||||
const bytes = (r: any) =>
|
||||
r.target_bytes === null ? Number.POSITIVE_INFINITY : Number(r.target_bytes)
|
||||
return [...rows].sort(
|
||||
(a, b) =>
|
||||
bytes(a) - bytes(b) ||
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ||
|
||||
Number(a.first_article) - Number(b.first_article)
|
||||
)
|
||||
}
|
||||
|
||||
function toProbe(r: any): PendingProbe {
|
||||
const targets = byEffort(r.candidates as PendingTarget[])
|
||||
const tries = Number(r.tries)
|
||||
const target = targets[Math.min(tries, targets.length - 1)]
|
||||
return {
|
||||
releaseKey: r.release_key,
|
||||
kind: target.kind,
|
||||
messageId: target.messageId,
|
||||
file: target.file,
|
||||
tool: r.tool,
|
||||
groupId: Number(r.group_id),
|
||||
klass: Number(r.klass ?? 0),
|
||||
poster: r.poster,
|
||||
tries,
|
||||
targets: targets.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending ladder (REBUILD.md decision f): pending_releases probed from least to most effort, a usable name
|
||||
* handing the release to the namer (app/services/pending_namer.ts), no name after the last target exhausting it;
|
||||
* every finished climb counted on its signature (tool, group, poster; and the tool and group together) and on its
|
||||
* class (tool, group, size bucket × name shape × partial; lib/release/classes.ts, LOG.md (130)), and a signature
|
||||
* or class with enough probes and next to no names dropped: its waiting releases are discarded without a probe,
|
||||
* a sample of them (the rules' samplePercent) still climbing so the rate keeps being measured. The claim takes the
|
||||
* classes with the best record first (classScore), the cheapest target first within one.
|
||||
*/
|
||||
class PendingLadder {
|
||||
/** A previous prober's claims back in the queue. */
|
||||
async release(): Promise<number> {
|
||||
const { rowCount } = await db.rawQuery(
|
||||
`UPDATE pending_releases SET status = ? WHERE status = ?`,
|
||||
[PENDING.waiting, PENDING.taken]
|
||||
)
|
||||
return rowCount ?? 0
|
||||
}
|
||||
|
||||
/** The ladder's record per class, cached CLASSES_MS between claims, best first; classes found empty, until when. */
|
||||
private classes: ClassRow[] = []
|
||||
private classesAt = 0
|
||||
private empty = new Map<string, number>()
|
||||
|
||||
/** The record read again on the next claim (the tests). */
|
||||
forget() {
|
||||
this.classesAt = 0
|
||||
this.empty.clear()
|
||||
}
|
||||
|
||||
/** pending_classes with waiting releases and not dropped, by their expected rate (a prior for the unexplored). */
|
||||
private async classRows(): Promise<ClassRow[]> {
|
||||
if (Date.now() - this.classesAt < CLASSES_MS) return this.classes
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT tool, group_id, klass, probed, named, waiting FROM pending_classes WHERE waiting > 0 AND NOT dropped`
|
||||
)
|
||||
this.classes = rows
|
||||
.map((r: any) => ({
|
||||
tool: r.tool as string,
|
||||
groupId: Number(r.group_id),
|
||||
klass: Number(r.klass),
|
||||
probed: Number(r.probed),
|
||||
named: Number(r.named),
|
||||
waiting: Number(r.waiting),
|
||||
}))
|
||||
.sort((a: ClassRow, b: ClassRow) => classScore(b) - classScore(a))
|
||||
this.classesAt = Date.now()
|
||||
return this.classes
|
||||
}
|
||||
|
||||
/**
|
||||
* The next target of up to `n` waiting releases with a target left, claimed: by class, the best record first,
|
||||
* at most CLASS_SHARE of the claim from one class (the rest explores the classes after it), the cheapest target
|
||||
* and then the oldest within one; what the classes don't fill, the oldest of the rest (rows from before the
|
||||
* classes, status 0 of a class without a row).
|
||||
*/
|
||||
async claim(n: number): Promise<PendingProbe[]> {
|
||||
if (n <= 0) return []
|
||||
const out: PendingProbe[] = []
|
||||
const perClass = Math.max(1, Math.floor(n * CLASS_SHARE))
|
||||
const now = Date.now()
|
||||
for (const c of await this.classRows()) {
|
||||
if (out.length >= n) break
|
||||
const key = `${c.tool}\t${c.groupId}\t${c.klass}`
|
||||
if ((this.empty.get(key) ?? 0) > now) continue
|
||||
const want = Math.min(perClass, n - out.length)
|
||||
const { rows } = await db.rawQuery(
|
||||
`UPDATE pending_releases p SET status = ? FROM (
|
||||
SELECT release_key FROM pending_releases
|
||||
WHERE status = ? AND tool = ? AND group_id = ? AND klass = ? AND finished AND tries < jsonb_array_length(candidates)
|
||||
ORDER BY target_bytes NULLS LAST, created_at, first_article LIMIT ? FOR UPDATE SKIP LOCKED) c
|
||||
WHERE p.release_key = c.release_key
|
||||
RETURNING encode(p.release_key, 'hex') AS release_key, p.candidates, p.tries, p.tool, p.group_id, p.klass, coalesce(p.poster, '') AS poster, p.target_bytes, p.created_at, p.first_article`,
|
||||
[PENDING.taken, PENDING.waiting, c.tool, c.groupId, c.klass, want]
|
||||
)
|
||||
if (rows.length < want) this.empty.set(key, now + EMPTY_MS)
|
||||
if (rows.length > 0) {
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_classes SET waiting = greatest(0, waiting - ?) WHERE tool = ? AND group_id = ? AND klass = ?`,
|
||||
[rows.length, c.tool, c.groupId, c.klass]
|
||||
)
|
||||
}
|
||||
out.push(...claimed(rows).map(toProbe))
|
||||
}
|
||||
if (out.length < n) {
|
||||
const { rows } = await db.rawQuery(
|
||||
`UPDATE pending_releases p SET status = ? FROM (
|
||||
SELECT release_key FROM pending_releases
|
||||
WHERE status = ? AND finished AND tries < jsonb_array_length(candidates)
|
||||
ORDER BY created_at, first_article LIMIT ? FOR UPDATE SKIP LOCKED) c
|
||||
WHERE p.release_key = c.release_key
|
||||
RETURNING encode(p.release_key, 'hex') AS release_key, p.candidates, p.tries, p.tool, p.group_id, p.klass, coalesce(p.poster, '') AS poster, p.target_bytes, p.created_at, p.first_article`,
|
||||
[PENDING.taken, PENDING.waiting, n - out.length]
|
||||
)
|
||||
out.push(...claimed(rows).map(toProbe))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* What the probes showed: a usable name moves the release to the namer (status 4), else the next target, or the
|
||||
* end of the ladder (status 3); the finished climbs counted on their signatures.
|
||||
*/
|
||||
async record(outcomes: PendingOutcome[], trx: TransactionClientContract | typeof db = db) {
|
||||
if (outcomes.length === 0) return
|
||||
// A twin by content names what the probe itself didn't (hash_twins.ts).
|
||||
const twins = await twinNames(
|
||||
outcomes
|
||||
.filter((o) => o.status === 'done' && o.inspection?.hash16k && o.inspection.size)
|
||||
.map((o) => ({ hash16k: o.inspection!.hash16k!, size: o.inspection!.size })),
|
||||
trx
|
||||
)
|
||||
const rows = outcomes.map((o) => {
|
||||
const p = o.probe
|
||||
if (o.status === 'waiting') {
|
||||
return {
|
||||
release_key: p.releaseKey,
|
||||
status: PENDING.waiting,
|
||||
tries: p.tries,
|
||||
named: null,
|
||||
inspection: null,
|
||||
final: null,
|
||||
}
|
||||
}
|
||||
const probed = o.status === 'done' && o.inspection ? usableName(o.inspection, p.kind) : null
|
||||
const twin =
|
||||
o.status === 'done' && o.inspection?.hash16k
|
||||
? twins.get(twinKey(o.inspection.hash16k, o.inspection.size))
|
||||
: undefined
|
||||
const name = probed ?? twin ?? null
|
||||
const tries = p.tries + 1
|
||||
const inspection = {
|
||||
kind: p.kind,
|
||||
status: o.status,
|
||||
result: o.inspection ? { ...o.inspection, par2: undefined } : null,
|
||||
}
|
||||
if (name)
|
||||
return {
|
||||
release_key: p.releaseKey,
|
||||
status: PENDING.naming,
|
||||
tries: 0,
|
||||
named: name,
|
||||
inspection,
|
||||
final: 'named',
|
||||
}
|
||||
if (tries >= p.targets)
|
||||
return {
|
||||
release_key: p.releaseKey,
|
||||
status: PENDING.exhausted,
|
||||
tries,
|
||||
named: null,
|
||||
inspection,
|
||||
final: 'exhausted',
|
||||
}
|
||||
return {
|
||||
release_key: p.releaseKey,
|
||||
status: PENDING.waiting,
|
||||
tries,
|
||||
named: null,
|
||||
inspection,
|
||||
final: null,
|
||||
}
|
||||
})
|
||||
// The rows locked in key order first: the UPDATE's join below takes them in whatever order the plan does.
|
||||
await trx.rawQuery(
|
||||
`SELECT 1 FROM pending_releases WHERE release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k)
|
||||
ORDER BY release_key FOR UPDATE`,
|
||||
[rows.map((r) => r.release_key).sort()]
|
||||
)
|
||||
await trx.rawQuery(
|
||||
`UPDATE pending_releases p SET status = v.status, tries = v.tries, named = coalesce(v.named, p.named),
|
||||
inspections = CASE WHEN v.inspection IS NULL THEN p.inspections ELSE p.inspections || v.inspection END,
|
||||
probed_at = CASE WHEN v.inspection IS NULL THEN p.probed_at ELSE now() END
|
||||
FROM jsonb_to_recordset(?::jsonb) AS v(release_key text, status int, tries int, named text, inspection jsonb)
|
||||
WHERE p.release_key = decode(v.release_key, 'hex')`,
|
||||
[
|
||||
JSON.stringify(
|
||||
lockOrder(
|
||||
rows.map(({ final: _final, ...row }) => row),
|
||||
(r) => r.release_key
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
// The signatures: the poster's own and the tool's and group's together.
|
||||
const counts = new Map<
|
||||
string,
|
||||
{ tool: string; group_id: number; poster: string; probed: number; named: number }
|
||||
>()
|
||||
for (const [i, o] of outcomes.entries()) {
|
||||
const final = rows[i].final
|
||||
if (!final) continue
|
||||
for (const poster of [o.probe.poster, '']) {
|
||||
const key = `${o.probe.tool}\t${o.probe.groupId}\t${poster}`
|
||||
const entry = counts.get(key) ?? {
|
||||
tool: o.probe.tool,
|
||||
group_id: o.probe.groupId,
|
||||
poster,
|
||||
probed: 0,
|
||||
named: 0,
|
||||
}
|
||||
entry.probed++
|
||||
if (final === 'named') entry.named++
|
||||
counts.set(key, entry)
|
||||
}
|
||||
}
|
||||
if (counts.size === 0) return
|
||||
const { pendingMinProbes, pendingMaxNamed } = coordinatorConfig.probe
|
||||
await trx.rawQuery(
|
||||
`INSERT INTO pending_signatures (tool, group_id, poster, probed, named)
|
||||
SELECT v.tool, v.group_id, v.poster, v.probed, v.named FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, poster text, probed int, named int)
|
||||
ON CONFLICT (tool, group_id, poster) DO UPDATE SET probed = pending_signatures.probed + excluded.probed, named = pending_signatures.named + excluded.named, updated_at = now()`,
|
||||
[JSON.stringify(lockOrder(counts.values(), (c) => `${c.tool}\t${c.group_id}\t${c.poster}`))]
|
||||
)
|
||||
await trx.rawQuery(
|
||||
`UPDATE pending_signatures s SET dropped = (s.probed >= ?::int AND s.named::float <= s.probed * ?::float),
|
||||
swept_at = CASE WHEN s.dropped THEN s.swept_at END
|
||||
FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, poster text)
|
||||
WHERE s.tool = v.tool AND s.group_id = v.group_id AND s.poster = v.poster`,
|
||||
[
|
||||
pendingMinProbes,
|
||||
pendingMaxNamed,
|
||||
JSON.stringify(
|
||||
lockOrder(counts.values(), (c) => `${c.tool}\t${c.group_id}\t${c.poster}`).map(
|
||||
({ tool, group_id, poster }) => ({ tool, group_id, poster })
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
// The classes: the same record per (tool, group, class), and a probe given back is waiting again.
|
||||
const classes = new Map<
|
||||
string,
|
||||
{
|
||||
tool: string
|
||||
group_id: number
|
||||
klass: number
|
||||
probed: number
|
||||
named: number
|
||||
waiting: number
|
||||
}
|
||||
>()
|
||||
for (const [i, o] of outcomes.entries()) {
|
||||
const key = `${o.probe.tool}\t${o.probe.groupId}\t${o.probe.klass}`
|
||||
const entry = classes.get(key) ?? {
|
||||
tool: o.probe.tool,
|
||||
group_id: o.probe.groupId,
|
||||
klass: o.probe.klass,
|
||||
probed: 0,
|
||||
named: 0,
|
||||
waiting: 0,
|
||||
}
|
||||
const final = rows[i].final
|
||||
if (final) entry.probed++
|
||||
if (final === 'named') entry.named++
|
||||
if (o.status === 'waiting') entry.waiting++
|
||||
classes.set(key, entry)
|
||||
}
|
||||
await trx.rawQuery(
|
||||
`INSERT INTO pending_classes (tool, group_id, klass, probed, named, waiting)
|
||||
SELECT v.tool, v.group_id, v.klass, v.probed, v.named, v.waiting FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, klass int, probed int, named int, waiting int)
|
||||
ON CONFLICT (tool, group_id, klass) DO UPDATE SET probed = pending_classes.probed + excluded.probed, named = pending_classes.named + excluded.named,
|
||||
waiting = pending_classes.waiting + excluded.waiting, updated_at = now()`,
|
||||
[JSON.stringify(lockOrder(classes.values(), (c) => `${c.tool}\t${c.group_id}\t${c.klass}`))]
|
||||
)
|
||||
await trx.rawQuery(
|
||||
`UPDATE pending_classes c SET dropped = (c.probed >= ?::int AND c.named::float <= c.probed * ?::float),
|
||||
swept_at = CASE WHEN c.dropped THEN c.swept_at END
|
||||
FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, klass int)
|
||||
WHERE c.tool = v.tool AND c.group_id = v.group_id AND c.klass = v.klass`,
|
||||
[
|
||||
pendingMinProbes,
|
||||
pendingMaxNamed,
|
||||
JSON.stringify(
|
||||
lockOrder(classes.values(), (c) => `${c.tool}\t${c.group_id}\t${c.klass}`).map(
|
||||
({ tool, group_id, klass }) => ({ tool, group_id, klass })
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waiting releases of a dropped class or signature discarded without a probe, the explored sample excepted; how
|
||||
* many. Each dropped class or signature is swept once (swept_at; null again when the record drops it anew),
|
||||
* `perSweep` of them at most, each by its index (the claim's for a class, the signature index for a poster's
|
||||
* rows, the ladder's for a tool's and group's) in rounds of `limit`. The prober runs this beside the lanes, not
|
||||
* in the claim (LOG.md (133)); the edge drops new releases of dropped signatures and classes itself, so one
|
||||
* sweep is all a drop needs.
|
||||
*/
|
||||
async discard(limit = 10_000, perSweep = 20): Promise<number> {
|
||||
const { samplePercent } = await RuleSets.current()
|
||||
let discarded = 0
|
||||
const { rows: classes } = await db.rawQuery(
|
||||
`SELECT tool, group_id, klass FROM pending_classes WHERE dropped AND swept_at IS NULL ORDER BY group_id, tool, klass LIMIT ?`,
|
||||
[perSweep]
|
||||
)
|
||||
for (const c of classes) {
|
||||
let rows: any[]
|
||||
do {
|
||||
;({ rows } = await db.rawQuery(
|
||||
`SELECT encode(release_key, 'hex') AS release_key FROM pending_releases
|
||||
WHERE status = ? AND tool = ? AND group_id = ? AND klass = ? LIMIT ?`,
|
||||
[PENDING.waiting, c.tool, c.group_id, c.klass, limit]
|
||||
))
|
||||
discarded += await this.discardKeys(rows, samplePercent)
|
||||
} while (rows.length === limit && rows.some((r) => !explored(r.release_key, samplePercent)))
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_classes SET swept_at = now() WHERE tool = ? AND group_id = ? AND klass = ?`,
|
||||
[c.tool, c.group_id, c.klass]
|
||||
)
|
||||
}
|
||||
const { rows: signatures } = await db.rawQuery(
|
||||
`SELECT tool, group_id, poster FROM pending_signatures WHERE dropped AND swept_at IS NULL ORDER BY group_id, tool, poster LIMIT ?`,
|
||||
[perSweep]
|
||||
)
|
||||
for (const s of signatures) {
|
||||
let rows: any[]
|
||||
do {
|
||||
// The poster's rows by the signature index (on the poster's md5: a poster can be longer than a btree
|
||||
// key), a tool's and group's by the ladder's: never a test on the rows, which was a scan of the whole
|
||||
// table per signature on the HDD (LOG.md (134)).
|
||||
;({ rows } =
|
||||
s.poster === ''
|
||||
? await db.rawQuery(
|
||||
`SELECT encode(release_key, 'hex') AS release_key FROM pending_releases
|
||||
WHERE status = ? AND group_id = ? AND tool = ? LIMIT ?`,
|
||||
[PENDING.waiting, s.group_id, s.tool, limit]
|
||||
)
|
||||
: await db.rawQuery(
|
||||
`SELECT encode(release_key, 'hex') AS release_key FROM pending_releases
|
||||
WHERE status = ? AND group_id = ? AND tool = ? AND md5(poster) = md5(?::text) AND poster = ? LIMIT ?`,
|
||||
[PENDING.waiting, s.group_id, s.tool, s.poster, s.poster, limit]
|
||||
))
|
||||
discarded += await this.discardKeys(rows, samplePercent)
|
||||
} while (rows.length === limit && rows.some((r) => !explored(r.release_key, samplePercent)))
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_signatures SET swept_at = now() WHERE tool = ? AND group_id = ? AND poster = ?`,
|
||||
[s.tool, s.group_id, s.poster]
|
||||
)
|
||||
}
|
||||
return discarded
|
||||
}
|
||||
|
||||
/** The rows discarded (status 2), the explored sample excepted, their classes' waiting counts lowered; how many. */
|
||||
private async discardKeys(rows: any[], samplePercent: number): Promise<number> {
|
||||
const keys = rows
|
||||
.map((r: any) => r.release_key as string)
|
||||
.filter((key: string) => !explored(key, samplePercent))
|
||||
.sort()
|
||||
if (keys.length === 0) return 0
|
||||
// Locked in key order (the record's lock order, LOG.md (137)): a plain `key = ANY(subquery)` locks in scan
|
||||
// order and deadlocked against a batch upsert; ORDER BY sorts before FOR UPDATE locks.
|
||||
const { rows: gone } = await db.rawQuery(
|
||||
`UPDATE pending_releases p SET status = ?
|
||||
FROM (SELECT release_key FROM pending_releases
|
||||
WHERE status = ? AND release_key = ANY(SELECT decode(k, 'hex') FROM unnest(?::text[]) AS k)
|
||||
ORDER BY release_key FOR UPDATE) k
|
||||
WHERE p.release_key = k.release_key
|
||||
RETURNING p.tool, p.group_id, p.klass`,
|
||||
[PENDING.discarded, PENDING.waiting, keys]
|
||||
)
|
||||
const counts = new Map<string, { tool: string; group_id: number; klass: number; n: number }>()
|
||||
for (const g of gone) {
|
||||
const key = `${g.tool}\t${g.group_id}\t${g.klass}`
|
||||
const entry = counts.get(key) ?? {
|
||||
tool: g.tool,
|
||||
group_id: Number(g.group_id),
|
||||
klass: Number(g.klass),
|
||||
n: 0,
|
||||
}
|
||||
entry.n++
|
||||
counts.set(key, entry)
|
||||
}
|
||||
if (counts.size > 0) {
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_classes c SET waiting = greatest(0, c.waiting - v.n)
|
||||
FROM jsonb_to_recordset(?::jsonb) AS v(tool text, group_id int, klass int, n int)
|
||||
WHERE c.tool = v.tool AND c.group_id = v.group_id AND c.klass = v.klass`,
|
||||
[JSON.stringify(lockOrder(counts.values(), (c) => `${c.tool}\t${c.group_id}\t${c.klass}`))]
|
||||
)
|
||||
}
|
||||
return gone.length
|
||||
}
|
||||
}
|
||||
|
||||
export default new PendingLadder()
|
||||
@@ -0,0 +1,253 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { promisify } from 'node:util'
|
||||
import { unzip } from 'node:zlib'
|
||||
import env from '#start/env'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import { XoverClient } from '#lib/nntp/index'
|
||||
import { RangeIndexer, type ReleaseChunk } from '#lib/edge/index'
|
||||
import { identityKey } from '#lib/release/keys'
|
||||
import BatchStore from '#services/batch_store'
|
||||
import ChunkPartitions from '#services/chunk_partitions'
|
||||
import Corpus from '#services/corpus'
|
||||
import RuleSets from '#services/rule_sets'
|
||||
import { PENDING } from '#services/pending_ladder'
|
||||
|
||||
const inflate = promisify(unzip)
|
||||
|
||||
interface NamedRow {
|
||||
release_key: Buffer
|
||||
identity_key: Buffer
|
||||
group_id: number
|
||||
named: string
|
||||
first_article: number
|
||||
last_article: number
|
||||
file_total: number | null
|
||||
tries: number
|
||||
}
|
||||
|
||||
/** The namer gives a release up after this many failed moves (its articles gone from the server and the corpus). */
|
||||
const MAX_MOVES = 3
|
||||
|
||||
/**
|
||||
* The widest article span the namer reads again (a busy group posts 50M articles a day, a large upload among
|
||||
* them spans a million or two): one wider is a name the edge saw again much later ("v-lgpc.3ds"), and its span
|
||||
* of segments would not fit the indexer's memory (LOG.md (133)).
|
||||
*/
|
||||
const MAX_SPAN = 3_000_000
|
||||
|
||||
/**
|
||||
* The move into the catalog (REBUILD.md decision f): a pending release a probe named (status 4) gets its segments
|
||||
* read again, from the corpus when its range is stored, else from the server (XOVER over its article span, one
|
||||
* connection of its own, opened for it and closed after), through the TypeScript indexer, and is written as a
|
||||
* release with the name the probe found (source weak-stem, the name as inner name), its chunk and its probe
|
||||
* candidates, which the prober answers from probe_cache. Runs inside the prober.
|
||||
*/
|
||||
export class PendingNamer {
|
||||
private client: XoverClient | null = null
|
||||
private usedAt = 0
|
||||
|
||||
constructor(private corpusDir: string | undefined = coordinatorConfig.corpus.dir) {}
|
||||
|
||||
async run(stopped: () => boolean) {
|
||||
while (!stopped()) {
|
||||
let rows: NamedRow[] = []
|
||||
try {
|
||||
rows = await this.take(20)
|
||||
for (const row of rows) {
|
||||
if (stopped()) break
|
||||
try {
|
||||
await this.move(row)
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
`Naming ${row.release_key.toString('hex')} "${row.named}" failed: ${error.message}`
|
||||
)
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_releases SET tries = tries + 1, probed_at = now() WHERE release_key = ?`,
|
||||
[row.release_key]
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Naming: ${error.message}`)
|
||||
}
|
||||
if (this.client && Date.now() - this.usedAt > coordinatorConfig.nntpIdleCloseMs) {
|
||||
this.client.close()
|
||||
this.client = null
|
||||
}
|
||||
if (rows.length === 0)
|
||||
for (let waited = 0; waited < 30_000 && !stopped(); waited += 1000) await sleep(1000)
|
||||
}
|
||||
this.client?.close()
|
||||
this.client = null
|
||||
}
|
||||
|
||||
/** Named releases awaiting their move, the longest waiting first; those failed MAX_MOVES times stay as they are. */
|
||||
async take(n: number): Promise<NamedRow[]> {
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT release_key, identity_key, group_id, named, first_article, last_article, file_total, tries
|
||||
FROM pending_releases WHERE status = ? AND tries < ? ORDER BY probed_at LIMIT ?`,
|
||||
[PENDING.naming, MAX_MOVES, n]
|
||||
)
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
group_id: Number(r.group_id),
|
||||
first_article: Number(r.first_article),
|
||||
last_article: Number(r.last_article),
|
||||
tries: Number(r.tries),
|
||||
}))
|
||||
}
|
||||
|
||||
/** One named release into the catalog. */
|
||||
async move(row: NamedRow): Promise<void> {
|
||||
if (row.last_article - row.first_article > MAX_SPAN)
|
||||
throw new Error(
|
||||
`its articles ${row.first_article}–${row.last_article} span more than ${MAX_SPAN}`
|
||||
)
|
||||
const group = await db.from('groups').where('id', row.group_id).firstOrFail()
|
||||
const range = await db
|
||||
.from('ranges')
|
||||
.where('group_id', row.group_id)
|
||||
.where('start_id', '<=', row.first_article)
|
||||
.where('end_id', '>=', row.first_article)
|
||||
.orderBy('id')
|
||||
.first()
|
||||
if (!range) throw new Error(`no range of ${group.name} holds article ${row.first_article}`)
|
||||
const rules = await RuleSets.current()
|
||||
const ix = new RangeIndexer({
|
||||
group: group.name,
|
||||
start: row.first_article,
|
||||
end: row.last_article,
|
||||
rules: { ...rules, samplePercent: 0 },
|
||||
// No file given up before the end: the span is read chunk by chunk (one text of it topped V8's string).
|
||||
idleChunks: Number.MAX_SAFE_INTEGER,
|
||||
edgeArticles: 0,
|
||||
// The edge wrote it partial when its file is short of parts on the server: so must the re-read.
|
||||
partialFiles: true,
|
||||
})
|
||||
for await (const piece of this.overviewPieces(group.name, row))
|
||||
ix.addText(piece.text, piece.next)
|
||||
ix.finish()
|
||||
const batch = ix.take()
|
||||
const found = batch.releases.find((r) => identityKey(r.key).equals(row.identity_key))
|
||||
if (!found)
|
||||
throw new Error(
|
||||
`its articles ${row.first_article}–${row.last_article} of ${group.name} no longer show it`
|
||||
)
|
||||
// The catalog row under the pending key (the identity, the group, the first article the edge saw).
|
||||
const chunk: ReleaseChunk = {
|
||||
...found,
|
||||
name: row.named,
|
||||
source: 'weak-stem',
|
||||
readable: true,
|
||||
finished: true,
|
||||
edge: false,
|
||||
firstArticle: row.first_article,
|
||||
fileTotal: row.file_total ?? found.fileTotal,
|
||||
tool: undefined,
|
||||
}
|
||||
const candidates = batch.candidates
|
||||
.filter((c) => c.releaseKey === found.key)
|
||||
.map((c) => ({ ...c, upload: found.upload }))
|
||||
// The chunk's month partition, as the batch store makes sure of before its writes.
|
||||
await ChunkPartitions.ensure()
|
||||
await db.transaction(async (trx) => {
|
||||
const blockId = await BatchStore.blockOf(trx, range)
|
||||
await BatchStore.writeReleases(
|
||||
trx,
|
||||
{ groupId: row.group_id, blockId },
|
||||
row.first_article,
|
||||
[chunk],
|
||||
candidates
|
||||
)
|
||||
await trx.rawQuery(
|
||||
`UPDATE releases SET inner_name = ?, inspected_at = now() WHERE release_key = ? AND inner_name IS NULL`,
|
||||
[row.named.slice(0, 255), row.release_key]
|
||||
)
|
||||
await trx.rawQuery(
|
||||
`UPDATE pending_releases SET status = ?, probed_at = now() WHERE release_key = ?`,
|
||||
[PENDING.named, row.release_key]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview lines of the release's article span, a chunk of articles at a time with the first article after
|
||||
* it: the stored corpus chunks when its ranges are stored and contiguous, else XOVER in windows of a chunk.
|
||||
*/
|
||||
private async *overviewPieces(
|
||||
groupName: string,
|
||||
row: NamedRow
|
||||
): AsyncGenerator<{ text: string; next: number }> {
|
||||
const size = coordinatorConfig.chunkArticles
|
||||
const stored = await this.storedChunks(groupName, row)
|
||||
if (stored) {
|
||||
for (const { raw, next } of stored)
|
||||
yield { text: (await inflate(raw)).toString('latin1'), next }
|
||||
return
|
||||
}
|
||||
this.client ??= await XoverClient.connect({
|
||||
host: env.get('NNTP_HOST')!,
|
||||
port: env.get('NNTP_PORT', 563),
|
||||
user: env.get('NNTP_USER'),
|
||||
password: env.get('NNTP_PASSWORD'),
|
||||
secure: env.get('NNTP_SECURE', true),
|
||||
compress: true,
|
||||
timeoutMs: coordinatorConfig.nntpTimeoutMs,
|
||||
})
|
||||
this.usedAt = Date.now()
|
||||
try {
|
||||
await this.client.group(groupName)
|
||||
for (let from = row.first_article; from <= row.last_article; from += size) {
|
||||
const to = Math.min(from + size - 1, row.last_article)
|
||||
// One zlib/gzip stream, as the server sent it or deflated by the client (the corpus stores it so).
|
||||
const raw = await this.client.xoverRaw(from, to)
|
||||
yield { text: (await inflate(raw)).toString('latin1'), next: to + 1 }
|
||||
}
|
||||
} catch (error) {
|
||||
this.client.close()
|
||||
this.client = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The span's stored corpus chunks (compressed, with the first article after each), or null when any is missing. */
|
||||
private async storedChunks(
|
||||
groupName: string,
|
||||
row: NamedRow
|
||||
): Promise<{ raw: Buffer; next: number }[] | null> {
|
||||
if (!this.corpusDir) return null
|
||||
const stored = await db
|
||||
.from('ranges')
|
||||
.where('group_id', row.group_id)
|
||||
.whereNotNull('stored_at')
|
||||
.where('start_id', '<=', row.last_article)
|
||||
.where('end_id', '>=', row.first_article)
|
||||
.orderBy('start_id')
|
||||
if (
|
||||
stored.length === 0 ||
|
||||
Number(stored[0].start_id) > row.first_article ||
|
||||
Number(stored[stored.length - 1].end_id) < row.last_article
|
||||
)
|
||||
return null
|
||||
const size = coordinatorConfig.chunkArticles
|
||||
const chunks: { raw: Buffer; next: number }[] = []
|
||||
for (const r of stored) {
|
||||
const range = { group: groupName, start: Number(r.start_id), end: Number(r.end_id) }
|
||||
for (
|
||||
let from =
|
||||
range.start +
|
||||
Math.floor((Math.max(row.first_article, range.start) - range.start) / size) * size;
|
||||
from <= Math.min(row.last_article, range.end);
|
||||
from += size
|
||||
) {
|
||||
const to = Math.min(from + size - 1, range.end)
|
||||
const raw = await Corpus.read(range, { from, to }, this.corpusDir)
|
||||
if (!raw) return null
|
||||
chunks.push({ raw, next: to + 1 })
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
}
|
||||
+177
-31
@@ -35,20 +35,8 @@ class Planner {
|
||||
|
||||
async planGroup(group: { id: number; name: string }): Promise<number> {
|
||||
const info = await NntpPool.use((client) => client.group(group.name))
|
||||
const size = coordinatorConfig.rangeArticles
|
||||
const last = await db.from('ranges').where('group_id', group.id).max('end_id as end').first()
|
||||
const from = Math.max(info.low, Number(last?.end ?? 0) + 1)
|
||||
const span = Math.max(1, info.high - info.low)
|
||||
const rows = []
|
||||
for (let start = from; start <= info.high; start += size) {
|
||||
rows.push({
|
||||
group_id: group.id,
|
||||
start_id: start,
|
||||
end_id: Math.min(start + size - 1, info.high),
|
||||
next_id: start,
|
||||
priority: Math.floor((1000 * (start - info.low)) / span),
|
||||
})
|
||||
}
|
||||
const rows = historyRows(group.id, info, Number(last?.end ?? 0), coordinatorConfig.rangeArticles)
|
||||
for (let i = 0; i < rows.length; i += 2000) await db.table('ranges').multiInsert(rows.slice(i, i + 2000))
|
||||
await db.from('groups').where('id', group.id).update({ low: info.low, high: info.high, planned_at: new Date() })
|
||||
return rows.length
|
||||
@@ -75,32 +63,99 @@ class Planner {
|
||||
* numbers come from LIST ACTIVE, one command per name prefix (GROUP per
|
||||
* group took minutes for 1000 groups).
|
||||
*/
|
||||
async planTail(now = new Date()): Promise<{ groups: number; ranges: number }> {
|
||||
async planTail(now = new Date()): Promise<TailPlanned> {
|
||||
const groups = await db.from('groups').where('active', true).where('tail', true).whereNotNull('planned_at').select('id', 'name', 'planned_at')
|
||||
if (groups.length === 0) return { groups: 0, ranges: 0 }
|
||||
const { rows: ends } = await db.rawQuery(`SELECT DISTINCT ON (group_id) group_id, end_id FROM ranges ORDER BY group_id, start_id DESC`)
|
||||
const plannedTo = new Map<number, number>(ends.map((r: any) => [Number(r.group_id), Number(r.end_id)]))
|
||||
if (groups.length === 0) return { groups: 0, ranges: 0, extended: 0 }
|
||||
const { rows: ends } = await db.rawQuery(
|
||||
`SELECT DISTINCT ON (group_id) group_id, id, start_id, end_id,
|
||||
(status = 'planned' AND priority = ? AND NOT capture AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL) AS open
|
||||
FROM ranges ORDER BY group_id, start_id DESC`,
|
||||
[coordinatorConfig.tail.priority]
|
||||
)
|
||||
const last = new Map<number, { id: number; start: number; end: number; open: boolean }>(
|
||||
ends.map((r: any) => [Number(r.group_id), { id: Number(r.id), start: Number(r.start_id), end: Number(r.end_id), open: r.open === true }])
|
||||
)
|
||||
const highs = new Map<string, number>()
|
||||
await NntpPool.use(async (client) => {
|
||||
for (const pattern of new Set(groups.map((g) => wildmatOf(g.name)))) {
|
||||
for (const g of await client.listActive(pattern)) highs.set(g.name, g.high)
|
||||
}
|
||||
})
|
||||
const rows = tailRanges(
|
||||
groups.map((g) => ({ id: Number(g.id), plannedTo: plannedTo.get(Number(g.id)) ?? null, plannedAt: new Date(g.planned_at), high: highs.get(g.name) ?? null })),
|
||||
const plan = tailRanges(
|
||||
groups.map((g) => {
|
||||
const l = last.get(Number(g.id))
|
||||
return { id: Number(g.id), plannedTo: l?.end ?? null, plannedAt: new Date(g.planned_at), high: highs.get(g.name) ?? null, open: l?.open ? { id: l.id, start: l.start } : null }
|
||||
}),
|
||||
now,
|
||||
{ rangeArticles: coordinatorConfig.rangeArticles, ...coordinatorConfig.tail }
|
||||
)
|
||||
if (rows.length === 0) return { groups: 0, ranges: 0 }
|
||||
return this.applyTail(plan, now)
|
||||
}
|
||||
|
||||
/** The tail's plan written: the open ranges extended (one taken meanwhile gets the slice as a row instead), the rows added. */
|
||||
async applyTail(plan: TailPlan, now = new Date()): Promise<TailPlanned> {
|
||||
const rows = [...plan.rows]
|
||||
let extended = 0
|
||||
if (rows.length === 0 && plan.extended.length === 0) return { groups: 0, ranges: 0, extended: 0 }
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.table('ranges').multiInsert(rows)
|
||||
for (const id of new Set(rows.map((r) => r.group_id))) {
|
||||
const high = Math.max(...rows.filter((r) => r.group_id === id).map((r) => r.end_id))
|
||||
await trx.from('groups').where('id', id).update({ high, planned_at: now })
|
||||
for (const e of plan.extended) {
|
||||
const { rows: done } = await trx.rawQuery(
|
||||
`UPDATE ranges SET end_id = ? WHERE id = ? AND status = 'planned' AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL AND end_id = ? RETURNING id`,
|
||||
[e.end_id, e.id, e.from - 1]
|
||||
)
|
||||
if (done.length > 0) extended++
|
||||
else rows.push({ group_id: e.group_id, start_id: e.from, end_id: e.end_id, next_id: e.from, priority: coordinatorConfig.tail.priority })
|
||||
}
|
||||
if (rows.length > 0) await trx.table('ranges').multiInsert(rows)
|
||||
const highs = new Map<number, number>()
|
||||
for (const r of [...rows, ...plan.extended]) highs.set(r.group_id, Math.max(highs.get(r.group_id) ?? 0, r.end_id))
|
||||
for (const [id, high] of highs) await trx.from('groups').where('id', id).update({ high, planned_at: now })
|
||||
})
|
||||
return { groups: new Set(rows.map((r) => r.group_id)).size, ranges: rows.length }
|
||||
return { groups: new Set([...rows, ...plan.extended].map((r) => r.group_id)).size, ranges: rows.length, extended }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjacent planned tail ranges of a group and priority, never spooled or taken, merged into ranges of at most
|
||||
* RANGE_ARTICLES (ranges:merge-tail): the tail planned a sliver a group every round while nothing leased the tail
|
||||
* (127k rows of a median 14k articles on 2026-10-27; LOG.md (118)). The first row of a run grows, the others go.
|
||||
*/
|
||||
async mergeTail(dryRun = false): Promise<{ candidates: number; merged: number; dropped: number }> {
|
||||
const { rows: candidates } = await db.rawQuery(
|
||||
`SELECT id, group_id, start_id, end_id, priority FROM ranges
|
||||
WHERE status = 'planned' AND priority < 0 AND NOT capture AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL
|
||||
ORDER BY group_id, start_id`
|
||||
)
|
||||
const merges = tailMerges(
|
||||
candidates.map((r: any) => ({ id: Number(r.id), group_id: Number(r.group_id), start_id: Number(r.start_id), end_id: Number(r.end_id), priority: Number(r.priority) })),
|
||||
coordinatorConfig.rangeArticles
|
||||
)
|
||||
const dropped = merges.reduce((n, m) => n + m.drop.length, 0)
|
||||
if (dryRun || merges.length === 0) return { candidates: candidates.length, merged: merges.length, dropped }
|
||||
await db.transaction(async (trx) => {
|
||||
// The lease picker reads several tables under this lock; the rows' guards catch a range taken meanwhile.
|
||||
await trx.rawQuery(`SELECT pg_advisory_xact_lock(hashtextextended('group-lease', 0))`)
|
||||
const { rows: gone } = await trx.rawQuery(
|
||||
`DELETE FROM ranges WHERE id = ANY(?::bigint[]) AND status = 'planned' AND spooled_chunks = 0 AND attempts = 0 AND node_id IS NULL RETURNING id`,
|
||||
[merges.flatMap((m) => m.drop)]
|
||||
)
|
||||
if (gone.length !== dropped) throw new Error(`${dropped - gone.length} of the ranges to drop were taken meanwhile; nothing merged`)
|
||||
const { rows: grown } = await trx.rawQuery(
|
||||
`UPDATE ranges r SET end_id = v.end_id FROM (SELECT unnest(?::bigint[]) AS id, unnest(?::bigint[]) AS end_id) v
|
||||
WHERE r.id = v.id AND r.status = 'planned' AND r.spooled_chunks = 0 AND r.attempts = 0 AND r.node_id IS NULL RETURNING r.id`,
|
||||
[merges.map((m) => m.id), merges.map((m) => m.end_id)]
|
||||
)
|
||||
if (grown.length !== merges.length) throw new Error(`${merges.length - grown.length} of the ranges to grow were taken meanwhile; nothing merged`)
|
||||
})
|
||||
return { candidates: candidates.length, merged: merges.length, dropped }
|
||||
}
|
||||
}
|
||||
|
||||
export interface TailPlanned {
|
||||
groups: number
|
||||
/** Rows added. */
|
||||
ranges: number
|
||||
/** Open ranges grown. */
|
||||
extended: number
|
||||
}
|
||||
|
||||
/** A LIST ACTIVE pattern for a group: its first two name parts (`alt.binaries.*`), or the name itself. */
|
||||
@@ -117,28 +172,119 @@ export interface TailGroup {
|
||||
plannedAt: Date
|
||||
/** The server's newest article (null: not listed). */
|
||||
high: number | null
|
||||
/**
|
||||
* Its last range when the tail may grow it instead of adding a row: a planned range at the tail's priority that
|
||||
* nothing spooled, took or tried (its start: the cut at rangeArticles counts from there).
|
||||
*/
|
||||
open?: { id: number; start: number } | null
|
||||
}
|
||||
|
||||
export interface TailRow {
|
||||
group_id: number
|
||||
start_id: number
|
||||
end_id: number
|
||||
next_id: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface TailPlan {
|
||||
/** New ranges. */
|
||||
rows: TailRow[]
|
||||
/** Open ranges grown to end_id (from: the first new article, the old end + 1). */
|
||||
extended: { id: number; group_id: number; from: number; end_id: number }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The tail's new ranges: per group, the articles after its last range, in
|
||||
* ranges of at most `rangeArticles`, once there are `minArticles` of them or
|
||||
* the group's last planning is `maxDelayMinutes` ago.
|
||||
* The tail's new ranges: per group, the articles after its last range, once there are `minArticles` of them or
|
||||
* the group's last planning is `maxDelayMinutes` ago. An open last range grows up to `rangeArticles` first, so a
|
||||
* tail nothing leases stays one range a group instead of a sliver a round; the rest is cut at `rangeArticles`.
|
||||
*/
|
||||
export function tailRanges(
|
||||
groups: TailGroup[],
|
||||
now: Date,
|
||||
options: { rangeArticles: number; minArticles: number; maxDelayMinutes: number; priority: number }
|
||||
) {
|
||||
const rows: { group_id: number; start_id: number; end_id: number; next_id: number; priority: number }[] = []
|
||||
): TailPlan {
|
||||
const rows: TailRow[] = []
|
||||
const extended: TailPlan['extended'] = []
|
||||
for (const g of groups) {
|
||||
if (g.plannedTo === null || g.high === null || g.high <= g.plannedTo) continue
|
||||
const waiting = g.high - g.plannedTo
|
||||
const waited = (now.getTime() - g.plannedAt.getTime()) / 60_000
|
||||
if (waiting < options.minArticles && waited < options.maxDelayMinutes) continue
|
||||
for (let start = g.plannedTo + 1; start <= g.high; start += options.rangeArticles) {
|
||||
let start = g.plannedTo + 1
|
||||
if (g.open && g.open.start + options.rangeArticles - 1 >= start) {
|
||||
const end = Math.min(g.open.start + options.rangeArticles - 1, g.high)
|
||||
extended.push({ id: g.open.id, group_id: g.id, from: start, end_id: end })
|
||||
start = end + 1
|
||||
}
|
||||
for (; start <= g.high; start += options.rangeArticles) {
|
||||
rows.push({ group_id: g.id, start_id: start, end_id: Math.min(start + options.rangeArticles - 1, g.high), next_id: start, priority: options.priority })
|
||||
}
|
||||
}
|
||||
return { rows, extended }
|
||||
}
|
||||
|
||||
export interface TailMerge {
|
||||
/** The run's first range, grown to end_id. */
|
||||
id: number
|
||||
end_id: number
|
||||
/** The ranges it absorbed. */
|
||||
drop: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs of adjacent ranges of one group and priority (sorted by group and start) packed into ranges of at most
|
||||
* `rangeArticles`: the first range of each packed run grows, the others are dropped. Only runs that merge anything.
|
||||
*/
|
||||
export function tailMerges(
|
||||
ranges: { id: number; group_id: number; start_id: number; end_id: number; priority: number }[],
|
||||
rangeArticles: number
|
||||
): TailMerge[] {
|
||||
const merges: TailMerge[] = []
|
||||
let cur: (TailMerge & { group_id: number; start_id: number; priority: number }) | null = null
|
||||
const flush = () => {
|
||||
if (cur && cur.drop.length > 0) merges.push({ id: cur.id, end_id: cur.end_id, drop: cur.drop })
|
||||
}
|
||||
for (const r of ranges) {
|
||||
if (cur && r.group_id === cur.group_id && r.priority === cur.priority && r.start_id === cur.end_id + 1 && r.end_id - cur.start_id + 1 <= rangeArticles) {
|
||||
cur.end_id = r.end_id
|
||||
cur.drop.push(r.id)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
cur = { id: r.id, group_id: r.group_id, start_id: r.start_id, end_id: r.end_id, priority: r.priority, drop: [] }
|
||||
}
|
||||
flush()
|
||||
return merges
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a group's history is cut into, from `lastEnd + 1` (0: the group has no ranges yet) up to the high
|
||||
* watermark: RANGE_ARTICLES slices at the position priority. A group planned for the first time also gets one
|
||||
* "ancient" range, articles 1 … low − 1: what had expired on the server before it was planned. Status
|
||||
* 'ancient', never leased or fetched; it keeps the tiling complete from article 1 (migration 6700 added it for
|
||||
* the groups planned before 2026-10-28).
|
||||
*/
|
||||
export function historyRows(
|
||||
groupId: number,
|
||||
info: { low: number; high: number },
|
||||
lastEnd: number,
|
||||
size: number
|
||||
): Record<string, unknown>[] {
|
||||
const rows: Record<string, unknown>[] = []
|
||||
if (lastEnd === 0 && info.low > 1)
|
||||
rows.push({ group_id: groupId, start_id: 1, end_id: info.low - 1, next_id: 1, priority: 0, status: 'ancient' })
|
||||
const from = Math.max(info.low, lastEnd + 1)
|
||||
const span = Math.max(1, info.high - info.low)
|
||||
for (let start = from; start <= info.high; start += size) {
|
||||
rows.push({
|
||||
group_id: groupId,
|
||||
start_id: start,
|
||||
end_id: Math.min(start + size - 1, info.high),
|
||||
next_id: start,
|
||||
priority: Math.floor((1000 * (start - info.low)) / span),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
|
||||
@@ -425,10 +425,23 @@ export class Prefetcher {
|
||||
)
|
||||
// Leased ranges, checked against the spool's files whatever spooled_at says: a worker waits for them.
|
||||
const leased = await rows(db.query()).where('r.status', 'leased').orderBy('r.leased_at')
|
||||
// The groups workers hold (group_leases; REBUILD.md design 1), by lane: the prefetcher runs ahead of their
|
||||
// cursors (a group's history and tail lanes are two cursors).
|
||||
const held: { group: number; lane: string }[] = (
|
||||
await db
|
||||
.from('group_leases')
|
||||
.whereNotNull('node_id')
|
||||
.where(
|
||||
'heartbeat_at',
|
||||
'>=',
|
||||
new Date(Date.now() - coordinatorConfig.leaseTtlSeconds * 1000)
|
||||
)
|
||||
.select('group_id', 'lane')
|
||||
).map((l) => ({ group: Number(l.group_id), lane: l.lane }))
|
||||
// Planned ranges ahead: those started already, then new ones up to SPOOL_AHEAD_PER_LEASE per lease.
|
||||
const ahead = Math.max(
|
||||
coordinatorConfig.spool.minAhead,
|
||||
coordinatorConfig.spool.aheadPerLease * leased.length
|
||||
coordinatorConfig.spool.aheadPerLease * Math.max(leased.length, held.length)
|
||||
)
|
||||
const started = await rows(db.query())
|
||||
.where('r.status', 'planned')
|
||||
@@ -448,14 +461,37 @@ export class Prefetcher {
|
||||
const want = Math.max(ahead - Number(ready), short)
|
||||
// The catalog at its cap on the NVMe: no history fetched ahead (CatalogSpace).
|
||||
const full = await CatalogSpace.full()
|
||||
// Ahead of each held cursor: the lane's next ranges in article order (the history lane's within what the group
|
||||
// leases hand out: LEASE_MIN/MAX_PRIORITY, LEASE_CAPTURE_ONLY, and none at the catalog's cap; the tail lane's
|
||||
// at the tail's priority), a range of every held cursor before any cursor's second; without held groups (no
|
||||
// window worker running) nothing is fetched ahead for workers.
|
||||
const { leasePriorityMin, leasePriorityMax, leaseCaptureOnly } = coordinatorConfig
|
||||
const tailPriority = coordinatorConfig.tail.priority
|
||||
const heldHistory = full ? [] : held.filter((h) => h.lane === 'history').map((h) => h.group)
|
||||
const heldTail = held.filter((h) => h.lane === 'tail').map((h) => h.group)
|
||||
const fresh =
|
||||
room && want > 0
|
||||
room && want > 0 && heldHistory.length + heldTail.length > 0
|
||||
? await rows(db.query())
|
||||
.where('r.status', 'planned')
|
||||
.where('r.spooled_chunks', 0)
|
||||
.whereRaw('r.next_id <= r.end_id')
|
||||
.where((q: any) => (full ? q.where('r.priority', '<', 0) : q))
|
||||
.orderBy([{ column: 'r.priority' }, { column: 'r.id' }])
|
||||
.where((q: any) =>
|
||||
q
|
||||
.where((h: any) =>
|
||||
h
|
||||
.whereIn('r.group_id', heldHistory)
|
||||
.where('r.priority', '>', tailPriority)
|
||||
.whereBetween('r.priority', [leasePriorityMin, leasePriorityMax])
|
||||
.where((c: any) => (leaseCaptureOnly ? c.where('r.capture', true) : c))
|
||||
)
|
||||
.orWhere((t: any) =>
|
||||
t.whereIn('r.group_id', heldTail).where('r.priority', '<=', tailPriority)
|
||||
)
|
||||
)
|
||||
.orderByRaw(
|
||||
'row_number() OVER (PARTITION BY r.group_id, r.priority <= ? ORDER BY r.start_id), r.priority, r.start_id',
|
||||
[tailPriority]
|
||||
)
|
||||
.limit(want)
|
||||
: []
|
||||
const targets: Target[] = []
|
||||
@@ -464,8 +500,12 @@ export class Prefetcher {
|
||||
const end = Number(row.end_id)
|
||||
const total = Spool.chunksOf({ start, end }).length
|
||||
const missing: ChunkSpan[] = []
|
||||
// A chunk already in the corpus isn't spooled: the stream serves it from there (REBUILD.md design 1).
|
||||
const corpusRange = row.capture === true ? { group: row.group as string, start, end } : null
|
||||
for (const span of Spool.chunksOf({ start, end }, Number(row.next_id))) {
|
||||
if (!this.claimed.has(`${row.id}:${span.from}`) && !(await Spool.has(Number(row.id), span)))
|
||||
if (this.claimed.has(`${row.id}:${span.from}`) || (await Spool.has(Number(row.id), span)))
|
||||
continue
|
||||
if (corpusRange && (await Corpus.has(corpusRange, span))) continue
|
||||
missing.push(span)
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
@@ -521,6 +561,8 @@ export class Prefetcher {
|
||||
const groups = new Map<string, number>()
|
||||
const candidates = await rows(db.query())
|
||||
.where('r.capture', true)
|
||||
// A group taken off the list (groups.active) isn't worth the connections.
|
||||
.where('g.active', true)
|
||||
.whereNull('r.stored_at')
|
||||
.where((q: any) =>
|
||||
q
|
||||
|
||||
+123
-11
@@ -9,12 +9,17 @@ import { inspect, innerName, PROBE_VERSION, type Inspection } from '#lib/probe/i
|
||||
import { NntpShare } from '#services/nntp_shares'
|
||||
import { DiskSpace } from '#services/disk_space'
|
||||
import { decidePar2Completeness } from '#services/par2_completeness'
|
||||
import { twinKey, twinNames } from '#services/hash_twins'
|
||||
import PendingLadder, { type PendingProbe } from '#services/pending_ladder'
|
||||
import { PendingNamer } from '#services/pending_namer'
|
||||
|
||||
interface Candidate {
|
||||
id: number
|
||||
releaseId: number
|
||||
kind: string
|
||||
messageId: string
|
||||
/** A pending release's target (the pending ladder), not a catalog candidate: id and releaseId are 0. */
|
||||
pending?: PendingProbe
|
||||
}
|
||||
|
||||
type Outcome = { candidate: Candidate; status: 'done' | 'missing' | 'error' | 'waiting'; inspection?: Inspection; cached?: boolean }
|
||||
@@ -34,6 +39,10 @@ const TIERS: { kind: string; unreadable: boolean }[] = [
|
||||
{ kind: '7z-tail', unreadable: false },
|
||||
]
|
||||
const FLUSH_MS = 2000
|
||||
/** Between sweeps that discard the waiting releases of dropped signatures. */
|
||||
const SWEEP_MS = 5 * 60_000
|
||||
/** A flush that deadlocks (against the batch store's pending upsert) keeps its outcomes for the next, this often. */
|
||||
const DEADLOCK_RETRIES = 5
|
||||
|
||||
/**
|
||||
* JSON for a jsonb parameter, without NUL characters: jsonb can't hold \u0000 (a name read from an article had one),
|
||||
@@ -43,6 +52,20 @@ function jsonb(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v) => (typeof v === 'string' ? v.replaceAll('\0', '') : v))
|
||||
}
|
||||
|
||||
/** Rounds of every lane's pipeline still queued when the next claim starts. */
|
||||
const LOW_ROUNDS = 8
|
||||
|
||||
/**
|
||||
* The candidate queue's levels: the next claim starts below `low`, LOW_ROUNDS rounds of every lane's pipeline (at
|
||||
* ~0.2 s an article and a pipeline of 8, ~13 s of work), and fills it to `target`, twice that. A claim takes 1.5–4 s
|
||||
* and more (random slices of an 11 M-row table); refilling at half of four rounds left 3–6 s, and at 20–24 lanes the
|
||||
* lanes waited for candidates up to 19% of their time (LOG.md 2026-10-28 (120)). At least `minBatch` either way.
|
||||
*/
|
||||
export function queueLevels(lanes: number, pipeline: number, minBatch: number): { low: number; target: number } {
|
||||
const low = Math.max(minBatch, lanes * pipeline * LOW_ROUNDS)
|
||||
return { low, target: 2 * low }
|
||||
}
|
||||
|
||||
/** Slices a claim takes from random points of the queue. */
|
||||
const SLICES = 20
|
||||
const REPORT_MS = 60_000
|
||||
@@ -61,6 +84,8 @@ export class Prober {
|
||||
private refilling: Promise<void> | null = null
|
||||
private stopped = false
|
||||
private stats = Prober.freshStats()
|
||||
/** Flushes deadlocked in a row, their outcomes kept. */
|
||||
private deadlocks = 0
|
||||
/** Lanes running (each one connection, once it has work); at most the share's grant. Counted apart from the set: a lane leaving takes itself off at once. */
|
||||
private lanes = new Set<Promise<void>>()
|
||||
private running = 0
|
||||
@@ -72,13 +97,29 @@ export class Prober {
|
||||
|
||||
/** Per minute: what was read, and where the lanes' time went (ms summed over lanes; flushes ms on their own). */
|
||||
private static freshStats() {
|
||||
return { articles: 0, bytes: 0, done: 0, missing: 0, error: 0, cached: 0, waitMs: 0, idleMs: 0, fetchMs: 0, inspectMs: 0, flushMs: 0, claimMs: 0, since: Date.now() }
|
||||
return { articles: 0, bytes: 0, done: 0, missing: 0, error: 0, cached: 0, pending: 0, discarded: 0, waitMs: 0, idleMs: 0, fetchMs: 0, inspectMs: 0, flushMs: 0, claimMs: 0, since: Date.now() }
|
||||
}
|
||||
/** The pending ladder's namer: named pending releases into the catalog, alongside the lanes. */
|
||||
private namer = new PendingNamer()
|
||||
|
||||
async run() {
|
||||
// A previous run's claims: one prober at a time.
|
||||
const { rowCount } = await db.rawQuery(`UPDATE probe_candidates SET status = 'waiting' WHERE status = 'taken'`)
|
||||
if (rowCount) logger.info(`Probing: ${rowCount} candidates from a previous run back in the queue.`)
|
||||
const released = await PendingLadder.release()
|
||||
if (released) logger.info(`Probing: ${released} pending releases from a previous run back on the ladder.`)
|
||||
const namer = this.namer.run(() => this.stopped).catch((error) => logger.error(`Naming failed: ${error.message}`))
|
||||
// The ladder's discard sweep beside the lanes: a dropped signature's rows take minutes on the HDD (LOG.md (133)).
|
||||
const sweeper = (async () => {
|
||||
while (!this.stopped) {
|
||||
try {
|
||||
this.stats.discarded += await PendingLadder.discard()
|
||||
} catch (error: any) {
|
||||
logger.error(`Probing: the discard sweep failed: ${error.message}`)
|
||||
}
|
||||
for (let waited = 0; waited < SWEEP_MS && !this.stopped; waited += 1000) await sleep(1000)
|
||||
}
|
||||
})()
|
||||
logger.info(`Probing with ${this.share.min}–${this.share.max} connections (its share of the account's).`)
|
||||
const flusher = (async () => {
|
||||
while (!this.stopped || this.outcomes.length > 0) {
|
||||
@@ -108,6 +149,8 @@ export class Prober {
|
||||
for (let waited = 0; waited < coordinatorConfig.nntpShares.everyMs && !this.stopped; waited += 1000) await sleep(1000)
|
||||
}
|
||||
await Promise.all(this.lanes)
|
||||
await namer
|
||||
await sweeper
|
||||
await this.share.leave().catch(() => {})
|
||||
// What was claimed and not probed goes back.
|
||||
for (const candidate of this.queue.splice(0)) this.outcomes.push({ candidate, status: 'waiting' })
|
||||
@@ -196,13 +239,18 @@ export class Prober {
|
||||
}
|
||||
}
|
||||
|
||||
/** Candidates claimed at a time: a few rounds of every lane's pipeline (a claim takes seconds; 400 lasted ~2 s at 37 lanes). */
|
||||
/** The queue's low-water mark and the level a claim fills it to (queueLevels). */
|
||||
private get levels() {
|
||||
return queueLevels(this.running, coordinatorConfig.probe.pipeline, coordinatorConfig.probe.batch)
|
||||
}
|
||||
|
||||
/** Claims to the queue's target; the claim after starts when it's below its low-water mark (queueLevels). */
|
||||
private get batch() {
|
||||
return Math.max(coordinatorConfig.probe.batch, this.running * coordinatorConfig.probe.pipeline * 4)
|
||||
return this.levels.target
|
||||
}
|
||||
|
||||
private async next(): Promise<Candidate | undefined> {
|
||||
if (this.queue.length < this.batch / 2) {
|
||||
if (this.queue.length < this.levels.low) {
|
||||
this.refilling ??= this.claim().finally(() => (this.refilling = null))
|
||||
if (this.queue.length === 0) await this.refilling
|
||||
}
|
||||
@@ -227,6 +275,33 @@ export class Prober {
|
||||
private async claimTiers() {
|
||||
// The queue empties, the lanes go dry and the share shrinks to its minimum until there's room again.
|
||||
if (await this.space.short()) return
|
||||
await this.claimCatalog()
|
||||
await this.claimPending()
|
||||
}
|
||||
|
||||
/** The pending ladder's share of a claim: at least pendingShare of the batch, and whatever the catalog's tiers left. */
|
||||
private async claimPending() {
|
||||
const want = Math.max(Math.floor(this.batch * coordinatorConfig.probe.pendingShare), this.batch - this.queue.length)
|
||||
const probes = await PendingLadder.claim(want)
|
||||
if (probes.length === 0) return
|
||||
const { rows: cached } = await db.rawQuery(`SELECT message_id, status, result FROM probe_cache WHERE message_id = ANY(?) AND version = ?`, [
|
||||
probes.map((p) => p.messageId),
|
||||
PROBE_VERSION,
|
||||
])
|
||||
const known = new Map(cached.map((c: any) => [c.message_id, c]))
|
||||
for (const probe of probes) {
|
||||
const candidate: Candidate = { id: 0, releaseId: 0, kind: probe.kind, messageId: probe.messageId, pending: probe }
|
||||
const hit: any = known.get(probe.messageId)
|
||||
if (hit) {
|
||||
this.stats.cached++
|
||||
this.outcomes.push({ candidate, status: hit.status, inspection: hit.result ?? undefined, cached: true })
|
||||
} else {
|
||||
this.queue.push(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async claimCatalog() {
|
||||
for (const { kind, unreadable } of TIERS) {
|
||||
const want = this.batch - this.queue.length
|
||||
if (want <= 0) return
|
||||
@@ -275,20 +350,46 @@ export class Prober {
|
||||
}
|
||||
}
|
||||
|
||||
/** The outcomes so far: candidates' status, inspections, and the releases' passworded and inner name. */
|
||||
/**
|
||||
* The outcomes so far: candidates' status, inspections, and the releases' passworded and inner name. A deadlock
|
||||
* (the batch store's pending upsert locks the same rows; LOG.md (134)) keeps the outcomes for the next flush
|
||||
* instead of losing the probes.
|
||||
*/
|
||||
private async flush() {
|
||||
const outcomes = this.outcomes.splice(0)
|
||||
if (outcomes.length === 0) return
|
||||
for (const o of outcomes) if (o.status !== 'waiting') this.stats[o.status]++
|
||||
const taken = this.outcomes.splice(0)
|
||||
if (taken.length === 0) return
|
||||
try {
|
||||
await this.store(taken)
|
||||
} catch (error: any) {
|
||||
if (error?.code === '40P01' && this.deadlocks < DEADLOCK_RETRIES) {
|
||||
this.deadlocks++
|
||||
this.outcomes.unshift(...taken)
|
||||
logger.warn(`Probing: storing results deadlocked, kept for the next flush (${this.deadlocks}).`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.deadlocks = 0
|
||||
for (const o of taken) if (o.status !== 'waiting') this.stats[o.status]++
|
||||
this.stats.pending += taken.filter((o) => o.candidate.pending && o.status !== 'waiting').length
|
||||
}
|
||||
|
||||
private async store(taken: Outcome[]) {
|
||||
// The ladder's outcomes apart from the catalog's: the next target, a name for the namer, or the end of the climb.
|
||||
const pending = taken.filter((o) => o.candidate.pending)
|
||||
const outcomes = taken.filter((o) => !o.candidate.pending)
|
||||
await db.transaction(async (trx) => {
|
||||
if (outcomes.length > 0) {
|
||||
await trx.rawQuery(
|
||||
`UPDATE probe_candidates pc SET status = v.status FROM jsonb_to_recordset(?::jsonb) AS v(id bigint, status text) WHERE pc.id = v.id`,
|
||||
[jsonb(outcomes.map((o) => ({ id: o.candidate.id, status: o.status })))]
|
||||
)
|
||||
}
|
||||
await PendingLadder.record(pending.map((o) => ({ probe: o.candidate.pending!, status: o.status, inspection: o.inspection })), trx)
|
||||
// Final answers read from the news server, kept by message-id for the next catalog (a reset).
|
||||
// One per message-id: the same article is a candidate of more than one release, and ON CONFLICT can't take a
|
||||
// row twice in one statement.
|
||||
const fresh = [...new Map(outcomes.filter((o) => !o.cached && o.status !== 'waiting').map((o) => [o.candidate.messageId, o])).values()]
|
||||
const fresh = [...new Map(taken.filter((o) => !o.cached && o.status !== 'waiting').map((o) => [o.candidate.messageId, o])).values()]
|
||||
if (fresh.length > 0) {
|
||||
await trx.rawQuery(
|
||||
`INSERT INTO probe_cache (message_id, status, result, version)
|
||||
@@ -322,10 +423,20 @@ export class Prober {
|
||||
entry.all.push({ kind: r.kind, result: r.result })
|
||||
byRelease.set(id, entry)
|
||||
}
|
||||
// A twin by content names a release its own inspections didn't (hash_twins.ts; LOG.md (130)).
|
||||
const twins = await twinNames(
|
||||
done.filter((o) => o.inspection!.hash16k && o.inspection!.size).map((o) => ({ hash16k: o.inspection!.hash16k!, size: o.inspection!.size })),
|
||||
trx
|
||||
)
|
||||
const twinOf = new Map<number, string>()
|
||||
for (const o of done) {
|
||||
const twin = o.inspection!.hash16k ? twins.get(twinKey(o.inspection!.hash16k, o.inspection!.size)) : undefined
|
||||
if (twin && twin !== byRelease.get(o.candidate.releaseId)?.name) twinOf.set(o.candidate.releaseId, twin)
|
||||
}
|
||||
const updates = [...byRelease].map(([id, { name, all }]) => ({
|
||||
id,
|
||||
passworded: all.some((i) => i.result.passworded === true),
|
||||
inner_name: innerName(all, name),
|
||||
inner_name: innerName(all, name) ?? twinOf.get(id) ?? null,
|
||||
}))
|
||||
await trx.rawQuery(
|
||||
`UPDATE releases r SET passworded = v.passworded, inner_name = v.inner_name, inspected_at = now()
|
||||
@@ -365,7 +476,8 @@ export class Prober {
|
||||
const { articles, bytes, done, missing, error } = this.stats
|
||||
logger.info(
|
||||
`Probed ${articles} articles in ${seconds.toFixed(0)} s: ${(articles / seconds).toFixed(1)}/s, ${((bytes * 8) / seconds / 1e6).toFixed(0)} Mbit/s; ` +
|
||||
`${done} inspected, ${missing} missing, ${error} not yEnc; ${this.stats.cached} from the probe cache. ` +
|
||||
`${done} inspected, ${missing} missing, ${error} not yEnc; ${this.stats.cached} from the probe cache; ` +
|
||||
`the pending ladder ${this.stats.pending} probed, ${this.stats.discarded} discarded by signature. ` +
|
||||
`Lanes: fetching ${(this.stats.fetchMs / 1000).toFixed(0)} s, waiting for candidates ${(this.stats.waitMs / 1000).toFixed(0)} s, ` +
|
||||
`idle ${(this.stats.idleMs / 1000).toFixed(0)} s, inspecting ${(this.stats.inspectMs / 1000).toFixed(1)} s; ` +
|
||||
`claims ${(this.stats.claimMs / 1000).toFixed(1)} s, flushes ${(this.stats.flushMs / 1000).toFixed(1)} s.`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { SOURCES } from '#lib/release/source'
|
||||
import { TINY_MIN_BYTES, NONSENSE_MIN_BYTES } from '#lib/rules/index'
|
||||
|
||||
/** Raised when a rule changes: verdicts of an older version are judged again (a full round, right away). */
|
||||
export const JUDGE_VERSION = 1
|
||||
@@ -28,6 +29,14 @@ const MEDIA =
|
||||
const SCRAPED_POSTERS = `celeb[-_ ]{0,1}movie|celebwow|celebfx|iluvcelebs|nude[-_ ]{0,1}celebs|celeb-sex-tapes`
|
||||
/** An executable's name. */
|
||||
const EXECUTABLE = `\\.(exe|scr|com|bat|cmd|msi|lnk|vbs|js|jar|pif|cpl|hta|ps1|dll)$`
|
||||
/**
|
||||
* The tiny rule's name tests, lib/rules/rule_set.ts's RELEASE_LIKE_SPACED, EBOOK, TINY_CONTENT and
|
||||
* STUB_EXEMPT_GROUPS in Postgres's dialect (and no "?", see MEDIA).
|
||||
*/
|
||||
const TINY_RELEASE_LIKE = `(\\y(19|20)[0-9][0-9]\\y|[._ -](720p|1080p|2160p|x264|x265|hevc|bluray|web-{0,1}dl|webrip|hdtv|xvid|s[0-9][0-9]e[0-9][0-9]|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v[0-9]+(\\.[0-9]+)+)\\y|-[a-z0-9]{2,}$|\\.[a-z0-9]+\\.[a-z0-9]+\\.)`
|
||||
const TINY_EBOOK = `\\y(epub|mobi|azw3{0,1}|pdf|djvu|cbr|cbz|e-{0,1}books{0,1})\\y`
|
||||
const TINY_CONTENT = `\\y(subs{0,1}|subtitles{0,1}|subpack|srt|untertitel|ondertitels{0,1}|ondertiteling|legendas{0,1}|napisy|sottotitoli|cracks{0,1}|cracked|keygen|keymaker|trainer|patch|patched|serials{0,1}|activator|loader|nocd|no-cd|fix|covers{0,1}|artwork|booklet|scans{0,1}|proof|sample|info|readme)\\y`
|
||||
const TINY_EXEMPT_GROUPS = `(sound|mp3|karaoke|music|audio|flac|lossless|e-{0,1}book|epub|comic|guitar|font|magazine)`
|
||||
|
||||
export interface Rule {
|
||||
name: string
|
||||
@@ -100,6 +109,20 @@ export const RULES: Rule[] = [
|
||||
// releases_passworded_idx: a scan of all of releases was 30 s a round.
|
||||
sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')} WHERE r.passworded AND NOT EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id)`,
|
||||
},
|
||||
{
|
||||
name: 'tiny',
|
||||
says: 'one file of one article, 2–100 kB, under a release-like name: the exe-stub flood in miniature',
|
||||
// The catalog's side of the edge's tiny-archive rule (lib/rules/rule_set.ts isTinyArchive; LOG.md (131),
|
||||
// (135)): the same name tests in Postgres's regex dialect ("\y" for "\b", "{0,1}" for "?"), the readable flag
|
||||
// for its scrambled and random tests, the group set for its groups. The 2015–17 flood that the edge indexed
|
||||
// before the rule: 1.5M rows, 98% of them with an archive candidate, the rest lone par2 files and scrambled
|
||||
// names, so no join on the candidates. A scan of releases (9 GB) a full round.
|
||||
sql: (scoped) => `SELECT r.id FROM releases r ${only(scoped, 'r.id')}
|
||||
WHERE r.files = 1 AND r.segments = 1 AND r.bytes >= ${TINY_MIN_BYTES} AND r.bytes < ${NONSENSE_MIN_BYTES}
|
||||
AND r.readable AND NOT coalesce(r.sidecar_only, false)
|
||||
AND r.name ~* '${TINY_RELEASE_LIKE}' AND r.name !~* '${TINY_EBOOK}' AND r.name !~* '${TINY_CONTENT}'
|
||||
AND NOT EXISTS (SELECT 1 FROM group_sets gs WHERE gs.id = r.group_set_id AND gs.groups::text ~* '${TINY_EXEMPT_GROUPS}')`,
|
||||
},
|
||||
{
|
||||
name: 'unusable',
|
||||
says: 'random names, no name inside, no par2 or inner-name merge, no spot, a day after settling, and final',
|
||||
|
||||
@@ -85,6 +85,19 @@ function contains(word: string): string {
|
||||
return `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* A search word's condition on release_texts: anywhere in a text (ILIKE), or with a leading "^" at a word's start, the
|
||||
* text's or after a character that's no letter or digit ("^zed" finds "Zed.Dead", "The_Zed", not "Facialized"). Both
|
||||
* use the trigram index.
|
||||
*/
|
||||
function wordMatch(word: string): { sql: string; binding: string } {
|
||||
if (word.startsWith('^') && word.length > 1) {
|
||||
const rest = word.slice(1).replace(/[\\^$.|?*+()[\]{}]/g, (c) => `\\${c}`)
|
||||
return { sql: 't.text ~* ?', binding: `(^|[^[:alnum:]])${rest}` }
|
||||
}
|
||||
return { sql: 't.text ILIKE ?', binding: contains(word) }
|
||||
}
|
||||
|
||||
/** Searching and browsing the catalog, for the web interface and Newznab. */
|
||||
class SearchService {
|
||||
async search(params: SearchParams) {
|
||||
@@ -93,10 +106,12 @@ class SearchService {
|
||||
if (!params.judged) where.push('NOT EXISTS (SELECT 1 FROM release_verdicts v WHERE v.release_id = r.id)')
|
||||
const bindings: unknown[] = []
|
||||
// Every word in one of the release's texts (its name, the names the prober found inside; release_texts, by its
|
||||
// trigram index, which yields ids: never an OR across the two tables); "The.Block.AU" and "the block au" alike.
|
||||
// trigram index, which yields ids: never an OR across the two tables); "The.Block.AU" and "the block au" alike;
|
||||
// "^zed" only at a word's start.
|
||||
for (const word of (params.q ?? '').split(/[\s._-]+/).filter(Boolean).slice(0, 8)) {
|
||||
where.push('r.id IN (SELECT t.release_id FROM release_texts t WHERE t.text ILIKE ?)')
|
||||
bindings.push(contains(word))
|
||||
const match = wordMatch(word)
|
||||
where.push(`r.id IN (SELECT t.release_id FROM release_texts t WHERE ${match.sql})`)
|
||||
bindings.push(match.binding)
|
||||
}
|
||||
if (params.readable) where.push('(r.readable OR r.inner_name IS NOT NULL)')
|
||||
if (params.complete) where.push('r.complete')
|
||||
@@ -304,9 +319,9 @@ class SearchService {
|
||||
// History by articles; the pass: the history ranges before those deferred behind them (priority
|
||||
// 2000 apart; e.g. groups added later that go first).
|
||||
const { rows: ranges } = await db.rawQuery(
|
||||
`WITH h AS (SELECT status, priority, end_id - start_id + 1 AS n FROM ranges WHERE priority >= 0),
|
||||
`WITH h AS (SELECT status, priority, end_id - start_id + 1 AS n FROM ranges WHERE priority >= 0 AND status <> 'ancient'),
|
||||
pass AS (SELECT (floor(coalesce(min(priority) FILTER (WHERE status <> 'done'), 0) / 2000) + 1) * 2000 AS below FROM h)
|
||||
SELECT (SELECT count(*) FILTER (WHERE status = 'done') FROM ranges) AS done, (SELECT count(*) FROM ranges) AS total,
|
||||
SELECT (SELECT count(*) FILTER (WHERE status = 'done') FROM ranges) AS done, (SELECT count(*) FROM ranges WHERE status <> 'ancient') AS total,
|
||||
coalesce(sum(n) FILTER (WHERE status = 'done'), 0) AS history_done, coalesce(sum(n), 0) AS history,
|
||||
coalesce(sum(n) FILTER (WHERE status = 'done' AND priority < (SELECT below FROM pass)), 0) AS pass_done,
|
||||
coalesce(sum(n) FILTER (WHERE priority < (SELECT below FROM pass)), 0) AS pass,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
/** Hours a complete provisional release stays open for its window's continuation before it is settled here. */
|
||||
export const SETTLE_COMPLETE_HOURS = 6
|
||||
|
||||
/**
|
||||
* Provisional releases (REBUILD.md, "Voyager 3: window indexing", 2): a release the window hadn't closed on at a
|
||||
* lease's end is written with `settled_at` null, for the window's continuation to finish. A continuation may
|
||||
* never come (the range-lease path of the first run left 109k such rows, LOG.md (118); a group's next lease is
|
||||
* hours away), and the catalog shows the release as still collecting. A complete one — every file its counter
|
||||
* announced found — can't gain a counted file any more, so once it has been quiet for SETTLE_COMPLETE_HOURS it is
|
||||
* settled here, as the finish would: `settled_at` set (the judge's trigger fires on it), its waiting probe
|
||||
* candidates made claimable. Incomplete ones keep waiting: the continuation adds their files.
|
||||
*/
|
||||
class Settler {
|
||||
async settleComplete(quietHours = SETTLE_COMPLETE_HOURS): Promise<number> {
|
||||
return db.transaction(async (trx) => {
|
||||
const { rows } = await trx.rawQuery(
|
||||
`UPDATE releases SET settled_at = now()
|
||||
WHERE settled_at IS NULL AND complete
|
||||
AND first_seen_at < now() - make_interval(hours => ?) AND last_posted_at < now() - make_interval(hours => ?)
|
||||
RETURNING id`,
|
||||
[quietHours, quietHours]
|
||||
)
|
||||
if (rows.length > 0)
|
||||
await trx.rawQuery(
|
||||
`UPDATE probe_candidates pc SET ready = true, unreadable = NOT r.readable FROM releases r
|
||||
WHERE pc.release_id = ANY(?::bigint[]) AND r.id = pc.release_id AND pc.status = 'waiting' AND NOT pc.ready`,
|
||||
[rows.map((r: { id: string }) => Number(r.id))]
|
||||
)
|
||||
return rows.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new Settler()
|
||||
@@ -0,0 +1,343 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { readdir, stat, statfs } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import GroupLeases from '#services/group_leases'
|
||||
import type { Lane } from '#lib/edge/index'
|
||||
|
||||
export interface RangeBand {
|
||||
band: string
|
||||
planned: number
|
||||
plannedArticles: number
|
||||
leased: number
|
||||
done: number
|
||||
doneArticles: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface HeldGroup {
|
||||
node: string
|
||||
group: string
|
||||
lane: Lane
|
||||
range: { start: number; end: number; next: number } | null
|
||||
heartbeatSeconds: number
|
||||
snapshotNext: number | null
|
||||
snapshotSeconds: number | null
|
||||
snapshotMb: number | null
|
||||
rangesDone: number
|
||||
headers: number
|
||||
}
|
||||
|
||||
export interface SnapshotFile {
|
||||
group: string
|
||||
lane: Lane
|
||||
mb: number
|
||||
hours: number
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
at: string
|
||||
catalog: {
|
||||
tablespace: string
|
||||
gb: number
|
||||
capGb: number
|
||||
full: boolean
|
||||
freeGb: number
|
||||
}
|
||||
bands: RangeBand[]
|
||||
lastHour: {
|
||||
indexedArticles: number
|
||||
doneRanges: number
|
||||
storedArticles: number
|
||||
storedRanges: number
|
||||
}
|
||||
corpus: { pendingRanges: number; pendingArticles: number }
|
||||
spool: { ranges: number; chunks: number }
|
||||
tail: { groups: number; ranges: number; articles: number }
|
||||
/** The 'ancient' ranges: articles below the low watermark the groups were planned at (expired, never fetched). */
|
||||
ancient: { groups: number; articles: number }
|
||||
held: HeldGroup[]
|
||||
snapshots: { count: number; gb: number; oldestHours: number | null; largest: SnapshotFile[] }
|
||||
releases: { estimated: number; provisional: number }
|
||||
/** `ready` and `waiting` are the planner's estimates (the last analyze), the others counts. */
|
||||
probes: { ready: number; waiting: number; pendingWaiting: number; pendingNamed: number }
|
||||
judge: { dirty: number }
|
||||
nodes: { name: string; lastSeenSeconds: number | null }[]
|
||||
}
|
||||
|
||||
const seconds = (since: Date | string | null) =>
|
||||
since ? Math.round((Date.now() - new Date(since).getTime()) / 1000) : null
|
||||
|
||||
/**
|
||||
* What the indexer is doing, in one object (commands/coordinator_status.ts prints it): the catalog's room on the
|
||||
* NVMe, the ranges by band (the tail, the capture windows, the history), the last hour's rates, the held groups
|
||||
* with their snapshots, the phase-2 backlogs. Every number comes from the database, the snapshot directory or the
|
||||
* filesystem; nothing is kept between calls.
|
||||
*/
|
||||
class StatusService {
|
||||
async collect(): Promise<Status> {
|
||||
const tail = coordinatorConfig.tail.priority
|
||||
const cap = coordinatorConfig.catalogMaxNvmeGb
|
||||
// The catalog's tablespace only (CatalogSpace measures it every minute anyway): pg_database_size walks the
|
||||
// chunk partitions and the probe cache on the HDD, minutes on homelab.
|
||||
const { rows: space } = await db.rawQuery(
|
||||
`SELECT coalesce(nullif(current_setting('default_tablespace'), ''), 'pg_default') AS tablespace,
|
||||
pg_tablespace_size(coalesce(nullif(current_setting('default_tablespace'), ''), 'pg_default')) AS bytes`
|
||||
)
|
||||
const tablespaceGb = Number(space[0].bytes) / 1e9
|
||||
const fs = await statfs('/').catch(() => null)
|
||||
const freeGb = fs ? (fs.bavail * fs.bsize) / 1e9 : 0
|
||||
|
||||
// The ranges by band: the tail, the capture windows (priorities 0–2; the sampling ranges in other years are
|
||||
// capture too, at their history priorities), the history by its thousand.
|
||||
const { rows: bands } = await db.rawQuery(
|
||||
`SELECT CASE WHEN priority <= ? THEN 'tail'
|
||||
WHEN priority < 0 THEN 'raised'
|
||||
WHEN capture AND priority <= 2 THEN 'window ' || priority
|
||||
WHEN priority < 1000 THEN 'history 0-999'
|
||||
ELSE 'history ' || (priority / 1000) * 1000 || '+' END AS band,
|
||||
count(*) FILTER (WHERE status = 'planned') AS planned,
|
||||
coalesce(sum(end_id - next_id + 1) FILTER (WHERE status = 'planned'), 0) AS planned_articles,
|
||||
count(*) FILTER (WHERE status = 'leased') AS leased,
|
||||
count(*) FILTER (WHERE status = 'done') AS done,
|
||||
coalesce(sum(end_id - start_id + 1) FILTER (WHERE status = 'done'), 0) AS done_articles,
|
||||
count(*) FILTER (WHERE status = 'failed') AS failed
|
||||
FROM ranges GROUP BY 1
|
||||
ORDER BY min(CASE WHEN priority <= ? THEN 0 WHEN priority < 0 THEN 1 WHEN capture AND priority <= 2 THEN 2 + priority
|
||||
WHEN priority < 1000 THEN 10 ELSE 11 + priority / 1000 END)`,
|
||||
[tail, tail]
|
||||
)
|
||||
const { rows: hour } = await db.rawQuery(
|
||||
`SELECT coalesce(sum(end_id - start_id + 1) FILTER (WHERE done_at > now() - interval '1 hour'), 0) AS indexed,
|
||||
count(*) FILTER (WHERE done_at > now() - interval '1 hour') AS done_ranges,
|
||||
coalesce(sum(end_id - start_id + 1) FILTER (WHERE stored_at > now() - interval '1 hour'), 0) AS stored,
|
||||
count(*) FILTER (WHERE stored_at > now() - interval '1 hour') AS stored_ranges,
|
||||
count(*) FILTER (WHERE capture AND stored_at IS NULL AND status <> 'done') AS corpus_pending,
|
||||
coalesce(sum(end_id - start_id + 1) FILTER (WHERE capture AND stored_at IS NULL AND status <> 'done'), 0) AS corpus_pending_articles,
|
||||
count(*) FILTER (WHERE status IN ('planned', 'leased') AND spooled_chunks > 0) AS spooled_ranges,
|
||||
coalesce(sum(spooled_chunks) FILTER (WHERE status IN ('planned', 'leased')), 0) AS spooled_chunks,
|
||||
count(DISTINCT group_id) FILTER (WHERE status = 'planned' AND priority <= ?) AS tail_groups,
|
||||
count(*) FILTER (WHERE status = 'planned' AND priority <= ?) AS tail_ranges,
|
||||
coalesce(sum(end_id - next_id + 1) FILTER (WHERE status = 'planned' AND priority <= ?), 0) AS tail_articles,
|
||||
count(DISTINCT group_id) FILTER (WHERE status = 'ancient') AS ancient_groups,
|
||||
coalesce(sum(end_id - start_id + 1) FILTER (WHERE status = 'ancient'), 0) AS ancient_articles
|
||||
FROM ranges`,
|
||||
[tail, tail, tail]
|
||||
)
|
||||
const h = hour[0]
|
||||
|
||||
const ttl = coordinatorConfig.leaseTtlSeconds
|
||||
const { rows: held } = await db.rawQuery(
|
||||
`SELECT n.name AS node, g.name AS "group", l.lane, l.heartbeat_at, l.snapshot_next, l.snapshot_at, l.ranges_done, l.headers,
|
||||
r.start_id, r.end_id, r.next_id, l.group_id
|
||||
FROM group_leases l JOIN groups g ON g.id = l.group_id JOIN nodes n ON n.id = l.node_id
|
||||
LEFT JOIN ranges r ON r.id = l.range_id
|
||||
WHERE l.node_id IS NOT NULL AND l.heartbeat_at >= now() - make_interval(secs => ?)
|
||||
ORDER BY l.lane, g.name`,
|
||||
[ttl]
|
||||
)
|
||||
const snapshotSizes = new Map<string, number>()
|
||||
const files: SnapshotFile[] = []
|
||||
const names = new Map<number, string>(
|
||||
(await db.from('groups').select('id', 'name')).map((g) => [Number(g.id), g.name as string])
|
||||
)
|
||||
for (const file of await readdir(GroupLeases.dir).catch(() => [] as string[])) {
|
||||
const m = /^(\d+)(-tail)?\.gz$/.exec(file)
|
||||
if (!m) continue
|
||||
const s = await stat(join(GroupLeases.dir, file)).catch(() => null)
|
||||
if (!s) continue
|
||||
const lane: Lane = m[2] ? 'tail' : 'history'
|
||||
snapshotSizes.set(`${m[1]}:${lane}`, s.size)
|
||||
files.push({
|
||||
group: names.get(Number(m[1])) ?? m[1],
|
||||
lane,
|
||||
mb: s.size / 1e6,
|
||||
hours: (Date.now() - s.mtimeMs) / 3600_000,
|
||||
})
|
||||
}
|
||||
files.sort((a, b) => b.mb - a.mb)
|
||||
|
||||
// The probe backlog from the planner's estimates of the partial indexes (counting 11 M rows took 10 s on
|
||||
// homelab); the rest are small partial indexes.
|
||||
const { rows: releases } = await db.rawQuery(
|
||||
`SELECT (SELECT reltuples::bigint FROM pg_class WHERE oid = 'releases'::regclass) AS estimated,
|
||||
(SELECT count(*) FROM releases WHERE settled_at IS NULL) AS provisional,
|
||||
(SELECT greatest(0, reltuples)::bigint FROM pg_class WHERE oid = 'probe_candidates_ready_idx'::regclass) AS ready,
|
||||
(SELECT greatest(0, reltuples)::bigint FROM pg_class WHERE oid = 'probe_candidates_waiting_idx'::regclass) AS waiting,
|
||||
(SELECT count(*) FROM pending_releases WHERE status = 0) AS pending_waiting,
|
||||
(SELECT count(*) FROM pending_releases WHERE status = 4) AS pending_named,
|
||||
(SELECT count(*) FROM judge_dirty) AS dirty`
|
||||
)
|
||||
const r = releases[0]
|
||||
const nodes = await db.from('nodes').select('name', 'last_seen_at').orderBy('name')
|
||||
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
catalog: {
|
||||
tablespace: space[0].tablespace,
|
||||
gb: tablespaceGb,
|
||||
capGb: cap,
|
||||
full: cap > 0 && tablespaceGb > cap,
|
||||
freeGb,
|
||||
},
|
||||
bands: bands.map((b: any) => ({
|
||||
band: b.band,
|
||||
planned: Number(b.planned),
|
||||
plannedArticles: Number(b.planned_articles),
|
||||
leased: Number(b.leased),
|
||||
done: Number(b.done),
|
||||
doneArticles: Number(b.done_articles),
|
||||
failed: Number(b.failed),
|
||||
})),
|
||||
lastHour: {
|
||||
indexedArticles: Number(h.indexed),
|
||||
doneRanges: Number(h.done_ranges),
|
||||
storedArticles: Number(h.stored),
|
||||
storedRanges: Number(h.stored_ranges),
|
||||
},
|
||||
corpus: {
|
||||
pendingRanges: Number(h.corpus_pending),
|
||||
pendingArticles: Number(h.corpus_pending_articles),
|
||||
},
|
||||
spool: { ranges: Number(h.spooled_ranges), chunks: Number(h.spooled_chunks) },
|
||||
ancient: { groups: Number(h.ancient_groups), articles: Number(h.ancient_articles) },
|
||||
tail: {
|
||||
groups: Number(h.tail_groups),
|
||||
ranges: Number(h.tail_ranges),
|
||||
articles: Number(h.tail_articles),
|
||||
},
|
||||
held: held.map((l: any) => {
|
||||
const size = snapshotSizes.get(`${l.group_id}:${l.lane}`)
|
||||
return {
|
||||
node: l.node,
|
||||
group: l.group,
|
||||
lane: l.lane,
|
||||
range:
|
||||
l.start_id !== null
|
||||
? { start: Number(l.start_id), end: Number(l.end_id), next: Number(l.next_id) }
|
||||
: null,
|
||||
heartbeatSeconds: seconds(l.heartbeat_at) ?? 0,
|
||||
snapshotNext: l.snapshot_next === null ? null : Number(l.snapshot_next),
|
||||
snapshotSeconds: seconds(l.snapshot_at),
|
||||
snapshotMb: size === undefined ? null : size / 1e6,
|
||||
rangesDone: Number(l.ranges_done),
|
||||
headers: Number(l.headers),
|
||||
}
|
||||
}),
|
||||
snapshots: {
|
||||
count: files.length,
|
||||
gb: files.reduce((sum, f) => sum + f.mb, 0) / 1000,
|
||||
oldestHours: files.length > 0 ? Math.max(...files.map((f) => f.hours)) : null,
|
||||
largest: files.slice(0, 5),
|
||||
},
|
||||
releases: { estimated: Math.max(0, Number(r.estimated)), provisional: Number(r.provisional) },
|
||||
probes: {
|
||||
ready: Number(r.ready),
|
||||
waiting: Number(r.waiting),
|
||||
pendingWaiting: Number(r.pending_waiting),
|
||||
pendingNamed: Number(r.pending_named),
|
||||
},
|
||||
judge: { dirty: Number(r.dirty) },
|
||||
nodes: nodes.map((n) => ({ name: n.name, lastSeenSeconds: seconds(n.last_seen_at) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** The status as a text panel. */
|
||||
render(s: Status): string {
|
||||
const n = (v: number) => Math.round(v).toLocaleString('en-US')
|
||||
const b = (v: number) =>
|
||||
v >= 1e9
|
||||
? `${(v / 1e9).toFixed(2)} B`
|
||||
: v >= 1e6
|
||||
? `${(v / 1e6).toFixed(1)} M`
|
||||
: v >= 1e3
|
||||
? `${(v / 1e3).toFixed(0)} k`
|
||||
: String(v)
|
||||
const age = (sec: number | null) =>
|
||||
sec === null
|
||||
? '-'
|
||||
: sec < 90
|
||||
? `${sec} s`
|
||||
: sec < 5400
|
||||
? `${Math.round(sec / 60)} min`
|
||||
: `${(sec / 3600).toFixed(1)} h`
|
||||
const lines: string[] = []
|
||||
lines.push(`Status at ${s.at}`)
|
||||
lines.push('')
|
||||
lines.push(
|
||||
`Catalog: ${s.catalog.gb.toFixed(1)} GB in tablespace ${s.catalog.tablespace}${s.catalog.capGb ? ` of ${s.catalog.capGb} GB${s.catalog.full ? ' (FULL: history paused)' : ''}` : ' (no cap)'}; ${s.catalog.freeGb.toFixed(1)} GB free on /`
|
||||
)
|
||||
lines.push(
|
||||
`Last hour: ${b(s.lastHour.indexedArticles)} articles indexed in ${n(s.lastHour.doneRanges)} ranges (${n(s.lastHour.indexedArticles / 3600)}/s); corpus fill ${b(s.lastHour.storedArticles)} in ${n(s.lastHour.storedRanges)} ranges, ${n(s.corpus.pendingRanges)} capture ranges (${b(s.corpus.pendingArticles)}) still to store`
|
||||
)
|
||||
lines.push(
|
||||
`Spool: ${n(s.spool.chunks)} chunks in ${n(s.spool.ranges)} ranges. Tail: ${b(s.tail.articles)} planned in ${n(s.tail.ranges)} ranges of ${n(s.tail.groups)} groups. Ancient: ${b(s.ancient.articles)} articles below the low watermark of ${n(s.ancient.groups)} groups`
|
||||
)
|
||||
lines.push('')
|
||||
const table = (header: string[], rows: string[][]) => {
|
||||
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
|
||||
const line = (r: string[]) =>
|
||||
r.map((c, i) => (i === 0 ? c.padEnd(widths[i]) : c.padStart(widths[i]))).join(' ')
|
||||
lines.push(line(header))
|
||||
for (const r of rows) lines.push(line(r))
|
||||
}
|
||||
lines.push('Ranges by band:')
|
||||
table(
|
||||
['band', 'planned', 'articles', 'leased', 'done', 'articles', 'failed'],
|
||||
s.bands.map((x) => [
|
||||
x.band,
|
||||
n(x.planned),
|
||||
b(x.plannedArticles),
|
||||
n(x.leased),
|
||||
n(x.done),
|
||||
b(x.doneArticles),
|
||||
n(x.failed),
|
||||
])
|
||||
)
|
||||
lines.push('')
|
||||
lines.push(`Held groups (${s.held.length}):`)
|
||||
if (s.held.length > 0)
|
||||
table(
|
||||
[
|
||||
'group',
|
||||
'lane',
|
||||
'node',
|
||||
'range',
|
||||
'at',
|
||||
'heartbeat',
|
||||
'snapshot',
|
||||
'size',
|
||||
'done',
|
||||
'headers',
|
||||
],
|
||||
s.held.map((x) => [
|
||||
x.group,
|
||||
x.lane,
|
||||
x.node,
|
||||
x.range ? `${x.range.start}-${x.range.end}` : '-',
|
||||
x.range ? String(x.range.next) : '-',
|
||||
age(x.heartbeatSeconds),
|
||||
x.snapshotNext === null ? '-' : `${x.snapshotNext} (${age(x.snapshotSeconds)})`,
|
||||
x.snapshotMb === null ? '-' : `${x.snapshotMb.toFixed(0)} MB`,
|
||||
n(x.rangesDone),
|
||||
b(x.headers),
|
||||
])
|
||||
)
|
||||
lines.push('')
|
||||
lines.push(
|
||||
`Snapshots: ${n(s.snapshots.count)} files, ${s.snapshots.gb.toFixed(2)} GB${s.snapshots.oldestHours === null ? '' : `, oldest ${s.snapshots.oldestHours.toFixed(1)} h`}${s.snapshots.largest.length > 0 ? `; largest ${s.snapshots.largest.map((f) => `${f.group}${f.lane === 'tail' ? ' (tail)' : ''} ${f.mb.toFixed(0)} MB`).join(', ')}` : ''}`
|
||||
)
|
||||
lines.push(
|
||||
`Releases: ~${n(s.releases.estimated)}, ${n(s.releases.provisional)} provisional. Judge: ${n(s.judge.dirty)} dirty`
|
||||
)
|
||||
lines.push(
|
||||
`Probes: ~${n(s.probes.ready)} ready of ~${n(s.probes.waiting)} waiting; pending ladder ${n(s.probes.pendingWaiting)} waiting, ${n(s.probes.pendingNamed)} named`
|
||||
)
|
||||
lines.push(
|
||||
`Nodes: ${s.nodes.map((x) => `${x.name} (${age(x.lastSeenSeconds)})`).join(', ') || 'none'}`
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
export default new StatusService()
|
||||
@@ -4,6 +4,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { Prefetcher } from '#services/prefetcher'
|
||||
import Planner from '#services/planner'
|
||||
import Leases from '#services/leases'
|
||||
import Settler from '#services/settler'
|
||||
import NntpPool from '#services/nntp_pool'
|
||||
import { NntpShare } from '#services/nntp_shares'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
@@ -101,6 +102,13 @@ export default class CoordinatorRun extends BaseCommand {
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Finishing abandoned leases failed: ${error.message}`)
|
||||
}
|
||||
try {
|
||||
const settled = await Settler.settleComplete()
|
||||
if (settled > 0)
|
||||
this.logger.info(`Settled ${settled} complete provisional releases quiet for hours.`)
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Settling complete releases failed: ${error.message}`)
|
||||
}
|
||||
for (let waited = 0; waited < ABANDONED_EVERY_MS && !stopping; waited += TICK_MS)
|
||||
await sleep(TICK_MS)
|
||||
}
|
||||
@@ -117,8 +125,11 @@ export default class CoordinatorRun extends BaseCommand {
|
||||
}
|
||||
if (main && Date.now() - tailedAt >= coordinatorConfig.tail.everySeconds * 1000) {
|
||||
tailedAt = Date.now()
|
||||
const { groups, ranges } = await Planner.planTail()
|
||||
if (ranges > 0) this.logger.info(`Tail: ${ranges} ranges for ${groups} groups.`)
|
||||
const { groups, ranges, extended } = await Planner.planTail()
|
||||
if (ranges + extended > 0)
|
||||
this.logger.info(
|
||||
`Tail: ${ranges} ranges added, ${extended} grown, for ${groups} groups.`
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Coordinator round failed: ${error.message}`)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import StatusService from '#services/status'
|
||||
|
||||
/**
|
||||
* What the indexer is doing (app/services/status.ts): the catalog's room, the ranges by band, the last hour's
|
||||
* rates, the held groups and their snapshots, the phase-2 backlogs. Reads only.
|
||||
*/
|
||||
export default class CoordinatorStatus extends BaseCommand {
|
||||
static commandName = 'coordinator:status'
|
||||
static description =
|
||||
'Prints what the indexer is doing: catalog room, ranges by band, rates, held groups, backlogs'
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.boolean({ description: 'JSON instead of the text panel' })
|
||||
declare json: boolean
|
||||
|
||||
async run() {
|
||||
const status = await StatusService.collect()
|
||||
this.logger.log(this.json ? JSON.stringify(status, null, 2) : StatusService.render(status))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import { describeClass } from '#lib/release/index'
|
||||
|
||||
/**
|
||||
* The pending ladder's classes (LOG.md (130)) on the rows from before them: `klass` (the SQL pending_class) and
|
||||
* `target_bytes` (the first target's bytes, when its candidates carry them). The finished rows (named, exhausted,
|
||||
* naming) in one scan, seeding each class's record, which gets its verdict (dropped) as the ladder gives it; the
|
||||
* waiting rows in windows of created_at by their index (a scan per window starved the prober on the HDD); then
|
||||
* pending_classes' `waiting` counted afresh from the claim's index. --report shows the record.
|
||||
*/
|
||||
export default class PendingClasses extends BaseCommand {
|
||||
static commandName = 'pending:classes'
|
||||
static description =
|
||||
'Classes the pending releases from before the classes and recounts pending_classes; --report shows the record'
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.number({ description: 'Hours of created_at per window' })
|
||||
declare hours: number
|
||||
|
||||
@flags.boolean({ description: 'Show the classes with the best and worst records, write nothing' })
|
||||
declare report: boolean
|
||||
|
||||
async run() {
|
||||
if (this.report) return this.showReport()
|
||||
const hours = this.hours ?? 6
|
||||
const started = Date.now()
|
||||
// The finished rows (named, exhausted, naming; 6.5M on 2026-11-02) in one pass: a scan of the table, no index
|
||||
// reaches them by status; they seed each class's record.
|
||||
const { rows: seeded } = await db.rawQuery(
|
||||
`WITH classed AS (
|
||||
UPDATE pending_releases SET klass = pending_class(name, bytes, partial),
|
||||
target_bytes = (SELECT min((c->>'bytes')::bigint) FROM jsonb_array_elements(candidates) c WHERE c->>'bytes' IS NOT NULL)
|
||||
WHERE klass = 0 AND status IN (1, 3, 4)
|
||||
RETURNING tool, group_id, klass, status),
|
||||
upserted AS (
|
||||
INSERT INTO pending_classes (tool, group_id, klass, probed, named)
|
||||
SELECT tool, group_id, klass, count(*), count(*) FILTER (WHERE status IN (1, 4)) FROM classed GROUP BY 1, 2, 3
|
||||
ON CONFLICT (tool, group_id, klass) DO UPDATE SET probed = pending_classes.probed + excluded.probed, named = pending_classes.named + excluded.named
|
||||
RETURNING 1)
|
||||
SELECT (SELECT count(*) FROM classed) AS classed, (SELECT count(*) FROM upserted) AS classes`
|
||||
)
|
||||
this.logger.info(
|
||||
`Finished rows: ${seeded[0].classed} classed into ${seeded[0].classes} classes (${Math.round((Date.now() - started) / 1000)} s).`
|
||||
)
|
||||
const { pendingMinProbes, pendingMaxNamed } = coordinatorConfig.probe
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_classes SET dropped = true, swept_at = NULL WHERE NOT dropped AND probed >= ?::int AND named::float <= probed * ?::float`,
|
||||
[pendingMinProbes, pendingMaxNamed]
|
||||
)
|
||||
// The waiting rows by windows of created_at (pending_releases_waiting_idx reaches them).
|
||||
const { rows: span } = await db.rawQuery(
|
||||
`SELECT (SELECT min(created_at) FROM pending_releases WHERE status = 0) AS lo, (SELECT max(created_at) FROM pending_releases WHERE status = 0) AS hi`
|
||||
)
|
||||
if (span[0].lo) {
|
||||
let from = new Date(span[0].lo)
|
||||
const hi = new Date(span[0].hi)
|
||||
let classed = 0
|
||||
while (from <= hi) {
|
||||
const to = new Date(from.getTime() + hours * 3600_000)
|
||||
const result = await db.rawQuery(
|
||||
`UPDATE pending_releases SET klass = pending_class(name, bytes, partial),
|
||||
target_bytes = (SELECT min((c->>'bytes')::bigint) FROM jsonb_array_elements(candidates) c WHERE c->>'bytes' IS NOT NULL)
|
||||
WHERE status = 0 AND created_at >= ? AND created_at < ? AND klass = 0`,
|
||||
[from.toISOString(), to.toISOString()]
|
||||
)
|
||||
classed += result.rowCount ?? 0
|
||||
from = to
|
||||
this.logger.info(
|
||||
`Waiting rows up to ${to.toISOString().slice(0, 16)}: ${classed} classed (${Math.round((Date.now() - started) / 1000)} s).`
|
||||
)
|
||||
}
|
||||
}
|
||||
// The waiting counts from the rows themselves (the claim, the discard and the batch store keep them from here).
|
||||
await db.rawQuery(`
|
||||
INSERT INTO pending_classes (tool, group_id, klass, waiting)
|
||||
SELECT tool, group_id, klass, count(*) FROM pending_releases WHERE status = 0 AND finished GROUP BY 1, 2, 3
|
||||
ON CONFLICT (tool, group_id, klass) DO UPDATE SET waiting = excluded.waiting`)
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_classes c SET waiting = 0 WHERE waiting > 0 AND NOT EXISTS (
|
||||
SELECT 1 FROM pending_releases p WHERE p.status = 0 AND p.finished AND p.tool = c.tool AND p.group_id = c.group_id AND p.klass = c.klass)`
|
||||
)
|
||||
const { rows: totals } = await db.rawQuery(
|
||||
`SELECT count(*) AS classes, coalesce(sum(waiting), 0) AS waiting, count(*) FILTER (WHERE dropped) AS dropped FROM pending_classes`
|
||||
)
|
||||
this.logger.info(
|
||||
`Done: ${totals[0].classes} classes, ${totals[0].waiting} waiting, ${totals[0].dropped} dropped.`
|
||||
)
|
||||
}
|
||||
|
||||
private async showReport() {
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT c.tool, g.name AS grp, c.klass, c.probed, c.named, c.waiting, c.dropped FROM pending_classes c JOIN groups g ON g.id = c.group_id
|
||||
WHERE c.probed >= 20 ORDER BY c.named::float / c.probed DESC, c.probed DESC`
|
||||
)
|
||||
const line = (r: any) =>
|
||||
` ${r.tool.padEnd(10)} ${r.grp.padEnd(32)} ${describeClass(Number(r.klass)).padEnd(26)} probed ${String(r.probed).padStart(7)} named ${String(Math.round((100 * r.named) / r.probed)).padStart(3)}% waiting ${String(r.waiting).padStart(8)}${r.dropped ? ' dropped' : ''}`
|
||||
this.logger.info(`${rows.length} classes with 20+ climbs; the best:`)
|
||||
for (const r of rows.slice(0, 25)) this.logger.info(line(r))
|
||||
this.logger.info('The worst:')
|
||||
for (const r of rows.slice(-25)) this.logger.info(line(r))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import Planner from '#services/planner'
|
||||
|
||||
/**
|
||||
* Merges adjacent planned tail ranges (Planner.mergeTail): the tail planner added a sliver a group every round
|
||||
* while nothing leased the tail; now it grows the group's open range instead, and this packs what is there.
|
||||
*/
|
||||
export default class RangesMergeTail extends BaseCommand {
|
||||
static commandName = 'ranges:merge-tail'
|
||||
static description = 'Merges adjacent planned tail ranges into ranges of RANGE_ARTICLES'
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.boolean({ description: 'Only count what would be merged' })
|
||||
declare dryRun: boolean
|
||||
|
||||
async run() {
|
||||
const { candidates, merged, dropped } = await Planner.mergeTail(this.dryRun)
|
||||
this.logger.info(
|
||||
`${candidates} planned tail ranges: ${dropped} ${this.dryRun ? 'would merge' : 'merged'} into ${merged} grown ranges (${candidates - dropped} left).`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,13 @@ export default class RangesPlan extends BaseCommand {
|
||||
|
||||
async run() {
|
||||
try {
|
||||
const { groups, ranges } = this.tail ? await Planner.planTail() : await Planner.planAll()
|
||||
this.logger.info(`${ranges} ${this.tail ? 'tail ' : ''}ranges planned over ${groups} groups.`)
|
||||
if (this.tail) {
|
||||
const { groups, ranges, extended } = await Planner.planTail()
|
||||
this.logger.info(`Tail: ${ranges} ranges added, ${extended} grown, over ${groups} groups.`)
|
||||
} else {
|
||||
const { groups, ranges } = await Planner.planAll()
|
||||
this.logger.info(`${ranges} ranges planned over ${groups} groups.`)
|
||||
}
|
||||
} finally {
|
||||
await NntpPool.close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { isUnreadableName } from '#lib/release/index'
|
||||
|
||||
/** Its place in worker_cursors (the last release id done), for a run that was stopped. */
|
||||
const CURSOR = 'readable'
|
||||
|
||||
/**
|
||||
* The readable flag of the catalog's releases by today's classifier (isUnreadableName, LOG.md (129)): rows flagged
|
||||
* readable whose name no one can read ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C") get readable =
|
||||
* false, so the readable filter hides them (an inner name from the prober still shows them), and their probe
|
||||
* candidates marked unreadable, the prober's first tiers. By the release name,
|
||||
* which is the shared file stem for most rows; not by the chunks' file names, which the edge judges from now on.
|
||||
* Batches by id, a dry run by default; --apply writes, and goes on from its cursor when run again.
|
||||
*/
|
||||
export default class ReleasesReadable extends BaseCommand {
|
||||
static commandName = 'releases:readable'
|
||||
static description =
|
||||
'Clears the readable flag of catalog rows whose name is unreadable by the current classifier; counts only without --apply'
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.number({ description: 'Release ids per batch' })
|
||||
declare batch: number
|
||||
|
||||
@flags.number({ description: 'Stop after this id (a sample)' })
|
||||
declare toId: number
|
||||
|
||||
@flags.boolean({ description: 'Write the flag; without it, count and show a sample' })
|
||||
declare apply: boolean
|
||||
|
||||
@flags.boolean({ description: 'Start from the first id, not the cursor' })
|
||||
declare restart: boolean
|
||||
|
||||
async run() {
|
||||
const batch = this.batch ?? 20_000
|
||||
const { rows: ends } = await db.rawQuery(`SELECT min(id) AS lo, max(id) AS hi FROM releases`)
|
||||
const hi = Math.min(Number(ends[0].hi), this.toId ?? Infinity)
|
||||
const { rows: cursors } = await db.rawQuery(`SELECT key FROM worker_cursors WHERE name = ?`, [
|
||||
CURSOR,
|
||||
])
|
||||
let after =
|
||||
this.restart || !this.apply || !cursors[0] ? Number(ends[0].lo) - 1 : Number(cursors[0].key)
|
||||
let looked = 0
|
||||
let changed = 0
|
||||
const sample: string[] = []
|
||||
const started = Date.now()
|
||||
while (after < hi) {
|
||||
const to = Math.min(after + batch, hi)
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT id, name FROM releases WHERE id > ? AND id <= ? AND readable`,
|
||||
[after, to]
|
||||
)
|
||||
looked += rows.length
|
||||
const ids: number[] = []
|
||||
for (const r of rows) {
|
||||
if (!isUnreadableName(r.name)) continue
|
||||
ids.push(Number(r.id))
|
||||
if (sample.length < 40) sample.push(r.name)
|
||||
}
|
||||
changed += ids.length
|
||||
if (this.apply) {
|
||||
if (ids.length > 0) {
|
||||
await db.rawQuery(`UPDATE releases SET readable = false WHERE id = ANY(?::bigint[])`, [
|
||||
ids,
|
||||
])
|
||||
// Their probe candidates to the unreadable tiers, which the prober takes first (LOG.md (130)).
|
||||
await db.rawQuery(
|
||||
`UPDATE probe_candidates SET unreadable = true WHERE release_id = ANY(?::bigint[]) AND NOT unreadable`,
|
||||
[ids]
|
||||
)
|
||||
}
|
||||
await db.rawQuery(
|
||||
`INSERT INTO worker_cursors (name, at, key) VALUES (?, now(), ?) ON CONFLICT (name) DO UPDATE SET at = excluded.at, key = excluded.key`,
|
||||
[CURSOR, String(to)]
|
||||
)
|
||||
}
|
||||
after = to
|
||||
if (Math.floor(after / 2_000_000) !== Math.floor((after - batch) / 2_000_000)) {
|
||||
this.logger.info(
|
||||
`Up to id ${after} of ${hi}: ${changed} unreadable of ${looked} readable (${Math.round((Date.now() - started) / 1000)} s).`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.logger.info(
|
||||
`${this.apply ? 'Cleared' : 'Would clear'} ${changed} of ${looked} readable rows.`
|
||||
)
|
||||
if (!this.apply) for (const n of sample) this.logger.info(` ${n}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { access, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import BatchStore from '#services/batch_store'
|
||||
import Leases from '#services/leases'
|
||||
import RuleSets from '#services/rule_sets'
|
||||
import { fromWire, type WireBatch } from '#lib/edge/index'
|
||||
|
||||
/**
|
||||
* Only a database named like one of these is written: the replay leases and finishes ranges and fills the catalog.
|
||||
* Not the test database: the rows left behind failed 27 functional tests (LOG.md 2026-10-26 (111)). With
|
||||
* REPLAY_INTO_CATALOG=1 any database but the test one: voyager 3 deployed, history replayed from the corpus into
|
||||
* the catalog itself (deploy/replay-window.sh, LOG.md (116)).
|
||||
*/
|
||||
const SCRATCH_DB = /_(replay|scratch)$/
|
||||
const TEST_DB = /_test$/
|
||||
const NODE_NAME = 'replay'
|
||||
|
||||
interface StoredRange {
|
||||
id: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The replay harness (REBUILD.md, "Voyager 3", 8): a group's stored corpus ranges in article order through one
|
||||
* window in the Rust edge (`edge-bench --group`, the window carried from range to range) and the batch store into a
|
||||
* scratch catalog, as the worker would store them: each range leased to a node named "replay", its batches stored
|
||||
* at its resume point, the range finished when its last batch is in. Then a report: releases finished, complete,
|
||||
* partial and provisional, and the catalog's bytes per header (the NVMe tables apart from those on CHUNK_TABLESPACE).
|
||||
*
|
||||
* node ace replay:corpus --group alt.binaries.ath [--dir CORPUS_DIR] [--from A] [--to B] [--limit N] [--batch-chunks 5]
|
||||
*
|
||||
* Refuses a database not named *_replay or *_scratch. The rules are the catalog's current rule set
|
||||
* (rule_sets, the groups' priorities): copy `groups` and `rule_sets` from the catalog for a faithful replay.
|
||||
*/
|
||||
export default class ReplayCorpus extends BaseCommand {
|
||||
static commandName = 'replay:corpus'
|
||||
static description =
|
||||
"Replays a group's corpus ranges through the Rust edge and the batch store into a scratch catalog"
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.string({ description: 'The group (its directory under the corpus)', required: true })
|
||||
declare group: string
|
||||
|
||||
@flags.string({ description: 'The corpus directory (default: CORPUS_DIR)' })
|
||||
declare dir?: string
|
||||
|
||||
@flags.number({ description: 'Replay this many ranges at most (default: all stored)' })
|
||||
declare limit?: number
|
||||
|
||||
@flags.number({ description: 'Skip ranges ending before this article (continue a replay)' })
|
||||
declare from?: number
|
||||
|
||||
@flags.number({
|
||||
description: "Skip ranges starting after this article (one window of the group's history)",
|
||||
})
|
||||
declare to?: number
|
||||
|
||||
@flags.number({
|
||||
description: 'Chunks of 100k articles per batch, as the worker posts them',
|
||||
default: 5,
|
||||
})
|
||||
declare batchChunks: number
|
||||
|
||||
@flags.string({
|
||||
description:
|
||||
'The edge-bench binary (default: EDGE_BENCH, edge/target/release/edge-bench, /usr/local/bin/edge-bench)',
|
||||
})
|
||||
declare bench?: string
|
||||
|
||||
@flags.boolean({
|
||||
description:
|
||||
'Leave the window open at the end (the last ranges provisional), to continue later',
|
||||
})
|
||||
declare noFinish: boolean
|
||||
|
||||
async run() {
|
||||
const database = db.connection().getReadClient().client.config.connection.database as string
|
||||
const intoCatalog = process.env.REPLAY_INTO_CATALOG === '1' && !TEST_DB.test(database)
|
||||
if (!SCRATCH_DB.test(database) && !intoCatalog) {
|
||||
this.logger.error(
|
||||
`The replay writes the catalog: it only runs against a database named *_replay or *_scratch (this is "${database}"; REPLAY_INTO_CATALOG=1 allows it)`
|
||||
)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
const dir = this.dir ?? coordinatorConfig.corpus.dir
|
||||
if (!dir) {
|
||||
this.logger.error('No corpus directory: --dir or CORPUS_DIR')
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
const bench = await this.benchBinary()
|
||||
if (!bench) {
|
||||
this.logger.error(
|
||||
'No edge-bench binary found: build it (cargo build --release --manifest-path edge/Cargo.toml) or set EDGE_BENCH'
|
||||
)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
const groupDir = join(dir, this.group)
|
||||
const stored = await this.storedRanges(groupDir)
|
||||
if (stored.length === 0) {
|
||||
this.logger.error(`${groupDir}: no complete range`)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
const nodeId = await this.nodeId()
|
||||
const groupId = await this.groupId()
|
||||
const ranges = await this.leaseRanges(groupId, nodeId, stored)
|
||||
const rules = await RuleSets.current()
|
||||
const rulesPath = join(tmpdir(), `replay-rules-${process.pid}.json`)
|
||||
await writeFile(rulesPath, JSON.stringify(rules))
|
||||
this.logger.info(
|
||||
`${this.group}: ${ranges.length} ranges (${ranges[0].start}–${ranges[ranges.length - 1].end}) through ${bench}, rules v${rules.version}, into ${database}.`
|
||||
)
|
||||
|
||||
// edge-bench walks the group directory in article order itself; --from, --to and --limit cut its list as ours.
|
||||
const args = [
|
||||
'--group',
|
||||
groupDir,
|
||||
'--rules',
|
||||
rulesPath,
|
||||
'--batch-chunks',
|
||||
String(this.batchChunks),
|
||||
'--wire',
|
||||
'-',
|
||||
]
|
||||
if (this.from) args.push('--from', String(this.from))
|
||||
if (this.to) args.push('--to', String(this.to))
|
||||
if (this.limit) args.push('--limit', String(this.limit))
|
||||
if (this.noFinish) args.push('--no-finish')
|
||||
const started = Date.now()
|
||||
const child = spawn(bench, args, { stdio: ['ignore', 'pipe', 'inherit'] })
|
||||
const totals = {
|
||||
batches: 0,
|
||||
headers: 0,
|
||||
releases: 0,
|
||||
newReleases: 0,
|
||||
finalized: 0,
|
||||
candidates: 0,
|
||||
storeMs: 0,
|
||||
}
|
||||
let current: StoredRange | null = null
|
||||
const storeLine = async (line: string) => {
|
||||
const batch = fromWire(JSON.parse(line) as WireBatch)
|
||||
if (!current || batch.from < current.start || batch.from > current.end) {
|
||||
current = ranges.find((r) => batch.from >= r.start && batch.from <= r.end) ?? null
|
||||
if (!current) throw new Error(`Batch at ${batch.from} is in no leased range`)
|
||||
}
|
||||
const at = Date.now()
|
||||
const result = await BatchStore.store(current.id, nodeId, batch)
|
||||
totals.storeMs += Date.now() - at
|
||||
totals.batches++
|
||||
totals.headers += batch.headers
|
||||
totals.releases += result.releases
|
||||
totals.newReleases += result.newReleases
|
||||
totals.finalized += result.finalized
|
||||
totals.candidates += result.candidates
|
||||
if (batch.next > current.end) {
|
||||
await Leases.finish(current.id, nodeId)
|
||||
const seconds = (Date.now() - started) / 1000
|
||||
this.logger.info(
|
||||
` range ${current.start}–${current.end} done: ${totals.headers.toLocaleString('en')} headers so far in ${seconds.toFixed(0)} s ` +
|
||||
`(${Math.round(totals.headers / seconds).toLocaleString('en')}/s), ${totals.releases} releases stored (${totals.finalized} finished later), store ${(totals.storeMs / 1000).toFixed(0)} s`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Lines pulled as the store keeps up: the child's stdout only moves when a chunk is read, so batches don't pile up.
|
||||
let rest = ''
|
||||
let failure: Error | null = null
|
||||
try {
|
||||
for await (const chunk of child.stdout as AsyncIterable<Buffer>) {
|
||||
rest += chunk.toString('utf8')
|
||||
let nl: number
|
||||
while ((nl = rest.indexOf('\n')) >= 0) {
|
||||
const line = rest.slice(0, nl)
|
||||
rest = rest.slice(nl + 1)
|
||||
if (line.length > 0) await storeLine(line)
|
||||
}
|
||||
}
|
||||
if (rest.trim().length > 0) await storeLine(rest)
|
||||
} catch (error: any) {
|
||||
failure = error
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
const code = await new Promise<number | null>((done) =>
|
||||
child.exitCode !== null ? done(child.exitCode) : child.on('exit', done)
|
||||
)
|
||||
if (failure) {
|
||||
this.logger.error(`Replay failed: ${failure.message}`)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
this.logger.error(`edge-bench exited with ${code}`)
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
await this.report(groupId, ranges, Date.now() - started)
|
||||
}
|
||||
|
||||
private async benchBinary(): Promise<string | null> {
|
||||
const candidates = [
|
||||
this.bench,
|
||||
process.env.EDGE_BENCH,
|
||||
resolve('edge/target/release/edge-bench'),
|
||||
'/usr/local/bin/edge-bench',
|
||||
].filter((p): p is string => !!p)
|
||||
for (const path of candidates) {
|
||||
if (
|
||||
await access(path).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
)
|
||||
return path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The group directory's complete ranges (a range.json), in article order, cut by --from, --to and --limit. */
|
||||
private async storedRanges(groupDir: string): Promise<{ start: number; end: number }[]> {
|
||||
const names = await readdir(groupDir).catch(() => [] as string[])
|
||||
const ranges: { start: number; end: number }[] = []
|
||||
for (const name of names) {
|
||||
const m = /^(\d+)-(\d+)$/.exec(name)
|
||||
if (!m) continue
|
||||
const json = await readFile(join(groupDir, name, 'range.json'), 'utf8').catch(() => null)
|
||||
if (!json) continue
|
||||
ranges.push({ start: Number(m[1]), end: Number(m[2]) })
|
||||
}
|
||||
ranges.sort((a, b) => a.start - b.start)
|
||||
let chosen = this.from ? ranges.filter((r) => r.end >= this.from!) : ranges
|
||||
if (this.to) chosen = chosen.filter((r) => r.start <= this.to!)
|
||||
if (this.limit) chosen = chosen.slice(0, this.limit)
|
||||
return chosen
|
||||
}
|
||||
|
||||
private async nodeId(): Promise<number> {
|
||||
const row = await db.from('nodes').where('name', NODE_NAME).first()
|
||||
if (row) return Number(row.id)
|
||||
const [inserted] = await db.table('nodes').insert({ name: NODE_NAME }).returning('id')
|
||||
return Number(inserted.id)
|
||||
}
|
||||
|
||||
/** The group's row (inserted after the last priority when the scratch catalog doesn't have it). */
|
||||
private async groupId(): Promise<number> {
|
||||
const row = await db.from('groups').where('name', this.group).first()
|
||||
if (row) return Number(row.id)
|
||||
const { rows } = await db.rawQuery(`SELECT coalesce(max(priority), 0) + 1 AS next FROM groups`)
|
||||
const [inserted] = await db
|
||||
.table('groups')
|
||||
.insert({ name: this.group, priority: Number(rows[0].next), active: true })
|
||||
.returning('id')
|
||||
this.logger.info(`Group ${this.group} added with priority ${rows[0].next}.`)
|
||||
return Number(inserted.id)
|
||||
}
|
||||
|
||||
/** The ranges as leased to the replay node, from their start (an earlier replay's rows are taken over). */
|
||||
private async leaseRanges(
|
||||
groupId: number,
|
||||
nodeId: number,
|
||||
stored: { start: number; end: number }[]
|
||||
): Promise<StoredRange[]> {
|
||||
const out: StoredRange[] = []
|
||||
const now = new Date()
|
||||
for (const r of stored) {
|
||||
const lease = {
|
||||
status: 'leased',
|
||||
node_id: nodeId,
|
||||
next_id: r.start,
|
||||
headers: 0,
|
||||
posted_low: null,
|
||||
posted_high: null,
|
||||
leased_at: now,
|
||||
heartbeat_at: now,
|
||||
done_at: null,
|
||||
error: null,
|
||||
capture: true,
|
||||
}
|
||||
const existing = await db
|
||||
.from('ranges')
|
||||
.where('group_id', groupId)
|
||||
.where('start_id', r.start)
|
||||
.first()
|
||||
if (existing) {
|
||||
await db
|
||||
.from('ranges')
|
||||
.where('id', existing.id)
|
||||
.update({ ...lease, end_id: r.end, attempts: Number(existing.attempts) + 1 })
|
||||
out.push({ id: Number(existing.id), start: r.start, end: r.end })
|
||||
continue
|
||||
}
|
||||
const [inserted] = await db
|
||||
.table('ranges')
|
||||
.insert({
|
||||
...lease,
|
||||
group_id: groupId,
|
||||
start_id: r.start,
|
||||
end_id: r.end,
|
||||
priority: 0,
|
||||
attempts: 1,
|
||||
})
|
||||
.returning('id')
|
||||
out.push({ id: Number(inserted.id), start: r.start, end: r.end })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** What the replay left in the catalog (this group's releases; the sizes are the whole catalog's), per article. */
|
||||
private async report(groupId: number, ranges: StoredRange[], elapsedMs: number) {
|
||||
const ids = ranges.map((r) => r.id)
|
||||
const { rows: counts } = await db.rawQuery(
|
||||
`SELECT count(*)::bigint AS releases,
|
||||
count(settled_at)::bigint AS finished,
|
||||
count(*) FILTER (WHERE settled_at IS NULL)::bigint AS provisional,
|
||||
count(*) FILTER (WHERE edge)::bigint AS edges,
|
||||
count(*) FILTER (WHERE complete)::bigint AS complete,
|
||||
count(*) FILTER (WHERE complete = false)::bigint AS incomplete,
|
||||
count(*) FILTER (WHERE complete IS NULL)::bigint AS undecided,
|
||||
count(*) FILTER (WHERE readable)::bigint AS readable,
|
||||
count(*) FILTER (WHERE has_par2)::bigint AS with_par2,
|
||||
coalesce(sum(files), 0)::bigint AS files,
|
||||
coalesce(sum(segments), 0)::bigint AS segments,
|
||||
coalesce(sum(bytes), 0)::bigint AS bytes
|
||||
FROM releases WHERE group_id = ?`,
|
||||
[groupId]
|
||||
)
|
||||
const { rows: sources } = await db.rawQuery(
|
||||
`SELECT source, count(*)::bigint AS n FROM releases WHERE group_id = ? GROUP BY source ORDER BY source`,
|
||||
[groupId]
|
||||
)
|
||||
const { rows: pending } = await db.rawQuery(
|
||||
`SELECT count(*)::bigint AS n, coalesce(sum(segments), 0)::bigint AS segments, count(*) FILTER (WHERE jsonb_array_length(candidates) = 0)::bigint AS unprobable
|
||||
FROM pending_releases WHERE group_id = ?`,
|
||||
[groupId]
|
||||
)
|
||||
const { rows: headerRows } = await db.rawQuery(
|
||||
`SELECT coalesce(sum(headers), 0)::bigint AS headers FROM ranges WHERE id = ANY(?::bigint[])`,
|
||||
[ids]
|
||||
)
|
||||
const headers = Number(headerRows[0].headers)
|
||||
const { rows: sizes } = await db.rawQuery(
|
||||
`SELECT CASE WHEN c.relname LIKE 'release_chunks%' THEN 'release_chunks' ELSE c.relname END AS name,
|
||||
sum(pg_total_relation_size(c.oid))::bigint AS bytes
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'r'
|
||||
AND (c.relname IN ('releases', 'release_texts', 'posters', 'group_sets', 'probe_candidates', 'rule_counts', 'rule_samples', 'blocks', 'pending_releases')
|
||||
OR c.relname LIKE 'release_chunks%')
|
||||
GROUP BY 1 ORDER BY 2 DESC`
|
||||
)
|
||||
// On CHUNK_TABLESPACE (the HDD) in the catalog: the chunks (migration 3200), inspections and candidates (6000).
|
||||
const hdd = new Set([
|
||||
'release_chunks',
|
||||
'probe_candidates',
|
||||
'release_inspections',
|
||||
'pending_releases',
|
||||
])
|
||||
let nvme = 0
|
||||
let other = 0
|
||||
const c = counts[0]
|
||||
const fmt = (n: unknown) => Number(n).toLocaleString('en')
|
||||
this.logger.info(
|
||||
`Replay of ${ranges.length} ranges: ${fmt(headers)} headers in ${(elapsedMs / 1000).toFixed(0)} s (${fmt(Math.round(headers / Math.max(1, elapsedMs / 1000)))}/s).`
|
||||
)
|
||||
this.logger.info(
|
||||
`Releases ${fmt(c.releases)}: finished ${fmt(c.finished)}, provisional ${fmt(c.provisional)}, edges ${fmt(c.edges)}; ` +
|
||||
`complete ${fmt(c.complete)}, incomplete ${fmt(c.incomplete)}, undecided ${fmt(c.undecided)}; readable ${fmt(c.readable)}, with par2 ${fmt(c.with_par2)}; ` +
|
||||
`${fmt(c.files)} files, ${fmt(c.segments)} segments (${((Number(c.segments) / Math.max(1, headers)) * 100).toFixed(1)}% of the headers), ${(Number(c.bytes) / 1e12).toFixed(2)} TB.`
|
||||
)
|
||||
this.logger.info(
|
||||
`By source: ${sources.map((s: any) => `${s.source}: ${fmt(s.n)}`).join(', ')}.`
|
||||
)
|
||||
this.logger.info(
|
||||
`Pending (unnamed, outside the catalog): ${fmt(pending[0].n)} releases, ${fmt(pending[0].segments)} segments, ${fmt(pending[0].unprobable)} without a probe target.`
|
||||
)
|
||||
for (const s of sizes) {
|
||||
const bytes = Number(s.bytes)
|
||||
if (hdd.has(s.name)) other += bytes
|
||||
else nvme += bytes
|
||||
this.logger.info(
|
||||
` ${s.name}: ${(bytes / 1e6).toFixed(1)} MB, ${(bytes / Math.max(1, headers)).toFixed(2)} B/header`
|
||||
)
|
||||
}
|
||||
this.logger.info(
|
||||
`NVMe tables: ${(nvme / 1e6).toFixed(1)} MB, ${(nvme / Math.max(1, headers)).toFixed(2)} B/header; HDD tables (CHUNK_TABLESPACE): ${(other / 1e6).toFixed(1)} MB, ${(other / Math.max(1, headers)).toFixed(2)} B/header (the whole catalog's sizes over this group's headers: only the first group's are its own).`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import RuleSets from '#services/rule_sets'
|
||||
import { classKey, describeClass } from '#lib/release/index'
|
||||
|
||||
/**
|
||||
* Publishes a rule set whose dropped classes are the pending ladder's (pending_classes.dropped; LOG.md (130)): the
|
||||
* edge keeps unnamed releases of those classes out of the pending store from then on, their explored sample
|
||||
* excepted. Run again as the record grows; unchanged classes publish nothing.
|
||||
*/
|
||||
export default class RulesClasses extends BaseCommand {
|
||||
static commandName = 'rules:classes'
|
||||
static description = "Publishes a rule set with the pending ladder's dropped classes"
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.boolean({ description: 'Show what would be published, write nothing' })
|
||||
declare dryRun: boolean
|
||||
|
||||
async run() {
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT c.tool, g.name AS grp, c.klass, c.probed, c.named FROM pending_classes c JOIN groups g ON g.id = c.group_id WHERE c.dropped ORDER BY 1, 2, 3`
|
||||
)
|
||||
const next = new Set<string>(rows.map((r: any) => classKey(r.tool, r.grp, Number(r.klass))))
|
||||
const { version, priorities: _p, ...current } = await RuleSets.current()
|
||||
const before = new Set(current.droppedClasses ?? [])
|
||||
const added = [...next].filter((c) => !before.has(c))
|
||||
const removed = [...before].filter((c) => !next.has(c))
|
||||
if (added.length === 0 && removed.length === 0) {
|
||||
this.logger.info(
|
||||
`Unchanged: rule set ${version} has the ${before.size} dropped classes already.`
|
||||
)
|
||||
return
|
||||
}
|
||||
for (const r of rows.slice(0, 40)) {
|
||||
this.logger.info(
|
||||
` ${r.tool.padEnd(10)} ${r.grp.padEnd(32)} ${describeClass(Number(r.klass)).padEnd(26)} ${r.named} of ${r.probed} named`
|
||||
)
|
||||
}
|
||||
if (this.dryRun) {
|
||||
this.logger.info(
|
||||
`Would publish ${next.size} dropped classes (${added.length} added, ${removed.length} removed).`
|
||||
)
|
||||
return
|
||||
}
|
||||
const published = await RuleSets.publish(
|
||||
{ ...current, droppedClasses: [...next].sort() },
|
||||
`${next.size} dropped classes from the pending ladder`
|
||||
)
|
||||
this.logger.info(
|
||||
`Rule set ${published}: ${next.size} dropped classes (${added.length} added, ${removed.length} removed).`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,22 @@ export default {
|
||||
},
|
||||
/** A leased range without a heartbeat for this long goes back to the queue. */
|
||||
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
|
||||
/**
|
||||
* The ranges group leases hand out (REBUILD.md design 1), by priority: the tail is negative, the capture windows
|
||||
* 0–2 (2016, 2021, 2025), the history by its position in the group (0 oldest … 1000 newest: its first buckets
|
||||
* share 0–2 with the windows) or 4000+. Bounds so a deployment can run one window first, with
|
||||
* LEASE_CAPTURE_ONLY so the history's 0 isn't taken along (homelab 2026-10-26: 0..0 and capture only, the 2016
|
||||
* window; LOG.md (118)); unbounded and any range by default.
|
||||
*/
|
||||
leasePriorityMin: env.get('LEASE_MIN_PRIORITY', -1_000_000),
|
||||
leasePriorityMax: env.get('LEASE_MAX_PRIORITY', 1_000_000),
|
||||
leaseCaptureOnly: env.get('LEASE_CAPTURE_ONLY', false),
|
||||
/**
|
||||
* How long a range's stream waits for a chunk the prefetcher hasn't spooled yet (app/controllers/node_controller.ts).
|
||||
* Long: a chunk that keeps coming in isn't given up (only a stalled one is), and some groups' chunks take 20+
|
||||
* minutes (a.b.0day.stuffz, 17 MB in 22 min; LOG.md 2026-10-10 (3)).
|
||||
*/
|
||||
streamChunkWaitMs: env.get('STREAM_CHUNK_WAIT_MINUTES', 60) * 60 * 1000,
|
||||
/** NNTP connections the coordinator fetches headers with: at most (its share, nntpShares), and at least while sharing. */
|
||||
nntpConnections: env.get('NNTP_CONNECTIONS', 4),
|
||||
nntpMinConnections: env.get('NNTP_MIN_CONNECTIONS', 8),
|
||||
@@ -103,6 +119,14 @@ export default {
|
||||
batch: 400,
|
||||
/** BODY commands in flight per connection (pipelined; XoverClient.bodies). */
|
||||
pipeline: env.get('PROBE_PIPELINE', 8),
|
||||
/**
|
||||
* The pending ladder (REBUILD.md decision f; app/services/pending_ladder.ts): the share of each claim taken
|
||||
* from pending_releases at least (the catalog's own candidates come first, the rest of the claim is theirs).
|
||||
*/
|
||||
pendingShare: env.get('PENDING_SHARE', 0.25),
|
||||
/** A signature (tool, group, poster) with this many climbs and at most pendingMaxNamed of them named is dropped. */
|
||||
pendingMinProbes: env.get('PENDING_MIN_PROBES', 100),
|
||||
pendingMaxNamed: env.get('PENDING_MAX_NAMED', 0.005),
|
||||
},
|
||||
/**
|
||||
* The catalog's tablespace may grow to this many GB (app/services/catalog_space.ts); above it only the tail and
|
||||
@@ -138,6 +162,11 @@ export default {
|
||||
*/
|
||||
filter: env.get('CORPUS_FILTER', true),
|
||||
},
|
||||
/**
|
||||
* The group windows' snapshots (REBUILD.md, "Voyager 3: window indexing", 5; app/services/group_leases.ts): a
|
||||
* file a group on bulk, next to the spool by default.
|
||||
*/
|
||||
snapshotDir: env.get('SNAPSHOT_DIR', `${env.get('SPOOL_DIR', app.tmpPath('spool'))}/snapshots`),
|
||||
spool: {
|
||||
dir: env.get('SPOOL_DIR', app.tmpPath('spool')),
|
||||
/** Ranges kept fetched ahead, per active lease. */
|
||||
|
||||
@@ -7,7 +7,7 @@ import env from '#start/env'
|
||||
* inspections, candidates, par2 data, file hashes, verdicts, merges, rule counts and samples; what survives:
|
||||
* groups, ranges, the corpus, probe_cache, spots, nntp_shares, nodes, users), so it refuses to run on a catalog
|
||||
* with releases unless VOYAGER3_RESET=1 (the user's go-ahead; resets are paused since 2026-10-12). Then:
|
||||
* - posters get an id; group_sets holds each distinct group combination once (~2,600); releases is created anew
|
||||
* - posters get an id; group_sets holds each distinct group combination once (~140k); releases is created anew
|
||||
* without its text copies (poster_id, group_set_id, the keys as 20-byte bytea, source a smallint), 8-byte
|
||||
* columns first, with `edge` (still open at its block's end, for the block merger) and settled_at as the finish
|
||||
* (null: a tail release still provisional). The category and judge triggers follow the new columns;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
import env from '#start/env'
|
||||
|
||||
/**
|
||||
* pending_releases (REBUILD.md decision f, 2026-10-26): nothing enters the catalog without a usable name. A release
|
||||
* the window closed on whose files all have random names is written here instead of releases: its keys (as the
|
||||
* catalog would store them), its counts and article span, the upload tool from its message-id (with the group its
|
||||
* signature), and its probe targets by effort (the edge's candidates: a par2, an archive start, a lone file, a 7z
|
||||
* tail), no chunk. The pending ladder probes them cheapest first and moves a named one into the catalog, reading its
|
||||
* segments again from the corpus or the server; a signature that names nothing is discarded. On CHUNK_TABLESPACE
|
||||
* (the HDD): written once, read by the ladder, never by search.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.raw(`
|
||||
CREATE TABLE pending_releases (
|
||||
-- lib/release/keys.ts: the identity, the group and the first article; the identity alone.
|
||||
release_key bytea PRIMARY KEY,
|
||||
identity_key bytea NOT NULL,
|
||||
group_id integer NOT NULL,
|
||||
-- The stem or post the edge keyed it by (a random name: no release name, but the inner-stem merge's handle).
|
||||
name varchar(255) NOT NULL,
|
||||
poster text,
|
||||
posted_at timestamptz,
|
||||
last_posted_at timestamptz,
|
||||
first_article bigint NOT NULL,
|
||||
last_article bigint NOT NULL,
|
||||
files integer NOT NULL DEFAULT 0,
|
||||
segments bigint NOT NULL DEFAULT 0,
|
||||
bytes bigint NOT NULL DEFAULT 0,
|
||||
-- A file misses parts.
|
||||
partial boolean NOT NULL DEFAULT false,
|
||||
-- False: a tail release still open; a later chunk of its key adds its counts up.
|
||||
finished boolean NOT NULL DEFAULT true,
|
||||
-- lib/rules uploadTool: ngPost, nyuu, hex32, random, other, …
|
||||
tool varchar(16) NOT NULL,
|
||||
-- [{kind, messageId, file}] by effort (lib/edge ProbeCandidate); empty: nothing to read, only a signature.
|
||||
candidates jsonb NOT NULL DEFAULT '[]',
|
||||
-- 0 waiting, 1 named (in the catalog), 2 discarded (its signature's verdict, or every probe gave nothing).
|
||||
status smallint NOT NULL DEFAULT 0,
|
||||
tries smallint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
probed_at timestamptz
|
||||
)`)
|
||||
// The ladder's order: a group's and tool's waiting releases, oldest first.
|
||||
this.schema.raw(
|
||||
`CREATE INDEX pending_releases_ladder_idx ON pending_releases (group_id, tool, status, created_at)`
|
||||
)
|
||||
const hdd = env.get('CHUNK_TABLESPACE')
|
||||
if (hdd) {
|
||||
this.schema.raw(`ALTER TABLE pending_releases SET TABLESPACE ${hdd}`)
|
||||
this.schema.raw(`ALTER INDEX pending_releases_pkey SET TABLESPACE ${hdd}`)
|
||||
this.schema.raw(`ALTER INDEX pending_releases_ladder_idx SET TABLESPACE ${hdd}`)
|
||||
}
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP TABLE IF EXISTS pending_releases`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The pending ladder (REBUILD.md decision f, 2026-10-26): what the prober needs to work pending_releases cheapest
|
||||
* first and to learn which signatures never name anything.
|
||||
* - pending_releases: `file_total` (the counter's, for the catalog row once named), `inspections` (what each probe
|
||||
* showed, by kind), `named` (the usable name a probe found; status 4 until the namer moved it into the catalog).
|
||||
* Status: 0 waiting, 1 named and in the catalog, 2 discarded (its signature's verdict), 3 exhausted (every
|
||||
* target probed, no name), 4 named, awaiting the move, 9 taken by a running prober.
|
||||
* - pending_signatures: per (tool, group, poster) how many pending releases were probed to the end and how many
|
||||
* got a name; `dropped` once probed ≥ PENDING_MIN_PROBES and named ≤ PENDING_MAX_NAMED of them (voyager 2's
|
||||
* garbage learner, LOG.md 2026-10-08 (5)). The poster '' row is the tool's and group's together.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.raw(`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS file_total integer`)
|
||||
this.schema.raw(
|
||||
`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS inspections jsonb NOT NULL DEFAULT '[]'`
|
||||
)
|
||||
this.schema.raw(`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS named varchar(255)`)
|
||||
// The ladder's claim: the oldest waiting ones; the namer's: the named ones.
|
||||
this.schema.raw(
|
||||
`CREATE INDEX IF NOT EXISTS pending_releases_waiting_idx ON pending_releases (created_at) WHERE status = 0`
|
||||
)
|
||||
this.schema.raw(
|
||||
`CREATE INDEX IF NOT EXISTS pending_releases_naming_idx ON pending_releases (probed_at) WHERE status = 4`
|
||||
)
|
||||
this.schema.raw(`
|
||||
CREATE TABLE pending_signatures (
|
||||
tool varchar(16) NOT NULL,
|
||||
group_id integer NOT NULL,
|
||||
poster text NOT NULL DEFAULT '',
|
||||
probed integer NOT NULL DEFAULT 0,
|
||||
named integer NOT NULL DEFAULT 0,
|
||||
dropped boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tool, group_id, poster)
|
||||
)`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP TABLE IF EXISTS pending_signatures`)
|
||||
this.schema.raw(`DROP INDEX IF EXISTS pending_releases_naming_idx`)
|
||||
this.schema.raw(`DROP INDEX IF EXISTS pending_releases_waiting_idx`)
|
||||
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS named`)
|
||||
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS inspections`)
|
||||
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS file_total`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5; 2026-10-26): a worker thread holds a group, not
|
||||
* a range, and indexes its ranges in article order through one carried window. One row a group: the holder and
|
||||
* its heartbeat, the range it works, and the snapshot of its window (the article it stands at: the next one to
|
||||
* index) that the coordinator keeps on bulk. Ranges stay what is planned, fetched and finished.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.raw(`
|
||||
CREATE TABLE group_leases (
|
||||
group_id integer PRIMARY KEY REFERENCES groups (id) ON DELETE CASCADE,
|
||||
-- The holder; null between holders. A holder quiet for LEASE_TTL_SECONDS is taken over.
|
||||
node_id integer,
|
||||
leased_at timestamptz,
|
||||
heartbeat_at timestamptz,
|
||||
-- The range the holder works (null between ranges).
|
||||
range_id bigint,
|
||||
-- The window's snapshot: the article it stands at (a resume point), and when it was taken; null: none.
|
||||
snapshot_next bigint,
|
||||
snapshot_at timestamptz,
|
||||
ranges_done integer NOT NULL DEFAULT 0,
|
||||
headers bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`)
|
||||
this.schema.raw(`CREATE INDEX group_leases_held_idx ON group_leases (heartbeat_at) WHERE node_id IS NOT NULL`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP TABLE IF EXISTS group_leases`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Lease lanes (REBUILD.md, "Voyager 3: window indexing", 1, amended 2026-10-27): a group has two cursors, each with
|
||||
* its own holder, window and snapshot. The `history` lane works the ranges the lease bounds hand out (the windows,
|
||||
* the history), the `tail` lane the tail planner's (priority at or below the tail's): a thread on the tail never
|
||||
* waits behind a group's history, and a held history group's tail isn't stuck behind its days of ranges.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.raw(
|
||||
`ALTER TABLE group_leases ADD COLUMN lane varchar(8) NOT NULL DEFAULT 'history'`
|
||||
)
|
||||
this.schema.raw(`ALTER TABLE group_leases DROP CONSTRAINT group_leases_pkey`)
|
||||
this.schema.raw(`ALTER TABLE group_leases ADD PRIMARY KEY (group_id, lane)`)
|
||||
// Counts the leases handed out: odd and even ones start from the other half of the order's mix.
|
||||
this.schema.raw(`CREATE SEQUENCE group_lease_turns`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP SEQUENCE IF EXISTS group_lease_turns`)
|
||||
this.schema.raw(`DELETE FROM group_leases WHERE lane <> 'history'`)
|
||||
this.schema.raw(`ALTER TABLE group_leases DROP CONSTRAINT group_leases_pkey`)
|
||||
this.schema.raw(`ALTER TABLE group_leases DROP COLUMN lane`)
|
||||
this.schema.raw(`ALTER TABLE group_leases ADD PRIMARY KEY (group_id)`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* One "ancient" range a group, articles 1 … (its first range − 1): what had expired on the server before the
|
||||
* group was planned (the planner cuts from the low watermark). Status 'ancient': nothing to fetch, never leased,
|
||||
* so the tiling is complete from article 1 and the span below the low watermark is on record (user, 2026-10-28).
|
||||
* Planner.historyRows adds it for groups planned from now on.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.raw(
|
||||
`INSERT INTO ranges (group_id, start_id, end_id, next_id, priority, status)
|
||||
SELECT group_id, 1, min(start_id) - 1, 1, 0, 'ancient' FROM ranges GROUP BY group_id HAVING min(start_id) > 1`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DELETE FROM ranges WHERE status = 'ancient'`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The pending ladder by class (REBUILD.md decision f; LOG.md (130)): a pending release's class (lib/release/classes.ts:
|
||||
* size bucket × name shape × partial, 1..70; 0 for rows from before) and the bytes of its first target, so the
|
||||
* claim takes the classes with the best record first and the cheapest article within one; pending_classes per
|
||||
* (tool, group, class) how many climbed to the end and how many got a name, `waiting` how many wait (kept by the
|
||||
* batch store, the claim and the discard; recounted by pending:classes), `dropped` as pending_signatures.
|
||||
* The SQL pending_class() is the TypeScript's pendingClass, for the batch store's insert and the backfill.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
this.schema.raw(`
|
||||
CREATE OR REPLACE FUNCTION pending_class(name text, bytes bigint, partial boolean) RETURNS smallint LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT ((CASE WHEN bytes < 1000000 THEN 0 WHEN bytes < 10000000 THEN 1 WHEN bytes < 100000000 THEN 2 WHEN bytes < 1000000000 THEN 3 ELSE 4 END) * 14
|
||||
+ (CASE WHEN s ~ '^[0-9a-fA-F]{16,}$' THEN 1
|
||||
WHEN s ~ '^[0-9]{6,}$' THEN 2
|
||||
WHEN s ~ '^[a-z2-7]{20,}$' AND s ~ '[2-7]' THEN 3
|
||||
WHEN s ~ '^[A-Za-z]+$' THEN 4
|
||||
WHEN s ~ '^[A-Za-z0-9]+$' THEN 5
|
||||
WHEN s ~ '^[A-Za-z0-9._-]+$' THEN 6
|
||||
ELSE 7 END - 1) * 2
|
||||
+ (CASE WHEN partial THEN 1 ELSE 0 END) + 1)::smallint
|
||||
FROM (SELECT regexp_replace(name, '\\.[A-Za-z0-9]{1,4}$', '') AS s) x
|
||||
$$`)
|
||||
this.schema.raw(
|
||||
`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS klass smallint NOT NULL DEFAULT 0`
|
||||
)
|
||||
this.schema.raw(`ALTER TABLE pending_releases ADD COLUMN IF NOT EXISTS target_bytes bigint`)
|
||||
this.schema.raw(`
|
||||
CREATE TABLE IF NOT EXISTS pending_classes (
|
||||
tool varchar(16) NOT NULL,
|
||||
group_id integer NOT NULL,
|
||||
klass smallint NOT NULL,
|
||||
probed integer NOT NULL DEFAULT 0,
|
||||
named integer NOT NULL DEFAULT 0,
|
||||
waiting integer NOT NULL DEFAULT 0,
|
||||
dropped boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tool, group_id, klass)
|
||||
)`)
|
||||
// The claim by class: the cheapest target first within one, the oldest among equals.
|
||||
this.schema.raw(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pending_releases_class_idx ON pending_releases (tool, group_id, klass, target_bytes, created_at) WHERE status = 0`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_class_idx`)
|
||||
this.schema.raw(`DROP TABLE IF EXISTS pending_classes`)
|
||||
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS target_bytes`)
|
||||
this.schema.raw(`ALTER TABLE pending_releases DROP COLUMN IF EXISTS klass`)
|
||||
this.schema.raw(`DROP FUNCTION IF EXISTS pending_class(text, bigint, boolean)`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The prober's start puts a previous run's claims back (PendingLadder.release: status 9 → 0), which scanned the
|
||||
* 58M-row pending table on the HDD for 20+ minutes after every deploy (LOG.md (130)): a partial index on the few
|
||||
* taken rows makes it instant.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
this.schema.raw(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pending_releases_taken_idx ON pending_releases (release_key) WHERE status = 9`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_taken_idx`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The prober's start puts a previous run's claims back (probe_candidates status taken → waiting), which scanned
|
||||
* the 7.8 GB candidates table on the HDD for 4 minutes after every deploy (LOG.md (133)): a partial index on the
|
||||
* few taken rows makes it instant.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
this.schema.raw(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS probe_candidates_taken_idx ON probe_candidates (id) WHERE status = 'taken'`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS probe_candidates_taken_idx`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The ladder's discard sweep (PendingLadder.discard) reads a dropped signature's or class's waiting rows once:
|
||||
* `swept_at` marks it done (null again when the record drops it anew). Before, every sweep read every dropped
|
||||
* signature's rows over again, minutes of HDD each, inside the claim (LOG.md (133)).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('pending_signatures', (table) => {
|
||||
table.timestamp('swept_at', { useTz: true }).nullable()
|
||||
})
|
||||
this.schema.alterTable('pending_classes', (table) => {
|
||||
table.timestamp('swept_at', { useTz: true }).nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('pending_signatures', (table) => {
|
||||
table.dropColumn('swept_at')
|
||||
})
|
||||
this.schema.alterTable('pending_classes', (table) => {
|
||||
table.dropColumn('swept_at')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The discard sweep of a dropped signature (tool, group, poster) read the whole pending_releases table per
|
||||
* signature: the planner took a parallel seq scan over the poster test, four minutes on the HDD that starved the
|
||||
* lanes' claims (LOG.md (134)). The waiting rows by signature make it an index range. By the poster's md5: a
|
||||
* poster of 2.7 kB broke the first build (a btree key is at most a third of a page), which left an invalid index
|
||||
* behind that IF NOT EXISTS would have taken for the real one; the drop clears it.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_signature_idx`)
|
||||
this.schema.raw(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pending_releases_signature_idx ON pending_releases (group_id, tool, md5(poster)) WHERE status = 0`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.raw(`DROP INDEX CONCURRENTLY IF EXISTS pending_releases_signature_idx`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* probe_candidates, with its indexes, in the catalog's tablespace (default_tablespace: nvme on homelab), out of
|
||||
* CHUNK_TABLESPACE where migration 6000 put it: the prober's catalog claim updates it from random points every
|
||||
* minute, and on the HDD, beside the pending ladder's sweep and autovacuum, a claim took 2–3 minutes with all the
|
||||
* lanes waiting (LOG.md 2026-11-03 (136)). Homelab moved it by hand (edge, node-api and the workers stopped; ~8 GB);
|
||||
* a no-op where it's there already, or without a default tablespace (tests).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
const { rows: setting } = await db.rawQuery(
|
||||
`SELECT current_setting('default_tablespace') AS target`
|
||||
)
|
||||
const target = setting[0].target
|
||||
if (!target) return
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT c.relname, c.relkind FROM pg_class c LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
|
||||
WHERE (c.relname = 'probe_candidates' OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'probe_candidates'::regclass))
|
||||
AND c.relkind IN ('r', 'i') AND coalesce(t.spcname, 'pg_default') <> ?
|
||||
ORDER BY c.relkind DESC, c.relname`,
|
||||
[target]
|
||||
)
|
||||
for (const { relname, relkind } of rows) {
|
||||
await db.rawQuery(
|
||||
`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${target}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Everything the prober reads or writes in the catalog's tablespace (default_tablespace: nvme on homelab), out of
|
||||
* CHUNK_TABLESPACE (the HDD): pending_releases with its two HDD indexes (migration 6300), release_inspections
|
||||
* (6000), probe_cache with its toast. The edge's batch upserts into pending_releases took minutes each on the HDD and
|
||||
* the prober's flushes waited on them (LOG.md 2026-11-03 (137)); and with these on the NVMe the prober can run
|
||||
* through the night without the HDD. Homelab moved them by hand (edge, node-api and the workers stopped; ~66 GB);
|
||||
* a no-op where they're there already, or without a default tablespace (tests).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
const { rows: setting } = await db.rawQuery(
|
||||
`SELECT current_setting('default_tablespace') AS target`
|
||||
)
|
||||
const target = setting[0].target
|
||||
if (!target) return
|
||||
for (const table of ['pending_releases', 'release_inspections', 'probe_cache']) {
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT c.relname, c.relkind FROM pg_class c LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
|
||||
WHERE (c.relname = ? OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = ?::regclass))
|
||||
AND c.relkind IN ('r', 'i') AND coalesce(t.spcname, 'pg_default') <> ?
|
||||
ORDER BY c.relkind DESC, c.relname`,
|
||||
[table, table, target]
|
||||
)
|
||||
for (const { relname, relkind } of rows) {
|
||||
await db.rawQuery(
|
||||
`ALTER ${relkind === 'i' ? 'INDEX' : 'TABLE'} ${relname} SET TABLESPACE ${target}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
+23
-1
@@ -126,7 +126,9 @@ services:
|
||||
prober:
|
||||
<<: *app
|
||||
command: ['node', 'ace', 'probe:run']
|
||||
volumes: []
|
||||
# The pending namer reads a named release's articles again from the corpus (no spool).
|
||||
volumes:
|
||||
- ./corpus:/corpus:ro
|
||||
environment:
|
||||
<<: *app-env
|
||||
PROBE_CONNECTIONS: ${PROBE_CONNECTIONS:-8}
|
||||
@@ -173,6 +175,11 @@ services:
|
||||
environment:
|
||||
COORDINATOR_URL: http://node-api:3335
|
||||
NODE_TOKEN: ${NODE_TOKEN}
|
||||
# The window worker (REBUILD.md design 1 and 5): a group per thread, the window's snapshot to the coordinator
|
||||
# this often; the coordinator keeps them under SNAPSHOT_DIR (the spool's snapshots/ on bulk by default).
|
||||
EDGE_SNAPSHOT_MINUTES: ${EDGE_SNAPSHOT_MINUTES:-30}
|
||||
# Of the threads, this many on the groups' tail lane (the live tail; the rest work the history lane).
|
||||
EDGE_TAIL_THREADS: ${EDGE_TAIL_THREADS:-1}
|
||||
stop_grace_period: 60s
|
||||
depends_on:
|
||||
node-api:
|
||||
@@ -190,6 +197,21 @@ services:
|
||||
node-api:
|
||||
condition: service_started
|
||||
|
||||
# The replay harness into the scratch catalog usenet_replay on bulk (REBUILD.md, "The replay harness"):
|
||||
# docker compose run --rm --no-deps replay --group alt.binaries.ath
|
||||
replay:
|
||||
<<: *app
|
||||
profiles: [replay]
|
||||
restart: 'no'
|
||||
entrypoint: ['node', 'ace', 'replay:corpus']
|
||||
command: ['--help']
|
||||
environment:
|
||||
<<: *app-env
|
||||
DB_DATABASE: usenet_replay
|
||||
volumes:
|
||||
- ./corpus:/corpus:ro
|
||||
depends_on: []
|
||||
|
||||
# One-off ace commands: docker compose run --rm ace releases:duplicates
|
||||
ace:
|
||||
<<: *app
|
||||
|
||||
+10
-2
@@ -47,11 +47,19 @@ echo "== recreating app services"
|
||||
docker compose stop edge
|
||||
if [ -e "$CACHE/paused" ]; then
|
||||
# Paused for the night (~/bin/usenet-rebuild-pause.sh): the workers stay stopped, the resume starts the new build.
|
||||
echo "== paused: coordinator, prober, merger, judge, spotter, spot-inspector and edge left stopped"
|
||||
docker compose up -d --no-deps node-api web
|
||||
# The prober runs through the night (2026-11-03, LOG.md (137)), so it gets the new build now.
|
||||
echo "== paused: coordinator, merger, judge, spotter, spot-inspector and edge left stopped"
|
||||
docker compose up -d --no-deps node-api web prober
|
||||
else
|
||||
docker compose up -d --no-deps node-api web coordinator prober merger judge spotter spot-inspector
|
||||
if [ -e "$CACHE/no-edge" ]; then
|
||||
# Voyager 3 (2026-10-26, LOG.md (116)): the worker indexes range by range, without the carried window, and
|
||||
# would split releases at every range edge with nothing to join them; history comes from the corpus through
|
||||
# deploy/replay-window.sh until the window worker is built. Remove the marker to start it again.
|
||||
echo "== no-edge: the edge worker left stopped"
|
||||
else
|
||||
docker compose up -d --no-deps edge
|
||||
fi
|
||||
fi
|
||||
echo "$SHA $(date -Is)" >> "$ROOT/deployed.log"
|
||||
# The images this replaced (321 MB each; the root filesystem is the NVMe, with a 10 GB floor).
|
||||
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# A corpus window replayed on homelab (REBUILD.md, "The replay harness"): every group whose window ranges (the
|
||||
# ranges marked `capture` at PRIORITY in the catalog, 0 for 2016) are all stored in the corpus is replayed once
|
||||
# into the scratch catalog usenet_replay through the voyager-3 app image, PARALLEL groups at a time, the
|
||||
# window's bounds passed as --from/--to (the corpus holds other windows of the same group). The group's log
|
||||
# ends in .cache/replay/<group>.log, a .done marker next to it (.failed when the replay failed: remove it to
|
||||
# retry). Loops until every group of the window is done (the corpus fill runs meanwhile), so run it detached
|
||||
# from the deployment root:
|
||||
# setsid nohup deploy/replay-window.sh > .cache/replay/driver.log 2>&1 &
|
||||
# No new group starts while the nightly pause is on (.cache/paused); the running ones finish.
|
||||
# DB=usenet_rebuild replays into the catalog itself (voyager 3 deployed, 2026-10-26: history comes from the corpus
|
||||
# this way until the window worker is built); the harness then needs REPLAY_INTO_CATALOG=1, passed here. OUT keeps
|
||||
# a run's logs and markers apart (.cache/replay-catalog for the catalog's).
|
||||
set -u
|
||||
ROOT=${ROOT:-/mnt/bulk/usenet-rebuild}
|
||||
PRIORITY=${PRIORITY:-0}
|
||||
PARALLEL=${PARALLEL:-4}
|
||||
MEMORY=${MEMORY:-12g}
|
||||
IMAGE=${IMAGE:-usenet-voyager3:latest}
|
||||
DB=${DB:-usenet_replay}
|
||||
OUT=${OUT:-$ROOT/.cache/replay}
|
||||
mkdir -p "$OUT"
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
say() { echo "$(date -Is) $*"; }
|
||||
catalog() { docker compose exec -T postgres psql -U rebuild -d usenet_rebuild -X -q -At -F ' ' -c "$1"; }
|
||||
|
||||
# The window's groups: name, first article, last article, whether every range is stored; smallest first.
|
||||
window() {
|
||||
catalog "SELECT g.name, min(r.start_id), max(r.end_id), (count(*) = count(r.stored_at))::int
|
||||
FROM ranges r JOIN groups g ON g.id = r.group_id
|
||||
WHERE r.capture AND r.priority = $PRIORITY
|
||||
GROUP BY g.name ORDER BY sum(r.end_id - r.start_id + 1)"
|
||||
}
|
||||
|
||||
replay() {
|
||||
local group=$1 from=$2 to=$3
|
||||
say "$group: replaying $from–$to"
|
||||
if docker run --rm --name "replay-${group//./-}" --network usenet-rebuild --env-file .env \
|
||||
-e NODE_ENV=production -e HOST=0.0.0.0 -e PORT=3334 -e APP_URL=http://web:3334 -e SPOOL_DIR=/spool -e CORPUS_DIR=/corpus \
|
||||
-e DB_HOST=postgres -e DB_PORT=5432 -e DB_DATABASE="$DB" -e CHUNK_TABLESPACE=pg_default -e REPLAY_INTO_CATALOG=1 \
|
||||
-v "$ROOT/corpus:/corpus:ro" --memory "$MEMORY" "$IMAGE" \
|
||||
node ace replay:corpus --group "$group" --from "$from" --to "$to" > "$OUT/$group.log" 2>&1; then
|
||||
touch "$OUT/$group.done"
|
||||
say "$group: done ($(grep -o 'Releases [0-9,]*' "$OUT/$group.log" | tail -1))"
|
||||
else
|
||||
touch "$OUT/$group.failed"
|
||||
say "$group: FAILED (see $OUT/$group.log)"
|
||||
fi
|
||||
rm -f "$OUT/$group.running"
|
||||
}
|
||||
|
||||
while true; do
|
||||
todo=0
|
||||
while read -r group from to complete; do
|
||||
[ -n "$group" ] || continue
|
||||
[ -e "$OUT/$group.done" ] && continue
|
||||
[ -e "$OUT/$group.failed" ] && continue
|
||||
todo=$((todo + 1))
|
||||
[ "$complete" = 1 ] || continue
|
||||
[ -e "$OUT/$group.running" ] && continue
|
||||
[ -e "$ROOT/.cache/paused" ] && continue
|
||||
while [ "$(find "$OUT" -name '*.running' | wc -l)" -ge "$PARALLEL" ]; do sleep 30; done
|
||||
touch "$OUT/$group.running"
|
||||
replay "$group" "$from" "$to" &
|
||||
sleep 5
|
||||
done <<< "$(window)"
|
||||
if [ "$todo" = 0 ]; then
|
||||
wait
|
||||
say "every group of the window at priority $PRIORITY is replayed"
|
||||
exit 0
|
||||
fi
|
||||
sleep 300
|
||||
done
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Installed as ~/bin/usenet-rebuild-pause.sh (by hand). Nightly (homelab crontab, 23:45): voyager 2's processing paused for the night; usenet-rebuild-resume.sh (07:45)
|
||||
# starts it again. Postgres, web and node-api stay up. Takes the deploy lock, so it never runs during a CI deploy, and
|
||||
# leaves .cache/paused, so a deploy in the night doesn't start the workers again.
|
||||
# starts it again. Postgres, web, node-api and the prober stay up: since 2026-11-03 (LOG.md (137)) everything the
|
||||
# prober touches is on the NVMe, so it probes through the night without the HDD. Takes the deploy lock, so it never
|
||||
# runs during a CI deploy, and leaves .cache/paused, so a deploy in the night doesn't start the workers again.
|
||||
cd /mnt/bulk/usenet-rebuild || exit 1
|
||||
exec 9>.cache/deploy.lock
|
||||
flock 9
|
||||
touch .cache/paused
|
||||
date "+%F %T pausing"
|
||||
docker compose stop edge
|
||||
docker compose stop coordinator prober merger judge spotter spot-inspector
|
||||
docker compose stop coordinator merger judge spotter spot-inspector
|
||||
docker compose ps --format "{{.Service}} {{.Status}}"
|
||||
@@ -7,5 +7,10 @@ flock 9
|
||||
rm -f .cache/paused
|
||||
date "+%F %T resuming"
|
||||
docker compose up -d --no-deps coordinator prober merger judge spotter spot-inspector
|
||||
docker compose up -d --no-deps edge
|
||||
if [ -e .cache/no-edge ]; then
|
||||
# Voyager 3 without its window worker yet (deploy.sh, LOG.md (116)): the edge stays stopped.
|
||||
echo "no-edge: the edge worker left stopped"
|
||||
else
|
||||
docker compose up -d --no-deps edge
|
||||
fi
|
||||
docker compose ps --format "{{.Service}} {{.Status}}"
|
||||
Generated
+10
@@ -44,6 +44,15 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -145,6 +154,7 @@ name = "edge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bincode",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"libdeflater",
|
||||
|
||||
@@ -14,6 +14,7 @@ path = "src/bin/edge-node.rs"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
bincode = "1.3"
|
||||
chrono = "0.4"
|
||||
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
|
||||
libdeflater = "1.26.1"
|
||||
|
||||
+62
-9
@@ -113,11 +113,30 @@ files, segments, payload size, candidates, fragments and samples. The
|
||||
summary can hide compensating differences, so compare the outputs
|
||||
themselves.
|
||||
|
||||
## Comparing the output
|
||||
## Replaying a group through one window
|
||||
|
||||
A corpus group directory (`<corpus>/<group>/<start>-<end>/`, each range with
|
||||
its `range.json`) is replayed in article order with the window carried from
|
||||
range to range, the end finished after the last range (REBUILD.md, "The
|
||||
replay harness"):
|
||||
|
||||
```sh
|
||||
./target/release/edge-bench --group /corpus/alt.binaries.ath --rules rules.json --batch-chunks 5 --wire - > batches.jsonl
|
||||
# --from <article> skips ranges ending before it, --to <article> ranges starting after it (one window of the
|
||||
# group's history), --limit <n> stops after n ranges, --no-finish leaves the window open
|
||||
```
|
||||
|
||||
`--rules` takes the lease's rules JSON (`src/lease.rs`: the catalog's rule
|
||||
set and group priorities, as `replay:corpus` writes it); without it the
|
||||
defaults with only the group indexed apply. `--wire -` writes each batch as
|
||||
the worker posts it to stdout, one JSON per line, and the report to stderr;
|
||||
`node ace replay:corpus` reads them into the batch store. Each range end
|
||||
prints the frontier, the releases out and the releases and files still open.
|
||||
|
||||
## The dump
|
||||
|
||||
```sh
|
||||
./target/release/edge-bench /path/to/capture --dump /tmp/out.jsonl
|
||||
cd .. && node --import=@poppinss/ts-exec edge/check.ts /path/to/capture /tmp/out.jsonl
|
||||
```
|
||||
|
||||
`--dump` writes every output of every batch as JSON lines:
|
||||
@@ -215,17 +234,45 @@ them:
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M]
|
||||
COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-node [--threads N] [--tail-threads T] [--batch-articles 500000] [--snapshot-minutes 30] [--groups N] [--max-mbit M]
|
||||
```
|
||||
|
||||
- **One range per thread** (default one per core): one thread reads the
|
||||
coordinator's stream and inflates a frame ahead, another posts the
|
||||
batches in order (one may wait), the indexer goes on meanwhile.
|
||||
- **Two lanes** (2026-10-27): `--tail-threads T` (EDGE_TAIL_THREADS; one of
|
||||
two or more threads by default) puts that many threads on the groups'
|
||||
*tail* lane (`?lane=tail` on the group routes): the tail planner's
|
||||
ranges, through a window and snapshot of their own
|
||||
(`<group>-tail.gz`). The other threads work the history lane: the
|
||||
windows and the history the coordinator's bounds hand out. A tail thread
|
||||
never wades through a group's history first, and a held history group's
|
||||
tail isn't stuck behind its days of ranges; the coordinator orders each
|
||||
lane's leases by alternating the group with the most planned articles
|
||||
and the longest unheld one.
|
||||
|
||||
- **One group per thread** (default one per core; REBUILD.md, "Voyager 3:
|
||||
window indexing", 1 and 5): a thread leases a group (`POST
|
||||
groups/lease`), takes its ranges in article order (`POST
|
||||
groups/:id/next`), streams each range's chunks (`GET ranges/:id/stream`:
|
||||
the corpus or the spool, the worker can't tell) through one carried
|
||||
window, posts a batch at least at every range end and finishes the
|
||||
range; one thread reads the stream and inflates a frame ahead, another
|
||||
posts the batches in order, the indexer goes on meanwhile. The window's
|
||||
snapshot (bincode, gzip) goes to the coordinator every
|
||||
`--snapshot-minutes` (EDGE_SNAPSHOT_MINUTES, 30) right after a batch,
|
||||
and when the group is given back: caught up (`next` answers 204: the
|
||||
tail), a stop, a lost lease or a failure; a stop or a failed stream
|
||||
posts the open batch first, so the snapshot stands at its resume point
|
||||
(a batch the coordinator refused can't, and that window is lost); the
|
||||
next holder restores it (`GET groups/:id/snapshot`) and continues at
|
||||
its article. `--groups N`
|
||||
stops after N groups; `--ranges N` is the old per-range lease
|
||||
(`POST lease`), one range per thread without a carried window.
|
||||
- **As `commands/edge_node.ts`:** retries for network trouble and 5xx;
|
||||
409 means the lease is lost (the range stops, isn't given back); a
|
||||
batch once 500k articles or 100k files wait (a picture group's batches
|
||||
went over the server's 256 MB limit); a chunk that doesn't inflate is
|
||||
reported (the coordinator fetches it plain next time); SIGTERM/SIGINT
|
||||
reported (the coordinator fetches it plain next time) after the batch
|
||||
before it went out, and a held group asks for the range again from
|
||||
that chunk, its window kept; SIGTERM/SIGINT
|
||||
give each range back after its current chunk; `--max-mbit` is the
|
||||
node's download cap, shared by its threads. Plus a heartbeat when no
|
||||
batch went out for a minute.
|
||||
@@ -237,5 +284,11 @@ COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-nod
|
||||
same (releases with every column, chunks, candidates, rule counts,
|
||||
fragments with their JSON).
|
||||
- **On homelab:** compose service `edge` (deploy/compose.yaml), the
|
||||
`edge` image target of the Dockerfile, `--threads $EDGE_THREADS` (8);
|
||||
it talks to `node-api`.
|
||||
`edge` image target of the Dockerfile, `--threads $EDGE_THREADS` (8),
|
||||
EDGE_TAIL_THREADS (1); it talks to `node-api`.
|
||||
- **End to end:** `tests/e2e/edge_node.spec.ts` runs this binary
|
||||
(`edge/target/release/edge-node`, or EDGE_NODE_BIN) against the test
|
||||
server, database, spool and corpus: a chunk the corpus fill stores while
|
||||
the stream waits, a failed stream and the restart that restores the
|
||||
snapshot, a restart whose snapshot file is gone, a takeover. `node ace
|
||||
test e2e` after `cargo build --release`; CI builds the binary first.
|
||||
+393
-69
@@ -1,16 +1,24 @@
|
||||
//! The edge worker in Rust (REBUILD.md, part 2; commands/edge_node.ts): leases
|
||||
//! ranges from the coordinator, reads their XOVER chunks from its stream,
|
||||
//! indexes them (the same RangeIndexer as edge-bench, kept identical to
|
||||
//! the TypeScript by check.ts) and posts the batches. One range per thread.
|
||||
//! The window worker in Rust (REBUILD.md, "Voyager 3: window indexing", 1 and 5): each thread leases a *group*
|
||||
//! from the coordinator, takes its ranges in article order, reads each range's XOVER chunks from the coordinator's
|
||||
//! stream (the corpus or the spool, the worker can't tell), indexes them through one carried window and posts the
|
||||
//! batches, a batch at least at every range end; the window's snapshot goes to the coordinator every
|
||||
//! --snapshot-minutes and when the group is given back, and the next holder restores it. A group with no range
|
||||
//! left (caught up: the tail) is given back and leased again later.
|
||||
//!
|
||||
//! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M]
|
||||
//! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--tail-threads T] [--batch-articles 500000] [--snapshot-minutes 30] [--groups N] [--max-mbit M]
|
||||
//!
|
||||
//! SIGTERM/SIGINT: each thread gives its range back after the current chunk
|
||||
//! (the next worker resumes at the last stored batch).
|
||||
//! `--tail-threads T` (EDGE_TAIL_THREADS; 1 of 2 or more threads by default): that many of the threads lease the
|
||||
//! groups' *tail* lane (`?lane=tail`: the tail planner's ranges, with a window and snapshot of their own), the rest
|
||||
//! the history lane (the windows and the history the coordinator's bounds hand out).
|
||||
//!
|
||||
//! SIGTERM/SIGINT: each thread finishes the chunk, posts its batch, snapshots and gives its group back.
|
||||
//! `--ranges`: the old range leases (POST lease), one range per thread without a carried window (kept for a
|
||||
//! coordinator without group leases).
|
||||
|
||||
use edge::indexer::{Batch, RangeIndexer};
|
||||
use edge::names::Rules;
|
||||
use edge::lease::{GroupLeaseJson, LeaseJson, NextRangeJson, RangeSpanJson};
|
||||
use edge::wire;
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
@@ -34,63 +42,6 @@ fn log(level: &str, message: &str) {
|
||||
println!("[ {level} ] {message}");
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LeaseJson {
|
||||
range: RangeJson,
|
||||
rules: RulesJson,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RangeJson {
|
||||
id: i64,
|
||||
group: String,
|
||||
start: i64,
|
||||
end: i64,
|
||||
next: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RulesJson {
|
||||
version: i64,
|
||||
priorities: HashMap<String, u32>,
|
||||
#[serde(default)]
|
||||
dropped_signatures: Vec<String>,
|
||||
#[serde(default)]
|
||||
dropped_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
per_article_max_parts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sample_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
drop_executables: Option<bool>,
|
||||
#[serde(default)]
|
||||
dropped_posters: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl RulesJson {
|
||||
fn rules(self) -> Rules {
|
||||
let mut rules = Rules::defaults(self.priorities.into_iter().collect());
|
||||
rules.version = self.version;
|
||||
rules.dropped_signatures = self.dropped_signatures.into_iter().collect();
|
||||
if let Some(tools) = self.dropped_tools {
|
||||
rules.dropped_tools = tools.into_iter().collect();
|
||||
}
|
||||
if let Some(n) = self.per_article_max_parts {
|
||||
rules.per_article_max_parts = n;
|
||||
}
|
||||
if let Some(p) = self.sample_percent {
|
||||
rules.sample_percent = p;
|
||||
}
|
||||
if let Some(e) = self.drop_executables {
|
||||
rules.drop_executables = e;
|
||||
}
|
||||
if let Some(posters) = self.dropped_posters {
|
||||
rules.dropped_posters = posters;
|
||||
}
|
||||
rules
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StoredJson {
|
||||
@@ -220,6 +171,81 @@ impl Api {
|
||||
}
|
||||
}
|
||||
|
||||
// Group leases (REBUILD.md design 1 and 5), per lane (`history` or `tail`).
|
||||
|
||||
fn lease_group(&self, lane: &str) -> Result<Option<GroupLeaseJson>, ApiError> {
|
||||
let mut response = self.request("POST", &format!("groups/lease?lane={lane}"), None, false, "application/json")?;
|
||||
match response.status().as_u16() {
|
||||
204 => Ok(None),
|
||||
200 => {
|
||||
let text = response.body_mut().read_to_string().map_err(|e| ApiError::Other(format!("groups/lease: {e}")))?;
|
||||
serde_json::from_str(&text).map(Some).map_err(|e| ApiError::Other(format!("groups/lease: {e}")))
|
||||
}
|
||||
status => Err(ApiError::Other(format!("groups/lease: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_range(&self, group: i64, lane: &str) -> Result<Option<RangeSpanJson>, ApiError> {
|
||||
let mut response = self.request("POST", &format!("groups/{group}/next?lane={lane}"), None, false, "application/json")?;
|
||||
match response.status().as_u16() {
|
||||
204 => Ok(None),
|
||||
200 => {
|
||||
let text = response.body_mut().read_to_string().map_err(|e| ApiError::Other(format!("next: {e}")))?;
|
||||
serde_json::from_str::<NextRangeJson>(&text).map(|n| Some(n.range)).map_err(|e| ApiError::Other(format!("next: {e}")))
|
||||
}
|
||||
status => Err(ApiError::Other(format!("next: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_call(&self, group: i64, lane: &str, action: &str) -> Result<(), ApiError> {
|
||||
let response = self.request("POST", &format!("groups/{group}/{action}?lane={lane}"), None, false, "application/json")?;
|
||||
match response.status().as_u16() {
|
||||
200..=299 => Ok(()),
|
||||
status => Err(ApiError::Other(format!("{action}: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's snapshot (bincode), gzipped, standing at `next`.
|
||||
fn put_snapshot(&self, group: i64, lane: &str, next: i64, snapshot: &[u8]) -> Result<usize, ApiError> {
|
||||
let mut gz = GzEncoder::new(Vec::with_capacity(snapshot.len() / 3), flate2::Compression::new(1));
|
||||
gz.write_all(snapshot).unwrap();
|
||||
let body = gz.finish().unwrap();
|
||||
let url = format!("{}/api/v1/node/groups/{group}/snapshot?next={next}&lane={lane}", self.base);
|
||||
let auth = format!("Bearer {}", self.token);
|
||||
for attempt in 1..=RETRIES {
|
||||
let result = self.agent.put(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(&body[..]);
|
||||
match result {
|
||||
Ok(response) => match response.status().as_u16() {
|
||||
200..=299 => return Ok(body.len()),
|
||||
409 => return Err(ApiError::LeaseLost("snapshot: the group is no longer this node's".into())),
|
||||
status if status < 500 => return Err(ApiError::Other(format!("snapshot: HTTP {status}"))),
|
||||
status if attempt == RETRIES => return Err(ApiError::Other(format!("snapshot: HTTP {status}"))),
|
||||
_ => {}
|
||||
},
|
||||
Err(error) if attempt == RETRIES => return Err(ApiError::Other(format!("snapshot: {error}"))),
|
||||
Err(_) => {}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(60_000.min(1000 * 2u64.pow(attempt))));
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// The group's snapshot: its bytes (inflated) and the article it stands at; None without one.
|
||||
fn get_snapshot(&self, group: i64, lane: &str) -> Result<Option<(i64, Vec<u8>)>, ApiError> {
|
||||
let response = self.request("GET", &format!("groups/{group}/snapshot?lane={lane}"), None, false, "application/octet-stream")?;
|
||||
match response.status().as_u16() {
|
||||
404 => Ok(None),
|
||||
200 => {
|
||||
let next: i64 = response.headers().get("x-snapshot-next").and_then(|v| v.to_str().ok()).and_then(|v| v.parse().ok()).ok_or_else(|| ApiError::Other("snapshot: no X-Snapshot-Next".into()))?;
|
||||
let mut gz = GzDecoder::new(response.into_body().into_reader());
|
||||
let mut bytes = Vec::new();
|
||||
gz.read_to_end(&mut bytes).map_err(|e| ApiError::Other(format!("snapshot: {e}")))?;
|
||||
Ok(Some((next, bytes)))
|
||||
}
|
||||
status => Err(ApiError::Other(format!("snapshot: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn corrupt(&self, range: i64, from: i64) -> Result<(), ApiError> {
|
||||
let body = format!("{{\"from\":{from}}}");
|
||||
let response = self.request("POST", &format!("ranges/{range}/corrupt"), Some(body.as_bytes()), false, "application/json")?;
|
||||
@@ -332,6 +358,8 @@ fn inflate(raw: &[u8], d: &mut libdeflater::Decompressor) -> Result<Vec<u8>, Str
|
||||
struct Config {
|
||||
batch_articles: i64,
|
||||
bytes_per_second: Option<f64>,
|
||||
/// Between two snapshots of a held group's window.
|
||||
snapshot_every: Duration,
|
||||
/// Ranges whose batch was too large (413), and how much smaller their batches are now (halved each time).
|
||||
smaller: Mutex<HashMap<i64, i64>>,
|
||||
}
|
||||
@@ -509,6 +537,272 @@ fn run_range(api: &Arc<Api>, lease: LeaseJson, config: &Config, stopping: &Atomi
|
||||
}
|
||||
}
|
||||
|
||||
/// What one range of a held group came to.
|
||||
enum RangeOutcome {
|
||||
Done,
|
||||
/// A chunk didn't inflate: reported (the coordinator fetches it again, plain the second time) after the batch
|
||||
/// before it went out, and the range is asked for again from there; the window stays.
|
||||
Again,
|
||||
/// The group is given back (a stop, a lost lease, a failure): no more ranges.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// One range of a held group through the carried window: the chunks streamed, batches posted at the range's end at
|
||||
/// least, the range finished. The indexer keeps the window for the next range. `group_name` is the label for the
|
||||
/// log (the tail lane's says so).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_group_range(api: &Arc<Api>, group_id: i64, lane: &str, group_name: &str, range: RangeSpanJson, indexer: &mut RangeIndexer, config: &Config, stopping: &AtomicBool, totals: &Arc<Mutex<Totals>>) -> RangeOutcome {
|
||||
let started = Instant::now();
|
||||
indexer.begin_range(range.next, range.end);
|
||||
let post_error: Arc<Mutex<Option<ApiError>>> = Arc::new(Mutex::new(None));
|
||||
let last_contact = Arc::new(AtomicI64::new(0));
|
||||
let since = Instant::now();
|
||||
let (post_tx, post_rx) = sync_channel::<Batch>(1);
|
||||
let poster = {
|
||||
let (api, totals, post_error, last_contact) = (api.clone(), totals.clone(), post_error.clone(), last_contact.clone());
|
||||
std::thread::spawn(move || {
|
||||
for batch in post_rx {
|
||||
if post_error.lock().unwrap().is_some() {
|
||||
continue;
|
||||
}
|
||||
let at = Instant::now();
|
||||
match api.batch(range.id, &batch) {
|
||||
Ok((releases, candidates, bytes)) => {
|
||||
let mut t = totals.lock().unwrap();
|
||||
t.post_ms += at.elapsed().as_millis();
|
||||
t.bytes += bytes;
|
||||
t.headers += batch.headers;
|
||||
t.releases += releases;
|
||||
t.candidates += candidates;
|
||||
last_contact.store(since.elapsed().as_millis() as i64, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => *post_error.lock().unwrap() = Some(e),
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
let frames = match api.stream(range.id) {
|
||||
Ok(stream) => read_frames(stream, config.bytes_per_second),
|
||||
Err(e) => {
|
||||
log("error", &format!("{group_name} {}–{}: stream failed: {e}; the group goes back.", range.start, range.end));
|
||||
drop(post_tx);
|
||||
let _ = poster.join();
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
};
|
||||
let (batch_articles, max_pending) = config.limits(range.id);
|
||||
let mut batch_from = range.next;
|
||||
let mut reached_end = false;
|
||||
let mut corrupt: Option<(i64, i64, String)> = None;
|
||||
let mut failure: Option<String> = None;
|
||||
let headers_before = totals.lock().unwrap().headers;
|
||||
for frame in frames.iter() {
|
||||
if stopping.load(Ordering::Relaxed) || post_error.lock().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
if since.elapsed().as_millis() as i64 - last_contact.load(Ordering::Relaxed) > HEARTBEAT.as_millis() as i64 {
|
||||
last_contact.store(since.elapsed().as_millis() as i64, Ordering::Relaxed);
|
||||
let _ = api.group_call(group_id, lane, "heartbeat");
|
||||
}
|
||||
match frame {
|
||||
Frame::KeepAlive => continue,
|
||||
Frame::End => break,
|
||||
Frame::Failed(e) => {
|
||||
failure = Some(e);
|
||||
break;
|
||||
}
|
||||
Frame::Chunk { from, to, text } => {
|
||||
let data = match text {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
corrupt = Some((from, to, e));
|
||||
break;
|
||||
}
|
||||
};
|
||||
indexer.add_text(&String::from_utf8_lossy(&data), to + 1);
|
||||
reached_end = to >= range.end;
|
||||
// The window carries on past the range's end: no finish here; a batch at the end at least.
|
||||
if reached_end || to + 1 - batch_from >= batch_articles || indexer.pending() >= max_pending {
|
||||
if post_tx.send(indexer.take()).is_err() {
|
||||
break;
|
||||
}
|
||||
batch_from = to + 1;
|
||||
}
|
||||
if reached_end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stopped mid-range (a stop, a stream that failed, a chunk that doesn't inflate): the batch so far goes out,
|
||||
// so the snapshot stands at a stored resume point and the window survives the group going back; a chunk
|
||||
// reported corrupt is read again from its own start. Only a batch the coordinator refused stays open.
|
||||
if !reached_end && post_error.lock().unwrap().is_none() && indexer.next() > batch_from {
|
||||
let _ = post_tx.send(indexer.take());
|
||||
}
|
||||
drop(post_tx);
|
||||
let _ = poster.join();
|
||||
let post_error = post_error.lock().unwrap().take();
|
||||
if let Some((from, to, e)) = corrupt {
|
||||
// The server's compressed reply cut short (LOG.md 2026-10-11): the coordinator fetches the chunk again,
|
||||
// plain the second time, and sets the range aside after that; the window carries on either way.
|
||||
log("warn", &format!("{group_name} {}–{}: chunk {from}–{to} doesn't inflate ({e}); reported, the range is asked for again", range.start, range.end));
|
||||
if let Err(e) = api.corrupt(range.id, from) {
|
||||
log("warn", &format!("{group_name} {}–{}: reporting the chunk: {e}; the group goes back", range.start, range.end));
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
if post_error.is_none() {
|
||||
return RangeOutcome::Again;
|
||||
}
|
||||
}
|
||||
match post_error {
|
||||
Some(ApiError::LeaseLost(m)) => {
|
||||
log("warn", &format!("{group_name} {}–{}: {m}", range.start, range.end));
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
Some(ApiError::TooLarge(m)) => {
|
||||
let divisor = {
|
||||
let mut smaller = config.smaller.lock().unwrap();
|
||||
let d = smaller.entry(range.id).or_insert(1);
|
||||
*d = (*d * 2).min(64);
|
||||
*d
|
||||
};
|
||||
log("warn", &format!("{group_name} {}–{}: {m}; the group goes back, its batches 1/{divisor} the size from now on.", range.start, range.end));
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
Some(ApiError::Other(m)) => {
|
||||
log("error", &format!("{group_name} {}–{}: {m}; the group goes back.", range.start, range.end));
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(e) = failure {
|
||||
log("error", &format!("{group_name} {}–{}: {e}; the group goes back.", range.start, range.end));
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
if !reached_end {
|
||||
return RangeOutcome::Stop;
|
||||
}
|
||||
match api.call(range.id, "finish") {
|
||||
Ok(()) => {
|
||||
let t = totals.lock().unwrap();
|
||||
let seconds = started.elapsed().as_secs_f64();
|
||||
let headers = t.headers - headers_before;
|
||||
log(
|
||||
"info",
|
||||
&format!(
|
||||
"{group_name} {}–{}: {} headers in {:.0} s ({:.0}/s); {} releases and {} files open in the window.",
|
||||
range.start,
|
||||
range.end,
|
||||
headers,
|
||||
seconds,
|
||||
headers as f64 / seconds.max(0.001),
|
||||
indexer.open_releases(),
|
||||
indexer.open_files()
|
||||
),
|
||||
);
|
||||
RangeOutcome::Done
|
||||
}
|
||||
Err(e) => {
|
||||
log("warn", &format!("{group_name} {}–{}: finish: {e}", range.start, range.end));
|
||||
RangeOutcome::Stop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's snapshot to the coordinator, standing at the indexer's next article.
|
||||
fn snapshot(api: &Api, group_id: i64, lane: &str, group_name: &str, indexer: &RangeIndexer) {
|
||||
match indexer.snapshot() {
|
||||
Ok(bytes) => match api.put_snapshot(group_id, lane, indexer.next(), &bytes) {
|
||||
Ok(gz) => log("info", &format!("{group_name}: snapshot at {} ({:.1} MB, {:.1} MB gzipped).", indexer.next(), bytes.len() as f64 / 1e6, gz as f64 / 1e6)),
|
||||
Err(e) => log("warn", &format!("{group_name}: snapshot failed: {e}")),
|
||||
},
|
||||
Err(e) => log("warn", &format!("{group_name}: no snapshot: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// One held group (one lane of it): its window restored from the snapshot (or new), its ranges one after another
|
||||
/// until it is caught up or the worker stops; then a snapshot, and the group given back.
|
||||
fn run_group(api: &Arc<Api>, lease: GroupLeaseJson, lane: &str, config: &Config, stopping: &AtomicBool) {
|
||||
let group_id = lease.group.id;
|
||||
let name = lease.group.name.clone();
|
||||
// The log's label; the indexer gets the group's name itself (keys, xrefs).
|
||||
let group_name = if lane == "tail" { format!("{name} (tail)") } else { name.clone() };
|
||||
let rules = lease.rules.rules();
|
||||
let mut indexer = match lease.snapshot {
|
||||
Some(s) => match api.get_snapshot(group_id, lane) {
|
||||
Ok(Some((next, bytes))) => match RangeIndexer::restore(&bytes, rules.clone(), 5) {
|
||||
Ok(ix) => {
|
||||
log("info", &format!("{group_name}: window restored from the snapshot at {next} ({:.1} MB).", bytes.len() as f64 / 1e6));
|
||||
ix
|
||||
}
|
||||
Err(e) => {
|
||||
log("warn", &format!("{group_name}: the snapshot at {} can't be restored ({e}); the window starts empty.", s.next));
|
||||
RangeIndexer::for_group(&name, rules.clone(), 5)
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
log("warn", &format!("{group_name}: the lease names a snapshot at {} but there is none; the window starts empty.", s.next));
|
||||
RangeIndexer::for_group(&name, rules.clone(), 5)
|
||||
}
|
||||
Err(e) => {
|
||||
log("warn", &format!("{group_name}: snapshot: {e}; the group goes back."));
|
||||
let _ = api.group_call(group_id, lane, "release");
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => RangeIndexer::for_group(&name, rules.clone(), 5),
|
||||
};
|
||||
let totals = Arc::new(Mutex::new(Totals::default()));
|
||||
let started = Instant::now();
|
||||
let mut snapshotted = Instant::now();
|
||||
let mut ranges = 0;
|
||||
loop {
|
||||
if stopping.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let range = match api.next_range(group_id, lane) {
|
||||
Ok(Some(range)) => range,
|
||||
Ok(None) => {
|
||||
log("info", &format!("{group_name}: caught up after {ranges} ranges; the group goes back."));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log("warn", &format!("{group_name}: next range: {e}; the group goes back."));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if range.next != indexer.next() && ranges > 0 {
|
||||
log("warn", &format!("{group_name}: the window stands at {} but the next range resumes at {}: the window carries on from there.", indexer.next(), range.next));
|
||||
}
|
||||
match run_group_range(api, group_id, lane, &group_name, range, &mut indexer, config, stopping, &totals) {
|
||||
RangeOutcome::Done => ranges += 1,
|
||||
// The same range again from the reported chunk: the stream waits for its new copy.
|
||||
RangeOutcome::Again => {}
|
||||
RangeOutcome::Stop => break,
|
||||
}
|
||||
if snapshotted.elapsed() >= config.snapshot_every {
|
||||
snapshot(api, group_id, lane, &group_name, &indexer);
|
||||
snapshotted = Instant::now();
|
||||
}
|
||||
}
|
||||
snapshot(api, group_id, lane, &group_name, &indexer);
|
||||
let _ = api.group_call(group_id, lane, "release");
|
||||
let t = totals.lock().unwrap();
|
||||
log(
|
||||
"info",
|
||||
&format!(
|
||||
"{group_name}: {ranges} ranges, {} headers in {:.0} s, {} release chunks, {} probe candidates; posted {:.1} MB in {:.1} s.",
|
||||
t.headers,
|
||||
started.elapsed().as_secs_f64(),
|
||||
t.releases,
|
||||
t.candidates,
|
||||
t.bytes as f64 / 1e6,
|
||||
t.post_ms as f64 / 1000.0
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
|
||||
@@ -520,9 +814,16 @@ fn main() {
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1));
|
||||
let ranges: Option<usize> = flag("--ranges").map(|v| v.parse().unwrap());
|
||||
// Of the threads, this many on the groups' tail lane: one by default when there are two or more.
|
||||
let tail_threads: usize = flag("--tail-threads")
|
||||
.or_else(|| std::env::var("EDGE_TAIL_THREADS").ok())
|
||||
.map(|v| v.parse().unwrap())
|
||||
.unwrap_or(if threads >= 2 { 1 } else { 0 });
|
||||
assert!(tail_threads <= threads, "--tail-threads {tail_threads} is more than the {threads} threads");
|
||||
let max_mbit: Option<f64> = flag("--max-mbit").or_else(|| std::env::var("EDGE_MAX_MBIT").ok()).map(|v| v.parse().unwrap()).filter(|&m: &f64| m > 0.0);
|
||||
let config = Arc::new(Config {
|
||||
batch_articles: flag("--batch-articles").map(|v| v.parse().unwrap()).unwrap_or(500_000),
|
||||
snapshot_every: Duration::from_secs(60 * flag("--snapshot-minutes").or_else(|| std::env::var("EDGE_SNAPSHOT_MINUTES").ok()).map(|v| v.parse::<u64>().unwrap()).unwrap_or(30)),
|
||||
// The node's cap, shared by its threads.
|
||||
bytes_per_second: max_mbit.map(|m| m * 1e6 / 8.0 / threads as f64),
|
||||
smaller: Mutex::new(HashMap::new()),
|
||||
@@ -531,26 +832,45 @@ fn main() {
|
||||
signal_hook::flag::register(signal_hook::consts::SIGTERM, stopping.clone()).unwrap();
|
||||
signal_hook::flag::register(signal_hook::consts::SIGINT, stopping.clone()).unwrap();
|
||||
let api = Arc::new(Api::new(&url, &token));
|
||||
log("info", &format!("Starting {threads} worker threads{}.", max_mbit.map(|m| format!(" (download cap {m} Mbit/s)")).unwrap_or_default()));
|
||||
log("info", &format!("Starting {threads} worker threads{}{}.", if range_leases_wanted(&ranges) { String::new() } else { format!(", {tail_threads} on the tail") }, max_mbit.map(|m| format!(" (download cap {m} Mbit/s)")).unwrap_or_default()));
|
||||
let groups: Option<usize> = flag("--groups").map(|v| v.parse().unwrap());
|
||||
let range_leases = ranges.is_some();
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
.map(|i| {
|
||||
let (api, config, stopping) = (api.clone(), config.clone(), stopping.clone());
|
||||
let lane = if i < tail_threads { "tail" } else { "history" };
|
||||
std::thread::spawn(move || {
|
||||
let mut done = 0;
|
||||
if range_leases {
|
||||
while !stopping.load(Ordering::Relaxed) && ranges.is_none_or(|n| done < n) {
|
||||
match api.lease() {
|
||||
Ok(Some(lease)) => {
|
||||
run_range(&api, lease, &config, &stopping);
|
||||
done += 1;
|
||||
}
|
||||
Ok(None) if ranges.is_some() => break,
|
||||
Ok(None) => idle(&stopping),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
log("warn", &format!("Lease failed: {e}"));
|
||||
idle(&stopping);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
while !stopping.load(Ordering::Relaxed) && groups.is_none_or(|n| done < n) {
|
||||
match api.lease_group(lane) {
|
||||
Ok(Some(lease)) => {
|
||||
run_group(&api, lease, lane, &config, &stopping);
|
||||
done += 1;
|
||||
}
|
||||
Ok(None) if groups.is_some() => break,
|
||||
Ok(None) => idle(&stopping),
|
||||
Err(e) => {
|
||||
log("warn", &format!("Group lease failed: {e}"));
|
||||
idle(&stopping);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -559,6 +879,10 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
fn range_leases_wanted(ranges: &Option<usize>) -> bool {
|
||||
ranges.is_some()
|
||||
}
|
||||
|
||||
fn idle(stopping: &AtomicBool) {
|
||||
let until = Instant::now() + IDLE;
|
||||
while Instant::now() < until && !stopping.load(Ordering::Relaxed) {
|
||||
|
||||
+758
-25
@@ -5,8 +5,9 @@
|
||||
|
||||
use crate::codec::{encode_chunk, ChunkFile};
|
||||
use crate::js::{self, sha1_hex};
|
||||
use crate::names::{self, is_random_file, JudgedFile, Release, Rules, Verdict};
|
||||
use crate::names::{self, is_random_file, is_unreadable_name, looks_scrambled, JudgedFile, Release, Rules, Verdict};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::rc::Rc;
|
||||
|
||||
pub static mut PROF: [u64; 8] = [0; 8];
|
||||
@@ -53,6 +54,11 @@ pub struct ReleaseChunk {
|
||||
pub edge: bool,
|
||||
/// A file misses parts.
|
||||
pub partial: bool,
|
||||
/// The upload tool from the first article's message-id (names::upload_tool), for an unnamed release: with the
|
||||
/// group its signature, what the pending ladder learns verdicts by. None for a named one.
|
||||
pub tool: Option<&'static str>,
|
||||
/// The files' names and segments (codec); empty for an unnamed release (decision f: nothing in the catalog
|
||||
/// without a usable name; the pending store keeps its keys and probe targets until a probe names it).
|
||||
pub payload: Vec<u8>,
|
||||
/// The first files' subjects (at most SUBJECTS): edge-bench's --releases dump for the grouping sweeps.
|
||||
pub subjects: Vec<String>,
|
||||
@@ -66,6 +72,8 @@ pub struct Candidate {
|
||||
pub kind: &'static str,
|
||||
pub message_id: String,
|
||||
pub file: String,
|
||||
/// The target article's bytes: the cheapest probe first within a class (LOG.md (130)).
|
||||
pub bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -479,6 +487,16 @@ pub const FRONTIER_ARTICLES: usize = 1024;
|
||||
pub const PLAUSIBLE_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||
/// The frontier is established once this many dated articles were seen (a lone bogus date doesn't set it).
|
||||
const FRONTIER_MIN: usize = 64;
|
||||
/// Albums (REBUILD.md, "Voyager 3: window indexing", 3): a poster's single-file releases whose file names differ only
|
||||
/// by number sequences, posted within WINDOW_MS of the album's first, written as one release (`source: "album"`); an
|
||||
/// album holds at most this many files (a dump of 38k pictures a day is a few albums, not one).
|
||||
pub const ALBUM_MAX_FILES: usize = 1000;
|
||||
/// A counter total this large, shared by this many releases of one poster, is a batch counter (Yenc-PP-A&A's
|
||||
/// [n/68067] over thousands of different programs), not a file count: those releases get no file total.
|
||||
pub const BATCH_COUNTER_MIN_TOTAL: i64 = 100;
|
||||
pub const BATCH_COUNTER_RELEASES: u32 = 20;
|
||||
/// Batches after which a poster's counter total is forgotten (BATCH_COUNTER_RELEASES must come within them).
|
||||
const BATCH_COUNTER_BATCHES: u64 = 100;
|
||||
|
||||
fn median(dates: &mut [i64]) -> i64 {
|
||||
let mid = dates.len() / 2;
|
||||
@@ -488,7 +506,7 @@ fn median(dates: &mut [i64]) -> i64 {
|
||||
/// Where the block is in posting time: the median Date of the last FRONTIER_ARTICLES plausible ones. Date headers
|
||||
/// alone aren't trusted: a date more than PLAUSIBLE_MS from the frontier is set aside, and only FRONTIER_ARTICLES
|
||||
/// such dates in a row, agreeing among themselves, move the frontier there.
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct Frontier {
|
||||
ring: Vec<i64>,
|
||||
at: usize,
|
||||
@@ -577,6 +595,163 @@ struct OpenFile {
|
||||
latest: Option<i64>,
|
||||
}
|
||||
|
||||
// The snapshot (REBUILD.md, "Voyager 3: window indexing", 5): the window's state between two batches, serialized
|
||||
// (bincode, gzip by the worker) and kept by the coordinator, restored by the group's next holder. Segments share
|
||||
// their file's FileMeta through an Rc; in the snapshot the files are a table and a segment points at its index.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SegSnap {
|
||||
file: u32,
|
||||
number: u32,
|
||||
message_id: String,
|
||||
bytes: i64,
|
||||
article: i64,
|
||||
posted_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct OpenFileSnap {
|
||||
parts: Vec<SegSnap>,
|
||||
total: u32,
|
||||
touched: u64,
|
||||
earliest: Option<i64>,
|
||||
latest: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct OpenReleaseSnap {
|
||||
upload: usize,
|
||||
files: Vec<Vec<SegSnap>>,
|
||||
first: Option<i64>,
|
||||
last: Option<i64>,
|
||||
touched: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct OpenAlbumSnap {
|
||||
upload: usize,
|
||||
members: Vec<(String, OpenReleaseSnap)>,
|
||||
first: Option<i64>,
|
||||
last: Option<i64>,
|
||||
touched: u64,
|
||||
}
|
||||
|
||||
/// A file's details in the snapshot (FileMeta, its release identity's source as text: a &'static str in memory).
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct FileMetaSnap {
|
||||
file_key: String,
|
||||
name: String,
|
||||
poster: String,
|
||||
subject: String,
|
||||
total_parts: u32,
|
||||
groups: Vec<String>,
|
||||
release_key: String,
|
||||
release_name: String,
|
||||
release_source: String,
|
||||
release_file_total: Option<i64>,
|
||||
}
|
||||
|
||||
/// The sources release_identity gives (lib/release/source.ts), as the static strings the code compares.
|
||||
fn source_static(source: &str) -> &'static str {
|
||||
match source {
|
||||
"subject" => "subject",
|
||||
"post" => "post",
|
||||
"stem" => "stem",
|
||||
"album" => "album",
|
||||
"weak-stem" => "weak-stem",
|
||||
_ => "stem",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub version: u32,
|
||||
pub group: String,
|
||||
/// The article the window stands at: the next one to index (a batch boundary).
|
||||
pub next: i64,
|
||||
frontier: Frontier,
|
||||
files: Vec<FileMetaSnap>,
|
||||
aggregator: Vec<(String, Vec<OpenFileSnap>)>,
|
||||
aggregator_batch: u64,
|
||||
open: Vec<(String, Vec<OpenReleaseSnap>)>,
|
||||
albums: Vec<(String, Vec<OpenAlbumSnap>)>,
|
||||
uploads: Vec<(String, usize)>,
|
||||
batch_totals: Vec<(String, i64, u32, u64)>,
|
||||
}
|
||||
|
||||
/// The snapshot's format version: raised when the state's shape changes (an older snapshot is refused: the group
|
||||
/// continues at its resume point, one seam).
|
||||
pub const SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
/// The files of a snapshot as they are taken: each FileMeta once, by its Rc.
|
||||
#[derive(Default)]
|
||||
struct FileTable {
|
||||
index: FxHashMap<*const FileMeta, u32>,
|
||||
files: Vec<Rc<FileMeta>>,
|
||||
}
|
||||
|
||||
impl FileTable {
|
||||
fn seg(&mut self, s: &Seg) -> SegSnap {
|
||||
let ptr = Rc::as_ptr(&s.file);
|
||||
let file = match self.index.get(&ptr) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = self.files.len() as u32;
|
||||
self.index.insert(ptr, i);
|
||||
self.files.push(s.file.clone());
|
||||
i
|
||||
}
|
||||
};
|
||||
SegSnap { file, number: s.number, message_id: s.message_id.clone(), bytes: s.bytes, article: s.article, posted_at: s.posted_at }
|
||||
}
|
||||
|
||||
fn segs(&mut self, segs: &[Seg]) -> Vec<SegSnap> {
|
||||
segs.iter().map(|s| self.seg(s)).collect()
|
||||
}
|
||||
|
||||
fn release(&mut self, u: &OpenRelease) -> OpenReleaseSnap {
|
||||
OpenReleaseSnap { upload: u.upload, files: u.files.iter().map(|f| self.segs(f)).collect(), first: u.first, last: u.last, touched: u.touched }
|
||||
}
|
||||
|
||||
/// The table's files as the snapshot stores them.
|
||||
fn into_files(self) -> Vec<FileMetaSnap> {
|
||||
self.files
|
||||
.iter()
|
||||
.map(|f| FileMetaSnap {
|
||||
file_key: f.file_key.clone(),
|
||||
name: f.name.clone(),
|
||||
poster: f.poster.clone(),
|
||||
subject: f.subject.clone(),
|
||||
total_parts: f.total_parts,
|
||||
groups: f.groups.clone(),
|
||||
release_key: f.release.key.clone(),
|
||||
release_name: f.release.name.clone(),
|
||||
release_source: f.release.source.to_string(),
|
||||
release_file_total: f.release.file_total,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn file_of(f: FileMetaSnap) -> FileMeta {
|
||||
FileMeta {
|
||||
file_key: f.file_key,
|
||||
name: f.name,
|
||||
poster: f.poster,
|
||||
subject: f.subject,
|
||||
total_parts: f.total_parts,
|
||||
groups: f.groups,
|
||||
release: Release { key: f.release_key, name: f.release_name, source: source_static(&f.release_source), file_total: f.release_file_total },
|
||||
}
|
||||
}
|
||||
|
||||
fn seg_of(s: SegSnap, files: &[Rc<FileMeta>]) -> Seg {
|
||||
Seg { file: files[s.file as usize].clone(), number: s.number, message_id: s.message_id, bytes: s.bytes, article: s.article, posted_at: s.posted_at }
|
||||
}
|
||||
|
||||
fn release_of(u: OpenReleaseSnap, files: &[Rc<FileMeta>]) -> OpenRelease {
|
||||
OpenRelease { upload: u.upload, files: u.files.into_iter().map(|f| f.into_iter().map(|s| seg_of(s, files)).collect()).collect(), first: u.first, last: u.last, touched: u.touched }
|
||||
}
|
||||
|
||||
impl OpenFile {
|
||||
fn distance(&self, t: Option<i64>) -> i64 {
|
||||
match (t, self.earliest, self.latest) {
|
||||
@@ -714,6 +889,100 @@ impl OpenRelease {
|
||||
}
|
||||
}
|
||||
|
||||
/// An album still open: the closed singles of one poster and name template (ALBUM_MAX_FILES at most), with their
|
||||
/// release keys, so a lone member is written as the release it was.
|
||||
struct OpenAlbum {
|
||||
upload: usize,
|
||||
members: Vec<(String, OpenRelease)>,
|
||||
first: Option<i64>,
|
||||
last: Option<i64>,
|
||||
touched: u64,
|
||||
}
|
||||
|
||||
/// The template a file name joins an album by: every digit run as "#" ("1481439079025_014.jpg.data" →
|
||||
/// "#_#.jpg.data"); none for a name without a number, a random one, an archive, a volume or a sidecar.
|
||||
pub fn album_template(name: &str) -> Option<String> {
|
||||
let (stem, ext) = split_extension(name);
|
||||
// A numbered extension is a volume ("acs5152s1.11"), and a stray suffix hides none of the above ("x.par2.temp").
|
||||
let volume = |n: &str| names::is_par2(n) || names::is_archive_start(n) || names::is_later_volume(n);
|
||||
if is_random_file(name) || names::is_sidecar(name) || volume(name) || volume(stem) || (ext.len() > 1 && ext.bytes().skip(1).all(|c| c.is_ascii_digit())) {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut runs = 0;
|
||||
let mut in_digits = false;
|
||||
for c in stem.chars() {
|
||||
if c.is_ascii_digit() {
|
||||
if !in_digits {
|
||||
out.push('#');
|
||||
runs += 1;
|
||||
in_digits = true;
|
||||
}
|
||||
} else {
|
||||
in_digits = false;
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.push_str(ext);
|
||||
(runs > 0).then_some(out)
|
||||
}
|
||||
|
||||
/// A name's stem and its extension (a last ".xxx" of 1–5 letters and digits, "mp4"'s 4 no number sequence).
|
||||
fn split_extension(name: &str) -> (&str, &str) {
|
||||
match name.rfind('.') {
|
||||
Some(dot) if dot > 0 && (1..=5).contains(&(name.len() - dot - 1)) && name[dot + 1..].bytes().all(|c| c.is_ascii_alphanumeric()) => (&name[..dot], &name[dot..]),
|
||||
_ => (name, ""),
|
||||
}
|
||||
}
|
||||
|
||||
/// A name's runs of digits and of other characters, in order.
|
||||
fn digit_runs(name: &str) -> Vec<(bool, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let b = name.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let digits = b[i].is_ascii_digit();
|
||||
let mut j = i + 1;
|
||||
while j < b.len() && b[j].is_ascii_digit() == digits {
|
||||
j += 1;
|
||||
}
|
||||
out.push((digits, &name[start..j]));
|
||||
start = j;
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An album's name: its members' shared name, with the numbers that vary among them as "#" and the ones they share
|
||||
/// kept ("1481439079025_#.jpg.data" for one timestamp's pictures).
|
||||
pub fn album_name(names: &[&str]) -> String {
|
||||
let (stem, ext) = split_extension(names[0]);
|
||||
let first = digit_runs(stem);
|
||||
let mut varies = vec![false; first.len()];
|
||||
for name in &names[1..] {
|
||||
let runs = digit_runs(split_extension(name).0);
|
||||
if runs.len() != first.len() {
|
||||
return album_template(names[0]).unwrap_or_else(|| names[0].to_string());
|
||||
}
|
||||
for (i, (digits, text)) in runs.iter().enumerate() {
|
||||
if *digits && first[i].1 != *text {
|
||||
varies[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::with_capacity(names[0].len());
|
||||
for (i, (digits, text)) in first.iter().enumerate() {
|
||||
if *digits && varies[i] {
|
||||
out.push('#');
|
||||
} else {
|
||||
out.push_str(text);
|
||||
}
|
||||
}
|
||||
out.push_str(ext);
|
||||
out
|
||||
}
|
||||
|
||||
pub struct RangeIndexer {
|
||||
group: String,
|
||||
rules: Rules,
|
||||
@@ -725,8 +994,12 @@ pub struct RangeIndexer {
|
||||
frontier: Frontier,
|
||||
/// Per release key its open uploads, oldest first.
|
||||
open: FxHashMap<String, Vec<OpenRelease>>,
|
||||
/// Per album key its open albums, oldest first.
|
||||
albums: FxHashMap<String, Vec<OpenAlbum>>,
|
||||
/// The next upload number per key (distinct within a batch: the store's slot is the key and the upload).
|
||||
uploads: FxHashMap<String, usize>,
|
||||
/// Per (poster, counter total ≥ BATCH_COUNTER_MIN_TOTAL) the releases started under it and the batch of the last.
|
||||
batch_totals: FxHashMap<(String, i64), (u32, u64)>,
|
||||
batch: Batch,
|
||||
counts: FxHashMap<(&'static str, String), (u64, u64)>,
|
||||
count_order: Vec<(&'static str, String)>,
|
||||
@@ -749,7 +1022,9 @@ impl RangeIndexer {
|
||||
aggregator: Aggregator::default(),
|
||||
frontier: Frontier::default(),
|
||||
open: FxHashMap::default(),
|
||||
albums: FxHashMap::default(),
|
||||
uploads: FxHashMap::default(),
|
||||
batch_totals: FxHashMap::default(),
|
||||
batch: Batch { from: resume_at, next: resume_at, ..Default::default() },
|
||||
counts: FxHashMap::default(),
|
||||
count_order: Vec::new(),
|
||||
@@ -758,6 +1033,107 @@ impl RangeIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker's indexer for a group it holds (REBUILD.md design 1): ranges come one after another (begin_range),
|
||||
/// the window carried between them; the batch starts at the first range's resume point.
|
||||
pub fn for_group(group: &str, rules: Rules, idle: u64) -> Self {
|
||||
Self::new(group, 0, 0, 0, rules, idle)
|
||||
}
|
||||
|
||||
/// The next range of the group: the batch starts at its resume point (after a gap too), the range's end is known.
|
||||
pub fn begin_range(&mut self, next: i64, end: i64) {
|
||||
self.end = end;
|
||||
if self.batch.headers == 0 && self.batch.releases.is_empty() && self.batch.candidates.is_empty() {
|
||||
self.batch.from = next;
|
||||
self.batch.next = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// The article the window stands at: the next one to index (right after a take, a batch boundary).
|
||||
pub fn next(&self) -> i64 {
|
||||
self.batch.next
|
||||
}
|
||||
|
||||
/// The window's state between two batches (right after a take; the batch must be empty), serialized (bincode).
|
||||
pub fn snapshot(&self) -> Result<Vec<u8>, String> {
|
||||
if self.batch.headers != 0 || !self.batch.releases.is_empty() || !self.batch.candidates.is_empty() {
|
||||
return Err("a snapshot is taken right after a batch, not with one open".to_string());
|
||||
}
|
||||
let mut table = FileTable::default();
|
||||
let aggregator: Vec<(String, Vec<OpenFileSnap>)> = self
|
||||
.aggregator
|
||||
.open
|
||||
.iter()
|
||||
.map(|(key, copies)| {
|
||||
(key.clone(), copies.iter().map(|c| OpenFileSnap { parts: table.segs(&c.parts), total: c.total, touched: c.touched, earliest: c.earliest, latest: c.latest }).collect())
|
||||
})
|
||||
.collect();
|
||||
let open: Vec<(String, Vec<OpenReleaseSnap>)> = self.open.iter().map(|(key, uploads)| (key.clone(), uploads.iter().map(|u| table.release(u)).collect())).collect();
|
||||
let albums: Vec<(String, Vec<OpenAlbumSnap>)> = self
|
||||
.albums
|
||||
.iter()
|
||||
.map(|(key, list)| {
|
||||
(
|
||||
key.clone(),
|
||||
list.iter()
|
||||
.map(|a| OpenAlbumSnap { upload: a.upload, members: a.members.iter().map(|(k, u)| (k.clone(), table.release(u))).collect(), first: a.first, last: a.last, touched: a.touched })
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let snapshot = Snapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
group: self.group.clone(),
|
||||
next: self.batch.next,
|
||||
frontier: Frontier { ring: self.frontier.ring.clone(), at: self.frontier.at, value: self.frontier.value, start: self.frontier.start, aside: self.frontier.aside.clone(), pending: self.frontier.pending },
|
||||
files: table.into_files(),
|
||||
aggregator,
|
||||
aggregator_batch: self.aggregator.batch,
|
||||
open,
|
||||
albums,
|
||||
uploads: self.uploads.iter().map(|(k, &n)| (k.clone(), n)).collect(),
|
||||
batch_totals: self.batch_totals.iter().map(|((poster, total), (n, touched))| (poster.clone(), *total, *n, *touched)).collect(),
|
||||
};
|
||||
bincode::serialize(&snapshot).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A worker's indexer for a group from its snapshot (the rules fresh from the lease).
|
||||
pub fn restore(bytes: &[u8], rules: Rules, idle: u64) -> Result<Self, String> {
|
||||
let snapshot: Snapshot = bincode::deserialize(bytes).map_err(|e| format!("snapshot: {e}"))?;
|
||||
if snapshot.version != SNAPSHOT_VERSION {
|
||||
return Err(format!("snapshot version {} (this build writes {SNAPSHOT_VERSION})", snapshot.version));
|
||||
}
|
||||
let files: Vec<Rc<FileMeta>> = snapshot.files.into_iter().map(|f| Rc::new(file_of(f))).collect();
|
||||
let mut ix = Self::for_group(&snapshot.group, rules, idle);
|
||||
ix.frontier = snapshot.frontier;
|
||||
ix.aggregator.batch = snapshot.aggregator_batch;
|
||||
for (key, copies) in snapshot.aggregator {
|
||||
ix.aggregator.open.insert(
|
||||
key,
|
||||
copies
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let parts: Vec<Seg> = c.parts.into_iter().map(|s| seg_of(s, &files)).collect();
|
||||
let seen = parts.iter().map(|s| s.number).collect();
|
||||
OpenFile { parts, seen, total: c.total, touched: c.touched, earliest: c.earliest, latest: c.latest }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
for (key, uploads) in snapshot.open {
|
||||
ix.open.insert(key, uploads.into_iter().map(|u| release_of(u, &files)).collect());
|
||||
}
|
||||
for (key, list) in snapshot.albums {
|
||||
ix.albums.insert(
|
||||
key,
|
||||
list.into_iter().map(|a| OpenAlbum { upload: a.upload, members: a.members.into_iter().map(|(k, u)| (k, release_of(u, &files))).collect(), first: a.first, last: a.last, touched: a.touched }).collect(),
|
||||
);
|
||||
}
|
||||
ix.uploads = snapshot.uploads.into_iter().collect();
|
||||
ix.batch_totals = snapshot.batch_totals.into_iter().map(|(poster, total, n, touched)| ((poster, total), (n, touched))).collect();
|
||||
ix.batch = Batch { from: snapshot.next, next: snapshot.next, ..Default::default() };
|
||||
Ok(ix)
|
||||
}
|
||||
|
||||
pub fn add_text(&mut self, text: &str, next: i64) {
|
||||
let mut segments: Vec<Seg> = Vec::new();
|
||||
let mut crossposts: Vec<String> = Vec::new();
|
||||
@@ -802,11 +1178,11 @@ impl RangeIndexer {
|
||||
let t = std::time::Instant::now();
|
||||
let done = self.aggregator.add(segments);
|
||||
unsafe { PROF[3] += t.elapsed().as_nanos() as u64; }
|
||||
self.files(done);
|
||||
self.files(done, false);
|
||||
self.frontier.update();
|
||||
let t = std::time::Instant::now();
|
||||
let gone = self.aggregator.evict(self.frontier.closed_before(), self.idle, false);
|
||||
self.files(gone);
|
||||
self.files(gone, false);
|
||||
self.close(false);
|
||||
unsafe { PROF[4] += t.elapsed().as_nanos() as u64; }
|
||||
self.batch.next = next;
|
||||
@@ -817,11 +1193,20 @@ impl RangeIndexer {
|
||||
pub fn finish(&mut self) {
|
||||
self.frontier.settle();
|
||||
let gone = self.aggregator.evict(None, 0, true);
|
||||
self.files(gone);
|
||||
self.files(gone, true);
|
||||
self.close(true);
|
||||
self.batch.next = self.end + 1;
|
||||
}
|
||||
|
||||
/// The next range of a group starts after a gap (edge-bench --group, the corpus holding some ranges only): a fresh
|
||||
/// batch starts there, not at the previous range's end; the window carries on. Nothing when the batch has anything.
|
||||
pub fn skip_to(&mut self, article: i64) {
|
||||
if self.batch.headers == 0 && self.batch.releases.is_empty() && self.batch.candidates.is_empty() && self.batch.from < article {
|
||||
self.batch.from = article;
|
||||
self.batch.next = article;
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in the releases finished since the last take (RangeIndexer.pending).
|
||||
pub fn pending(&self) -> usize {
|
||||
self.pending_files
|
||||
@@ -832,6 +1217,16 @@ impl RangeIndexer {
|
||||
self.frontier.value()
|
||||
}
|
||||
|
||||
/// Releases the window still holds (edge-bench's progress line).
|
||||
pub fn open_releases(&self) -> usize {
|
||||
self.open.values().map(|u| u.len()).sum::<usize>() + self.albums.values().map(|a| a.len()).sum::<usize>()
|
||||
}
|
||||
|
||||
/// Files still short of parts, or complete and not yet in a release.
|
||||
pub fn open_files(&self) -> usize {
|
||||
self.aggregator.open.values().map(|f| f.len()).sum()
|
||||
}
|
||||
|
||||
pub fn take(&mut self) -> Batch {
|
||||
self.scanner.files.clear();
|
||||
let next = self.batch.next;
|
||||
@@ -843,17 +1238,23 @@ impl RangeIndexer {
|
||||
}
|
||||
self.pending_files = 0;
|
||||
// Upload numbers only have to be distinct within a batch: those of keys with nothing open go.
|
||||
let open = &self.open;
|
||||
self.uploads.retain(|key, _| open.contains_key(key));
|
||||
let (open, albums) = (&self.open, &self.albums);
|
||||
self.uploads.retain(|key, _| open.contains_key(key) || albums.contains_key(key));
|
||||
let batch = self.aggregator.batch;
|
||||
self.batch_totals.retain(|_, (_, touched)| batch - *touched < BATCH_COUNTER_BATCHES);
|
||||
out
|
||||
}
|
||||
|
||||
/// Finished files (complete, or the window closed on them) into the open release of their key and upload.
|
||||
fn files(&mut self, files: Vec<Vec<Seg>>) {
|
||||
/// Finished files (complete, or the window closed on them) into the open release of their key and upload; `at_end`
|
||||
/// when the block ends on them.
|
||||
fn files(&mut self, files: Vec<Vec<Seg>>, at_end: bool) {
|
||||
let batch = self.aggregator.batch;
|
||||
for file in files {
|
||||
if file.len() == 1 && (file[0].number != 1 || file[0].file.total_parts > 1) && is_random_file(&file[0].file.name) {
|
||||
// One article of a multi-part random file (its upload per article: ngPost's, nyuu's): not a release.
|
||||
let lone = file.len() == 1 && (file[0].number != 1 || file[0].file.total_parts > 1);
|
||||
// A scrambled name only before the block's end, where the rest of a file can still be in the next block.
|
||||
if lone && (is_random_file(&file[0].file.name) || (!at_end && looks_scrambled(&file[0].file.name))) {
|
||||
// One article of a multi-part random file (its upload per article: ngPost's, nyuu's, and a random poster's
|
||||
// per article, 160k in alt.binaries.storage; LOG.md (122)): not a release.
|
||||
let tool = names::upload_tool(&file[0].message_id);
|
||||
self.count("per-article", tool.to_string(), 1, 0);
|
||||
continue;
|
||||
@@ -869,6 +1270,11 @@ impl RangeIndexer {
|
||||
let n = self.uploads.entry(key).or_insert(0);
|
||||
let upload = *n;
|
||||
*n += 1;
|
||||
if let Some(total) = file[0].file.release.file_total_truthy().filter(|&t| t >= BATCH_COUNTER_MIN_TOTAL) {
|
||||
let e = self.batch_totals.entry((file[0].file.poster.clone(), total)).or_insert((0, batch));
|
||||
e.0 += 1;
|
||||
e.1 = batch;
|
||||
}
|
||||
uploads.push(OpenRelease { upload, files: Vec::new(), first: None, last: None, touched: batch });
|
||||
uploads.last_mut().unwrap()
|
||||
}
|
||||
@@ -916,30 +1322,158 @@ impl RangeIndexer {
|
||||
self.open.remove(&key);
|
||||
}
|
||||
}
|
||||
for (key, u, closed) in done {
|
||||
// An edge: still open at the block's end, or posted within the window of the block's start (its other
|
||||
// part may be in the neighbour block); undated ones can't be placed, so they are edges too.
|
||||
let near_start = match (start, u.first) {
|
||||
let near_start = |first: Option<i64>| match (start, first) {
|
||||
(Some(s), Some(f)) => f < s + WINDOW_MS,
|
||||
_ => true,
|
||||
};
|
||||
self.emit(&key, u, closed, !closed || near_start);
|
||||
// In posting order, so an album's members join it from its first file on (the keys' order is a hash's).
|
||||
done.sort_by(|a, b| a.1.first.cmp(&b.1.first).then_with(|| a.0.cmp(&b.0)));
|
||||
for (key, u, closed) in done {
|
||||
// A single the window closed on waits in its album (a lone one is written as it is when that closes).
|
||||
if closed {
|
||||
if let Some(template) = album_candidate(&u) {
|
||||
self.album(key, u, template);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let edge = !closed || near_start(u.first);
|
||||
self.emit(&key, u, closed, edge);
|
||||
}
|
||||
// Albums: closed once every single that could join (posted within WINDOW_MS of the first) has closed too.
|
||||
let mut done_albums: Vec<(String, OpenAlbum, bool)> = Vec::new();
|
||||
let keys: Vec<String> = self.albums.keys().cloned().collect();
|
||||
for key in keys {
|
||||
let list = self.albums.get_mut(&key).unwrap();
|
||||
let mut i = 0;
|
||||
while i < list.len() {
|
||||
let a = &list[i];
|
||||
let closed = match a.first {
|
||||
Some(f) => before.is_some_and(|b| f + WINDOW_MS < b),
|
||||
None => idle > 0 && batch - a.touched >= idle,
|
||||
};
|
||||
if !all && !closed {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
done_albums.push((key.clone(), list.remove(i), closed));
|
||||
}
|
||||
if list.is_empty() {
|
||||
self.albums.remove(&key);
|
||||
}
|
||||
}
|
||||
for (key, mut a, closed) in done_albums {
|
||||
let edge = !closed || near_start(a.first);
|
||||
if a.members.len() < 2 {
|
||||
let (release_key, u) = a.members.remove(0);
|
||||
self.emit(&release_key, u, closed, edge);
|
||||
continue;
|
||||
}
|
||||
let names: Vec<&str> = a.members.iter().map(|(_, u)| u.files[0][0].file.name.as_str()).collect();
|
||||
let name = album_name(&names);
|
||||
let files: Vec<Vec<Seg>> = a.members.into_iter().flat_map(|(_, u)| u.files).collect();
|
||||
self.write(&key, a.upload, files, closed, edge, Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed single into the open album of its poster and template posted within WINDOW_MS of its first file
|
||||
/// (with room), else a new one.
|
||||
fn album(&mut self, release_key: String, u: OpenRelease, template: String) {
|
||||
let batch = self.aggregator.batch;
|
||||
let poster = &u.files[0][0].file.poster;
|
||||
let poster = if names::random_poster(poster) { "" } else { poster.as_str() };
|
||||
let key = sha1_hex(&format!("album\n{poster}\n{template}"));
|
||||
let albums = self.albums.entry(key.clone()).or_default();
|
||||
let fits = |a: &OpenAlbum| {
|
||||
a.members.len() < ALBUM_MAX_FILES
|
||||
&& match (a.first, u.first) {
|
||||
(Some(af), Some(f)) => f >= af && f < af + WINDOW_MS,
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
let index = match albums.iter().position(fits) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let n = self.uploads.entry(key).or_insert(0);
|
||||
let upload = *n;
|
||||
*n += 1;
|
||||
albums.push(OpenAlbum { upload, members: Vec::new(), first: None, last: None, touched: batch });
|
||||
albums.len() - 1
|
||||
}
|
||||
};
|
||||
let a = &mut albums[index];
|
||||
if let Some(f) = u.first {
|
||||
if a.first.is_none_or(|r| f < r) {
|
||||
a.first = Some(f);
|
||||
}
|
||||
}
|
||||
if let Some(l) = u.last {
|
||||
if a.last.is_none_or(|r| l > r) {
|
||||
a.last = Some(l);
|
||||
}
|
||||
}
|
||||
a.touched = batch;
|
||||
a.members.push((release_key, u));
|
||||
}
|
||||
|
||||
fn emit(&mut self, key: &str, release: OpenRelease, finished: bool, edge: bool) {
|
||||
let list = release.files;
|
||||
self.write(key, release.upload, release.files, finished, edge, None);
|
||||
}
|
||||
|
||||
/// A release (or an album, under its name) judged and written to the batch.
|
||||
fn write(&mut self, key: &str, upload: usize, list: Vec<Vec<Seg>>, finished: bool, edge: bool, album: Option<String>) {
|
||||
let judged: Vec<JudgedFile> = list
|
||||
.iter()
|
||||
.map(|f| JudgedFile { name: &f[0].file.name, total_parts: f[0].file.total_parts, groups: &f[0].file.groups, first_message_id: &f[0].message_id, poster: &f[0].file.poster })
|
||||
.map(|f| JudgedFile {
|
||||
name: &f[0].file.name,
|
||||
total_parts: f[0].file.total_parts,
|
||||
groups: &f[0].file.groups,
|
||||
first_message_id: &f[0].message_id,
|
||||
poster: &f[0].file.poster,
|
||||
segments: f.len() as u32,
|
||||
bytes: f.iter().map(|s| s.bytes.max(0) as u64).sum(),
|
||||
})
|
||||
.collect();
|
||||
match names::judge(key, &judged, &self.rules) {
|
||||
// Settled for the single-archive rules: the window closed on it (a par2 can still join an open one). The
|
||||
// start edge (posted within the window of the block's start) doesn't count: a par2 comes after its rar, so
|
||||
// one in the previous block is rare (0 par2 siblings of 3,000 catalog hits, LOG.md (125)), and 7% of the
|
||||
// catalog's releases are start edges, which would escape the rule.
|
||||
match names::judge(key, &judged, finished, &self.rules) {
|
||||
Verdict::Keep => {
|
||||
let candidates = candidates_of(key, &list, release.upload);
|
||||
let mut chunk = chunk_of(key, &list, release.upload);
|
||||
let candidates = candidates_of(key, &list, upload);
|
||||
let mut chunk = chunk_of(key, &list, upload);
|
||||
chunk.finished = finished;
|
||||
chunk.edge = edge;
|
||||
let album_none = album.is_none();
|
||||
if let Some(name) = album {
|
||||
chunk.name = name;
|
||||
chunk.source = "album";
|
||||
chunk.file_total = None;
|
||||
}
|
||||
if !chunk.readable {
|
||||
// Unnamed: no chunk (its segments are read again once a probe names it), the tool for its signature.
|
||||
let tool = names::upload_tool(&list[0][0].message_id);
|
||||
// A class the ladder found never names anything stays out of the pending store, its explored
|
||||
// sample excepted (so the rate keeps being measured); counted as garbage (LOG.md (130)).
|
||||
let klass = names::pending_class(&chunk.name, chunk.bytes.max(0) as u64, chunk.partial);
|
||||
if self.rules.dropped_classes.contains(&names::class_key(tool, &self.group, klass)) && !names::explored(key, self.rules.sample_percent) {
|
||||
let segments: usize = list.iter().map(|f| f.len()).sum();
|
||||
self.count("garbage", format!("{tool} {} #{klass}", self.group), segments as u64, list.len() as u64);
|
||||
return;
|
||||
}
|
||||
chunk.tool = Some(tool);
|
||||
chunk.payload = encode_chunk(&[]);
|
||||
}
|
||||
if chunk.readable {
|
||||
self.pending_files += chunk.files;
|
||||
}
|
||||
if let Some(total) = chunk.file_total.filter(|&t| album_none && t >= BATCH_COUNTER_MIN_TOTAL && (chunk.files as i64) < t) {
|
||||
// A batch counter: no file total (the release is as complete as its files are).
|
||||
if self.batch_totals.get(&(chunk.poster.clone(), total)).is_some_and(|(n, _)| *n >= BATCH_COUNTER_RELEASES) {
|
||||
chunk.file_total = None;
|
||||
}
|
||||
}
|
||||
self.batch.releases.push(chunk);
|
||||
self.batch.candidates.extend(candidates);
|
||||
}
|
||||
@@ -947,7 +1481,7 @@ impl RangeIndexer {
|
||||
let segments: usize = list.iter().map(|f| f.len()).sum();
|
||||
self.count(rule, rule_key.clone(), segments as u64, list.len() as u64);
|
||||
if sample {
|
||||
self.batch.samples.push((chunk_of(key, &list, release.upload), rule, rule_key));
|
||||
self.batch.samples.push((chunk_of(key, &list, upload), rule, rule_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -967,6 +1501,19 @@ impl RangeIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed release that may be an album's member: one file, named by its stem or its post (no release name in the
|
||||
/// subject), with a number in its name (album_template).
|
||||
fn album_candidate(u: &OpenRelease) -> Option<String> {
|
||||
if u.files.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let meta = &u.files[0][0].file;
|
||||
if !matches!(meta.release.source, "stem" | "post") {
|
||||
return None;
|
||||
}
|
||||
album_template(&meta.name)
|
||||
}
|
||||
|
||||
fn release_key_of(s: &Seg) -> String {
|
||||
// A named file always has a release (releaseIdentity isn't null unless weak).
|
||||
s.file.release.key.clone()
|
||||
@@ -1024,11 +1571,12 @@ fn chunk_of(key: &str, files: &[Vec<Seg>], upload: usize) -> ReleaseChunk {
|
||||
bytes,
|
||||
first_article,
|
||||
last_article,
|
||||
readable: chunk_files.iter().any(|f| !is_random_file(f.name)),
|
||||
readable: chunk_files.iter().any(|f| !is_unreadable_name(f.name)),
|
||||
has_par2: chunk_files.iter().any(|f| names::is_par2(f.name)),
|
||||
finished: true,
|
||||
edge: false,
|
||||
partial: files.iter().any(|f| (f.len() as u32) < f[0].file.total_parts),
|
||||
tool: None,
|
||||
payload: encode_chunk(&chunk_files),
|
||||
subjects: chunk_files.iter().take(SUBJECTS).map(|f| f.subject.to_string()).collect(),
|
||||
}
|
||||
@@ -1037,7 +1585,7 @@ fn chunk_of(key: &str, files: &[Vec<Seg>], upload: usize) -> ReleaseChunk {
|
||||
fn candidates_of(key: &str, files: &[Vec<Seg>], upload: usize) -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
let mut tail: Option<(i64, &Vec<Seg>)> = None;
|
||||
let c = |kind, s: &Seg| Candidate { release_key: key.to_string(), upload, kind, message_id: s.message_id.clone(), file: s.file.name.clone() };
|
||||
let c = |kind, s: &Seg| Candidate { release_key: key.to_string(), upload, kind, message_id: s.message_id.clone(), file: s.file.name.clone(), bytes: s.bytes };
|
||||
for f in files {
|
||||
let name = &f[0].file.name;
|
||||
let volume = names::seven_zip_volume(name);
|
||||
@@ -1058,10 +1606,15 @@ fn candidates_of(key: &str, files: &[Vec<Seg>], upload: usize) -> Vec<Candidate>
|
||||
}
|
||||
}
|
||||
if let Some((_, f)) = tail {
|
||||
out.push(Candidate { release_key: key.to_string(), upload, kind: "7z-tail", message_id: f.last().unwrap().message_id.clone(), file: f[0].file.name.clone() });
|
||||
out.push(Candidate { release_key: key.to_string(), upload, kind: "7z-tail", message_id: f.last().unwrap().message_id.clone(), file: f[0].file.name.clone(), bytes: f.last().unwrap().bytes });
|
||||
}
|
||||
// No set and no archive: the first random-named file's first segment, for its yEnc name or a twin by content
|
||||
// (hash_twins.ts). Any count of files: a hash-named release of several pieces (Bob's, LOG.md (140)) had no
|
||||
// target at all, so the ladder never probed it, never named it and never learned its class.
|
||||
if out.is_empty() {
|
||||
if let Some(f) = files.iter().find(|f| is_random_file(&f[0].file.name)) {
|
||||
out.push(c("lone", &f[0]));
|
||||
}
|
||||
if out.is_empty() && files.len() == 1 && is_random_file(&files[0][0].file.name) {
|
||||
out.push(c("lone", &files[0][0]));
|
||||
}
|
||||
out.truncate(4);
|
||||
out
|
||||
@@ -1144,6 +1697,186 @@ mod tests {
|
||||
assert_eq!(b4.next, 1_000_001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singles_of_one_template_become_an_album() {
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(GROUP.to_string(), 1);
|
||||
let mut ix = RangeIndexer::new(GROUP, 1, 1_000_000, 1, Rules::defaults(priorities), 5);
|
||||
let day = 24 * 60;
|
||||
// A dump's pictures of one timestamp (a stem each: ".data" is no image), two scans, a lone document, and dated fillers.
|
||||
let mut lines: Vec<String> = (1..=14).map(|i| line(1000 + i, &format!(r#"<IMGBANK> [1/1] "1481439079025_{i:03}.jpg.data" yEnc (1/1)"#), i)).collect();
|
||||
lines.push(line(2001, r#"[1/1] "Scan_001.pdf" yEnc (1/1)"#, 20));
|
||||
lines.push(line(2002, r#"[1/1] "Scan_002.pdf" yEnc (1/1)"#, 21));
|
||||
lines.push(line(2003, r#"[1/1] "Lonely_007.pdf" yEnc (1/1)"#, 22));
|
||||
lines.extend(fillers(3000, 100, 23, 1));
|
||||
ix.add_text(&lines.join("\n"), 4000);
|
||||
let b1 = ix.take();
|
||||
assert!(b1.releases.iter().all(|r| r.name.starts_with("Filler")));
|
||||
// Three days on: the window closed on the singles, and on their albums.
|
||||
ix.add_text(&fillers(5000, 300, 3 * day, 1).join("\n"), 6000);
|
||||
let b2 = ix.take();
|
||||
let mut albums: Vec<(&str, &str, usize, bool)> = b2.releases.iter().filter(|r| !r.name.starts_with("Filler")).map(|r| (r.name.as_str(), r.source, r.files, r.finished)).collect();
|
||||
albums.sort();
|
||||
assert_eq!(albums, vec![("1481439079025_#.jpg.data", "album", 14, true), ("Lonely_007", "stem", 1, true), ("Scan_#.pdf", "album", 2, true)]);
|
||||
let album = b2.releases.iter().find(|r| r.source == "album" && r.files == 14).unwrap();
|
||||
// Posted within the window of the block's start: an edge, like any release there.
|
||||
assert!(album.file_total.is_none() && album.edge && !album.partial && album.readable);
|
||||
assert_eq!(album.segments, 14);
|
||||
assert_eq!(b2.candidates.len(), 0);
|
||||
assert!(ix.albums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unnamed_release_keeps_no_chunk_but_its_tool_and_probe_target() {
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(GROUP.to_string(), 1);
|
||||
let mut ix = RangeIndexer::new(GROUP, 1, 10_000, 1, Rules::defaults(priorities), 5);
|
||||
let lines: Vec<String> = (0..4).map(|i| line(100 + i, &format!(r#"[1/1] - "q3B9OrpKiCxUnzgtUGnp.part01.rar" yEnc ({}/4)"#, i + 1), i)).collect();
|
||||
ix.add_text(&lines.join("\n"), 10_001);
|
||||
ix.finish();
|
||||
let batch = ix.take();
|
||||
assert_eq!(batch.releases.len(), 1);
|
||||
let r = &batch.releases[0];
|
||||
assert!(!r.readable && r.tool == Some("other") && r.files == 1 && r.segments == 4);
|
||||
assert_eq!(r.payload, encode_chunk(&[]));
|
||||
assert_eq!(batch.candidates.iter().map(|c| c.kind).collect::<Vec<_>>(), vec!["archive"]);
|
||||
assert_eq!(ix.pending(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_file_random_release_gets_a_lone_target() {
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(GROUP.to_string(), 1);
|
||||
let mut ix = RangeIndexer::new(GROUP, 1, 10_000, 1, Rules::defaults(priorities), 5);
|
||||
// Three hash-named pieces of one hash-named upload, five segments each (more than a per-article post): no
|
||||
// archive, no par2, no 7z volume.
|
||||
let mut lines = Vec::new();
|
||||
for (i, name) in ["9e7fc2741cdd450287aff2733ca4c193", "c5ebdd5487f5b132c76e8d5c2572e560", "8114c42c9694380c8b25857e8b3331c3"].iter().enumerate() {
|
||||
for part in 0..5 {
|
||||
lines.push(line(100 + (i * 5 + part) as i64, &format!(r#"b3a75d234aa73891e78a70fc1f5496b1 [{}/3] - "{name}" yEnc ({}/5)"#, i + 1, part + 1), (i * 5 + part) as i64));
|
||||
}
|
||||
}
|
||||
ix.add_text(&lines.join("\n"), 10_001);
|
||||
ix.finish();
|
||||
let batch = ix.take();
|
||||
assert_eq!(batch.releases.len(), 1, "counts {:?}", batch.counts);
|
||||
let r = &batch.releases[0];
|
||||
assert!(!r.readable && r.files == 3 && r.segments == 15, "readable {} files {} segments {}", r.readable, r.files, r.segments);
|
||||
let kinds: Vec<_> = batch.candidates.iter().map(|c| (c.kind, c.file.as_str())).collect();
|
||||
assert_eq!(kinds, vec![("lone", "9e7fc2741cdd450287aff2733ca4c193")]);
|
||||
assert_eq!(batch.candidates[0].message_id, "u100@test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_templates_and_names() {
|
||||
assert_eq!(album_template("1481439079025_014.jpg.data").as_deref(), Some("#_#.jpg.data"));
|
||||
assert_eq!(album_template("ActiCalc 1.06.rar"), None);
|
||||
assert_eq!(album_template("Scan_001.vol00+01.par2"), None);
|
||||
assert_eq!(album_template("Scan_001.nfo"), None);
|
||||
assert_eq!(album_template("NoNumber.mp4"), None);
|
||||
assert_eq!(album_template("acs5152s1.11"), None);
|
||||
assert_eq!(album_template("Broforce.vol00+01.par2.temp"), None);
|
||||
assert_eq!(album_template("Starship For Rent 3 (2024).epub").as_deref(), Some("Starship For Rent # (#).epub"));
|
||||
assert_eq!(album_template("c3453cdd2a3645a8f3cf3d22319abe63"), None);
|
||||
assert_eq!(album_name(&["1481439079025_001.jpg.data", "1481439079025_014.jpg.data"]), "1481439079025_#.jpg.data");
|
||||
assert_eq!(album_name(&["1481439079025_001.jpg.data", "1481439080000_001.jpg.data"]), "#_001.jpg.data");
|
||||
assert_eq!(album_name(&["Scan_001.pdf", "Scan_002.pdf", "Scan_003.pdf"]), "Scan_#.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_counter_is_no_file_total() {
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(GROUP.to_string(), 1);
|
||||
let mut ix = RangeIndexer::new(GROUP, 1, 1_000_000, 1, Rules::defaults(priorities), 5);
|
||||
let day = 24 * 60;
|
||||
// 25 different programs of one poster under one running counter, and one upload with a counter of its own.
|
||||
let mut lines: Vec<String> = (0..25).map(|i| line(100 + i, &format!(r#"[{}/68067] - "Program.Number.{i}.v1.0.rar" yEnc (1/1)"#, 1000 + i), i)).collect();
|
||||
lines.push(line(200, r#"[1/120] - "Real.Upload.part001.rar" yEnc (1/1)"#, 30));
|
||||
lines.extend(fillers(300, 100, 31, 1));
|
||||
ix.add_text(&lines.join("\n"), 1000);
|
||||
ix.add_text(&fillers(2000, 300, 3 * day, 1).join("\n"), 3000);
|
||||
let batch = ix.take();
|
||||
let programs: Vec<&ReleaseChunk> = batch.releases.iter().filter(|r| r.name.starts_with("Program")).collect();
|
||||
assert_eq!(programs.len(), 25);
|
||||
assert!(programs.iter().all(|r| r.file_total.is_none() && r.files == 1));
|
||||
let real = batch.releases.iter().find(|r| r.name.starts_with("Real")).unwrap();
|
||||
assert_eq!(real.file_total, Some(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_restores_the_window_between_two_ranges() {
|
||||
let rules = || {
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(GROUP.to_string(), 1);
|
||||
Rules::defaults(priorities)
|
||||
};
|
||||
let day = 24 * 60;
|
||||
// Range 1: a release whose last file comes in range 2, a day later; an album's first member; fillers.
|
||||
let mut first = vec![
|
||||
line(10, r#"Two.Ranges [1/2] - "Two.Ranges.part1.rar" yEnc (1/1)"#, 0),
|
||||
line(11, r#"[1/1] "Scan_001.pdf" yEnc (1/1)"#, 1),
|
||||
];
|
||||
first.extend(fillers(100, 100, 2, 1));
|
||||
// The second file within the upload gap of the first (3 h), the album's second member within its window.
|
||||
let mut second = vec![
|
||||
line(1000, r#"Two.Ranges [2/2] - "Two.Ranges.part2.rar" yEnc (1/1)"#, 100),
|
||||
line(1001, r#"[1/1] "Scan_002.pdf" yEnc (1/1)"#, 101),
|
||||
];
|
||||
second.extend(fillers(2000, 300, 3 * day, 1));
|
||||
let summary = |batches: Vec<Batch>| {
|
||||
let mut got: Vec<(String, &str, usize, bool)> = batches.iter().flat_map(|b| b.releases.iter()).filter(|r| !r.name.starts_with("Filler")).map(|r| (r.name.clone(), r.source, r.files, r.finished)).collect();
|
||||
got.sort();
|
||||
got
|
||||
};
|
||||
// In one go.
|
||||
let mut whole = RangeIndexer::for_group(GROUP, rules(), 5);
|
||||
whole.begin_range(1, 999);
|
||||
whole.add_text(&first.join("\n"), 1000);
|
||||
let w1 = whole.take();
|
||||
whole.begin_range(1000, 1_000_000);
|
||||
whole.add_text(&second.join("\n"), 1_000_001);
|
||||
let w2 = whole.take();
|
||||
// With a snapshot between the ranges, restored into a fresh indexer.
|
||||
let mut before = RangeIndexer::for_group(GROUP, rules(), 5);
|
||||
before.begin_range(1, 999);
|
||||
before.add_text(&first.join("\n"), 1000);
|
||||
let s1 = before.take();
|
||||
assert_eq!(before.next(), 1000);
|
||||
let bytes = before.snapshot().unwrap();
|
||||
assert!(bytes.len() > 1000);
|
||||
let mut after = RangeIndexer::restore(&bytes, rules(), 5).unwrap();
|
||||
assert_eq!(after.next(), 1000);
|
||||
assert_eq!(after.open_releases(), before.open_releases());
|
||||
assert_eq!(after.open_files(), before.open_files());
|
||||
after.begin_range(1000, 1_000_000);
|
||||
after.add_text(&second.join("\n"), 1_000_001);
|
||||
let s2 = after.take();
|
||||
assert_eq!(summary(vec![s1, s2]), summary(vec![w1, w2]));
|
||||
let got = summary(vec![after.take()]);
|
||||
assert!(got.is_empty());
|
||||
// The two-range release is one, finished, with both files; the album joined its second member.
|
||||
let mut a2 = RangeIndexer::for_group(GROUP, rules(), 5);
|
||||
a2.begin_range(1, 999);
|
||||
a2.add_text(&first.join("\n"), 1000);
|
||||
let _ = a2.take();
|
||||
let restored = RangeIndexer::restore(&a2.snapshot().unwrap(), rules(), 5).unwrap();
|
||||
let _ = restored;
|
||||
let s2b = {
|
||||
let mut r = RangeIndexer::restore(&bytes, rules(), 5).unwrap();
|
||||
r.begin_range(1000, 1_000_000);
|
||||
r.add_text(&second.join("\n"), 1_000_001);
|
||||
r.take()
|
||||
};
|
||||
let two = s2b.releases.iter().find(|r| r.name == "Two Ranges").expect("the two-range release");
|
||||
assert!(two.finished && two.files == 2);
|
||||
assert!(s2b.releases.iter().any(|r| r.source == "album" && r.files == 2));
|
||||
// A snapshot with a batch open is refused; one of another version too.
|
||||
let mut open = RangeIndexer::for_group(GROUP, rules(), 5);
|
||||
open.begin_range(1, 999);
|
||||
open.add_text(&first.join("\n"), 1000);
|
||||
assert!(open.snapshot().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frontier_ignores_implausible_dates_and_follows_a_pause() {
|
||||
let hour = 3_600_000i64;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! A lease as the coordinator sends it (lib/edge/lease.ts): the range and the rules, JSON with the TypeScript
|
||||
//! names. The edge-node worker takes one per range; edge-bench's replay takes the rules alone (`--rules`).
|
||||
|
||||
use crate::names::Rules;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LeaseJson {
|
||||
pub range: RangeJson,
|
||||
pub rules: RulesJson,
|
||||
}
|
||||
|
||||
/// A group lease (lib/edge/lease.ts GroupLease): the group a worker thread holds, the rules, the window's snapshot if any.
|
||||
#[derive(Deserialize)]
|
||||
pub struct GroupLeaseJson {
|
||||
pub group: GroupJson,
|
||||
pub rules: RulesJson,
|
||||
pub snapshot: Option<SnapshotJson>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GroupJson {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub priority: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SnapshotJson {
|
||||
pub next: i64,
|
||||
}
|
||||
|
||||
/// The group's next range (lib/edge/lease.ts NextRange).
|
||||
#[derive(Deserialize)]
|
||||
pub struct NextRangeJson {
|
||||
pub range: RangeSpanJson,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Copy)]
|
||||
pub struct RangeSpanJson {
|
||||
pub id: i64,
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
pub next: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RangeJson {
|
||||
pub id: i64,
|
||||
pub group: String,
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
pub next: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RulesJson {
|
||||
pub version: i64,
|
||||
pub priorities: HashMap<String, u32>,
|
||||
#[serde(default)]
|
||||
pub dropped_signatures: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub dropped_classes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub dropped_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub per_article_max_parts: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub sample_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub drop_executables: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub dropped_posters: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub drop_stub_archives: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub drop_nonsense_pieces: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub drop_tiny_archives: Option<bool>,
|
||||
}
|
||||
|
||||
impl RulesJson {
|
||||
/// The rules with today's defaults under them (a field the stored set doesn't have keeps the default).
|
||||
pub fn rules(self) -> Rules {
|
||||
let mut rules = Rules::defaults(self.priorities.into_iter().collect());
|
||||
rules.version = self.version;
|
||||
rules.dropped_signatures = self.dropped_signatures.into_iter().collect();
|
||||
rules.dropped_classes = self.dropped_classes.into_iter().collect();
|
||||
if let Some(tools) = self.dropped_tools {
|
||||
rules.dropped_tools = tools.into_iter().collect();
|
||||
}
|
||||
if let Some(n) = self.per_article_max_parts {
|
||||
rules.per_article_max_parts = n;
|
||||
}
|
||||
if let Some(p) = self.sample_percent {
|
||||
rules.sample_percent = p;
|
||||
}
|
||||
if let Some(e) = self.drop_executables {
|
||||
rules.drop_executables = e;
|
||||
}
|
||||
if let Some(posters) = self.dropped_posters {
|
||||
rules.dropped_posters = posters;
|
||||
}
|
||||
if let Some(d) = self.drop_stub_archives {
|
||||
rules.drop_stub_archives = d;
|
||||
}
|
||||
if let Some(d) = self.drop_nonsense_pieces {
|
||||
rules.drop_nonsense_pieces = d;
|
||||
}
|
||||
if let Some(d) = self.drop_tiny_archives {
|
||||
rules.drop_tiny_archives = d;
|
||||
}
|
||||
rules
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@
|
||||
pub mod codec;
|
||||
pub mod indexer;
|
||||
pub mod js;
|
||||
pub mod lease;
|
||||
pub mod names;
|
||||
pub mod wire;
|
||||
+154
-34
@@ -1,10 +1,11 @@
|
||||
//! The edge worker's indexing in Rust, on a range captured by `edge:bench
|
||||
//! --capture` (REBUILD.md, part 2: TypeScript or Rust). Same work as
|
||||
//! edge:bench's replay: inflate each chunk, scan it, aggregate files, cut off
|
||||
//! idle ones, and every N chunks judge and encode the releases. `--dump`
|
||||
//! writes every output as JSON lines for edge/check.ts.
|
||||
//! The Rust indexer over stored chunks (REBUILD.md, part 2 and "Voyager 3"): a range captured by `edge:bench
|
||||
//! --capture` or stored in the corpus, or a group's corpus ranges in order through one window (`--group`, the
|
||||
//! replay harness behind `replay:corpus`). Same work as the edge-node worker: inflate each chunk, scan it,
|
||||
//! aggregate files, finish releases through the window, and every N chunks judge and encode them. `--dump`
|
||||
//! writes every output as JSON lines; `--wire` the batches as the worker posts them (`-`: stdout).
|
||||
//!
|
||||
//! edge-bench <dir> [--batch-chunks 10] [--idle 5] [--dump out.jsonl] [--wire batches.jsonl]
|
||||
//! edge-bench <range dir> | --group <group dir> [--from article] [--to article] [--limit ranges] [--rules rules.json]
|
||||
//! [--batch-chunks 10] [--idle 5] [--no-finish] [--dump out.jsonl] [--wire batches.jsonl|-] [--releases out.jsonl]
|
||||
|
||||
use base64::Engine;
|
||||
use edge::indexer::{self, Batch, RangeIndexer};
|
||||
@@ -167,55 +168,160 @@ fn dump_batch(batch: &Batch, out: &mut impl Write) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored range: its directory and article span (range.json).
|
||||
struct Range {
|
||||
dir: String,
|
||||
start: i64,
|
||||
end: i64,
|
||||
}
|
||||
|
||||
/// The range in a directory with a range.json, and its group.
|
||||
fn range_in(dir: &str) -> Option<(String, Range)> {
|
||||
let json = std::fs::read_to_string(format!("{dir}/range.json")).ok()?;
|
||||
Some((json_field(&json, "group"), Range { dir: dir.to_string(), start: json_field(&json, "start").parse().ok()?, end: json_field(&json, "end").parse().ok()? }))
|
||||
}
|
||||
|
||||
/// A group directory's complete ranges in article order (the corpus layout: `<group>/<start>-<end>/`).
|
||||
fn ranges_in(group_dir: &str) -> (String, Vec<Range>) {
|
||||
let mut group = String::new();
|
||||
let mut ranges: Vec<Range> = Vec::new();
|
||||
for entry in std::fs::read_dir(group_dir).unwrap_or_else(|e| panic!("{group_dir}: {e}")) {
|
||||
let path = entry.unwrap().path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Some((g, range)) = range_in(path.to_str().unwrap()) {
|
||||
group = g;
|
||||
ranges.push(range);
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
(group, ranges)
|
||||
}
|
||||
|
||||
fn date(ms: Option<i64>) -> String {
|
||||
match ms.and_then(chrono::DateTime::from_timestamp_millis) {
|
||||
Some(t) => t.format("%Y-%m-%d %H:%M").to_string(),
|
||||
None => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Files waiting in finished releases after which a batch goes out whatever its chunks (edge-node's limit).
|
||||
const MAX_PENDING_FILES: usize = 100_000;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let dir = args.get(1).expect("usage: edge-bench <dir> [--batch-chunks N] [--idle N] [--dump file]").clone();
|
||||
let usage = "usage: edge-bench <range dir> | --group <group dir> [--from article] [--to article] [--limit ranges] [--rules rules.json] [--batch-chunks N] [--idle N] [--no-finish] [--dump file] [--wire file|-] [--releases file]";
|
||||
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
|
||||
let has = |name: &str| args.iter().any(|a| a == name);
|
||||
let batch_chunks: usize = flag("--batch-chunks").map(|v| v.parse().unwrap()).unwrap_or(10);
|
||||
let idle: u64 = flag("--idle").map(|v| v.parse().unwrap()).unwrap_or(5);
|
||||
let mut dump = flag("--dump").map(|p| std::io::BufWriter::new(std::fs::File::create(p).unwrap()));
|
||||
// Each batch as the worker posts it (wire.rs), one JSON per line: check.ts compares it with toWire.
|
||||
let mut wire_out = flag("--wire").map(|p| std::io::BufWriter::new(std::fs::File::create(p).unwrap()));
|
||||
// Each batch as the worker posts it (wire.rs), one JSON per line: a file, or stdout (`-`) for replay:corpus.
|
||||
let wire_path = flag("--wire");
|
||||
let wire_stdout = wire_path.as_deref() == Some("-");
|
||||
let mut wire_out: Option<Box<dyn Write>> = match wire_path.as_deref() {
|
||||
Some("-") => Some(Box::new(std::io::BufWriter::with_capacity(1 << 20, std::io::stdout().lock()))),
|
||||
Some(p) => Some(Box::new(std::io::BufWriter::new(std::fs::File::create(p).unwrap()))),
|
||||
None => None,
|
||||
};
|
||||
// One line per release chunk without its payload, with its first subjects (deploy/corpus_sweep.py).
|
||||
let mut releases_out = flag("--releases").map(|p| std::io::BufWriter::new(std::fs::File::create(p).unwrap()));
|
||||
|
||||
let range = std::fs::read_to_string(format!("{dir}/range.json")).unwrap();
|
||||
let group = json_field(&range, "group");
|
||||
let start: i64 = json_field(&range, "start").parse().unwrap();
|
||||
let end: i64 = json_field(&range, "end").parse().unwrap();
|
||||
let mut files: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).filter(|f| f.ends_with(".gz")).collect();
|
||||
files.sort();
|
||||
|
||||
// One range, or a group's ranges in order through one window (the window carries over from range to range;
|
||||
// the end is only finished after the last one, unless --no-finish: a replay to be continued).
|
||||
let (group, ranges) = match flag("--group") {
|
||||
Some(dir) => {
|
||||
let (group, mut ranges) = ranges_in(&dir);
|
||||
// --from: ranges ending before it are skipped (a replay continued); --to: ranges starting after it (one
|
||||
// window of the group's history, the corpus holding others); --limit: this many at most.
|
||||
if let Some(from) = flag("--from").map(|v| v.parse::<i64>().unwrap()) {
|
||||
ranges.retain(|r| r.end >= from);
|
||||
}
|
||||
if let Some(to) = flag("--to").map(|v| v.parse::<i64>().unwrap()) {
|
||||
ranges.retain(|r| r.start <= to);
|
||||
}
|
||||
if let Some(limit) = flag("--limit").map(|v| v.parse::<usize>().unwrap()) {
|
||||
ranges.truncate(limit);
|
||||
}
|
||||
(group, ranges)
|
||||
}
|
||||
None => {
|
||||
let positional = (1..args.len()).find(|&i| !args[i].starts_with("--") && !args[i - 1].starts_with("--")).map(|i| args[i].clone());
|
||||
let dir = positional.expect(usage);
|
||||
let (group, range) = range_in(&dir).unwrap_or_else(|| panic!("{dir}: no range.json"));
|
||||
(group, vec![range])
|
||||
}
|
||||
};
|
||||
if ranges.is_empty() {
|
||||
eprintln!("no complete range found");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let finish = !has("--no-finish");
|
||||
let rules = match flag("--rules") {
|
||||
Some(path) => {
|
||||
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
serde_json::from_str::<edge::lease::RulesJson>(&json).unwrap_or_else(|e| panic!("{path}: {e}")).rules()
|
||||
}
|
||||
None => {
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(group.clone(), 1);
|
||||
let mut indexer = RangeIndexer::new(&group, start, end, start, names::Rules::defaults(priorities), idle);
|
||||
names::Rules::defaults(priorities)
|
||||
}
|
||||
};
|
||||
// The report goes to stderr when the wire takes stdout.
|
||||
let say = |line: String| {
|
||||
if wire_stdout {
|
||||
eprintln!("{line}");
|
||||
} else {
|
||||
println!("{line}");
|
||||
}
|
||||
};
|
||||
|
||||
let (start, end) = (ranges[0].start, ranges[ranges.len() - 1].end);
|
||||
let mut indexer = RangeIndexer::new(&group, start, end, start, rules, idle);
|
||||
|
||||
let (mut inflate_s, mut index_s) = (0.0, 0.0);
|
||||
let (mut compressed, mut text_bytes) = (0usize, 0usize);
|
||||
let (mut compressed, mut text_bytes, mut chunks) = (0usize, 0usize, 0usize);
|
||||
let mut out = (0u64, 0usize, 0usize, 0usize, 0usize, 0usize, 0usize); // headers, releases, files, segments, payload, candidates, samples
|
||||
let mut finished = 0usize;
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
let mut decompressor = libdeflater::Decompressor::new();
|
||||
let wall = Instant::now();
|
||||
let mut since_batch = 0usize;
|
||||
for (ri, range) in ranges.iter().enumerate() {
|
||||
let range_wall = Instant::now();
|
||||
let headers_before = out.0;
|
||||
// After a gap between stored ranges the batch starts at this range (every range ends with a batch taken).
|
||||
if ri > 0 {
|
||||
indexer.skip_to(range.start);
|
||||
}
|
||||
let mut files: Vec<String> = std::fs::read_dir(&range.dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).filter(|f| f.ends_with(".gz")).collect();
|
||||
files.sort();
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let raw = std::fs::read(format!("{dir}/{file}")).unwrap();
|
||||
let raw = std::fs::read(format!("{}/{file}", range.dir)).unwrap();
|
||||
compressed += raw.len();
|
||||
let t = Instant::now();
|
||||
let data = inflate(&raw, &mut decompressor);
|
||||
inflate_s += t.elapsed().as_secs_f64();
|
||||
text_bytes += data.len();
|
||||
chunks += 1;
|
||||
let t = Instant::now();
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let next: i64 = file.split('-').nth(2).unwrap().trim_end_matches(".gz").parse::<i64>().unwrap() + 1;
|
||||
indexer.add_text(&text, next);
|
||||
let last = i + 1 == files.len();
|
||||
if last {
|
||||
since_batch += 1;
|
||||
let range_end = i + 1 == files.len();
|
||||
let last = range_end && ri + 1 == ranges.len();
|
||||
if last && finish {
|
||||
indexer.finish();
|
||||
}
|
||||
if !last && (i + 1) % batch_chunks != 0 {
|
||||
if !range_end && since_batch < batch_chunks && indexer.pending() < MAX_PENDING_FILES {
|
||||
index_s += t.elapsed().as_secs_f64();
|
||||
continue;
|
||||
}
|
||||
let batch = indexer.take();
|
||||
since_batch = 0;
|
||||
index_s += t.elapsed().as_secs_f64();
|
||||
out.0 += batch.headers;
|
||||
out.1 += batch.releases.len();
|
||||
@@ -224,6 +330,7 @@ fn main() {
|
||||
out.2 += r.files;
|
||||
out.3 += r.segments;
|
||||
out.4 += r.payload.len();
|
||||
finished += r.finished as usize;
|
||||
}
|
||||
out.5 += batch.candidates.len();
|
||||
out.6 += batch.samples.len();
|
||||
@@ -233,33 +340,46 @@ fn main() {
|
||||
if let Some(w) = wire_out.as_mut() {
|
||||
w.write_all(&wire::to_json(&batch)).unwrap();
|
||||
w.write_all(b"\n").unwrap();
|
||||
w.flush().unwrap();
|
||||
}
|
||||
if let Some(w) = releases_out.as_mut() {
|
||||
dump_releases(&batch, &group, w);
|
||||
}
|
||||
}
|
||||
if ranges.len() > 1 {
|
||||
eprintln!(
|
||||
"{} {}–{}: {} headers in {:.1} s; frontier {}; {} releases out ({} finished), {} releases and {} files open; RSS {} MB",
|
||||
group,
|
||||
range.start,
|
||||
range.end,
|
||||
out.0 - headers_before,
|
||||
range_wall.elapsed().as_secs_f64(),
|
||||
date(indexer.frontier()),
|
||||
out.1,
|
||||
finished,
|
||||
indexer.open_releases(),
|
||||
indexer.open_files(),
|
||||
peak_rss_mb()
|
||||
);
|
||||
}
|
||||
}
|
||||
let total = inflate_s + index_s;
|
||||
#[allow(static_mut_refs)]
|
||||
let p = unsafe { indexer::PROF };
|
||||
eprintln!(" of which aggregate {:.2} s, evict+cutoff {:.2} s, judge+encode {:.2} s", p[3] as f64 / 1e9, p[4] as f64 / 1e9, p[5] as f64 / 1e9);
|
||||
println!("{group} {start}–{end}: {} chunks, {} MB compressed, {} MB text", files.len(), compressed / 1_000_000, text_bytes / 1_000_000);
|
||||
println!(
|
||||
"{} headers in {:.2} s ({:.0}/s on one core; wall {:.2} s)",
|
||||
out.0,
|
||||
total,
|
||||
out.0 as f64 / total,
|
||||
wall.elapsed().as_secs_f64()
|
||||
);
|
||||
println!(" inflate {inflate_s:.2} s, index {index_s:.2} s; peak RSS {} MB", peak_rss_mb());
|
||||
println!(
|
||||
" kept: {} release chunks ({} releases), {} files, {} segments, payload {:.1} MB ({:.1} B/segment); {} candidates, {} samples",
|
||||
say(format!("{group} {start}–{end}: {} ranges, {chunks} chunks, {} MB compressed, {} MB text", ranges.len(), compressed / 1_000_000, text_bytes / 1_000_000));
|
||||
say(format!("{} headers in {:.2} s ({:.0}/s on one core; wall {:.2} s)", out.0, total, out.0 as f64 / total, wall.elapsed().as_secs_f64()));
|
||||
say(format!(" inflate {inflate_s:.2} s, index {index_s:.2} s; peak RSS {} MB", peak_rss_mb()));
|
||||
say(format!(
|
||||
" kept: {} release chunks ({} releases, {} finished), {} files, {} segments, payload {:.1} MB ({:.1} B/segment); {} candidates, {} samples",
|
||||
out.1,
|
||||
keys.len(),
|
||||
finished,
|
||||
out.2,
|
||||
out.3,
|
||||
out.4 as f64 / 1e6,
|
||||
out.4 as f64 / out.3.max(1) as f64,
|
||||
out.5,
|
||||
out.6
|
||||
);
|
||||
));
|
||||
}
|
||||
+570
-6
@@ -449,6 +449,43 @@ fn finish_remainder(s: &str) -> String {
|
||||
js::trim(&out).to_string()
|
||||
}
|
||||
|
||||
/// displayName: a subject name as shown, brackets the counters left unmatched removed ("… Facialized 002/477] - …"
|
||||
/// gave "… Facialized ]"). Only the name: the key keeps the remainder.
|
||||
pub fn display_name(remainder: &str) -> String {
|
||||
let b = remainder.as_bytes();
|
||||
if !b.iter().any(|c| matches!(c, b'[' | b']' | b'(' | b')')) {
|
||||
return remainder.to_string();
|
||||
}
|
||||
let mut drop = vec![false; b.len()];
|
||||
for (open, close) in [(b'[', b']'), (b'(', b')')] {
|
||||
let mut opens: Vec<usize> = Vec::new();
|
||||
for (i, &c) in b.iter().enumerate() {
|
||||
if c == open {
|
||||
opens.push(i);
|
||||
} else if c == close && opens.pop().is_none() {
|
||||
drop[i] = true;
|
||||
}
|
||||
}
|
||||
for i in opens {
|
||||
drop[i] = true;
|
||||
}
|
||||
}
|
||||
if !drop.contains(&true) {
|
||||
return remainder.to_string();
|
||||
}
|
||||
let kept: Vec<u8> = b.iter().zip(&drop).filter(|(_, &d)| !d).map(|(&c, _)| c).collect();
|
||||
let kept = String::from_utf8(kept).expect("only ASCII brackets removed");
|
||||
let mut out = String::with_capacity(kept.len());
|
||||
for c in kept.chars() {
|
||||
if c == ' ' && out.ends_with(' ') {
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
let name = js::trim(&out);
|
||||
if name.is_empty() { remainder.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// counterTotal: the subject's file counter total, first form found first.
|
||||
pub fn counter_total(subject: &str) -> Option<String> {
|
||||
let s = replace("ED, subject, " ", true);
|
||||
@@ -841,11 +878,11 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release {
|
||||
if !image && test(&RANDOM_VOLUME, name) && stem.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') && looks_random(&stripped) {
|
||||
parts.push(&stem);
|
||||
}
|
||||
return Release { key: hash(&parts), name: remainder, source: "subject", file_total };
|
||||
return Release { key: hash(&parts), name: display_name(&remainder), source: "subject", file_total };
|
||||
}
|
||||
if let Some(total) = post_total.filter(|&n| n != 0 && !tag) {
|
||||
if is_post_token(&remainder) {
|
||||
return Release { key: hash(&["post", poster, &remainder, &total.to_string()]), name: remainder, source: "post", file_total };
|
||||
return Release { key: hash(&["post", poster, &remainder, &total.to_string()]), name: display_name(&remainder), source: "post", file_total };
|
||||
}
|
||||
}
|
||||
// A scene music post's index files ("00-ammar-turk_uit_de_kroeg-single-web-nl-2022-cover.jpg") under a bare counter go
|
||||
@@ -1028,6 +1065,228 @@ pub fn is_random_file(name: &str) -> bool {
|
||||
looks_random(&file_stem(name))
|
||||
}
|
||||
|
||||
/// Readable or not (lib/release/readable.ts): whether a name says what a release is, read token by token, so
|
||||
/// separators, spaces and random "extensions" don't hide a scramble that looks_random's single-token shapes miss
|
||||
/// (LOG.md (129)). The readable flag; the drop rules keep looks_random, which is narrower.
|
||||
/// Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed words.
|
||||
static NOISE_TOKEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)^(addon|samples?|proof|subs?|nfo|part[0-9]+|vol[0-9]+|description|repost|rep|extras?|bonus|cd[0-9]*|dvd[0-9]*|dis[ck][0-9]*|par2|rar|zip|7z|mkv|mp4|avi|vol)$"));
|
||||
/// Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th.
|
||||
static CODE_TOKEN: LazyLock<Regex> = LazyLock::new(|| re(r"^([sS][0-9]{1,2}[eE][0-9]{1,3}|[xh]26[45]|[A-Za-z]+[0-9]+([a-z]{1,3}|x(64|86))?|[0-9]+[A-Za-z]{1,3}|[0-9]+[A-Za-z]+[0-9]+)$"));
|
||||
/// The NZBSPLIT tools' hash between their markers is no part of the name.
|
||||
static NZBSPLIT_HASH: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)__NZBS[P]LIT__[0-9a-f]{32}__NZBS[P]LIT__")); // "SP" is re()'s space class
|
||||
/// An inner run of 2+ capitals with lowercase on both sides ("tBWTuz", "YDoaROv"; not "3DMark03").
|
||||
static INNER_UPPER: LazyLock<Regex> = LazyLock::new(|| re(r"[a-z][A-Z]{2,}[a-z]"));
|
||||
|
||||
/// Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there.
|
||||
fn scene_case(t: &[u8]) -> Vec<u8> {
|
||||
let mut out = t.to_vec();
|
||||
for i in 1..t.len().saturating_sub(1) {
|
||||
if t[i] == b'i' && t[i - 1].is_ascii_uppercase() && t[i + 1].is_ascii_uppercase() {
|
||||
out[i] = b'I';
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The signals of a scrambled token (readable.ts signals()).
|
||||
struct TokenSignals {
|
||||
/// Letter/digit and lower-to-upper switches.
|
||||
switches: usize,
|
||||
/// Case switches in both directions.
|
||||
case_switches: usize,
|
||||
/// Lone lowercase letters (not "i") between capitals ("NtpUgCmS").
|
||||
lone: usize,
|
||||
inner_upper: bool,
|
||||
max_consonants: usize,
|
||||
/// Vowels, a lowercase y too ("McCartney").
|
||||
vowels: usize,
|
||||
/// Runs of one character class.
|
||||
runs: usize,
|
||||
letters: usize,
|
||||
/// The longest lowercase run.
|
||||
max_lower: usize,
|
||||
}
|
||||
|
||||
fn token_signals(t: &[u8]) -> TokenSignals {
|
||||
let is_vowel = |c: u8| matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u') || c == b'y';
|
||||
let class = |c: u8| if c.is_ascii_digit() { 0 } else if c.is_ascii_lowercase() { 1 } else { 2 };
|
||||
let mut s = TokenSignals { switches: 0, case_switches: 0, lone: 0, inner_upper: false, max_consonants: 0, vowels: 0, runs: 0, letters: 0, max_lower: 0 };
|
||||
let mut consonants = 0;
|
||||
let mut lower = 0;
|
||||
for i in 0..t.len() {
|
||||
let c = t[i];
|
||||
if c.is_ascii_alphabetic() && !is_vowel(c) && c.to_ascii_lowercase() != b'y' {
|
||||
consonants += 1;
|
||||
s.max_consonants = s.max_consonants.max(consonants);
|
||||
} else {
|
||||
consonants = 0;
|
||||
}
|
||||
if is_vowel(c) {
|
||||
s.vowels += 1;
|
||||
}
|
||||
if c.is_ascii_alphabetic() {
|
||||
s.letters += 1;
|
||||
}
|
||||
if c.is_ascii_lowercase() {
|
||||
lower += 1;
|
||||
s.max_lower = s.max_lower.max(lower);
|
||||
} else {
|
||||
lower = 0;
|
||||
}
|
||||
if i == 0 || class(t[i - 1]) != class(c) {
|
||||
s.runs += 1;
|
||||
}
|
||||
if i == 0 {
|
||||
continue;
|
||||
}
|
||||
let a = t[i - 1];
|
||||
if a.is_ascii_digit() != c.is_ascii_digit() {
|
||||
s.switches += 1;
|
||||
} else if a.is_ascii_lowercase() && c.is_ascii_uppercase() {
|
||||
s.switches += 1;
|
||||
}
|
||||
if (a.is_ascii_lowercase() && c.is_ascii_uppercase()) || (a.is_ascii_uppercase() && c.is_ascii_lowercase()) {
|
||||
s.case_switches += 1;
|
||||
}
|
||||
if i < t.len() - 1 && c.is_ascii_lowercase() && c != b'i' && a.is_ascii_uppercase() && t[i + 1].is_ascii_uppercase() {
|
||||
s.lone += 1;
|
||||
}
|
||||
}
|
||||
s.inner_upper = INNER_UPPER.is_match(t);
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
|
||||
pub enum TokenKind {
|
||||
Word,
|
||||
Random,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
/// What one alphanumeric token of a name is: a word (letters only, in one case or Capitalized, 3+ long, not a
|
||||
/// packaging word), a scramble, or neither (digits, codes, CamelCase, short tokens). Kept in step with readable.ts.
|
||||
pub fn token_kind(t: &[u8]) -> TokenKind {
|
||||
let n = t.len();
|
||||
if t.iter().all(u8::is_ascii_digit) || NOISE_TOKEN.is_match(t) || CODE_TOKEN.is_match(t) {
|
||||
return TokenKind::Neutral;
|
||||
}
|
||||
if t.iter().all(u8::is_ascii_alphabetic) {
|
||||
let s = token_signals(&scene_case(t));
|
||||
let capitalized = t[0].is_ascii_uppercase() && t[1..].iter().all(u8::is_ascii_lowercase);
|
||||
if s.case_switches == 0 || (s.case_switches == 1 && capitalized) {
|
||||
// A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word.
|
||||
if n >= 10 && s.vowels * 6 < n {
|
||||
return TokenKind::Random;
|
||||
}
|
||||
return if n >= 3 { TokenKind::Word } else { TokenKind::Neutral };
|
||||
}
|
||||
// Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs.
|
||||
// Two CamelCase words with few vowels (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone
|
||||
// letter; a scramble has more runs, a lone letter or an inner capital run.
|
||||
if n >= 6 && s.case_switches >= 2 && s.vowels * 4 < n && (s.runs >= 5 || s.lone >= 1 || s.inner_upper) {
|
||||
return TokenKind::Random;
|
||||
}
|
||||
return TokenKind::Neutral;
|
||||
}
|
||||
if n >= 8 && t.iter().all(u8::is_ascii_hexdigit) && t.iter().filter(|c| c.is_ascii_digit()).count() >= 2 {
|
||||
return TokenKind::Random;
|
||||
}
|
||||
if t.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
|
||||
// 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay.
|
||||
return if n >= 12 && token_signals(t).switches >= 6 { TokenKind::Random } else { TokenKind::Neutral };
|
||||
}
|
||||
// Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym.
|
||||
if t.split(|c: &u8| !c.is_ascii_alphabetic()).filter(|w| !w.is_empty()).all(|w| {
|
||||
let vowels = w.iter().filter(|c| matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u' | b'y')).count();
|
||||
(w.len() >= 2 && w.iter().all(u8::is_ascii_uppercase))
|
||||
|| (w.len() >= 2 && w[1..].iter().all(u8::is_ascii_lowercase) && (w.len() >= 3 || w[0].is_ascii_lowercase()) && vowels * 5 >= w.len())
|
||||
}) {
|
||||
return TokenKind::Neutral;
|
||||
}
|
||||
let s = token_signals(&scene_case(t));
|
||||
if n >= 5 && s.switches >= 3 {
|
||||
return TokenKind::Random;
|
||||
}
|
||||
if n >= 6 && s.switches >= 2 && (s.lone >= 1 || s.inner_upper || s.max_consonants >= 4) {
|
||||
return TokenKind::Random;
|
||||
}
|
||||
// Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus").
|
||||
if n >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.max_lower <= 2 {
|
||||
return TokenKind::Random;
|
||||
}
|
||||
TokenKind::Neutral
|
||||
}
|
||||
|
||||
/// A file or release name no one can read (readable.ts isUnreadableName): looks_random's shapes, or more scrambled
|
||||
/// tokens than words by letter count ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ"; not
|
||||
/// "Paul McCartney - Band on the Run", "ShesSoWild - Ester Ana 1080p").
|
||||
pub fn is_unreadable_name(name: &str) -> bool {
|
||||
let stem = file_stem(name);
|
||||
let stem = NZBSPLIT_HASH.replace(stem.as_bytes(), &b" "[..]);
|
||||
if looks_random(&String::from_utf8_lossy(&stem)) {
|
||||
return true;
|
||||
}
|
||||
let mut words = 0;
|
||||
let mut random = 0;
|
||||
for t in stem.split(|c| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()) {
|
||||
match token_kind(t) {
|
||||
TokenKind::Word => words += t.len(),
|
||||
TokenKind::Random => random += t.len(),
|
||||
TokenKind::Neutral => {}
|
||||
}
|
||||
}
|
||||
random > 0 && random > words
|
||||
}
|
||||
|
||||
/// The class of an unnamed release (lib/release/classes.ts pendingClass; LOG.md (130)): size bucket × name shape ×
|
||||
/// partial, 1..70. The lease's dropped classes ("tool\tgroup\tklass") are skipped at the edge.
|
||||
pub fn pending_class(name: &str, bytes: u64, partial: bool) -> u8 {
|
||||
let size = [1_000_000u64, 10_000_000, 100_000_000, 1_000_000_000].iter().filter(|&&b| bytes >= b).count() as u8;
|
||||
let s = match name.rfind('.') {
|
||||
Some(i) if (1..=4).contains(&(name.len() - i - 1)) && name[i + 1..].bytes().all(|b| b.is_ascii_alphanumeric()) => &name[..i],
|
||||
_ => name,
|
||||
};
|
||||
let b = s.as_bytes();
|
||||
let all = |f: fn(&u8) -> bool| !b.is_empty() && b.iter().all(f);
|
||||
let shape: u8 = if b.len() >= 16 && all(u8::is_ascii_hexdigit) {
|
||||
1
|
||||
} else if b.len() >= 6 && all(u8::is_ascii_digit) {
|
||||
2
|
||||
} else if b.len() >= 20 && all(|c| c.is_ascii_lowercase() || (b'2'..=b'7').contains(c)) && b.iter().any(|c| (b'2'..=b'7').contains(c)) {
|
||||
3
|
||||
} else if all(u8::is_ascii_alphabetic) {
|
||||
4
|
||||
} else if all(u8::is_ascii_alphanumeric) {
|
||||
5
|
||||
} else if all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')) {
|
||||
6
|
||||
} else {
|
||||
7
|
||||
};
|
||||
size * 14 + (shape - 1) * 2 + u8::from(partial) + 1
|
||||
}
|
||||
|
||||
/// A class as droppedClasses lists it.
|
||||
pub fn class_key(tool: &str, group: &str, klass: u8) -> String {
|
||||
format!("{tool}\t{group}\t{klass}")
|
||||
}
|
||||
|
||||
/// looksScrambled: at least half the letters and digits of a file's stem in tokens of 5 or more that switch case
|
||||
/// (lower to upper) or between letters and digits 3 times or more ("nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa").
|
||||
/// The names a random-subject-per-article upload gives each article that looksRandom misses for their separators.
|
||||
pub fn looks_scrambled(name: &str) -> bool {
|
||||
let stem = file_stem(name);
|
||||
let (mut total, mut scrambled) = (0, 0);
|
||||
for token in stem.as_bytes().split(|c| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()) {
|
||||
total += token.len();
|
||||
let switches = token.windows(2).filter(|w| (w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase()) || w[0].is_ascii_digit() != w[1].is_ascii_digit()).count();
|
||||
if token.len() >= 5 && switches >= 3 {
|
||||
scrambled += token.len();
|
||||
}
|
||||
}
|
||||
total >= 8 && scrambled * 2 >= total
|
||||
}
|
||||
|
||||
static SEVEN_SPLIT: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z\.([0-9]{1,4})$"));
|
||||
static SEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z$"));
|
||||
static PART1: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part0*1\.rar$"));
|
||||
@@ -1237,7 +1496,45 @@ pub fn explored(key: &str, percent: f64) -> bool {
|
||||
}
|
||||
|
||||
static SIDECAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$"));
|
||||
|
||||
/// A sidecar by its extension (the judge's SIDECAR): no release of its own, and no album's member.
|
||||
pub fn is_sidecar(name: &str) -> bool {
|
||||
test(&SIDECAR, name)
|
||||
}
|
||||
static EXECUTABLE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(exe|scr|com|pif|bat|cmd|vbs|lnk)SP*$"));
|
||||
/// An unsplit archive (the judge's ARCHIVE and VOLUME): one zip, rar or 7z that is not a volume of a set.
|
||||
static ARCHIVE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(zip|rar|7z)$"));
|
||||
/// "cneg" is "part" in ROT13: the a.b.erotica posts named that way are volumes of passworded videos (LOG.md (128)).
|
||||
static VOLUME: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.((part|cneg)[0-9]+\.rar|r[0-9][0-9]|z[0-9][0-9]|7z\.[0-9][0-9][0-9]|[0-9][0-9][0-9])$"));
|
||||
/// A poster at a free mail provider (the judge's WEBMAIL): the stub floods' throwaway accounts.
|
||||
static WEBMAIL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
re(r"(?i)@(gmail|googlemail|yahoo|hotmail|outlook|live|msn|aol|gmx|web|t-online|freenet|mail|yandex|icloud|me|hushmail|protonmail|libero|orange|free|arcor|wanadoo|ymail|rocketmail|inbox|mail\.ru|bk|list)\.")
|
||||
});
|
||||
/// A name that reads as a release (the judge's RELEASE_LIKE): a year, a scene tag, a group suffix or dotted words.
|
||||
static RELEASE_LIKE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
re(r"(?i)(\b(19|20)[0-9][0-9]\b|[._-](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s[0-9][0-9]e[0-9][0-9]|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v[0-9]+(\.[0-9]+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)")
|
||||
});
|
||||
|
||||
/// RELEASE_LIKE with spaces as separators too: the tiny fakes are posted under names written either way.
|
||||
static RELEASE_LIKE_SPACED: LazyLock<Regex> = LazyLock::new(|| {
|
||||
re(r"(?i)(\b(19|20)[0-9][0-9]\b|[._ -](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s[0-9][0-9]e[0-9][0-9]|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v[0-9]+(\.[0-9]+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)")
|
||||
});
|
||||
|
||||
/// The stub-archive rule (LOG.md (123)-(126), (128)): one unsplit archive, no par2, settled, posted to 2+ groups,
|
||||
/// under STUB_MAX_BYTES (the fakes stop at ~90 MB; the hash-named uploads, real albums and passworded videos above
|
||||
/// it don't), and its articles at most STUB_ARTICLE_BYTES with the release at least STUB_MIN_BYTES (Power-Post's
|
||||
/// 250 kB default; a one-article release always has small articles, so e-books and sidecars are exempt), or 4+
|
||||
/// groups, or a webmail poster.
|
||||
pub const STUB_ARTICLE_BYTES: u64 = 262_144;
|
||||
pub const STUB_MIN_BYTES: u64 = 1_000_000;
|
||||
pub const STUB_MAX_BYTES: u64 = 100_000_000;
|
||||
pub const STUB_WIDE_GROUPS: usize = 4;
|
||||
/// The nonsense-piece rule: one article holding one archive of NONSENSE_MIN_BYTES to NONSENSE_MAX_BYTES under a
|
||||
/// release-like name that isn't scrambled (the newznzb.* pieces; under 100 kB are cue/log sidecars and adverts).
|
||||
pub const NONSENSE_MIN_BYTES: u64 = 100_000;
|
||||
pub const NONSENSE_MAX_BYTES: u64 = 1_000_000;
|
||||
/// The tiny-archive rule's floor: a RAR holding one exe stub is 2 kB and up; below are empty archives and nfo rars.
|
||||
pub const TINY_MIN_BYTES: u64 = 2_000;
|
||||
|
||||
pub struct JudgedFile<'a> {
|
||||
pub name: &'a str,
|
||||
@@ -1245,6 +1542,79 @@ pub struct JudgedFile<'a> {
|
||||
pub groups: &'a [String],
|
||||
pub first_message_id: &'a str,
|
||||
pub poster: &'a str,
|
||||
/// Articles seen and their bytes (the overview's :bytes).
|
||||
pub segments: u32,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// One unsplit archive and nothing else.
|
||||
fn single_archive<'a, 'b>(files: &'b [JudgedFile<'a>]) -> Option<&'b JudgedFile<'a>> {
|
||||
match files {
|
||||
[f] if test(&ARCHIVE, f.name) && !test(&VOLUME, f.name) => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups whose communities post real single small archives (karaoke zips, e-books, comics, tabs, fonts) the way
|
||||
/// the stub floods do (Power-Post defaults, a crosspost or two): a release touching one is exempt from the
|
||||
/// stub-archive rule. Costs 16 of 32,308 known fakes in the 217k sample (LOG.md (127)).
|
||||
static STUB_EXEMPT_GROUPS: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)(sound|mp3|karaoke|music|audio|flac|lossless|e-?book|epub|comic|guitar|font|magazine)"));
|
||||
|
||||
/// The legs of the stub-archive rule a settled single archive under STUB_MAX_BYTES trips ("A" article size, "X" 4+
|
||||
/// groups, "W" webmail), empty when none.
|
||||
pub fn stub_archive_legs(f: &JudgedFile) -> String {
|
||||
let mut legs = String::new();
|
||||
if f.groups.len() < 2 || f.bytes >= STUB_MAX_BYTES || f.groups.iter().any(|g| test(&STUB_EXEMPT_GROUPS, g)) {
|
||||
return legs;
|
||||
}
|
||||
let article = f.bytes / u64::from(f.segments.max(1));
|
||||
if article <= STUB_ARTICLE_BYTES && f.bytes >= STUB_MIN_BYTES {
|
||||
legs.push('A');
|
||||
}
|
||||
if f.groups.len() >= STUB_WIDE_GROUPS {
|
||||
legs.push('X');
|
||||
}
|
||||
if test(&WEBMAIL, f.poster) {
|
||||
legs.push('W');
|
||||
}
|
||||
legs
|
||||
}
|
||||
|
||||
/// An e-book by its name ("Author - Title (2020) (epub).rar"): one small article is a book, not a nonsense piece.
|
||||
static EBOOK: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(epub|mobi|azw3?|pdf|djvu|cbr|cbz|e-?books?)\b"));
|
||||
|
||||
/// A nonsense piece: one article, one small archive under a release-like, unscrambled name without spaces (the
|
||||
/// newznzb.* pieces are scene-styled or random with a "-XX" suffix; books and albums are written with spaces).
|
||||
pub fn is_nonsense_piece(f: &JudgedFile) -> bool {
|
||||
f.total_parts == 1
|
||||
&& f.segments == 1
|
||||
&& (NONSENSE_MIN_BYTES..NONSENSE_MAX_BYTES).contains(&f.bytes)
|
||||
&& !f.name.contains(' ')
|
||||
&& test(&RELEASE_LIKE, &file_stem(f.name))
|
||||
&& !test(&EBOOK, f.name)
|
||||
&& !looks_scrambled(f.name)
|
||||
&& !is_random_file(f.name)
|
||||
}
|
||||
|
||||
/// Words of the real tiny archives under release-like names: subtitles, cracks, trainers and keygens (a few kB of
|
||||
/// exe is the content), covers and sidecars.
|
||||
static TINY_CONTENT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
re(r"(?i)\b(subs?|subtitles?|subpack|srt|untertitel|ondertitels?|ondertiteling|legendas?|napisy|sottotitoli|cracks?|cracked|keygen|keymaker|trainer|patch|patched|serials?|activator|loader|nocd|no-cd|fix|covers?|artwork|booklet|scans?|proof|sample|info|readme)\b")
|
||||
});
|
||||
|
||||
/// A tiny archive: one article of TINY_MIN_BYTES to NONSENSE_MIN_BYTES holding one archive under a release-like,
|
||||
/// unscrambled name (spaces allowed), outside the groups that post real small archives, and without an e-book,
|
||||
/// subtitle, crack or sidecar word: the 2015-17 exe-stub flood in miniature (LOG.md (131)).
|
||||
pub fn is_tiny_archive(f: &JudgedFile) -> bool {
|
||||
f.total_parts == 1
|
||||
&& f.segments == 1
|
||||
&& (TINY_MIN_BYTES..NONSENSE_MIN_BYTES).contains(&f.bytes)
|
||||
&& !f.groups.iter().any(|g| test(&STUB_EXEMPT_GROUPS, g))
|
||||
&& test(&RELEASE_LIKE_SPACED, &file_stem(f.name))
|
||||
&& !test(&EBOOK, f.name)
|
||||
&& !test(&TINY_CONTENT, f.name)
|
||||
&& !looks_scrambled(f.name)
|
||||
&& !is_random_file(f.name)
|
||||
}
|
||||
|
||||
pub enum Verdict {
|
||||
@@ -1260,12 +1630,19 @@ pub struct Rules {
|
||||
pub priorities: rustc_hash::FxHashMap<String, u32>,
|
||||
/// "tool\tgroup"
|
||||
pub dropped_signatures: rustc_hash::FxHashSet<String>,
|
||||
/// "tool\tgroup\tklass" (pending_class): unnamed releases of these classes don't go to the pending store.
|
||||
pub dropped_classes: rustc_hash::FxHashSet<String>,
|
||||
pub dropped_tools: rustc_hash::FxHashSet<String>,
|
||||
pub per_article_max_parts: u32,
|
||||
pub sample_percent: f64,
|
||||
pub drop_executables: bool,
|
||||
/// Fragments of the poster lowercased without punctuation (droppedPosters).
|
||||
pub dropped_posters: Vec<String>,
|
||||
/// The stub-archive rule (dropStubArchives), the nonsense-piece rule (dropNonsensePieces) and the tiny-archive
|
||||
/// rule (dropTinyArchives).
|
||||
pub drop_stub_archives: bool,
|
||||
pub drop_nonsense_pieces: bool,
|
||||
pub drop_tiny_archives: bool,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
@@ -1275,11 +1652,15 @@ impl Rules {
|
||||
version: 1,
|
||||
priorities,
|
||||
dropped_signatures: Default::default(),
|
||||
dropped_classes: Default::default(),
|
||||
dropped_tools: ["ngPost", "hex32", "yuun", "JBinUp", "random"].iter().map(|t| t.to_string()).collect(),
|
||||
per_article_max_parts: 3,
|
||||
sample_percent: 1.0,
|
||||
drop_executables: true,
|
||||
dropped_posters: ["celebmovie", "celebwow", "celebfx", "iluvcelebs", "nudecelebs", "celebsextapes"].iter().map(|p| p.to_string()).collect(),
|
||||
drop_stub_archives: true,
|
||||
drop_nonsense_pieces: true,
|
||||
drop_tiny_archives: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1289,8 +1670,9 @@ pub fn poster_words(poster: &str) -> String {
|
||||
poster.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// RuleSet.judge.
|
||||
pub fn judge(release_key: &str, files: &[JudgedFile], rules: &Rules) -> Verdict {
|
||||
/// RuleSet.judge. `settled`: the window closed on the release (a par2 can still join an open one), which the
|
||||
/// single-archive rules need.
|
||||
pub fn judge(release_key: &str, files: &[JudgedFile], settled: bool, rules: &Rules) -> Verdict {
|
||||
let sample = explored(release_key, rules.sample_percent);
|
||||
if let (false, Some(first)) = (rules.dropped_posters.is_empty(), files.first()) {
|
||||
let words = poster_words(first.poster);
|
||||
@@ -1304,6 +1686,20 @@ pub fn judge(release_key: &str, files: &[JudgedFile], rules: &Rules) -> Verdict
|
||||
let key = std::str::from_utf8(&c[1]).unwrap().to_lowercase();
|
||||
return Verdict::Drop { rule: "executable", key, sample };
|
||||
}
|
||||
if let (true, Some(f)) = (settled, single_archive(files)) {
|
||||
if rules.drop_stub_archives {
|
||||
let legs = stub_archive_legs(f);
|
||||
if !legs.is_empty() {
|
||||
return Verdict::Drop { rule: "stub-archive", key: legs, sample };
|
||||
}
|
||||
}
|
||||
if rules.drop_nonsense_pieces && is_nonsense_piece(f) {
|
||||
return Verdict::Drop { rule: "nonsense-piece", key: upload_tool(f.first_message_id).to_string(), sample };
|
||||
}
|
||||
if rules.drop_tiny_archives && is_tiny_archive(f) {
|
||||
return Verdict::Drop { rule: "tiny-archive", key: upload_tool(f.first_message_id).to_string(), sample };
|
||||
}
|
||||
}
|
||||
if files.iter().any(|f| !is_random_file(f.name)) {
|
||||
return Verdict::Keep;
|
||||
}
|
||||
@@ -1334,6 +1730,29 @@ mod tests {
|
||||
assert_eq!(file_stem_re("Morning Lovers.wmv.058"), "Morning Lovers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrambled_names() {
|
||||
for n in ["nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa", "PNSVWSV-xLaNv6o5qS840e3lwSZyH", "Biq-YsXu8G33ktt"] {
|
||||
assert!(looks_scrambled(n), "{n}");
|
||||
}
|
||||
// Short ones of the spam too (8% of alt.binaries.storage's): too few switches to tell.
|
||||
for n in ["A1Monitor v6.2.2", "PDF2Word v1.3", "Some.Show.S01E01.1080p.WEB.part1.rar", "MacGyver.S01E01.mkv", "Jimmy and the Crawler (mobi)", "Q25m.PDUYtbA", "82WH.2iuTK"] {
|
||||
assert!(!looks_scrambled(n), "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_names() {
|
||||
let porn4you = "webmaster@hdbangvidz.com (Porn4You)";
|
||||
let a = release_identity(porn4you, "Babe with Plump Bubble Butt Fucked and Facialized 002/477] - 0178.jpg", "0178.jpg");
|
||||
let b = release_identity(porn4you, "Babe with Plump Bubble Butt Fucked and Facialized 003/477] - 0179.jpg", "0179.jpg");
|
||||
assert_eq!(a.name, "Babe with Plump Bubble Butt Fucked and Facialized");
|
||||
assert_eq!(a.key, b.key);
|
||||
assert_eq!(display_name("[Group] Show (2020)"), "[Group] Show (2020)");
|
||||
assert_eq!(display_name("( Show [x] name"), "Show [x] name");
|
||||
assert_eq!(display_name("]"), "]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remainders() {
|
||||
assert_eq!(subject_remainder(r#"Some.Release [01/45] - "x.rar" yEnc (1/50) 7168"#), "Some Release");
|
||||
@@ -1446,6 +1865,46 @@ mod tests {
|
||||
assert_eq!(id(r#"[2/6] - "x.jpg" yEnc"#, "x.jpg").key, id(r#"[1/6] - "x.part01.rar" yEnc"#, "x.part01.rar").key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_classes() {
|
||||
use super::pending_class;
|
||||
// Size bucket × shape × partial: a 5 MB hex name whole, the same partial, a 500 MB scramble with separators.
|
||||
assert_eq!(pending_class("c04212a389cf69edc54767355ab5285f", 5_000_000, false), 15);
|
||||
assert_eq!(pending_class("c04212a389cf69edc54767355ab5285f", 5_000_000, true), 16);
|
||||
assert_eq!(pending_class("Zx5AjNaK-mh7CFg6Hf8uu", 500_000_000, false), 3 * 14 + 5 * 2 + 1);
|
||||
assert_eq!(pending_class("1650777167", 100, false), 3);
|
||||
assert_eq!(pending_class("mrrtmzrzmi4dgmrzmyytqmlbztczbx", 2_000_000_000, false), 4 * 14 + 2 * 2 + 1);
|
||||
assert_eq!(pending_class("PsNdnYiugfZ.rar", 20_000_000, false), 2 * 14 + 3 * 2 + 1);
|
||||
assert_eq!(pending_class("n5d1ueIf34X", 20_000_000, true), 2 * 14 + 4 * 2 + 2);
|
||||
assert_eq!(pending_class("sgkra vU7u2yuw9LDLCjDu04pOez", 999, false), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_names() {
|
||||
use super::is_unreadable_name;
|
||||
// Scrambles with separators, spaces, a random "extension", a packaging word, a hex with an addon (LOG.md (129)).
|
||||
for n in [
|
||||
"Zx5AjNaK-mh7CFg6Hf8uu.part01.rar", "yWjfnT2ujG4wJj3fG46o0FLzsgkvhmrTsAfW2.E0WsOU", "PsNdnYiugfZ.rar", "phfplC9y07U2yrj", "n5d1ueIf34X",
|
||||
"tJqr1tq7xK", "Description - Lt5nHjlo3RiuBb3C.par2", "bd5b4a59f3aa87114dcf75c0-addon.rar", "mrrtmzrzmi4dgmrzmyytqmlbztczbx",
|
||||
"AA-NaR16302830434zxP32108309f3Ab", "_QBs01_MqZYnYwM_iAyRquhc-", "sgkra vU7u2yuw9LDLCjDu04pOez", "Jk5Pud.zQdNj", "7uKsD_NRgT-G_Uc_p8",
|
||||
"NtpUgCmS_Xa1B2Pk", "{E11BF8E9-E2C9-4DAE-A1B2-8027BC465F3F}", "IkYovjrtZrItjQ.mkv", "ff75ce3f16c746c18b2d78cf.6c52f",
|
||||
] {
|
||||
assert!(is_unreadable_name(n), "{n}");
|
||||
}
|
||||
// Real names: scene tags, CamelCase, codes, leet, an acronym, a hash beside words.
|
||||
for n in [
|
||||
"Dark.Matter.2024.S02E10.No.Quiet.Life.1080p.ATVP.WEB-DL.DDP5.1.Atmos.H.264-Kitsune", "Paul McCartney - Band on the Run",
|
||||
"ShesSoWild - Ester Ana 1080p", "MyDadsHotGirlfriend.13.02.01.Luna.Star.XXX.1080p.x264-SEXORS", "cFosSpeed 11.0 Build 2420 x86 x64 incl Patch.rar",
|
||||
"Rocketship-Hurt_Feelings-WEB-2026-PreMaxXxing", "Drum-and-BassDJ_Samurai-Sound_With_Fire_Artemis-(FQY026)-WEB-2006-PRESCRiPTiON",
|
||||
"Kleverig-H4rd-N12uk3b18-luc16037-720p-smpwr", "Eclipse_IDE_2023-12_R_win32_x86_64.zip", "PartsHPGL 1.06c", "Go4Customer Fax Machine v1.2b",
|
||||
"AdBeGone 1.2", "3DMark03 320", "COD4MW2REMASTERED", "Movie.2020.1080p-GRP [a3f9e2b1]", "EPL__NZBSPLIT__f1fdb433772a1a7771d6b73c875009ff__NZBSPLIT__EPL.2026.Liverpool.vs.Brighton.1080pEN50fps",
|
||||
"iPhone", "Some.Show.S01E01.1080p.mkv", "FastFilms 2.04", "BestCrypt NP v4.0", "EasyHTML 2.35 German", "SchiffsModell - September 2026",
|
||||
"CrossStitcher - Issue 440, October 2026", "Laura15Juli2026", "IHaveAWife - Isa Bella", "DickHDDaily - Santana Red", "1904-SmithsWork-Hasluck",
|
||||
] {
|
||||
assert!(!is_unreadable_name(n), "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_names() {
|
||||
assert!(looks_random("prvjr7hapd0i1ny14pz4"));
|
||||
@@ -1486,8 +1945,8 @@ mod poster_rule_tests {
|
||||
|
||||
fn verdict(poster: &str, rules: &Rules) -> Option<(&'static str, String)> {
|
||||
let groups = vec!["alt.binaries.erotica".to_string()];
|
||||
let files = [JudgedFile { name: "!index0001.jpg", total_parts: 1, groups: &groups, first_message_id: "<a@b>", poster }];
|
||||
match judge("k", &files, rules) {
|
||||
let files = [JudgedFile { name: "!index0001.jpg", total_parts: 1, groups: &groups, first_message_id: "<a@b>", poster, segments: 1, bytes: 200_000 }];
|
||||
match judge("k", &files, true, rules) {
|
||||
Verdict::Keep => None,
|
||||
Verdict::Drop { rule, key, .. } => Some((rule, key)),
|
||||
}
|
||||
@@ -1507,6 +1966,111 @@ mod poster_rule_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod single_archive_rule_tests {
|
||||
use super::{judge, JudgedFile, Rules, Verdict};
|
||||
|
||||
const NYUU: &str = "FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu";
|
||||
|
||||
fn verdict(name: &str, poster: &str, groups: usize, segments: u32, bytes: u64, settled: bool, rules: &Rules) -> Option<(&'static str, String)> {
|
||||
let groups: Vec<String> = (0..groups).map(|i| format!("alt.binaries.g{i}")).collect();
|
||||
let files = [JudgedFile { name, total_parts: segments, groups: &groups, first_message_id: NYUU, poster, segments, bytes }];
|
||||
match judge("k", &files, settled, rules) {
|
||||
Verdict::Keep => None,
|
||||
Verdict::Drop { rule, key, .. } => Some((rule, key)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_archives_by_article_size_groups_or_webmail() {
|
||||
let rules = Rules::defaults(Default::default());
|
||||
// Power-Post's 250 kB articles, two groups, 12 MB: the 2016 wave.
|
||||
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com (Jahnunsinos007)", 2, 48, 12_000_000, true, &rules), Some(("stub-archive", "AW".to_string())));
|
||||
assert_eq!(verdict("Some.Movie.2014.720p.BluRay-GRP.rar", "Yenc@power-post.org (Yenc-PP-A&A)", 2, 48, 12_000_000, true, &rules), Some(("stub-archive", "A".to_string())));
|
||||
// Five groups, 319 kB in two articles: the 2015 a.b.cracks wave (under 1 MB, so the groups leg alone).
|
||||
assert_eq!(verdict("Big Hero 6 (2014) 720p BDRip [COMPRESSED].rar", "Yenc@power-post.org (Yenc-PP-A&A)", 5, 2, 319_000, true, &rules), Some(("stub-archive", "X".to_string())));
|
||||
// A webmail poster with 650 kB articles: the 2014 "leaked Sex Video" wave.
|
||||
assert_eq!(verdict("Leaked Sex Video.rar", "first.last88@gmail.com", 2, 400, 260_000_000, true, &rules), None, "over 100 MB without the article-size leg");
|
||||
assert_eq!(verdict("Leaked Sex Video.rar", "first.last88@gmail.com", 2, 100, 65_000_000, true, &rules), Some(("stub-archive", "W".to_string())));
|
||||
// Settled only: a par2 can still join an open release.
|
||||
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com", 2, 48, 12_000_000, false, &rules), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_and_single_group_and_split_archives_are_kept() {
|
||||
let rules = Rules::defaults(Default::default());
|
||||
// An e-book: one or two small articles always have small articles (LOG.md (126)).
|
||||
assert_eq!(verdict("Maeve Binchy - Tara Road (epub).rar", "kie@home.now (KiE)", 2, 2, 521_000, true, &rules), None);
|
||||
// One group: most real single rars.
|
||||
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com", 1, 48, 12_000_000, true, &rules), None);
|
||||
// Modern 700 kB articles, no webmail, two groups.
|
||||
assert_eq!(verdict("Some.Movie.2014.720p.BluRay-GRP.rar", "poster@example.org", 2, 100, 70_000_000, true, &rules), None);
|
||||
// A volume, not a single archive; a hash-named 2 GB upload from a gmail-looking poster.
|
||||
assert_eq!(verdict("Some.Movie.2014.part01.rar", "a@gmail.com", 2, 48, 12_000_000, true, &rules), None);
|
||||
assert_eq!(verdict("cbcd32359460df1fa9b7d01d8720.rar", "b648de0d1c0f7a9a77be273d@gmail.com", 2, 3163, 2_340_000_000, true, &rules), None);
|
||||
// ROT13-named volumes of passworded videos in a.b.erotica, 258 kB articles, two groups, 1-270 MB; and the
|
||||
// 100 MB cap on every leg (LOG.md (128)).
|
||||
assert_eq!(verdict("Ghful.17.12.07.Qnvvfl.Fgbar.cneg3.rar", "nospam@nospam.nl (EvH)", 2, 5, 1_147_000, true, &rules), None);
|
||||
assert_eq!(verdict("Ghful.16.07.05.Wbwb.Xvff.cneg1.rar", "nospam@nospam.nl (EvH)", 2, 1051, 271_000_000, true, &rules), None);
|
||||
assert_eq!(verdict("Tinasoft EasyCafe 2.2.14 by OmerSalaj.rar", "Seven7i@mail.com ({Seven7i})", 4, 463, 117_000_000, true, &rules), None);
|
||||
assert_eq!(verdict("Tipard DVD Ripper - 10 0 12 x86 x64 incl Patch.rar", "Seven7i@mail.com ({Seven7i})", 4, 244, 62_000_000, true, &rules), Some(("stub-archive", "AXW".to_string())));
|
||||
let off = Rules { drop_stub_archives: false, ..Rules::defaults(Default::default()) };
|
||||
assert_eq!(verdict("Chat Watch 1.2.1.zip", "jahnunsinos007@gmail.com", 2, 48, 12_000_000, true, &off), None);
|
||||
// A karaoke zip crossposted to a.b.test with Power-Post's articles: its group exempts it (LOG.md (127)).
|
||||
let groups = vec!["alt.binaries.sounds.karaoke".to_string(), "alt.binaries.test".to_string()];
|
||||
let files = [JudgedFile { name: "KV-1055806 - Walker Hayes - U Gurl.zip", total_parts: 13, groups: &groups, first_message_id: NYUU, poster: "home@home.com (TexasDj)", segments: 13, bytes: 3_300_000 }];
|
||||
assert!(matches!(judge("k", &files, true, &rules), Verdict::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_pieces() {
|
||||
let rules = Rules::defaults(Default::default());
|
||||
// One 790 kB article under a release-like name: the newznzb.* pieces.
|
||||
assert_eq!(verdict("Some.Show.S01E01.1080p.WEB-DL.rar", "poster@example.org", 1, 1, 790_000, true, &rules), Some(("nonsense-piece", "nyuu".to_string())));
|
||||
// A 3 kB cue/log sidecar rar with a year in its name: under this rule's floor (the tiny-archive rule's outside the music groups).
|
||||
assert_eq!(verdict("The Smiths-the queen is dead(Remaster)(2011).rar", "Yenc@power-post.org (Sir Cedrik)", 1, 1, 3_000, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
|
||||
// A book (a year or a format in its name, written with spaces), a dotted one too: not a piece.
|
||||
assert_eq!(verdict("Kevin Ashman - [Roman 02] - The Rise of Caratacus (2020) (epub).rar", "fake@address.com (Psychopath)", 2, 1, 303_000, true, &rules), None);
|
||||
assert_eq!(verdict("One.Wrong.Turn.Deanna.Lynn.Sletten.epub.rar", "fake@address.com (Psychopath)", 1, 1, 382_000, true, &rules), None);
|
||||
assert_eq!(verdict("Vieweg.Teubner.Verlag.-.Numerische.Mathematik.2011.German.RETAIL.eBook-BitBook.rar", "saggir@domain.com", 1, 1, 113_000, true, &rules), None);
|
||||
// Two articles, or no release-like name, or scrambled: not this rule.
|
||||
assert_eq!(verdict("Some.Show.S01E01.1080p.WEB-DL.rar", "poster@example.org", 1, 2, 790_000, true, &rules), None);
|
||||
assert_eq!(verdict("holiday snaps.rar", "poster@example.org", 1, 1, 790_000, true, &rules), None);
|
||||
assert_eq!(verdict("nYDeXeF49dieZ1J7flK-JFzZ3j.rar", "poster@example.org", 1, 1, 790_000, true, &rules), None);
|
||||
let off = Rules { drop_nonsense_pieces: false, ..Rules::defaults(Default::default()) };
|
||||
assert_eq!(verdict("Some.Show.S01E01.1080p.WEB-DL.rar", "poster@example.org", 1, 1, 790_000, true, &off), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_archives() {
|
||||
let rules = Rules::defaults(Default::default());
|
||||
// One article of 2-100 kB under a release name, dotted or with spaces: a RAR holding "<title>.exe" (LOG.md (131)).
|
||||
assert_eq!(verdict("Humans.S01E04.German.DD20.Dubbed.DL.1080p.BD.x264-TVS.rar", "Yanc@power-post.org <Yanc@power-post.org>", 1, 1, 15_084, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
|
||||
assert_eq!(verdict("Cicero Magazin fuer politische Kultur Mai 05 2016.rar", "WarezGeek@WarezGeek.org (WarezGeek)", 1, 1, 17_478, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
|
||||
// A scene name without a year, a tag or a group suffix: not release-like, so kept (the flood's porn names mostly carry one).
|
||||
assert_eq!(verdict("[BigTitsAtWork Brazzers] Eva Angelina (Camera Cums In Handy).rar", "Yanc@power-post.org", 2, 1, 23_449, true, &rules), None);
|
||||
assert_eq!(verdict("Graphisoft.Archicad.v14.0.GERMAN.iSO.X86-PillePalle.rar", "WarezGeek@WarezGeek.org (WarezGeek)", 1, 1, 7_869, true, &rules), Some(("tiny-archive", "nyuu".to_string())));
|
||||
// Subtitles, a crack, a trainer, a cover, an e-book: the real tiny archives.
|
||||
assert_eq!(verdict("The.Big.Bang.Theory.S04E13.720p.HDTV.subs.rar", "henk@example.org", 1, 1, 38_000, true, &rules), None);
|
||||
assert_eq!(verdict("Tipard.DVD.Ripper.v10.0.12.Crack-XXX.rar", "poster@example.org", 1, 1, 60_000, true, &rules), None);
|
||||
assert_eq!(verdict("Far.Cry.4.v1.7.0.Plus.12.Trainer-FLiNG.rar", "poster@example.org", 1, 1, 45_000, true, &rules), None);
|
||||
assert_eq!(verdict("Some Band - Album (2011) covers.rar", "poster@example.org", 1, 1, 45_000, true, &rules), None);
|
||||
assert_eq!(verdict("One.Wrong.Turn.Deanna.Lynn.Sletten.epub.rar", "fake@address.com (Psychopath)", 1, 1, 38_000, true, &rules), None);
|
||||
// A karaoke zip in its group; a plain name; a scrambled one; two articles; under the floor; above 100 kB is the nonsense-piece rule's.
|
||||
let groups = vec!["alt.binaries.sounds.karaoke".to_string()];
|
||||
let files = [JudgedFile { name: "KV-1055806 - Walker Hayes - U Gurl (2017).zip", total_parts: 1, groups: &groups, first_message_id: NYUU, poster: "home@home.com", segments: 1, bytes: 60_000 }];
|
||||
assert!(matches!(judge("k", &files, true, &rules), Verdict::Keep));
|
||||
assert_eq!(verdict("holiday snaps.rar", "poster@example.org", 1, 1, 20_000, true, &rules), None);
|
||||
assert_eq!(verdict("nYDeXeF49dieZ1J7flK-JFzZ3j.rar", "poster@example.org", 1, 1, 20_000, true, &rules), None);
|
||||
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 2, 20_000, true, &rules), None);
|
||||
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 1_500, true, &rules), None);
|
||||
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 150_000, true, &rules), Some(("nonsense-piece", "nyuu".to_string())));
|
||||
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 20_000, false, &rules), None);
|
||||
let off = Rules { drop_tiny_archives: false, ..Rules::defaults(Default::default()) };
|
||||
assert_eq!(verdict("Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar", "poster@example.org", 1, 1, 20_000, true, &off), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod image_set_tests {
|
||||
use super::{release_identity, sized_name, subject_remainder, subject_remainder_re};
|
||||
|
||||
+5
-1
@@ -27,6 +27,8 @@ struct WireRelease<'a> {
|
||||
finished: bool,
|
||||
edge: bool,
|
||||
partial: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool: Option<&'a str>,
|
||||
payload: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<&'a str>,
|
||||
@@ -42,6 +44,7 @@ struct WireCandidate<'a> {
|
||||
kind: &'a str,
|
||||
message_id: &'a str,
|
||||
file: &'a str,
|
||||
bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -87,6 +90,7 @@ fn release<'a>(r: &'a ReleaseChunk, rule: Option<(&'a str, &'a str)>) -> WireRel
|
||||
finished: r.finished,
|
||||
edge: r.edge,
|
||||
partial: r.partial,
|
||||
tool: r.tool,
|
||||
payload: base64::engine::general_purpose::STANDARD.encode(&r.payload),
|
||||
rule: rule.map(|(rule, _)| rule),
|
||||
rule_key: rule.map(|(_, key)| key),
|
||||
@@ -94,7 +98,7 @@ fn release<'a>(r: &'a ReleaseChunk, rule: Option<(&'a str, &'a str)>) -> WireRel
|
||||
}
|
||||
|
||||
fn candidate(c: &Candidate) -> WireCandidate<'_> {
|
||||
WireCandidate { release_key: &c.release_key, upload: c.upload, kind: c.kind, message_id: &c.message_id, file: &c.file }
|
||||
WireCandidate { release_key: &c.release_key, upload: c.upload, kind: c.kind, message_id: &c.message_id, file: &c.file, bytes: c.bytes }
|
||||
}
|
||||
|
||||
/// toWire(batch) as JSON bytes.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
export { RangeIndexer, postedSpan, chunkOf, candidatesOf, releaseKeyOf, uploadsOf, UPLOAD_GAP_MS, type EdgeBatch, type ReleaseChunk, type DroppedChunk, type ProbeCandidate, type CandidateKind, type RuleCount, type Fragment, type RangeIndexerOptions } from './range_indexer.js'
|
||||
export { encodeFrame, decodeFrames, isKeepalive, KEEPALIVE, type Frame } from './stream.js'
|
||||
export { toWire, fromWire, type WireBatch } from './wire.js'
|
||||
export type { Lease } from './lease.js'
|
||||
export type { Lease, GroupLease, Lane, NextRange } from './lease.js'
|
||||
export type { EdgeSegment, FileMeta } from './scan.js'
|
||||
export { throttle } from './throttle.js'
|
||||
export { filterOverviews, undelta, type ChunkDrops } from './corpus_filter.js'
|
||||
@@ -5,3 +5,27 @@ export interface Lease {
|
||||
range: { id: number; group: string; start: number; end: number; next: number }
|
||||
rules: Rules
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's two cursors (REBUILD.md, "Voyager 3: window indexing", 1): `history` works the ranges the lease bounds
|
||||
* hand out (the windows, the history), `tail` the tail planner's ranges. Each lane has its own holder, window and
|
||||
* snapshot; the group routes take `?lane=` (history by default).
|
||||
*/
|
||||
export type Lane = 'history' | 'tail'
|
||||
|
||||
/**
|
||||
* A group lease (REBUILD.md, "Voyager 3: window indexing", 1): the group a worker thread holds, the lane, the rules,
|
||||
* and the snapshot of the lane's window when there is one (its next article; the body comes from GET
|
||||
* groups/:id/snapshot).
|
||||
*/
|
||||
export interface GroupLease {
|
||||
group: { id: number; name: string; priority: number }
|
||||
lane: Lane
|
||||
rules: Rules
|
||||
snapshot: { next: number } | null
|
||||
}
|
||||
|
||||
/** The group's next range in article order, leased to the holder (POST groups/:id/next; 204 when caught up). */
|
||||
export interface NextRange {
|
||||
range: { id: number; start: number; end: number; next: number }
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { hash } from 'node:crypto'
|
||||
import type { Overview } from '../nntp/index.js'
|
||||
import { FileAggregator, type AggregatedFile, isArchiveStart, isLaterVolume, sevenZipVolume } from '../files/index.js'
|
||||
import { RuleSet, isRandomFile, uploadTool, type Rules } from '../rules/index.js'
|
||||
import { RuleSet, isRandomFile, isScrambledFile, uploadTool, type Rules } from '../rules/index.js'
|
||||
import { classKey, isUnreadableName, pendingClass } from '../release/index.js'
|
||||
import { explored } from '../rules/upload_tools.js'
|
||||
import { SegmentScanner, scanOverviewText, type EdgeSegment } from './scan.js'
|
||||
import { encodeChunk, type ChunkFile } from '../chunks/index.js'
|
||||
|
||||
@@ -35,6 +37,11 @@ export interface ReleaseChunk {
|
||||
edge?: boolean
|
||||
/** A file of the release misses parts (the window closed on it): not complete, whatever the counter says. */
|
||||
partial?: boolean
|
||||
/**
|
||||
* Voyager 3, an unnamed release (readable false): the upload tool from its first article's message-id, its
|
||||
* signature with the group; the payload is empty (no chunk until a probe names it: pending_releases).
|
||||
*/
|
||||
tool?: string
|
||||
payload: Buffer
|
||||
}
|
||||
|
||||
@@ -53,11 +60,13 @@ export interface ProbeCandidate {
|
||||
kind: CandidateKind
|
||||
messageId: string
|
||||
file: string
|
||||
/** The target article's bytes: the cheapest probe first within a class (LOG.md (130)). */
|
||||
bytes?: number
|
||||
}
|
||||
|
||||
/** What a rule dropped in a batch (REBUILD.md, rules and recovery). */
|
||||
export interface RuleCount {
|
||||
rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'incomplete'
|
||||
rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'stub-archive' | 'nonsense-piece' | 'tiny-archive' | 'incomplete'
|
||||
key: string
|
||||
articles: number
|
||||
files: number
|
||||
@@ -118,6 +127,11 @@ export interface RangeIndexerOptions {
|
||||
* chunks seen so far), as uploads in busy groups spread over minutes.
|
||||
*/
|
||||
edgeArticles?: number
|
||||
/**
|
||||
* A file given up short of parts is a file of its release all the same (partial), as the Rust edge's window writes
|
||||
* it, not a fragment: the pending namer reads such a release again (LOG.md 2026-10-29 (121)).
|
||||
*/
|
||||
partialFiles?: boolean
|
||||
}
|
||||
|
||||
const MAX_CANDIDATES = 4
|
||||
@@ -236,12 +250,34 @@ export class RangeIndexer {
|
||||
this.completed = new Map()
|
||||
for (const [key, all] of byRelease) {
|
||||
uploadsOf(all).forEach((list, upload) => {
|
||||
// Settled as far as this indexer knows: it judges a release's files as each batch completes them (the Rust
|
||||
// window passes whether the window closed on the release and no block edge cut it).
|
||||
const verdict = this.rules.judge(
|
||||
key,
|
||||
list.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId, poster: f.segments[0].file.poster }))
|
||||
list.map((f) => ({
|
||||
name: f.segments[0].file.name,
|
||||
totalParts: f.segments[0].totalParts,
|
||||
groups: f.segments[0].file.groups,
|
||||
firstMessageId: f.segments[0].messageId,
|
||||
poster: f.segments[0].file.poster,
|
||||
segments: f.segments.length,
|
||||
bytes: f.segments.reduce((n, s) => n + s.bytes, 0),
|
||||
})),
|
||||
true
|
||||
)
|
||||
if (verdict.rule === null) {
|
||||
this.batch.releases.push(chunkOf(key, list, upload))
|
||||
const chunk = chunkOf(key, list, upload)
|
||||
if (!chunk.readable) {
|
||||
// Unnamed: a class the ladder found never names anything stays out of the pending store, its explored
|
||||
// sample excepted (so the rate keeps being measured); counted as garbage (LOG.md (130)).
|
||||
const tool = uploadTool(list[0].segments[0].messageId)
|
||||
const klass = pendingClass(chunk.name, chunk.bytes, chunk.partial === true)
|
||||
if (this.rules.classes.has(classKey(tool, this.options.group, klass)) && !explored(key, this.options.rules.samplePercent)) {
|
||||
this.count('garbage', `${tool} ${this.options.group} #${klass}`, list.reduce((n, f) => n + f.segments.length, 0), list.length)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.batch.releases.push(chunk)
|
||||
this.batch.candidates.push(...candidatesOf(key, list, upload))
|
||||
return
|
||||
}
|
||||
@@ -269,6 +305,20 @@ export class RangeIndexer {
|
||||
else byFile.set(s.fileKey, [s])
|
||||
}
|
||||
for (const list of byFile.values()) {
|
||||
if (this.options.partialFiles) {
|
||||
// As the Rust window's files(): one article of a multi-part random file is no release; a scrambled name only
|
||||
// before the end, where the rest of the file can still come.
|
||||
const name = list[0].file.name
|
||||
if (list.length === 1 && (list[0].number !== 1 || list[0].totalParts > 1) && (isRandomFile(name) || (!atEnd && isScrambledFile(name)))) {
|
||||
this.count('per-article', uploadTool(list[0].messageId), 1, 0)
|
||||
continue
|
||||
}
|
||||
// One segment per part (two copies of a file share its key here), the first article seen.
|
||||
const parts = new Map<number, EdgeSegment>()
|
||||
for (const s of list.sort((a, b) => a.articleNumber - b.articleNumber)) if (!parts.has(s.number)) parts.set(s.number, s)
|
||||
this.files([{ fileKey: list[0].fileKey, segments: [...parts.values()].sort((a, b) => a.number - b.number) }])
|
||||
continue
|
||||
}
|
||||
// One article of a "file" of many parts under a random name: a random subject per article, which
|
||||
// never completes from headers (99.8% of the boneless fragments; LOG.md 2026-10-09).
|
||||
if (list.length === 1 && isRandomFile(list[0].file.name)) {
|
||||
@@ -423,8 +473,9 @@ export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[], uploa
|
||||
bytes,
|
||||
firstArticle,
|
||||
lastArticle,
|
||||
readable: chunkFiles.some((f) => !isRandomFile(f.name)),
|
||||
readable: chunkFiles.some((f) => !isUnreadableName(f.name)),
|
||||
hasPar2: chunkFiles.some((f) => /\.par2$/i.test(f.name)),
|
||||
partial: files.some((f) => f.segments.length < f.segments[0].totalParts),
|
||||
payload: encodeChunk(chunkFiles),
|
||||
}
|
||||
}
|
||||
@@ -446,12 +497,12 @@ export function candidatesOf(key: string, files: AggregatedFile<EdgeSegment>[],
|
||||
}
|
||||
// An unsplit 7z of more than one segment: its listing is in its last one.
|
||||
if (volume === 0 && f.segments.length > 1 && !tail) tail = { volume, file: f }
|
||||
if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name })
|
||||
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name })
|
||||
if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name, bytes: f.segments[0].bytes })
|
||||
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name, bytes: f.segments[0].bytes })
|
||||
}
|
||||
if (tail) out.push({ releaseKey: key, upload, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name })
|
||||
if (tail) out.push({ releaseKey: key, upload, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name, bytes: tail.file.segments.at(-1)!.bytes })
|
||||
if (out.length === 0 && files.length === 1 && isRandomFile(files[0].segments[0].file.name)) {
|
||||
out.push({ releaseKey: key, upload, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name })
|
||||
out.push({ releaseKey: key, upload, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name, bytes: files[0].segments[0].bytes })
|
||||
}
|
||||
return out.slice(0, MAX_CANDIDATES)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { inspect, innerName, MAX_NAMES, PROBE_VERSION, type Inspection } from './inspect.js'
|
||||
export { inspect, innerName, usableName, MAX_NAMES, PROBE_VERSION, type Inspection } from './inspect.js'
|
||||
+54
-8
@@ -5,6 +5,7 @@ import { parseRarHeaders } from '#lib/rar/index'
|
||||
import { parseSevenZipTail } from '#lib/sevenzip/index'
|
||||
import { parseZipStart } from '#lib/zip/index'
|
||||
import { sniffContentType, type ContentType } from '#lib/content/index'
|
||||
import { isUnreadableName } from '#lib/release/index'
|
||||
|
||||
/** What one probed article showed (REBUILD.md, part 6; stored per release and kind). */
|
||||
export interface Inspection {
|
||||
@@ -25,7 +26,10 @@ export interface Inspection {
|
||||
*/
|
||||
hash16k?: string
|
||||
/** A par2 body's recovery set: every file it describes (not kept in release_inspections; par2_files). */
|
||||
par2?: { setId: string; files: { fileId: string; hash16k: string; length: number; name: string }[] }
|
||||
par2?: {
|
||||
setId: string
|
||||
files: { fileId: string; hash16k: string; length: number; name: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
const HASH_BYTES = 16 * 1024
|
||||
@@ -63,9 +67,16 @@ function largestFirst(items: { name: string; size: number }[]): string[] {
|
||||
*/
|
||||
export function inspect(body: Buffer, kind?: string): Inspection {
|
||||
const segment = decodeBody(body)
|
||||
const result: Inspection = { yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH), size: segment.meta.begin.size, type: sniffContentType(segment.data) }
|
||||
const result: Inspection = {
|
||||
yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH),
|
||||
size: segment.meta.begin.size,
|
||||
type: sniffContentType(segment.data),
|
||||
}
|
||||
// The file's first 16 KiB, or all of a smaller file.
|
||||
if (segment.offset === 0 && (segment.data.length >= HASH_BYTES || segment.data.length === result.size)) {
|
||||
if (
|
||||
segment.offset === 0 &&
|
||||
(segment.data.length >= HASH_BYTES || segment.data.length === result.size)
|
||||
) {
|
||||
result.hash16k = createHash('md5').update(segment.data.subarray(0, HASH_BYTES)).digest('hex')
|
||||
}
|
||||
if (isPar2(segment.data)) {
|
||||
@@ -76,7 +87,12 @@ export function inspect(body: Buffer, kind?: string): Inspection {
|
||||
result.names = largestFirst(set.files.map((f) => ({ name: f.name, size: f.length })))
|
||||
result.par2 = {
|
||||
setId: set.recoverySetId,
|
||||
files: set.files.map((f) => ({ fileId: f.fileId, hash16k: f.md5_16k, length: f.length, name: f.name.slice(0, MAX_NAME_LENGTH) })),
|
||||
files: set.files.map((f) => ({
|
||||
fileId: f.fileId,
|
||||
hash16k: f.md5_16k,
|
||||
length: f.length,
|
||||
name: f.name.slice(0, MAX_NAME_LENGTH),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -99,7 +115,10 @@ export function inspect(body: Buffer, kind?: string): Inspection {
|
||||
}
|
||||
}
|
||||
// 7z keeps its listing at the end: the last segment of the last volume (a 7z-tail), or a small archive whole.
|
||||
if ((kind === '7z-tail' || result.type === '7z') && segment.offset + segment.data.length === result.size) {
|
||||
if (
|
||||
(kind === '7z-tail' || result.type === '7z') &&
|
||||
segment.offset + segment.data.length === result.size
|
||||
) {
|
||||
const listing = parseSevenZipTail(segment.data)
|
||||
if (listing) {
|
||||
result.entries = listing.files.length
|
||||
@@ -110,16 +129,43 @@ export function inspect(body: Buffer, kind?: string): Inspection {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The usable name one probe of an unnamed release found (the pending ladder, REBUILD.md decision f): the largest
|
||||
* named file of a par2 set or an archive's listing, or a lone file's yEnc name, when it isn't packaging and isn't
|
||||
* random itself (an obfuscated upload's par2 names its random volumes; its yEnc name is mostly the random name
|
||||
* again). Null: this probe named nothing, the next target is tried.
|
||||
*/
|
||||
export function usableName(inspection: Inspection, kind: string): string | null {
|
||||
const usable = (n: string | undefined) => {
|
||||
if (!n) return null
|
||||
const name = n.replace(/^.*[\\/]/, '').slice(0, 255)
|
||||
return name && !PACKAGING.test(name) && !isUnreadableName(name) ? name : null
|
||||
}
|
||||
const named = (inspection.names ?? []).map(usable).find((n) => n)
|
||||
if (named) return named
|
||||
return kind === 'lone' || !inspection.names?.length ? usable(inspection.yencName) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A release's best inner name from its inspections (by probe kind): the
|
||||
* largest named entry of an archive, else the largest file of a par2 set
|
||||
* that isn't packaging, else a lone file's yEnc name. Null when none says
|
||||
* more than the release's own name.
|
||||
*/
|
||||
export function innerName(inspections: { kind: string; result: Inspection }[], releaseName = ''): string | null {
|
||||
export function innerName(
|
||||
inspections: { kind: string; result: Inspection }[],
|
||||
releaseName = ''
|
||||
): string | null {
|
||||
const of = (kind: string) => inspections.filter((i) => i.kind === kind).map((i) => i.result)
|
||||
const content = (kind: string) => of(kind).flatMap((i) => i.names ?? []).find((n) => !PACKAGING.test(n))
|
||||
const pick = content('archive') ?? content('7z-tail') ?? content('par2') ?? of('lone').find((i) => i.yencName && !PACKAGING.test(i.yencName))?.yencName
|
||||
const content = (kind: string) =>
|
||||
of(kind)
|
||||
.flatMap((i) => i.names ?? [])
|
||||
.find((n) => !PACKAGING.test(n))
|
||||
const pick =
|
||||
content('archive') ??
|
||||
content('7z-tail') ??
|
||||
content('par2') ??
|
||||
of('lone').find((i) => i.yencName && !PACKAGING.test(i.yencName))?.yencName
|
||||
const name = pick?.replace(/^.*[\\/]/, '').slice(0, 255)
|
||||
// The release's own name with an extension says nothing new (a random-named file's yEnc name is mostly its random name).
|
||||
return name && !name.toLowerCase().startsWith(releaseName.toLowerCase() || '\0') ? name : null
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The class of an unnamed release (REBUILD.md decision f; LOG.md (130)): what the headers say about its chance of a
|
||||
* name, from the pending ladder's own record. Size bucket × name shape × partial, 1..70, kept in step with
|
||||
* names.rs pending_class and the SQL pending_class() (migration 6800): the batch store sets it with the SQL, the
|
||||
* edge skips the classes a lease marks dropped.
|
||||
*/
|
||||
|
||||
/** Size buckets: under 1 MB, 10 MB, 100 MB, 1 GB, and from 1 GB. */
|
||||
export const SIZE_BUCKETS = [1_000_000, 10_000_000, 100_000_000, 1_000_000_000]
|
||||
/** Name shapes, 1..7: hex, digits, base32, letters only, mixed letters and digits, with separators, other. */
|
||||
export const SHAPES = [
|
||||
'hex',
|
||||
'digits',
|
||||
'base32',
|
||||
'letters',
|
||||
'alnum',
|
||||
'separators',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export function sizeBucket(bytes: number): number {
|
||||
let i = 0
|
||||
while (i < SIZE_BUCKETS.length && bytes >= SIZE_BUCKETS[i]) i++
|
||||
return i
|
||||
}
|
||||
|
||||
export function nameShape(name: string): number {
|
||||
const s = name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
|
||||
if (/^[0-9a-fA-F]{16,}$/.test(s)) return 1
|
||||
if (/^[0-9]{6,}$/.test(s)) return 2
|
||||
if (/^[a-z2-7]{20,}$/.test(s) && /[2-7]/.test(s)) return 3
|
||||
if (/^[A-Za-z]+$/.test(s)) return 4
|
||||
if (/^[A-Za-z0-9]+$/.test(s)) return 5
|
||||
if (/^[A-Za-z0-9._-]+$/.test(s)) return 6
|
||||
return 7
|
||||
}
|
||||
|
||||
/** 1..70: (size bucket × 7 + shape − 1) × 2 + partial + 1. */
|
||||
export function pendingClass(name: string, bytes: number, partial: boolean): number {
|
||||
return sizeBucket(bytes) * 14 + (nameShape(name) - 1) * 2 + (partial ? 1 : 0) + 1
|
||||
}
|
||||
|
||||
/** A class as droppedClasses lists it: "tool\tgroup\tklass". */
|
||||
export function classKey(tool: string, group: string, klass: number): string {
|
||||
return `${tool}\t${group}\t${klass}`
|
||||
}
|
||||
|
||||
/** A class in words ("10-100M hex partial"). */
|
||||
export function describeClass(klass: number): string {
|
||||
const k = klass - 1
|
||||
const size = Math.floor(k / 14)
|
||||
const shape = SHAPES[Math.floor((k % 14) / 2)]
|
||||
const sizes = ['<1M', '1-10M', '10-100M', '100M-1G', '1G+']
|
||||
return `${sizes[size]} ${shape}${k % 2 ? ' partial' : ''}`
|
||||
}
|
||||
+25
-2
@@ -105,6 +105,29 @@ export function subjectRemainder(subject: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* A subject name as shown: brackets the counters left unmatched removed ("… Facialized 002/477] - 0177.jpg", the
|
||||
* counter's "[" missing, gave "… Facialized ]"; LOG.md 2026-10-29 (121)). Only the name: the key keeps the remainder.
|
||||
*/
|
||||
export function displayName(remainder: string): string {
|
||||
if (!/[[\]()]/.test(remainder)) return remainder
|
||||
const drop = new Set<number>()
|
||||
for (const [open, close] of [['[', ']'], ['(', ')']]) {
|
||||
const opens: number[] = []
|
||||
for (let i = 0; i < remainder.length; i++) {
|
||||
if (remainder[i] === open) opens.push(i)
|
||||
else if (remainder[i] === close) {
|
||||
if (opens.length > 0) opens.pop()
|
||||
else drop.add(i)
|
||||
}
|
||||
}
|
||||
for (const i of opens) drop.add(i)
|
||||
}
|
||||
if (drop.size === 0) return remainder
|
||||
const name = remainder.split('').filter((_, i) => !drop.has(i)).join('').replace(/ {2,}/g, ' ').trim()
|
||||
return name || remainder
|
||||
}
|
||||
|
||||
/**
|
||||
* Posting tools put random per-file tokens and poster IDs in subjects (e.g.
|
||||
* `elys-de81bcabef5faf6a - [Nyh5Q07Y]`); only text that still reads like a
|
||||
@@ -448,7 +471,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
|
||||
if (!image && RANDOM_VOLUME.test(input.name) && /^[A-Za-z0-9_-]+$/.test(stem) && looksRandom(stem.replace(/[-_]/g, ''))) parts.push(stem)
|
||||
return {
|
||||
key: hash(...parts),
|
||||
name: remainder,
|
||||
name: displayName(remainder),
|
||||
fileTotal,
|
||||
source: 'subject',
|
||||
}
|
||||
@@ -456,7 +479,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
|
||||
// Not a name, but a post id (e.g. `83110629-n [10/18] - "random"`): with the
|
||||
// [i/M] counter it groups the files of one post whose names are random.
|
||||
if (!tag && postTotal && isPostToken(remainder)) {
|
||||
return { key: hash('post', poster, remainder, postTotal), name: remainder, fileTotal, source: 'post' }
|
||||
return { key: hash('post', poster, remainder, postTotal), name: displayName(remainder), fileTotal, source: 'post' }
|
||||
}
|
||||
// A scene music post's index files under a bare counter go with its tracks: the artist and the total ("[01/13] -
|
||||
// "00-ammar-turk_uit_de_kroeg-single-web-nl-2022-cover.jpg"" with "[05/13] - "01-ammar-turk_uit_de_kroeg.mp3"": the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Grouping of files into releases (downloads), independent of AdonisJS.
|
||||
*/
|
||||
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, sizeAfterName, runOn, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js'
|
||||
export { releaseIdentity, displayName, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, sizeAfterName, runOn, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js'
|
||||
export { looksRandom } from './random.js'
|
||||
export { isUnreadableName, tokenKind } from './readable.js'
|
||||
export { pendingClass, classKey, describeClass, nameShape, sizeBucket } from './classes.js'
|
||||
@@ -0,0 +1,148 @@
|
||||
import { fileStem } from './identity.js'
|
||||
import { looksRandom } from './random.js'
|
||||
|
||||
/**
|
||||
* Readable or not: whether a name says what a release is (the catalog's readable flag, the prober's inner names).
|
||||
* looksRandom (random.ts) knows the single-token shapes; this reads a name token by token, so separators, spaces
|
||||
* and random "extensions" don't hide a scramble (LOG.md (129)).
|
||||
*/
|
||||
/**
|
||||
* Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed
|
||||
* words ("Description - Lt5nHjlo3RiuBb3C", "bd5b4a59f3aa87114dcf75c0-addon").
|
||||
*/
|
||||
const NOISE =
|
||||
/^(addon|samples?|proof|subs?|nfo|part\d+|vol\d+|description|repost|rep|extras?|bonus|cd\d*|dvd\d*|dis[ck]\d*|par2|rar|zip|7z|mkv|mp4|avi|vol)$/i
|
||||
/** Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th. */
|
||||
const CODE =
|
||||
/^([sS]\d{1,2}[eE]\d{1,3}|[xh]26[45]|[A-Za-z]+\d+([a-z]{1,3}|x(64|86))?|\d+[A-Za-z]{1,3}|\d+[A-Za-z]+\d+)$/
|
||||
/** The NZBSPLIT tools' hash between their markers is no part of the name. */
|
||||
const NZBSPLIT = /__NZBSPLIT__[0-9a-f]{32}__NZBSPLIT__/i
|
||||
|
||||
/** Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there. */
|
||||
function sceneCase(t: string): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
out +=
|
||||
t[i] === 'i' && i > 0 && i < t.length - 1 && /[A-Z]/.test(t[i - 1]) && /[A-Z]/.test(t[i + 1])
|
||||
? 'I'
|
||||
: t[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The signals of a scrambled token: letter/digit and lower-to-upper switches; case switches in both directions; lone
|
||||
* lowercase letters between capitals ("NtpUgCmS"); an inner run of 2+ capitals with lowercase on both sides
|
||||
* ("tBWTuz", "YDoaROv"); the longest consonant run; vowels (a lowercase y too: "McCartney"); runs of one character class.
|
||||
*/
|
||||
function signals(t: string) {
|
||||
let switches = 0
|
||||
let caseSwitches = 0
|
||||
let lone = 0
|
||||
let consonants = 0
|
||||
let maxConsonants = 0
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
const c = t[i]
|
||||
if (/[b-df-hj-np-tv-xz]/i.test(c)) maxConsonants = Math.max(maxConsonants, ++consonants)
|
||||
else consonants = 0
|
||||
if (i === 0) continue
|
||||
const a = t[i - 1]
|
||||
if (/\d/.test(a) !== /\d/.test(c)) switches++
|
||||
else if (/[a-z]/.test(a) && /[A-Z]/.test(c)) switches++
|
||||
if ((/[a-z]/.test(a) && /[A-Z]/.test(c)) || (/[A-Z]/.test(a) && /[a-z]/.test(c))) caseSwitches++
|
||||
if (
|
||||
i < t.length - 1 &&
|
||||
/[a-z]/.test(c) &&
|
||||
c !== 'i' &&
|
||||
/[A-Z]/.test(a) &&
|
||||
/[A-Z]/.test(t[i + 1])
|
||||
)
|
||||
lone++
|
||||
}
|
||||
const innerUpper = /[a-z][A-Z]{2,}[a-z]/.test(t)
|
||||
const vowels = (t.match(/[aeiouAEIOU]|y/g) ?? []).length
|
||||
const runs = (t.match(/[a-z]+|[A-Z]+|\d+/g) ?? []).length
|
||||
const letters = (t.match(/[A-Za-z]/g) ?? []).length
|
||||
const maxLower = Math.max(0, ...(t.match(/[a-z]+/g) ?? []).map((r) => r.length))
|
||||
return {
|
||||
switches,
|
||||
caseSwitches,
|
||||
lone,
|
||||
innerUpper,
|
||||
maxConsonants,
|
||||
vowels,
|
||||
runs,
|
||||
letters,
|
||||
maxLower,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What one alphanumeric token of a name is: a word (letters only, in one case or Capitalized, 3+ long, not a packaging
|
||||
* word), a scramble, or neither (digits, codes, CamelCase, short tokens). Kept in step with names.rs token_kind.
|
||||
*/
|
||||
export function tokenKind(t: string): 'word' | 'random' | null {
|
||||
if (/^\d+$/.test(t) || NOISE.test(t) || CODE.test(t)) return null
|
||||
if (/^[A-Za-z]+$/.test(t)) {
|
||||
const s = signals(sceneCase(t))
|
||||
if (s.caseSwitches === 0 || (s.caseSwitches === 1 && /^[A-Z][a-z]+$/.test(t))) {
|
||||
// A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word.
|
||||
if (t.length >= 10 && s.vowels * 6 < t.length) return 'random'
|
||||
return t.length >= 3 ? 'word' : null
|
||||
}
|
||||
// Mixed case beyond Capitalization: CamelCase (neutral) or a scramble. Two CamelCase words with few vowels
|
||||
// (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone letter; a scramble has more runs, a lone
|
||||
// letter or an inner capital run.
|
||||
if (
|
||||
t.length >= 6 &&
|
||||
s.caseSwitches >= 2 &&
|
||||
s.vowels * 4 < t.length &&
|
||||
(s.runs >= 5 || s.lone >= 1 || s.innerUpper)
|
||||
)
|
||||
return 'random'
|
||||
return null
|
||||
}
|
||||
if (t.length >= 8 && /^[0-9a-f]+$/i.test(t) && (t.match(/\d/g)?.length ?? 0) >= 2) return 'random'
|
||||
if (/^[A-Z0-9]+$/.test(t)) {
|
||||
// 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay.
|
||||
return t.length >= 12 && signals(t).switches >= 6 ? 'random' : null
|
||||
}
|
||||
// Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym.
|
||||
if (
|
||||
(t.match(/[A-Za-z]+/g) ?? []).every(
|
||||
(w) =>
|
||||
/^[A-Z]{2,}$/.test(w) ||
|
||||
(/^[A-Z]?[a-z]{2,}$/.test(w) && (w.match(/[aeiouy]/g) ?? []).length * 5 >= w.length)
|
||||
)
|
||||
)
|
||||
return null
|
||||
const s = signals(sceneCase(t))
|
||||
if (t.length >= 5 && s.switches >= 3) return 'random'
|
||||
if (t.length >= 6 && s.switches >= 2 && (s.lone >= 1 || s.innerUpper || s.maxConsonants >= 4))
|
||||
return 'random'
|
||||
// Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus").
|
||||
if (t.length >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.maxLower <= 2)
|
||||
return 'random'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A file or release name no one can read: looksRandom's shapes, or more scrambled tokens than words by letter count
|
||||
* ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ"; not "Paul McCartney - Band on the Run",
|
||||
* "ShesSoWild - Ester Ana 1080p"). Separators and a random "extension" of any length don't hide a scramble, which
|
||||
* looksRandom's single-token shapes miss (15% of the readable catalog on 2026-11-02; LOG.md (129)). The readable
|
||||
* flag; the drop rules keep looksRandom, which is narrower.
|
||||
*/
|
||||
export function isUnreadableName(name: string): boolean {
|
||||
const stem = fileStem(name).replace(NZBSPLIT, ' ')
|
||||
if (looksRandom(stem)) return true
|
||||
let words = 0
|
||||
let random = 0
|
||||
for (const t of stem.split(/[^A-Za-z0-9]+/)) {
|
||||
if (!t) continue
|
||||
const kind = tokenKind(t)
|
||||
if (kind === 'word') words += t.length
|
||||
else if (kind === 'random') random += t.length
|
||||
}
|
||||
return random > 0 && random > words
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export const TEXT_KINDS = {
|
||||
par2: 4,
|
||||
/** A title from a media container's metadata. */
|
||||
media: 5,
|
||||
/** An album's member, by its own file name (the edge's albums: one poster's files differing by a number sequence). */
|
||||
member: 6,
|
||||
} as const
|
||||
|
||||
export type TextKind = keyof typeof TEXT_KINDS
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export { uploadTool, signatureGroup, signatureKey, explored } from './upload_tools.js'
|
||||
export { RuleSet, DEFAULT_RULES, isRandomFile, ownerOf, posterWords, type Rules, type Verdict } from './rule_set.js'
|
||||
export { RuleSet, DEFAULT_RULES, isRandomFile, isScrambledFile, ownerOf, posterWords, stubArchiveLegs, isNonsensePiece, isTinyArchive, STUB_ARTICLE_BYTES, STUB_MIN_BYTES, STUB_MAX_BYTES, STUB_WIDE_GROUPS, NONSENSE_MIN_BYTES, NONSENSE_MAX_BYTES, TINY_MIN_BYTES, type Rules, type Verdict, type JudgedFile } from './rule_set.js'
|
||||
+174
-4
@@ -12,6 +12,11 @@ export interface Rules {
|
||||
priorities: Record<string, number>
|
||||
/** Dropped signatures, as "tool\tgroup" (GarbageFilter's table). */
|
||||
droppedSignatures: string[]
|
||||
/**
|
||||
* Dropped classes of unnamed releases, as "tool\tgroup\tklass" (lib/release/classes.ts; the pending ladder's record,
|
||||
* LOG.md (130)): the edge keeps them out of the pending store, the explored sample excepted.
|
||||
*/
|
||||
droppedClasses: string[]
|
||||
/** Tools whose random-named uploads are dropped in every group (named < 1%; LOG.md 2026-10-08 (5)). */
|
||||
droppedTools: string[]
|
||||
/** A random-named file of at most this many parts: a random subject per article, needs bodies. */
|
||||
@@ -30,6 +35,24 @@ export interface Rules {
|
||||
* again under other titles (119k releases on 2026-10-16; LOG.md 2026-10-16 (65)).
|
||||
*/
|
||||
droppedPosters: string[]
|
||||
/**
|
||||
* Drop settled releases of one unsplit archive without a par2, posted to 2+ groups, under STUB_MAX_BYTES, whose
|
||||
* articles are at most STUB_ARTICLE_BYTES with the release at least STUB_MIN_BYTES, or posted to 4+ groups, or by
|
||||
* a webmail poster: the exe-stub and recycled-installer floods under film and software names (55% of the catalog
|
||||
* on 2026-10-30; 99.3% of the known fakes, 0 known content; LOG.md (123)-(126), (128)).
|
||||
*/
|
||||
dropStubArchives: boolean
|
||||
/**
|
||||
* Drop one-article archives of NONSENSE_MIN_BYTES to NONSENSE_MAX_BYTES under a release-like, unscrambled name:
|
||||
* the newznzb.* pieces (9% of the catalog; LOG.md (123), (125)).
|
||||
*/
|
||||
dropNonsensePieces: boolean
|
||||
/**
|
||||
* Drop one-article archives of TINY_MIN_BYTES to NONSENSE_MIN_BYTES under a release-like name (spaces allowed),
|
||||
* outside the exempt groups, without an e-book, subtitle, crack or sidecar word: the 2015-17 exe-stub flood in
|
||||
* miniature (a RAR holding "<title>.exe" at 2-60 kB; 1.65M catalog rows; LOG.md (131), (133)).
|
||||
*/
|
||||
dropTinyArchives: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_RULES: Omit<Rules, 'priorities' | 'droppedSignatures'> = {
|
||||
@@ -39,9 +62,37 @@ export const DEFAULT_RULES: Omit<Rules, 'priorities' | 'droppedSignatures'> = {
|
||||
samplePercent: 1,
|
||||
dropExecutables: true,
|
||||
droppedPosters: ['celebmovie', 'celebwow', 'celebfx', 'iluvcelebs', 'nudecelebs', 'celebsextapes'],
|
||||
dropStubArchives: true,
|
||||
dropNonsensePieces: true,
|
||||
dropTinyArchives: true,
|
||||
droppedClasses: [],
|
||||
}
|
||||
|
||||
export type Verdict = { rule: null } | { rule: 'poster' | 'executable' | 'garbage' | 'per-article'; key: string; sample: boolean }
|
||||
/** The stub-archive rule's bounds (edge/src/names.rs): Power-Post's 250 kB default article, the fakes' size span. */
|
||||
export const STUB_ARTICLE_BYTES = 262144
|
||||
export const STUB_MIN_BYTES = 1_000_000
|
||||
export const STUB_MAX_BYTES = 100_000_000
|
||||
export const STUB_WIDE_GROUPS = 4
|
||||
/** The nonsense-piece rule's bounds: under 100 kB are cue/log sidecars and adverts, 1 MB and up is rule 1's. */
|
||||
export const NONSENSE_MIN_BYTES = 100_000
|
||||
export const NONSENSE_MAX_BYTES = 1_000_000
|
||||
/** The tiny-archive rule's floor: a RAR holding one exe stub is 2 kB and up; below are empty archives and nfo rars. */
|
||||
export const TINY_MIN_BYTES = 2_000
|
||||
|
||||
export type Verdict =
|
||||
| { rule: null }
|
||||
| { rule: 'poster' | 'executable' | 'garbage' | 'per-article' | 'stub-archive' | 'nonsense-piece' | 'tiny-archive'; key: string; sample: boolean }
|
||||
|
||||
/** A file as the judge sees it: its name, parts, groups, poster, and the articles seen with their bytes. */
|
||||
export interface JudgedFile {
|
||||
name: string
|
||||
totalParts: number
|
||||
groups: string[]
|
||||
firstMessageId: string
|
||||
poster: string
|
||||
segments: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/** A poster as droppedPosters matches it: lowercased, letters and digits only. */
|
||||
export function posterWords(poster: string): string {
|
||||
@@ -51,11 +102,117 @@ export function posterWords(poster: string): string {
|
||||
/** Files that come along with a release's content: they don't make it one of executables or not. */
|
||||
const SIDECAR = /\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$/i
|
||||
const EXECUTABLE = /\.(exe|scr|com|pif|bat|cmd|vbs|lnk)\s*$/i
|
||||
/** One zip, rar or 7z that is not a volume of a set. */
|
||||
const ARCHIVE = /\.(zip|rar|7z)$/i
|
||||
/** "cneg" is "part" in ROT13: the a.b.erotica posts named that way are volumes of passworded videos (LOG.md (128)). */
|
||||
const VOLUME = /\.((part|cneg)\d+\.rar|r\d\d|z\d\d|7z\.\d\d\d|\d\d\d)$/i
|
||||
/** A poster at a free mail provider: the stub floods' throwaway accounts. */
|
||||
const WEBMAIL = /@(gmail|googlemail|yahoo|hotmail|outlook|live|msn|aol|gmx|web|t-online|freenet|mail|yandex|icloud|me|hushmail|protonmail|libero|orange|free|arcor|wanadoo|ymail|rocketmail|inbox|mail\.ru|bk|list)\./i
|
||||
/** A name that reads as a release: a year, a scene tag, a group suffix or dotted words. */
|
||||
const RELEASE_LIKE = /(\b(19|20)\d\d\b|[._-](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s\d\de\d\d|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v\d+(\.\d+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)/i
|
||||
|
||||
/**
|
||||
* Groups whose communities post real single small archives (karaoke zips, e-books, comics, tabs, fonts) the way the
|
||||
* stub floods do (Power-Post defaults, a crosspost or two): a release touching one is exempt from the stub-archive
|
||||
* rule. Costs 16 of 32,308 known fakes in the 217k sample (LOG.md (127)).
|
||||
*/
|
||||
const STUB_EXEMPT_GROUPS = /(sound|mp3|karaoke|music|audio|flac|lossless|e-?book|epub|comic|guitar|font|magazine)/i
|
||||
|
||||
/** The single unsplit archive a release consists of, or null. */
|
||||
function singleArchive(files: JudgedFile[]): JudgedFile | null {
|
||||
return files.length === 1 && ARCHIVE.test(files[0].name) && !VOLUME.test(files[0].name) ? files[0] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The legs of the stub-archive rule a settled single archive under STUB_MAX_BYTES (the fakes stop at ~90 MB;
|
||||
* hash-named uploads, real albums and passworded videos above it don't) trips: "A" (articles <= STUB_ARTICLE_BYTES,
|
||||
* release >= STUB_MIN_BYTES: a one-article release always has small articles, so e-books and sidecars are exempt),
|
||||
* "X" (4+ groups) and "W" (webmail poster). Empty when none.
|
||||
*/
|
||||
export function stubArchiveLegs(f: JudgedFile): string {
|
||||
if (f.groups.length < 2 || f.bytes >= STUB_MAX_BYTES || f.groups.some((g) => STUB_EXEMPT_GROUPS.test(g))) return ''
|
||||
let legs = ''
|
||||
if (f.bytes / Math.max(f.segments, 1) <= STUB_ARTICLE_BYTES && f.bytes >= STUB_MIN_BYTES) legs += 'A'
|
||||
if (f.groups.length >= STUB_WIDE_GROUPS) legs += 'X'
|
||||
if (WEBMAIL.test(f.poster)) legs += 'W'
|
||||
return legs
|
||||
}
|
||||
|
||||
/** An e-book by its name ("Author - Title (2020) (epub).rar"): one small article is a book, not a nonsense piece. */
|
||||
const EBOOK = /\b(epub|mobi|azw3?|pdf|djvu|cbr|cbz|e-?books?)\b/i
|
||||
|
||||
/**
|
||||
* A nonsense piece: one article, one small archive under a release-like, unscrambled name without spaces (the
|
||||
* newznzb.* pieces are scene-styled or random with a "-XX" suffix; books and albums are written with spaces).
|
||||
*/
|
||||
export function isNonsensePiece(f: JudgedFile): boolean {
|
||||
return (
|
||||
f.totalParts === 1 &&
|
||||
f.segments === 1 &&
|
||||
f.bytes >= NONSENSE_MIN_BYTES &&
|
||||
f.bytes < NONSENSE_MAX_BYTES &&
|
||||
!f.name.includes(' ') &&
|
||||
RELEASE_LIKE.test(fileStem(f.name)) &&
|
||||
!EBOOK.test(f.name) &&
|
||||
!isScrambledFile(f.name) &&
|
||||
!isRandomFile(f.name)
|
||||
)
|
||||
}
|
||||
|
||||
/** RELEASE_LIKE with spaces as separators too: the tiny fakes are posted under names written either way. */
|
||||
const RELEASE_LIKE_SPACED = new RegExp(RELEASE_LIKE.source.replace('[._-]', '[._ -]'), 'i')
|
||||
|
||||
/**
|
||||
* Words of the real tiny archives under release-like names: subtitles, cracks, trainers and keygens (a few kB of
|
||||
* exe is the content), covers and sidecars.
|
||||
*/
|
||||
const TINY_CONTENT =
|
||||
/\b(subs?|subtitles?|subpack|srt|untertitel|ondertitels?|ondertiteling|legendas?|napisy|sottotitoli|cracks?|cracked|keygen|keymaker|trainer|patch|patched|serials?|activator|loader|nocd|no-cd|fix|covers?|artwork|booklet|scans?|proof|sample|info|readme)\b/i
|
||||
|
||||
/**
|
||||
* A tiny archive: one article of TINY_MIN_BYTES to NONSENSE_MIN_BYTES holding one archive under a release-like,
|
||||
* unscrambled name (spaces allowed), outside the groups that post real small archives, and without an e-book,
|
||||
* subtitle, crack or sidecar word: the 2015-17 exe-stub flood in miniature (LOG.md (131)).
|
||||
*/
|
||||
export function isTinyArchive(f: JudgedFile): boolean {
|
||||
return (
|
||||
f.totalParts === 1 &&
|
||||
f.segments === 1 &&
|
||||
f.bytes >= TINY_MIN_BYTES &&
|
||||
f.bytes < NONSENSE_MIN_BYTES &&
|
||||
!f.groups.some((g) => STUB_EXEMPT_GROUPS.test(g)) &&
|
||||
RELEASE_LIKE_SPACED.test(fileStem(f.name)) &&
|
||||
!EBOOK.test(f.name) &&
|
||||
!TINY_CONTENT.test(f.name) &&
|
||||
!isScrambledFile(f.name) &&
|
||||
!isRandomFile(f.name)
|
||||
)
|
||||
}
|
||||
|
||||
export function isRandomFile(name: string) {
|
||||
return looksRandom(fileStem(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* At least half the letters and digits of a file's stem in tokens of 5 or more that switch case (lower to upper) or
|
||||
* between letters and digits 3 times or more ("nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa"): the names a
|
||||
* random-subject-per-article upload gives each article that looksRandom misses for their separators.
|
||||
*/
|
||||
export function isScrambledFile(name: string) {
|
||||
let total = 0
|
||||
let scrambled = 0
|
||||
for (const token of fileStem(name).split(/[^A-Za-z0-9]+/)) {
|
||||
total += token.length
|
||||
let switches = 0
|
||||
for (let i = 1; i < token.length; i++) {
|
||||
const [a, b] = [token[i - 1], token[i]]
|
||||
if ((/[a-z]/.test(a) && /[A-Z]/.test(b)) || /\d/.test(a) !== /\d/.test(b)) switches++
|
||||
}
|
||||
if (token.length >= 5 && switches >= 3) scrambled += token.length
|
||||
}
|
||||
return total >= 8 && scrambled * 2 >= total
|
||||
}
|
||||
|
||||
/** The owner of an article posted to `groups` (its Xref), or null when none of them is indexed. */
|
||||
export function ownerOf(groups: string[], priorities: Record<string, number>): string | null {
|
||||
let owner: string | null = null
|
||||
@@ -68,20 +225,24 @@ export function ownerOf(groups: string[], priorities: Record<string, number>): s
|
||||
|
||||
export class RuleSet {
|
||||
private signatures: Set<string>
|
||||
readonly classes: Set<string>
|
||||
private tools: Set<string>
|
||||
private posters: string[]
|
||||
constructor(readonly rules: Rules) {
|
||||
this.posters = rules.droppedPosters ?? []
|
||||
this.signatures = new Set(rules.droppedSignatures)
|
||||
this.classes = new Set(rules.droppedClasses ?? [])
|
||||
this.tools = new Set(rules.droppedTools)
|
||||
}
|
||||
|
||||
/**
|
||||
* A release's verdict from its files: dropped when its poster is; when its content is bare
|
||||
* executables; else kept as a whole when any file has a readable name; else dropped as garbage when its signature or tool is
|
||||
* dropped, as per-article random subjects when every file has few parts.
|
||||
* executables; when it is a settled single archive that is a stub or a nonsense piece; else kept as a whole when
|
||||
* any file has a readable name; else dropped as garbage when its signature or tool is dropped, as per-article
|
||||
* random subjects when every file has few parts. `settled`: the window closed on the release (a par2 can still
|
||||
* join an open one), which the single-archive rules need.
|
||||
*/
|
||||
judge(releaseKey: string, files: { name: string; totalParts: number; groups: string[]; firstMessageId: string; poster: string }[]): Verdict {
|
||||
judge(releaseKey: string, files: JudgedFile[], settled = true): Verdict {
|
||||
const sample = explored(releaseKey, this.rules.samplePercent)
|
||||
if (this.posters.length > 0 && files.length > 0) {
|
||||
const words = posterWords(files[0].poster)
|
||||
@@ -94,6 +255,15 @@ export class RuleSet {
|
||||
return { rule: 'executable', key: EXECUTABLE.exec(content[0].name)![1].toLowerCase(), sample }
|
||||
}
|
||||
}
|
||||
const archive = settled ? singleArchive(files) : null
|
||||
if (archive) {
|
||||
if (this.rules.dropStubArchives) {
|
||||
const legs = stubArchiveLegs(archive)
|
||||
if (legs !== '') return { rule: 'stub-archive', key: legs, sample }
|
||||
}
|
||||
if (this.rules.dropNonsensePieces && isNonsensePiece(archive)) return { rule: 'nonsense-piece', key: uploadTool(archive.firstMessageId), sample }
|
||||
if (this.rules.dropTinyArchives && isTinyArchive(archive)) return { rule: 'tiny-archive', key: uploadTool(archive.firstMessageId), sample }
|
||||
}
|
||||
if (files.some((f) => !isRandomFile(f.name))) return { rule: null }
|
||||
const first = files[0]
|
||||
const tool = uploadTool(first.firstMessageId)
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
<label class="search-box">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false"
|
||||
placeholder="Search release names…" aria-label="Search" />
|
||||
placeholder="Search release names… (^word: at a word's start)" aria-label="Search" />
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</label>
|
||||
<label class="select">
|
||||
|
||||
@@ -74,6 +74,14 @@ export default await Env.create(new URL('../', import.meta.url), {
|
||||
SPOT_STANDALONE_AFTER_HOURS: Env.schema.number.optional(),
|
||||
CORPUS_AHEAD: Env.schema.number.optional(),
|
||||
PROBE_PIPELINE: Env.schema.number.optional(),
|
||||
SNAPSHOT_DIR: Env.schema.string.optional(),
|
||||
LEASE_MIN_PRIORITY: Env.schema.number.optional(),
|
||||
LEASE_MAX_PRIORITY: Env.schema.number.optional(),
|
||||
LEASE_CAPTURE_ONLY: Env.schema.boolean.optional(),
|
||||
STREAM_CHUNK_WAIT_MINUTES: Env.schema.number.optional(),
|
||||
PENDING_SHARE: Env.schema.number.optional(),
|
||||
PENDING_MIN_PROBES: Env.schema.number.optional(),
|
||||
PENDING_MAX_NAMED: Env.schema.number.optional(),
|
||||
SPOOL_AHEAD_PER_LEASE: Env.schema.number.optional(),
|
||||
SPOOL_MIN_AHEAD: Env.schema.number.optional(),
|
||||
SPOOL_MAX_GB: Env.schema.number.optional(),
|
||||
|
||||
@@ -63,6 +63,13 @@ router
|
||||
router
|
||||
.group(() => {
|
||||
router.post('lease', [controllers.Node, 'lease'])
|
||||
// Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): the window worker's.
|
||||
router.post('groups/lease', [controllers.Node, 'leaseGroup'])
|
||||
router.post('groups/:id/next', [controllers.Node, 'nextRange'])
|
||||
router.post('groups/:id/heartbeat', [controllers.Node, 'groupHeartbeat'])
|
||||
router.post('groups/:id/release', [controllers.Node, 'releaseGroup'])
|
||||
router.put('groups/:id/snapshot', [controllers.Node, 'putSnapshot'])
|
||||
router.get('groups/:id/snapshot', [controllers.Node, 'getSnapshot'])
|
||||
router.get('ranges/:id/stream', [controllers.Node, 'stream'])
|
||||
router.post('ranges/:id/batches', [controllers.Node, 'batch'])
|
||||
router.post('ranges/:id/heartbeat', [controllers.Node, 'heartbeat'])
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Node from '#models/node'
|
||||
import Spool from '#services/spool'
|
||||
import Corpus from '#services/corpus'
|
||||
import GroupLeases from '#services/group_leases'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import { GROUP, overviewChunk, runEdge, startEdge, waitFor } from '#tests/e2e/helpers/edge'
|
||||
|
||||
/**
|
||||
* The Rust edge worker against the real node API, database, spool and corpus (REBUILD.md, "Voyager 3: window
|
||||
* indexing", 1 and 5): what broke in production on 2026-10-27 (LOG.md (118)) — a stream waiting on a chunk the
|
||||
* corpus fill stored, a failed stream, a restart with and without the snapshot, a takeover.
|
||||
*/
|
||||
test.group('Edge worker end to end', (group) => {
|
||||
const saved = {
|
||||
spool: coordinatorConfig.spool.dir,
|
||||
corpus: coordinatorConfig.corpus.dir,
|
||||
snapshots: coordinatorConfig.snapshotDir,
|
||||
chunk: coordinatorConfig.chunkArticles,
|
||||
wait: coordinatorConfig.streamChunkWaitMs,
|
||||
}
|
||||
let dir = ''
|
||||
group.each.setup(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'e2e-'))
|
||||
coordinatorConfig.spool.dir = join(dir, 'spool')
|
||||
coordinatorConfig.corpus.dir = join(dir, 'corpus')
|
||||
coordinatorConfig.snapshotDir = join(dir, 'snapshots')
|
||||
coordinatorConfig.chunkArticles = 100
|
||||
return testUtils.db().truncate()
|
||||
})
|
||||
group.each.teardown(async () => {
|
||||
Object.assign(coordinatorConfig.spool, { dir: saved.spool })
|
||||
coordinatorConfig.corpus.dir = saved.corpus
|
||||
coordinatorConfig.snapshotDir = saved.snapshots
|
||||
coordinatorConfig.chunkArticles = saved.chunk
|
||||
coordinatorConfig.streamChunkWaitMs = saved.wait
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A group with one range of three chunks and a node with a token. */
|
||||
async function setup(opts: { capture?: boolean } = {}) {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
const [r] = await db
|
||||
.table('ranges')
|
||||
.insert({
|
||||
group_id: g.id,
|
||||
start_id: 1,
|
||||
end_id: 300,
|
||||
next_id: 1,
|
||||
priority: 0,
|
||||
capture: opts.capture ?? false,
|
||||
})
|
||||
.returning('id')
|
||||
const node = await Node.create({ name: 'e2e-node' })
|
||||
const token = (await Node.accessTokens.create(node)).value!.release()
|
||||
return { groupId: Number(g.id), rangeId: Number(r.id), nodeId: Number(node.id), token }
|
||||
}
|
||||
const range = async (id: number) => (await db.from('ranges').where('id', id).firstOrFail()) as any
|
||||
const lease = async (groupId: number) =>
|
||||
(await db
|
||||
.from('group_leases')
|
||||
.where('group_id', groupId)
|
||||
.where('lane', 'history')
|
||||
.first()) as any
|
||||
/** The release whose first article is `at` (a release's name isn't the file's: the identity strips it). */
|
||||
const releaseAt = async (at: number) =>
|
||||
(await db.from('releases').where('first_article', at).first()) as any
|
||||
const names = async () =>
|
||||
JSON.stringify(
|
||||
await db
|
||||
.from('releases')
|
||||
.select('name', 'first_article', 'last_article', 'segments', 'complete')
|
||||
.orderBy('first_article')
|
||||
)
|
||||
const has = (lines: string[], text: string) => lines.some((l) => l.includes(text))
|
||||
|
||||
test('a range streamed from the spool and the corpus, with a chunk the corpus fill stores while the stream waits', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { groupId, rangeId, token } = await setup({ capture: true })
|
||||
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
|
||||
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
|
||||
const run = await startEdge(token)
|
||||
// The worker holds the group and streams: chunk 1 goes out, chunk 2 is waited for — the corpus fill stores it.
|
||||
await waitFor(
|
||||
'the group to be held',
|
||||
async () =>
|
||||
(await lease(groupId))?.range_id !== null && (await lease(groupId))?.range_id !== undefined
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 700))
|
||||
await Corpus.write(
|
||||
{ group: GROUP, start: 1, end: 300 },
|
||||
{ from: 101, to: 200 },
|
||||
overviewChunk(101, 200),
|
||||
3
|
||||
)
|
||||
const { code, lines } = { ...(await run.exit), lines: run.lines }
|
||||
assert.equal(code, 0, lines.join('\n'))
|
||||
assert.isTrue(has(lines, 'caught up after 1 ranges'), lines.join('\n'))
|
||||
assert.isTrue(has(lines, 'snapshot at 301'), lines.join('\n'))
|
||||
assert.include(await range(rangeId), { status: 'done' })
|
||||
assert.equal(Number((await range(rangeId)).headers), 300)
|
||||
const l = await lease(groupId)
|
||||
assert.isNull(l.node_id)
|
||||
assert.equal(Number(l.snapshot_next), 301)
|
||||
assert.equal(Number(l.ranges_done), 1)
|
||||
// The window closed on the early files (the frontier is a day past them); every closed release is settled.
|
||||
const releases = await db
|
||||
.from('releases')
|
||||
.select('name', 'settled_at', 'first_article', 'last_article', 'segments')
|
||||
assert.isAtLeast(releases.length, 10)
|
||||
assert.isTrue(releases.every((r) => r.settled_at !== null))
|
||||
const spanning = releases.find((r) => Number(r.first_article) === 100)
|
||||
assert.isDefined(spanning, await names())
|
||||
assert.deepEqual(
|
||||
[Number(spanning.first_article), Number(spanning.last_article), Number(spanning.segments)],
|
||||
[100, 101, 2],
|
||||
'the file over the chunk boundary came through the carried window'
|
||||
)
|
||||
})
|
||||
|
||||
test('a stream that fails: the batch so far is posted, the window snapshotted, the group given back; the next run restores it', async ({
|
||||
assert,
|
||||
}) => {
|
||||
coordinatorConfig.streamChunkWaitMs = 1500
|
||||
const { groupId, rangeId, token } = await setup()
|
||||
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
|
||||
const first = await runEdge(token)
|
||||
assert.equal(first.code, 0, first.lines.join('\n'))
|
||||
assert.isTrue(has(first.lines, 'the group goes back'), first.lines.join('\n'))
|
||||
assert.isTrue(has(first.lines, 'snapshot at 101'), first.lines.join('\n'))
|
||||
let r = await range(rangeId)
|
||||
assert.include(r, { status: 'planned', node_id: null })
|
||||
assert.equal(
|
||||
Number(r.next_id),
|
||||
101,
|
||||
'the batch for chunk 1 was stored before the group went back'
|
||||
)
|
||||
let l = await lease(groupId)
|
||||
assert.isNull(l.node_id)
|
||||
assert.equal(Number(l.snapshot_next), 101)
|
||||
const before = await db.from('releases').count('* as n').first()
|
||||
assert.isAtLeast(Number(before!.n), 10)
|
||||
assert.isNull(await releaseAt(100), 'the file over the boundary is still open in the window')
|
||||
|
||||
// The next run: the window restored, the rest of the range streamed, the open file completed.
|
||||
coordinatorConfig.streamChunkWaitMs = saved.wait
|
||||
await Spool.write(rangeId, { from: 101, to: 200 }, overviewChunk(101, 200))
|
||||
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
|
||||
const second = await runEdge(token)
|
||||
assert.equal(second.code, 0, second.lines.join('\n'))
|
||||
assert.isTrue(
|
||||
has(second.lines, 'window restored from the snapshot at 101'),
|
||||
second.lines.join('\n')
|
||||
)
|
||||
r = await range(rangeId)
|
||||
assert.equal(r.status, 'done')
|
||||
assert.equal(Number(r.headers), 300)
|
||||
l = await lease(groupId)
|
||||
assert.equal(Number(l.snapshot_next), 301)
|
||||
const spanning = await releaseAt(100)
|
||||
assert.isNotNull(spanning, await names())
|
||||
assert.deepEqual(
|
||||
[Number(spanning.first_article), Number(spanning.last_article), Number(spanning.segments)],
|
||||
[100, 101, 2]
|
||||
)
|
||||
})
|
||||
|
||||
test('a restart whose snapshot file is gone: the window starts empty and the file over the seam ends incomplete', async ({
|
||||
assert,
|
||||
}) => {
|
||||
coordinatorConfig.streamChunkWaitMs = 1500
|
||||
const { groupId, rangeId, token } = await setup()
|
||||
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
|
||||
const first = await runEdge(token)
|
||||
assert.isTrue(has(first.lines, 'snapshot at 101'), first.lines.join('\n'))
|
||||
await rm(GroupLeases.snapshotPath(groupId), { force: true })
|
||||
|
||||
coordinatorConfig.streamChunkWaitMs = saved.wait
|
||||
await Spool.write(rangeId, { from: 101, to: 200 }, overviewChunk(101, 200))
|
||||
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
|
||||
const second = await runEdge(token)
|
||||
assert.equal(second.code, 0, second.lines.join('\n'))
|
||||
assert.isTrue(
|
||||
has(second.lines, 'but there is none; the window starts empty'),
|
||||
second.lines.join('\n')
|
||||
)
|
||||
assert.equal((await range(rangeId)).status, 'done')
|
||||
// Part 1 was in the lost window: one seam. Part 2 alone is a file short of a part.
|
||||
assert.isNull(await releaseAt(100), await names())
|
||||
const half = await releaseAt(101)
|
||||
assert.isNotNull(half, await names())
|
||||
assert.deepEqual([Number(half.segments), half.complete], [1, false], await names())
|
||||
})
|
||||
|
||||
test("a takeover: the stale holder's next batch is refused, its snapshot too, and the group stays the new holder's", async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { groupId, rangeId, token, nodeId } = await setup()
|
||||
await Spool.write(rangeId, { from: 1, to: 100 }, overviewChunk(1, 100))
|
||||
const run = await startEdge(token)
|
||||
// Chunk 1 posted; the stream now waits for chunk 2.
|
||||
await waitFor('the first batch', async () => Number((await range(rangeId)).next_id) === 101)
|
||||
// The holder looks dead (its group and range heartbeats stale): another node takes the group over (no snapshot
|
||||
// yet: the range goes back to its resume point).
|
||||
const stale = new Date(Date.now() - 24 * 3600_000)
|
||||
await db.from('group_leases').where('group_id', groupId).update({ heartbeat_at: stale })
|
||||
await db.from('ranges').where('id', rangeId).update({ heartbeat_at: stale })
|
||||
const other = await Node.create({ name: 'e2e-other' })
|
||||
const taken = await GroupLeases.lease(Number(other.id))
|
||||
assert.equal(taken?.group.id, groupId)
|
||||
assert.isNull(taken?.snapshot)
|
||||
assert.include(await range(rangeId), { status: 'planned', node_id: null })
|
||||
// The old holder gets chunk 2 all the same, posts, and is refused.
|
||||
await Spool.write(rangeId, { from: 101, to: 200 }, overviewChunk(101, 200))
|
||||
await Spool.write(rangeId, { from: 201, to: 300 }, overviewChunk(201, 300))
|
||||
const { code } = await run.exit
|
||||
const lines = run.lines
|
||||
assert.equal(code, 0, lines.join('\n'))
|
||||
assert.isTrue(has(lines, 'snapshot failed'), lines.join('\n'))
|
||||
assert.isTrue(
|
||||
has(lines, 'not leased to this node') || has(lines, 'no longer this node'),
|
||||
lines.join('\n')
|
||||
)
|
||||
const l = await lease(groupId)
|
||||
assert.equal(Number(l.node_id), Number(other.id))
|
||||
assert.isNull(l.snapshot_next)
|
||||
const r = await range(rangeId)
|
||||
assert.equal(r.status, 'planned')
|
||||
assert.equal(Number(r.next_id), 101, 'nothing past the first batch was stored')
|
||||
assert.notEqual(Number(r.node_id), nodeId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { access } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { deflateSync } from 'node:zlib'
|
||||
import env from '#start/env'
|
||||
|
||||
export const GROUP = 'alt.binaries.e2e'
|
||||
|
||||
/** The edge worker binary: EDGE_NODE_BIN, else the release or debug build under edge/target. */
|
||||
export async function edgeBinary(): Promise<string> {
|
||||
const candidates = [
|
||||
process.env.EDGE_NODE_BIN,
|
||||
'edge/target/release/edge-node',
|
||||
'edge/target/debug/edge-node',
|
||||
]
|
||||
.filter((c): c is string => !!c)
|
||||
.map((c) => resolve(c))
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
await access(path)
|
||||
return path
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(
|
||||
`No edge-node binary (looked at ${candidates.join(', ')}): build it with "cargo build --release --manifest-path edge/Cargo.toml --bin edge-node", or set EDGE_NODE_BIN`
|
||||
)
|
||||
}
|
||||
|
||||
/** The test server's address for the worker (tests/bootstrap.ts starts it on HOST:PORT). */
|
||||
export function coordinatorUrl(): string {
|
||||
const host = env.get('HOST', 'localhost')
|
||||
return `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${env.get('PORT', 3333)}`
|
||||
}
|
||||
|
||||
export interface EdgeRun {
|
||||
exit: Promise<{ code: number | null; signal: string | null }>
|
||||
lines: string[]
|
||||
kill: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The real edge worker against the test server: one thread on the history lane, one group, a batch per chunk
|
||||
* (100 articles in these tests), a snapshot after every range.
|
||||
*/
|
||||
export async function startEdge(token: string, args: string[] = []): Promise<EdgeRun> {
|
||||
const bin = await edgeBinary()
|
||||
// The test server first (tests/bootstrap.ts starts it for this suite): a worker that can't connect retries for
|
||||
// minutes before it says so.
|
||||
const probe = await fetch(`${coordinatorUrl()}/api/v1/node/lease`, { method: 'POST' }).catch(
|
||||
(e) => e as Error
|
||||
)
|
||||
if (probe instanceof Error || probe.status !== 401)
|
||||
throw new Error(
|
||||
`The test server doesn't answer at ${coordinatorUrl()}: ${probe instanceof Error ? probe.message : `HTTP ${probe.status}`}`
|
||||
)
|
||||
const child = spawn(
|
||||
bin,
|
||||
[
|
||||
'--threads',
|
||||
'1',
|
||||
'--tail-threads',
|
||||
'0',
|
||||
'--groups',
|
||||
'1',
|
||||
'--batch-articles',
|
||||
'100',
|
||||
'--snapshot-minutes',
|
||||
'0',
|
||||
...args,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, COORDINATOR_URL: coordinatorUrl(), NODE_TOKEN: token },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
)
|
||||
const lines: string[] = []
|
||||
let rest = ''
|
||||
const collect = (chunk: Buffer) => {
|
||||
rest += chunk.toString('utf8')
|
||||
const parts = rest.split('\n')
|
||||
rest = parts.pop() ?? ''
|
||||
lines.push(...parts)
|
||||
}
|
||||
child.stdout.on('data', collect)
|
||||
child.stderr.on('data', collect)
|
||||
const exit = new Promise<{ code: number | null; signal: string | null }>((done) => {
|
||||
child.on('exit', (code, signal) => {
|
||||
if (rest) lines.push(rest)
|
||||
done({ code, signal })
|
||||
})
|
||||
})
|
||||
return { exit, lines, kill: () => child.kill('SIGKILL') }
|
||||
}
|
||||
|
||||
/** Runs the worker to its end (at most `timeoutMs`), its log lines and exit code. */
|
||||
export async function runEdge(token: string, args: string[] = [], timeoutMs = 60_000) {
|
||||
const run = await startEdge(token, args)
|
||||
const timer = setTimeout(() => run.kill(), timeoutMs)
|
||||
const result = await run.exit
|
||||
clearTimeout(timer)
|
||||
return { ...result, lines: run.lines }
|
||||
}
|
||||
|
||||
/** Polls `condition` every 100 ms until it holds or `timeoutMs` passed (then throws). */
|
||||
export async function waitFor(what: string, condition: () => Promise<boolean>, timeoutMs = 30_000) {
|
||||
const until = Date.now() + timeoutMs
|
||||
while (Date.now() < until) {
|
||||
if (await condition()) return
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${what}`)
|
||||
}
|
||||
|
||||
const BASE_MS = Date.UTC(2026, 9, 5, 10)
|
||||
|
||||
/** A file name without digits (a number sequence would make an album of a poster's files): letters from `k`. */
|
||||
export function nameOf(k: number): string {
|
||||
let s = ''
|
||||
let n = k
|
||||
do {
|
||||
s = String.fromCharCode(97 + (n % 26)) + s
|
||||
n = Math.floor(n / 26)
|
||||
} while (n > 0)
|
||||
return `Set.${s}.mkv`
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview lines of articles `from`..`to`, one an hour from 2026-10-05: article 1 is a one-part file, and from 2
|
||||
* on each even article is part 1 of a two-part file whose part 2 is the next (odd) article — so a file spans every
|
||||
* chunk boundary at an even `to` (100 and 101, 200 and 201: the window has to carry it). Dates an hour apart make
|
||||
* the posting frontier move a day every 24 articles, so the window closes on files as it goes.
|
||||
*/
|
||||
export function overviewLines(from: number, to: number): string[] {
|
||||
const lines: string[] = []
|
||||
for (let n = from; n <= to; n++) {
|
||||
const date = new Date(BASE_MS + n * 3600_000).toUTCString().replace(' GMT', ' +0000')
|
||||
const [name, part] =
|
||||
n === 1 ? [nameOf(1), '(1/1)'] : n % 2 === 0 ? [nameOf(n), '(1/2)'] : [nameOf(n - 1), '(2/2)']
|
||||
lines.push(
|
||||
`${n}\t"${name}" yEnc ${part}\tposter <p@x>\t${date}\t<a${n}@e2e>\t\t700000\t5000\tXref: news.example ${GROUP}:${n}`
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** One XOVER chunk as the news server sends it (deflated). */
|
||||
export function overviewChunk(from: number, to: number): Buffer {
|
||||
return deflateSync(Buffer.from(overviewLines(from, to).join('\r\n') + '\r\n'))
|
||||
}
|
||||
@@ -181,7 +181,13 @@ test.group('BatchStore', (group) => {
|
||||
)
|
||||
const first = ix.take()
|
||||
const stored = await BatchStore.store(rangeId, nodeId, first)
|
||||
assert.deepEqual(stored, { releases: 1, newReleases: 1, finalized: 0, candidates: 2 })
|
||||
assert.deepEqual(stored, {
|
||||
releases: 1,
|
||||
newReleases: 1,
|
||||
finalized: 0,
|
||||
candidates: 2,
|
||||
pending: 0,
|
||||
})
|
||||
|
||||
const release = await db.from('releases').first()
|
||||
assert.include(release, {
|
||||
@@ -425,10 +431,10 @@ test.group('BatchStore', (group) => {
|
||||
assert,
|
||||
}) => {
|
||||
await storeIn(GROUP, 1, [
|
||||
...upload(10, 'TsIgNBewuFbuwWy.7z.001', 2, '[1/4]', '2026-10-23T08:24:00Z'),
|
||||
...upload(20, 'TsIgNBewuFbuwWy.7z.002', 2, '[2/4]', '2026-10-23T08:26:00Z'),
|
||||
...upload(30, 'TsIgNBewuFbuwWy.7z.003', 2, '[3/4]', '2026-10-23T08:28:00Z'),
|
||||
...upload(40, 'TsIgNBewuFbuwWy.7z.004', 2, '[4/4]', '2026-10-23T08:30:00Z'),
|
||||
...upload(10, 'Some.Show.S01E04.7z.001', 2, '[1/4]', '2026-10-23T08:24:00Z'),
|
||||
...upload(20, 'Some.Show.S01E04.7z.002', 2, '[2/4]', '2026-10-23T08:26:00Z'),
|
||||
...upload(30, 'Some.Show.S01E04.7z.003', 2, '[3/4]', '2026-10-23T08:28:00Z'),
|
||||
...upload(40, 'Some.Show.S01E04.7z.004', 2, '[4/4]', '2026-10-23T08:30:00Z'),
|
||||
...upload(
|
||||
50,
|
||||
'Some.Show.S01E03.part01.rar',
|
||||
@@ -449,8 +455,8 @@ test.group('BatchStore', (group) => {
|
||||
candidates.map((c) => [c.kind, c.file]),
|
||||
[
|
||||
['archive', 'Some.Show.S01E03.part01.rar'],
|
||||
['archive', 'TsIgNBewuFbuwWy.7z.001'],
|
||||
['7z-tail', 'TsIgNBewuFbuwWy.7z.004'],
|
||||
['archive', 'Some.Show.S01E04.7z.001'],
|
||||
['7z-tail', 'Some.Show.S01E04.7z.004'],
|
||||
]
|
||||
)
|
||||
})
|
||||
@@ -487,6 +493,122 @@ test.group('BatchStore', (group) => {
|
||||
assert.notEqual(rows[0].release_key, rows[1].release_key)
|
||||
})
|
||||
|
||||
test('an unnamed release goes to the pending store with its probe targets, not into the catalog', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { rangeId, nodeId } = await rangeIn(GROUP, 1)
|
||||
const batch = batchOf(GROUP, 1, [
|
||||
...upload(10, 'q3B9OrpKiCxUnzgtUGnp.part01.rar', 4, '[1/2]', '2026-10-05T10:00:00Z'),
|
||||
...upload(20, 'q3B9OrpKiCxUnzgtUGnp.par2', 1, '[2/2]', '2026-10-05T10:01:00Z'),
|
||||
])
|
||||
assert.lengthOf(batch.releases, 1)
|
||||
assert.isFalse(batch.releases[0].readable)
|
||||
const stored = await BatchStore.store(rangeId, nodeId, batch)
|
||||
assert.include(stored, { releases: 1, newReleases: 0, candidates: 0, pending: 1 })
|
||||
assert.equal(Number((await db.from('releases').count('* as n').first())!.n), 0)
|
||||
assert.equal(Number((await db.from('probe_candidates').count('* as n').first())!.n), 0)
|
||||
assert.equal(Number((await db.from('release_chunks').count('* as n').first())!.n), 0)
|
||||
const pending = await db.from('pending_releases').firstOrFail()
|
||||
assert.include(pending, {
|
||||
files: 2,
|
||||
segments: '5',
|
||||
partial: false,
|
||||
finished: true,
|
||||
tool: 'other',
|
||||
status: 0,
|
||||
poster: 'p <p@x>',
|
||||
})
|
||||
assert.lengthOf(pending.release_key, 20)
|
||||
assert.deepEqual(
|
||||
pending.candidates.map((c: { kind: string; file: string }) => [c.kind, c.file]).sort(),
|
||||
[
|
||||
['archive', 'q3B9OrpKiCxUnzgtUGnp.part01.rar'],
|
||||
['par2', 'q3B9OrpKiCxUnzgtUGnp.par2'],
|
||||
]
|
||||
)
|
||||
// Sent again as a later chunk of a tail release: its counts add up while it is open, not once finished.
|
||||
const again = await BatchStore.store(rangeId, nodeId, {
|
||||
...batch,
|
||||
from: batch.next,
|
||||
next: batch.next + 1000,
|
||||
releases: [{ ...batch.releases[0], files: 1, segments: 5, bytes: 100 }],
|
||||
candidates: [],
|
||||
})
|
||||
assert.include(again, { pending: 0 })
|
||||
assert.include(await db.from('pending_releases').firstOrFail(), { files: 2, segments: '5' })
|
||||
})
|
||||
|
||||
test('a finished unnamed release without a probe target is exhausted at once, not waiting', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { rangeId, nodeId } = await rangeIn(GROUP, 1)
|
||||
// Two hash-named pieces: the edge gives the first a lone target; without one the ladder could never claim it.
|
||||
const batch = batchOf(GROUP, 1, [
|
||||
...upload(
|
||||
10,
|
||||
'9e7fc2741cdd450287aff2733ca4c193',
|
||||
4,
|
||||
'b3a75d234aa73891e78a70fc1f5496b1 [1/2]',
|
||||
'2026-10-05T10:00:00Z'
|
||||
),
|
||||
...upload(
|
||||
20,
|
||||
'c5ebdd5487f5b132c76e8d5c2572e560',
|
||||
4,
|
||||
'b3a75d234aa73891e78a70fc1f5496b1 [2/2]',
|
||||
'2026-10-05T10:01:00Z'
|
||||
),
|
||||
])
|
||||
assert.lengthOf(batch.releases, 1)
|
||||
assert.isFalse(batch.releases[0].readable)
|
||||
const stored = await BatchStore.store(rangeId, nodeId, { ...batch, candidates: [] })
|
||||
assert.include(stored, { releases: 1, newReleases: 0, candidates: 0, pending: 1 })
|
||||
const pending = await db.from('pending_releases').firstOrFail()
|
||||
assert.include(pending, { files: 2, finished: true, status: 3 })
|
||||
assert.deepEqual(pending.candidates, [])
|
||||
const klass = await db.from('pending_classes').firstOrFail()
|
||||
assert.include(klass, { waiting: 0 })
|
||||
})
|
||||
|
||||
test("an album's members stay findable by their own names (release_texts kind member)", async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { rangeId, nodeId } = await rangeIn(GROUP, 1)
|
||||
// Two scans the TypeScript indexer puts under their subject; the Rust edge's album of them is the same chunk
|
||||
// with its name and source.
|
||||
const batch = batchOf(GROUP, 1, [
|
||||
...upload(10, 'Scan_001.dat', 1, 'Some Scans [1/2]', '2026-10-05T10:00:00Z'),
|
||||
...upload(20, 'Scan_002.dat', 1, 'Some Scans [2/2]', '2026-10-05T10:01:00Z'),
|
||||
])
|
||||
assert.lengthOf(batch.releases, 1)
|
||||
const album: ReleaseChunk = {
|
||||
...batch.releases[0],
|
||||
name: 'Scan_#.dat',
|
||||
source: 'album',
|
||||
fileTotal: null,
|
||||
}
|
||||
const stored = await BatchStore.store(rangeId, nodeId, { ...batch, releases: [album] })
|
||||
assert.include(stored, { releases: 1, newReleases: 1 })
|
||||
const release = await db.from('releases').firstOrFail()
|
||||
assert.include(release, {
|
||||
name: 'Scan_#.dat',
|
||||
source: 6,
|
||||
files: 2,
|
||||
file_total: null,
|
||||
complete: null,
|
||||
})
|
||||
assert.deepEqual(
|
||||
(
|
||||
await db.from('release_texts').where('release_id', release.id).orderBy(['kind', 'text'])
|
||||
).map((t) => [t.kind, t.text]),
|
||||
[
|
||||
[1, 'Scan_#.dat'],
|
||||
[6, 'Scan_001.dat'],
|
||||
[6, 'Scan_002.dat'],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('a small release of sidecars only is marked for the sidecar fold', async ({ assert }) => {
|
||||
await storeIn(GROUP, 1_000_000, [
|
||||
...upload(1_000_000, 'Murdoch Mysteries S8D1.nzb', 1, '[00/80]', '2026-10-05T10:00:00Z'),
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import GroupLeases from '#services/group_leases'
|
||||
import Leases, { LeaseLost } from '#services/leases'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
async function node(name: string) {
|
||||
return Number((await db.table('nodes').insert({ name }).returning('id'))[0].id)
|
||||
}
|
||||
|
||||
async function group(name: string, priority: number) {
|
||||
return Number((await db.table('groups').insert({ name, priority }).returning('id'))[0].id)
|
||||
}
|
||||
|
||||
async function range(groupId: number, start: number, priority: number, extra: Record<string, unknown> = {}) {
|
||||
const [row] = await db
|
||||
.table('ranges')
|
||||
.insert({ group_id: groupId, start_id: start, end_id: start + 99, next_id: start, priority, ...extra })
|
||||
.returning('id')
|
||||
return Number(row.id)
|
||||
}
|
||||
|
||||
const rangeRow = async (id: number) => (await db.from('ranges').where('id', id).firstOrFail()) as any
|
||||
|
||||
test.group('Group leases', (g) => {
|
||||
let dir = ''
|
||||
let snapshotDir = ''
|
||||
g.each.setup(async () => {
|
||||
const rollback = await testUtils.db().withGlobalTransaction()
|
||||
dir = await mkdtemp(join(tmpdir(), 'snapshots-'))
|
||||
snapshotDir = coordinatorConfig.snapshotDir
|
||||
coordinatorConfig.snapshotDir = dir
|
||||
return rollback
|
||||
})
|
||||
g.each.teardown(async () => {
|
||||
coordinatorConfig.snapshotDir = snapshotDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('a lease takes the group with the most urgent planned range of its lane; its ranges come in article order; caught up it gives nothing', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const a = await group('alt.binaries.gl.a', 1)
|
||||
const b = await group('alt.binaries.gl.b', 2)
|
||||
// Group a: history at priority 0 and 1; group b: a tail range (priority -3) and history.
|
||||
const a1 = await range(a, 1, 0)
|
||||
const a2 = await range(a, 101, 0)
|
||||
const a3 = await range(a, 201, 1)
|
||||
const b2 = await range(b, 101, 0)
|
||||
const b1 = await range(b, 1, -3)
|
||||
const n1 = await node('gl-1')
|
||||
const n2 = await node('gl-2')
|
||||
|
||||
// The history lane: both groups have priority 0 work; a has the most planned articles (300 to 100).
|
||||
const first = await GroupLeases.lease(n1)
|
||||
assert.equal(first?.group.id, a, 'the most planned articles first')
|
||||
assert.equal(first?.lane, 'history')
|
||||
assert.isNull(first?.snapshot)
|
||||
// Held: the next node gets the other group; a third gets nothing.
|
||||
const second = await GroupLeases.lease(n2)
|
||||
assert.equal(second?.group.id, b)
|
||||
assert.isNull(await GroupLeases.lease(await node('gl-3')))
|
||||
|
||||
// Ranges in article order, whatever their priority; the tail range isn't the history lane's.
|
||||
assert.deepEqual((await GroupLeases.next(b, n2))?.range, { id: b2, start: 101, end: 200, next: 101 })
|
||||
assert.include(await rangeRow(b2), { status: 'leased', node_id: n2 })
|
||||
// Asked again before finishing: the same range.
|
||||
assert.equal((await GroupLeases.next(b, n2))?.range.id, b2)
|
||||
// A chunk of b2 that doesn't inflate, reported after the batch before it: the range is asked for again and
|
||||
// comes back at its resume point (the chunk fetched plain next time).
|
||||
await db.from('ranges').where('id', b2).update({ next_id: 151 })
|
||||
await Leases.corrupt(b2, n2, 151)
|
||||
assert.include(await rangeRow(b2), { status: 'planned', node_id: null })
|
||||
assert.deepEqual((await GroupLeases.next(b, n2))?.range, { id: b2, start: 101, end: 200, next: 151 })
|
||||
assert.deepEqual((await rangeRow(b2)).plain_chunks.map(Number), [151])
|
||||
await db.from('ranges').where('id', b2).update({ status: 'done', next_id: 201 })
|
||||
assert.isNull(await GroupLeases.next(b, n2), 'caught up: the tail range is the other lane\'s')
|
||||
assert.include(await rangeRow(b1), { status: 'planned' })
|
||||
|
||||
// The tail lane: group b's tail, held by a third node while n2 holds b's history.
|
||||
const n3 = await node('gl-3b')
|
||||
const tail = await GroupLeases.lease(n3, 'tail')
|
||||
assert.equal(tail?.group.id, b)
|
||||
assert.equal(tail?.lane, 'tail')
|
||||
assert.isNull(await GroupLeases.lease(await node('gl-3c'), 'tail'), 'no other group has tail work')
|
||||
assert.deepEqual((await GroupLeases.next(b, n3, 'tail'))?.range, { id: b1, start: 1, end: 100, next: 1 })
|
||||
// The history lane is n2's.
|
||||
await assert.rejects(() => GroupLeases.next(b, n3), LeaseLost)
|
||||
await db.from('ranges').where('id', b1).update({ status: 'done', next_id: 101 })
|
||||
assert.isNull(await GroupLeases.next(b, n3, 'tail'), 'the tail lane is caught up')
|
||||
// Each lane its own snapshot.
|
||||
await GroupLeases.putSnapshot(b, n3, 101, Readable.from([Buffer.from('tail at 101')]), 'tail')
|
||||
await GroupLeases.putSnapshot(b, n2, 201, Readable.from([Buffer.from('history at 201')]))
|
||||
assert.equal((await GroupLeases.getSnapshot(b, n3, 'tail'))?.next, 101)
|
||||
assert.equal((await GroupLeases.getSnapshot(b, n2))?.next, 201)
|
||||
await GroupLeases.release(b, n3, 'tail')
|
||||
assert.include(await GroupLeases.held(b, n2), { node_id: n2 })
|
||||
await assert.rejects(() => GroupLeases.held(b, n3, 'tail'), LeaseLost)
|
||||
|
||||
assert.deepEqual([a1, a2, a3].length, 3)
|
||||
await assert.rejects(() => GroupLeases.next(a, n2), LeaseLost)
|
||||
})
|
||||
|
||||
test('the order mixes the group with the most planned articles and the longest unheld one', async ({ assert }) => {
|
||||
// Four groups with priority 0 work: c was held before (5000 articles left), a, b and d never (100, 1000, 50).
|
||||
const a = await group('alt.binaries.gl.o.a', 1)
|
||||
const b = await group('alt.binaries.gl.o.b', 2)
|
||||
const c = await group('alt.binaries.gl.o.c', 3)
|
||||
const d = await group('alt.binaries.gl.o.d', 4)
|
||||
await range(a, 1, 0)
|
||||
for (let i = 0; i < 10; i++) await range(b, 1 + 100 * i, 0)
|
||||
for (let i = 0; i < 50; i++) await range(c, 1 + 100 * i, 0)
|
||||
await db.table('ranges').insert({ group_id: d, start_id: 1, end_id: 50, next_id: 1, priority: 0 })
|
||||
await db.table('group_leases').insert({ group_id: c, lane: 'history', node_id: null, heartbeat_at: new Date(Date.now() - 3600_000) })
|
||||
// By articles c, b, a, d; by age a, b, d (never held, by group priority), c. The leases alternate which
|
||||
// ranking goes first: c, a, b, d (or a, c, b, d when the sequence stands on the other parity).
|
||||
await db.rawQuery(`SELECT setval('group_lease_turns', 2)`)
|
||||
const order = []
|
||||
for (const name of ['o1', 'o2', 'o3', 'o4']) order.push((await GroupLeases.lease(await node(name)))?.group.id)
|
||||
assert.deepEqual(order, [c, a, b, d])
|
||||
})
|
||||
|
||||
test('a stale holder is taken over and the ranges after the snapshot are read again; a release keeps the resume point', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const a = await group('alt.binaries.gl.c', 1)
|
||||
const r1 = await range(a, 1, 0)
|
||||
const r2 = await range(a, 101, 0)
|
||||
const r3 = await range(a, 201, 0)
|
||||
const r4 = await range(a, 301, 0)
|
||||
const n1 = await node('gl-4')
|
||||
const n2 = await node('gl-5')
|
||||
assert.equal((await GroupLeases.lease(n1))?.group.id, a)
|
||||
// The worker did r1 and r2, snapshotted at 201 (r3's start), then got halfway through r3 and went quiet.
|
||||
await db.from('ranges').whereIn('id', [r1, r2]).update({ status: 'done', next_id: db.raw('end_id + 1') })
|
||||
await GroupLeases.putSnapshot(a, n1, 201, Readable.from([Buffer.from('window at 201')]))
|
||||
await GroupLeases.next(a, n1)
|
||||
await db.from('ranges').where('id', r3).update({ next_id: 251 })
|
||||
await db.from('group_leases').where('group_id', a).update({ heartbeat_at: new Date(Date.now() - 24 * 3600_000) })
|
||||
|
||||
const lease = await GroupLeases.lease(n2)
|
||||
assert.equal(lease?.group.id, a)
|
||||
assert.deepEqual(lease?.snapshot, { next: 201 })
|
||||
// r1 and r2 stay done (before the snapshot); r3 is read again from the snapshot's article, r4 untouched.
|
||||
assert.include(await rangeRow(r1), { status: 'done' })
|
||||
assert.include(await rangeRow(r2), { status: 'done' })
|
||||
assert.include(await rangeRow(r3), { status: 'planned', node_id: null })
|
||||
assert.equal(Number((await rangeRow(r3)).next_id), 201)
|
||||
assert.include(await rangeRow(r4), { status: 'planned' })
|
||||
const snapshot = await GroupLeases.getSnapshot(a, n2)
|
||||
assert.equal(snapshot?.next, 201)
|
||||
assert.equal(snapshot?.size, 'window at 201'.length)
|
||||
await assert.rejects(() => GroupLeases.next(a, n1), LeaseLost)
|
||||
|
||||
// The new holder works r3, snapshots at 301 and gives the group back: r3 done, r4 waits at its start.
|
||||
assert.equal((await GroupLeases.next(a, n2))?.range.id, r3)
|
||||
await db.from('ranges').where('id', r3).update({ status: 'done', next_id: 301 })
|
||||
await GroupLeases.putSnapshot(a, n2, 301, Readable.from([Buffer.from('window at 301')]))
|
||||
assert.equal((await GroupLeases.next(a, n2))?.range.id, r4)
|
||||
await db.from('ranges').where('id', r4).update({ next_id: 301 })
|
||||
await GroupLeases.release(a, n2)
|
||||
assert.include(await rangeRow(r4), { status: 'planned', node_id: null })
|
||||
assert.isNull((await db.from('group_leases').where('group_id', a).firstOrFail()).node_id)
|
||||
// Leased again at once (free, not stale): the snapshot at 301 covers everything done.
|
||||
const again = await GroupLeases.lease(n1)
|
||||
assert.deepEqual(again?.snapshot, { next: 301 })
|
||||
assert.include(await rangeRow(r3), { status: 'done' })
|
||||
assert.equal((await GroupLeases.next(a, n1))?.range.id, r4)
|
||||
})
|
||||
|
||||
test('capture only: the history ranges sharing a window priority are neither leased nor handed out', async ({
|
||||
assert,
|
||||
}) => {
|
||||
// Group e: history at priority 0 only (the planner's oldest bucket); group f: a 2016 window range (capture)
|
||||
// between two history ranges at 0.
|
||||
const e = await group('alt.binaries.gl.e', 1)
|
||||
await range(e, 1, 0)
|
||||
const f = await group('alt.binaries.gl.f', 2)
|
||||
await range(f, 1, 0)
|
||||
const f2 = await range(f, 101, 0, { capture: true })
|
||||
await range(f, 201, 0)
|
||||
const n1 = await node('gl-8')
|
||||
const was = coordinatorConfig.leaseCaptureOnly
|
||||
coordinatorConfig.leaseCaptureOnly = true
|
||||
try {
|
||||
assert.equal((await GroupLeases.lease(n1))?.group.id, f, 'the group with a capture range, not the older one')
|
||||
assert.equal((await GroupLeases.next(f, n1))?.range.id, f2)
|
||||
await db.from('ranges').where('id', f2).update({ status: 'done', next_id: 201 })
|
||||
assert.isNull(await GroupLeases.next(f, n1), 'the history range after the window is not handed out')
|
||||
assert.isNull(await GroupLeases.lease(await node('gl-9')))
|
||||
} finally {
|
||||
coordinatorConfig.leaseCaptureOnly = was
|
||||
}
|
||||
assert.equal((await GroupLeases.lease(await node('gl-10')))?.group.id, e, 'any range again without the flag')
|
||||
})
|
||||
|
||||
test('a snapshot older than a done range makes that range be read again from its start', async ({ assert }) => {
|
||||
const a = await group('alt.binaries.gl.d', 1)
|
||||
const r1 = await range(a, 1, 0)
|
||||
const r2 = await range(a, 101, 0)
|
||||
const n1 = await node('gl-6')
|
||||
await GroupLeases.lease(n1)
|
||||
// A range after the snapshot's article done long before the snapshot was taken (the old range leases did it):
|
||||
// not the window's, it stays done.
|
||||
const r3 = await range(a, 201, 0)
|
||||
await db.from('ranges').where('id', r3).update({ status: 'done', next_id: 301, done_at: new Date(Date.now() - 48 * 3600_000) })
|
||||
// The worker snapshotted at the start, finished r1, and crashed before its next snapshot: r1 is read again.
|
||||
await GroupLeases.putSnapshot(a, n1, 1, Readable.from([Buffer.from('window at 1')]))
|
||||
await db.from('ranges').where('id', r1).update({ status: 'done', next_id: 101, done_at: new Date() })
|
||||
await db.from('group_leases').where('group_id', a).update({ heartbeat_at: new Date(Date.now() - 24 * 3600_000) })
|
||||
await GroupLeases.lease(await node('gl-7'))
|
||||
assert.equal((await rangeRow(r1)).status, 'planned')
|
||||
assert.equal(Number((await rangeRow(r1)).next_id), 1)
|
||||
assert.equal((await rangeRow(r2)).status, 'planned')
|
||||
assert.equal(Number((await rangeRow(r2)).next_id), 101)
|
||||
assert.equal((await rangeRow(r3)).status, 'done')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Node from '#models/node'
|
||||
import Spool from '#services/spool'
|
||||
import Corpus from '#services/corpus'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import { decodeFrames, fromWire, toWire, RangeIndexer, type Lease } from '#lib/edge/index'
|
||||
import { parseOverviews } from '#lib/nntp/index'
|
||||
@@ -86,6 +87,121 @@ test.group('Node API', (group) => {
|
||||
assert.equal(range.status, 'done')
|
||||
assert.isFalse(await Spool.has(rangeId, { from: 1, to: 100 }))
|
||||
})
|
||||
test('a group lease: the next range in order, its stream from the corpus, the snapshot through the API', async ({ client, assert }) => {
|
||||
const corpus = await mkdtemp(join(tmpdir(), 'corpus-'))
|
||||
const snapshots = await mkdtemp(join(tmpdir(), 'snapshots-'))
|
||||
const saved = { corpus: coordinatorConfig.corpus.dir, snapshots: coordinatorConfig.snapshotDir }
|
||||
coordinatorConfig.corpus.dir = corpus
|
||||
coordinatorConfig.snapshotDir = snapshots
|
||||
try {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
// Two ranges of the group: the first stored in the corpus (no spool), the second not.
|
||||
const [r1] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 200, next_id: 1, capture: true, stored_at: new Date() }).returning('id')
|
||||
const [r2] = await db.table('ranges').insert({ group_id: g.id, start_id: 201, end_id: 400, next_id: 201 }).returning('id')
|
||||
const corpusRange = { group: GROUP, start: 1, end: 200 }
|
||||
await Corpus.write(corpusRange, { from: 1, to: 100 }, chunk(11), 2)
|
||||
await Corpus.write(corpusRange, { from: 101, to: 200 }, chunk(111), 2)
|
||||
const node = await Node.create({ name: 'n1' })
|
||||
const token = (await Node.accessTokens.create(node)).value!.release()
|
||||
|
||||
const lease = await client.post('/api/v1/node/groups/lease').bearerToken(token)
|
||||
lease.assertStatus(200)
|
||||
const groupId = Number(lease.body().group.id)
|
||||
assert.equal(groupId, Number(g.id))
|
||||
assert.isNull(lease.body().snapshot)
|
||||
;(await client.post('/api/v1/node/groups/lease').bearerToken(token)).assertStatus(204)
|
||||
|
||||
const next = await client.post(`/api/v1/node/groups/${groupId}/next`).bearerToken(token)
|
||||
next.assertStatus(200)
|
||||
assert.deepEqual(next.body().range, { id: Number(r1.id), start: 1, end: 200, next: 1 })
|
||||
|
||||
// The stored range's chunks come from the corpus, nothing spooled.
|
||||
const stream = await client.get(`/api/v1/node/ranges/${r1.id}/stream`).bearerToken(token)
|
||||
stream.assertStatus(200)
|
||||
const raw = stream.body() as unknown
|
||||
const body: Buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(stream.text(), 'binary')
|
||||
const frames = []
|
||||
for await (const frame of decodeFrames([body])) frames.push(frame)
|
||||
assert.deepEqual(frames.map((f) => [f.from, f.to]), [[1, 100], [101, 200]])
|
||||
assert.equal(parseOverviews(unzipSync(frames[0].data).toString('utf8')).length, 3)
|
||||
|
||||
// The snapshot, standing at the second range's start.
|
||||
const putRaw = (url: string, bytes: Buffer) => {
|
||||
const request = client.put(url).bearerToken(token)
|
||||
request.request.set('Content-Type', 'application/octet-stream').send(bytes)
|
||||
return request
|
||||
}
|
||||
;(await putRaw(`/api/v1/node/groups/${groupId}/snapshot`, Buffer.from('window'))).assertStatus(400)
|
||||
;(await putRaw(`/api/v1/node/groups/${groupId}/snapshot?next=201`, Buffer.from('window at 201'))).assertStatus(200)
|
||||
await db.from('ranges').where('id', r1.id).update({ status: 'done', next_id: 201 })
|
||||
const got = await client.get(`/api/v1/node/groups/${groupId}/snapshot`).bearerToken(token)
|
||||
got.assertStatus(200)
|
||||
assert.equal(got.header('x-snapshot-next'), '201')
|
||||
const gotRaw = got.body() as unknown
|
||||
assert.equal((Buffer.isBuffer(gotRaw) ? gotRaw : Buffer.from(got.text(), 'binary')).toString(), 'window at 201')
|
||||
assert.equal(Number((await client.post(`/api/v1/node/groups/${groupId}/next`).bearerToken(token)).body().range.id), Number(r2.id))
|
||||
|
||||
;(await client.post(`/api/v1/node/groups/${groupId}/heartbeat`).bearerToken(token)).assertStatus(200)
|
||||
;(await client.post(`/api/v1/node/groups/${groupId}/release`).bearerToken(token)).assertStatus(200)
|
||||
;(await client.post(`/api/v1/node/groups/${groupId}/heartbeat`).bearerToken(token)).assertStatus(409)
|
||||
assert.include(await db.from('ranges').where('id', r2.id).firstOrFail(), { status: 'planned', node_id: null })
|
||||
|
||||
// The tail lane (`?lane=tail`): the group's tail range, with a cursor and snapshot of its own.
|
||||
const [t] = await db.table('ranges').insert({ group_id: g.id, start_id: 401, end_id: 500, next_id: 401, priority: -3 }).returning('id')
|
||||
;(await client.post('/api/v1/node/groups/lease?lane=sideways').bearerToken(token)).assertStatus(400)
|
||||
const tail = await client.post('/api/v1/node/groups/lease?lane=tail').bearerToken(token)
|
||||
tail.assertStatus(200)
|
||||
assert.equal(tail.body().lane, 'tail')
|
||||
assert.equal(Number(tail.body().group.id), groupId)
|
||||
assert.deepEqual((await client.post(`/api/v1/node/groups/${groupId}/next?lane=tail`).bearerToken(token)).body().range, { id: Number(t.id), start: 401, end: 500, next: 401 })
|
||||
;(await client.post(`/api/v1/node/groups/${groupId}/heartbeat`).bearerToken(token)).assertStatus(409)
|
||||
;(await client.post(`/api/v1/node/groups/${groupId}/heartbeat?lane=tail`).bearerToken(token)).assertStatus(200)
|
||||
;(await putRaw(`/api/v1/node/groups/${groupId}/snapshot?next=401&lane=tail`, Buffer.from('tail at 401'))).assertStatus(200)
|
||||
const gotTail = await client.get(`/api/v1/node/groups/${groupId}/snapshot?lane=tail`).bearerToken(token)
|
||||
gotTail.assertStatus(200)
|
||||
assert.equal(gotTail.header('x-snapshot-next'), '401')
|
||||
;(await client.post(`/api/v1/node/groups/${groupId}/release?lane=tail`).bearerToken(token)).assertStatus(200)
|
||||
assert.include(await db.from('ranges').where('id', t.id).firstOrFail(), { status: 'planned', node_id: null })
|
||||
} finally {
|
||||
coordinatorConfig.corpus.dir = saved.corpus
|
||||
coordinatorConfig.snapshotDir = saved.snapshots
|
||||
await rm(corpus, { recursive: true, force: true })
|
||||
await rm(snapshots, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('a chunk the corpus fill stores while the stream waits for it comes from the corpus', async ({ client, assert }) => {
|
||||
const corpus = await mkdtemp(join(tmpdir(), 'corpus-'))
|
||||
const saved = coordinatorConfig.corpus.dir
|
||||
coordinatorConfig.corpus.dir = corpus
|
||||
try {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
// A capture range not in the corpus: its first chunk spooled by the leased run, the second claimed by
|
||||
// the corpus fill, which stores it in the corpus only, after the stream started.
|
||||
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 200, next_id: 1, capture: true }).returning('id')
|
||||
const rangeId = Number(r.id)
|
||||
await Spool.write(rangeId, { from: 1, to: 100 }, chunk(11))
|
||||
const node = await Node.create({ name: 'n1' })
|
||||
const token = (await Node.accessTokens.create(node)).value!.release()
|
||||
await db.from('ranges').where('id', rangeId).update({ status: 'leased', node_id: node.id, leased_at: new Date(), heartbeat_at: new Date() })
|
||||
|
||||
const pending = client.get(`/api/v1/node/ranges/${rangeId}/stream`).bearerToken(token)
|
||||
setTimeout(() => Corpus.write({ group: GROUP, start: 1, end: 200 }, { from: 101, to: 200 }, chunk(111), 2), 800)
|
||||
const stream = await pending
|
||||
stream.assertStatus(200)
|
||||
const raw = stream.body() as unknown
|
||||
const body: Buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(stream.text(), 'binary')
|
||||
const frames = []
|
||||
for await (const frame of decodeFrames([body])) frames.push(frame)
|
||||
assert.deepEqual(frames.map((f) => [f.from, f.to]), [[1, 100], [101, 200]])
|
||||
assert.equal(parseOverviews(unzipSync(frames[1].data).toString('utf8')).length, 3)
|
||||
assert.isFalse(await Spool.has(rangeId, { from: 101, to: 200 }))
|
||||
} finally {
|
||||
coordinatorConfig.corpus.dir = saved
|
||||
await rm(corpus, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('a chunk that does not inflate: reported, removed from the spool, the range goes back', async ({ client, assert }) => {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 300, next_id: 1, spooled_chunks: 3 }).returning('id')
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { pendingClass } from '#lib/release/index'
|
||||
|
||||
test.group('Pending classes / SQL pending_class()', () => {
|
||||
test('the SQL function agrees with pendingClass', async ({ assert }) => {
|
||||
const cases: [string, number, boolean][] = [
|
||||
['c04212a389cf69edc54767355ab5285f', 5_000_000, false],
|
||||
['c04212a389cf69edc54767355ab5285f', 5_000_000, true],
|
||||
['Zx5AjNaK-mh7CFg6Hf8uu', 500_000_000, false],
|
||||
['1650777167', 100, false],
|
||||
['mrrtmzrzmi4dgmrzmyytqmlbztczbx', 2_000_000_000, false],
|
||||
['PsNdnYiugfZ.rar', 20_000_000, false],
|
||||
['n5d1ueIf34X', 20_000_000, true],
|
||||
['sgkra vU7u2yuw9LDLCjDu04pOez', 999, false],
|
||||
['Description - Lt5nHjlo3RiuBb3C', 40_000_000, false],
|
||||
['ABCDEFGHIJKLMNOP', 0, false],
|
||||
]
|
||||
for (const [name, bytes, partial] of cases) {
|
||||
const { rows } = await db.rawQuery(`SELECT pending_class(?, ?::bigint, ?) AS k`, [
|
||||
name,
|
||||
bytes,
|
||||
partial,
|
||||
])
|
||||
assert.equal(Number(rows[0].k), pendingClass(name, bytes, partial), name)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,403 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import PendingLadder, { PENDING, byEffort, type PendingProbe } from '#services/pending_ladder'
|
||||
import { identityKey, releaseKey } from '#lib/release/keys'
|
||||
|
||||
let groupId = 0
|
||||
let n = 0
|
||||
|
||||
async function pending(
|
||||
name: string,
|
||||
candidates: { kind: string; messageId: string; file: string; bytes?: number }[],
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
n++
|
||||
const key = releaseKey(name, groupId, n)
|
||||
await db.table('pending_releases').insert({
|
||||
release_key: key,
|
||||
identity_key: identityKey(name),
|
||||
group_id: groupId,
|
||||
name,
|
||||
poster: 'Bob <bob@home.me>',
|
||||
first_article: n,
|
||||
last_article: n,
|
||||
files: 1,
|
||||
segments: 10,
|
||||
bytes: 1000,
|
||||
tool: 'other',
|
||||
candidates: JSON.stringify(candidates),
|
||||
...extra,
|
||||
})
|
||||
return key.toString('hex')
|
||||
}
|
||||
|
||||
const inspection = (yencName: string, names?: string[]) => ({
|
||||
yencName,
|
||||
size: 1000,
|
||||
type: null,
|
||||
names,
|
||||
entries: names?.length,
|
||||
})
|
||||
|
||||
test.group('Pending ladder', (group) => {
|
||||
group.each.setup(async () => {
|
||||
const rollback = await testUtils.db().withGlobalTransaction()
|
||||
groupId = Number(
|
||||
(
|
||||
await db
|
||||
.table('groups')
|
||||
.insert({ name: 'alt.binaries.pending', priority: 1 })
|
||||
.returning('id')
|
||||
)[0].id
|
||||
)
|
||||
n = 0
|
||||
return rollback
|
||||
})
|
||||
|
||||
test('targets are climbed by effort: a par2 before an archive before a lone file', ({
|
||||
assert,
|
||||
}) => {
|
||||
assert.deepEqual(
|
||||
byEffort([
|
||||
{ kind: 'lone', messageId: 'l', file: 'l' },
|
||||
{ kind: '7z-tail', messageId: 't', file: 't' },
|
||||
{ kind: 'archive', messageId: 'a', file: 'a' },
|
||||
{ kind: 'par2', messageId: 'p', file: 'p' },
|
||||
]).map((t) => t.kind),
|
||||
['par2', 'archive', 'lone', '7z-tail']
|
||||
)
|
||||
})
|
||||
|
||||
test('a claim takes the oldest waiting releases, their cheapest target first; outcomes move them up or to the end', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const twoTargets = await pending('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', [
|
||||
{ kind: 'archive', messageId: '<a@x>', file: 'x.rar' },
|
||||
{ kind: 'par2', messageId: '<p@x>', file: 'x.par2' },
|
||||
])
|
||||
const lone = await pending('f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3', [
|
||||
{ kind: 'lone', messageId: '<l@x>', file: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3' },
|
||||
])
|
||||
const none = await pending('00000000000000000000000000000001', [])
|
||||
const provisional = await pending(
|
||||
'00000000000000000000000000000002',
|
||||
[{ kind: 'lone', messageId: '<v@x>', file: 'v' }],
|
||||
{ finished: false }
|
||||
)
|
||||
|
||||
const probes = await PendingLadder.claim(10)
|
||||
assert.deepEqual(
|
||||
probes.map((p) => [p.releaseKey, p.kind, p.tries, p.targets]),
|
||||
[
|
||||
[twoTargets, 'par2', 0, 2],
|
||||
[lone, 'lone', 0, 1],
|
||||
]
|
||||
)
|
||||
const statusOf = async (key: string) =>
|
||||
(await db
|
||||
.from('pending_releases')
|
||||
.where('release_key', Buffer.from(key, 'hex'))
|
||||
.firstOrFail()) as any
|
||||
assert.equal((await statusOf(twoTargets)).status, PENDING.taken)
|
||||
assert.equal((await statusOf(none)).status, PENDING.waiting)
|
||||
assert.equal((await statusOf(provisional)).status, PENDING.waiting)
|
||||
|
||||
// The par2 names only random volumes: the next target. The lone file's yEnc name is its random name: exhausted.
|
||||
await PendingLadder.record([
|
||||
{
|
||||
probe: probes[0],
|
||||
status: 'done',
|
||||
inspection: inspection('x.par2', ['a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4.part01.rar']),
|
||||
},
|
||||
{
|
||||
probe: probes[1],
|
||||
status: 'done',
|
||||
inspection: inspection('f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3'),
|
||||
},
|
||||
])
|
||||
const first = await statusOf(twoTargets)
|
||||
assert.include(first, { status: PENDING.waiting, tries: 1, named: null })
|
||||
assert.lengthOf(first.inspections, 1)
|
||||
assert.include(await statusOf(lone), { status: PENDING.exhausted, tries: 1 })
|
||||
|
||||
// The archive's listing names it: for the namer.
|
||||
const [second] = await PendingLadder.claim(10)
|
||||
assert.deepEqual([second.releaseKey, second.kind, second.tries], [twoTargets, 'archive', 1])
|
||||
await PendingLadder.record([
|
||||
{
|
||||
probe: second,
|
||||
status: 'done',
|
||||
inspection: inspection('x.rar', ['Some.Movie.2016.1080p.mkv']),
|
||||
},
|
||||
])
|
||||
assert.include(await statusOf(twoTargets), {
|
||||
status: PENDING.naming,
|
||||
named: 'Some.Movie.2016.1080p.mkv',
|
||||
tries: 0,
|
||||
})
|
||||
|
||||
// The signature counted both ways: the poster's own and the tool's in the group.
|
||||
const signatures = await db.from('pending_signatures').orderBy('poster')
|
||||
assert.deepEqual(
|
||||
signatures.map((s) => [s.tool, s.poster, s.probed, s.named, s.dropped]),
|
||||
[
|
||||
['other', '', 2, 1, false],
|
||||
['other', 'Bob <bob@home.me>', 2, 1, false],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('a signature that names nothing is dropped, and its waiting releases discarded without a probe', async ({
|
||||
assert,
|
||||
}) => {
|
||||
// 100 climbs of the tool in the group, none named.
|
||||
await db
|
||||
.table('pending_signatures')
|
||||
.insert({ tool: 'other', group_id: groupId, poster: '', probed: 99, named: 0 })
|
||||
const last = await pending('00000000000000000000000000000009', [
|
||||
{ kind: 'lone', messageId: '<z@x>', file: 'z' },
|
||||
])
|
||||
const [probe] = await PendingLadder.claim(1)
|
||||
assert.equal(probe.releaseKey, last)
|
||||
await PendingLadder.record([{ probe, status: 'missing' }])
|
||||
const signature = await db
|
||||
.from('pending_signatures')
|
||||
.where({ tool: 'other', group_id: groupId, poster: '' })
|
||||
.firstOrFail()
|
||||
assert.include(signature, { probed: 100, named: 0, dropped: true })
|
||||
|
||||
const waiting = await pending('00000000000000000000000000000010', [
|
||||
{ kind: 'lone', messageId: '<w@x>', file: 'w' },
|
||||
])
|
||||
const other = await pending(
|
||||
'00000000000000000000000000000011',
|
||||
[{ kind: 'lone', messageId: '<o@x>', file: 'o' }],
|
||||
{ tool: 'nyuu' }
|
||||
)
|
||||
// samplePercent 0 in the test rules: no explored sample.
|
||||
await db.table('rule_sets').insert({
|
||||
version: 99,
|
||||
rules: JSON.stringify({ samplePercent: 0, droppedSignatures: [] }),
|
||||
note: 'test',
|
||||
})
|
||||
assert.equal(await PendingLadder.discard(), 1)
|
||||
const statusOf = async (key: string) =>
|
||||
(
|
||||
await db
|
||||
.from('pending_releases')
|
||||
.where('release_key', Buffer.from(key, 'hex'))
|
||||
.firstOrFail()
|
||||
).status
|
||||
assert.equal(await statusOf(waiting), PENDING.discarded)
|
||||
assert.equal(await statusOf(other), PENDING.waiting)
|
||||
})
|
||||
|
||||
test('a probe given back goes to the queue again at the same step', async ({ assert }) => {
|
||||
const key = await pending('00000000000000000000000000000020', [
|
||||
{ kind: 'lone', messageId: '<g@x>', file: 'g' },
|
||||
])
|
||||
const [probe] = await PendingLadder.claim(1)
|
||||
await PendingLadder.record([{ probe: probe as PendingProbe, status: 'waiting' }])
|
||||
assert.include(
|
||||
await db.from('pending_releases').where('release_key', Buffer.from(key, 'hex')).firstOrFail(),
|
||||
{ status: PENDING.waiting, tries: 0 }
|
||||
)
|
||||
assert.lengthOf(await db.from('pending_signatures'), 0)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('Pending ladder / classes', (group) => {
|
||||
group.each.setup(async () => {
|
||||
PendingLadder.forget()
|
||||
const rollback = await testUtils.db().withGlobalTransaction()
|
||||
groupId = Number(
|
||||
(
|
||||
await db
|
||||
.table('groups')
|
||||
.insert({ name: 'alt.binaries.classes', priority: 1 })
|
||||
.returning('id')
|
||||
)[0].id
|
||||
)
|
||||
n = 0
|
||||
return rollback
|
||||
})
|
||||
|
||||
test('a claim takes the class with the best record first, the cheapest target within it, the rest oldest first', async ({
|
||||
assert,
|
||||
}) => {
|
||||
// Class 15 (1-10 MB hex, whole) named 9 of 10; class 16 (partial) named 0 of 50; class 0: rows from before.
|
||||
await db.table('pending_classes').multiInsert([
|
||||
{ tool: 'other', group_id: groupId, klass: 15, probed: 10, named: 9, waiting: 2 },
|
||||
{ tool: 'other', group_id: groupId, klass: 16, probed: 50, named: 0, waiting: 1 },
|
||||
])
|
||||
const old = await pending('00000000000000000000000000000031', [
|
||||
{ kind: 'lone', messageId: '<o@x>', file: 'o' },
|
||||
])
|
||||
const poor = await pending(
|
||||
'00000000000000000000000000000032',
|
||||
[{ kind: 'lone', messageId: '<p@x>', file: 'p' }],
|
||||
{ klass: 16 }
|
||||
)
|
||||
const bigTarget = await pending(
|
||||
'00000000000000000000000000000033',
|
||||
[{ kind: 'archive', messageId: '<b@x>', file: 'b.rar', bytes: 700_000 }],
|
||||
{ klass: 15, target_bytes: 700_000 }
|
||||
)
|
||||
const smallTarget = await pending(
|
||||
'00000000000000000000000000000034',
|
||||
[{ kind: 'archive', messageId: '<s@x>', file: 's.rar', bytes: 50_000 }],
|
||||
{ klass: 15, target_bytes: 50_000 }
|
||||
)
|
||||
const probes = await PendingLadder.claim(10)
|
||||
assert.deepEqual(
|
||||
probes.map((p) => [p.releaseKey, p.klass]),
|
||||
[
|
||||
[smallTarget, 15],
|
||||
[bigTarget, 15],
|
||||
[poor, 16],
|
||||
[old, 0],
|
||||
]
|
||||
)
|
||||
const waiting = await db
|
||||
.from('pending_classes')
|
||||
.where({ group_id: groupId })
|
||||
.orderBy('klass')
|
||||
.select('klass', 'waiting')
|
||||
assert.deepEqual(
|
||||
waiting.map((c) => [c.klass, c.waiting]),
|
||||
[
|
||||
[15, 0],
|
||||
[16, 0],
|
||||
]
|
||||
)
|
||||
// Outcomes count on the class; a probe given back waits again.
|
||||
await PendingLadder.record([
|
||||
{
|
||||
probe: probes[0],
|
||||
status: 'done',
|
||||
inspection: inspection('s.rar', ['Some.Movie.2016.1080p.mkv']),
|
||||
},
|
||||
{ probe: probes[1], status: 'waiting' },
|
||||
{ probe: probes[2], status: 'missing' },
|
||||
])
|
||||
const classes = await db.from('pending_classes').where({ group_id: groupId }).orderBy('klass')
|
||||
assert.deepEqual(
|
||||
classes.map((c) => [c.klass, c.probed, c.named, c.waiting, c.dropped]),
|
||||
[
|
||||
[15, 11, 10, 1, false],
|
||||
[16, 51, 0, 0, false],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('a class that names nothing is dropped and its waiting releases discarded, the other classes untouched', async ({
|
||||
assert,
|
||||
}) => {
|
||||
await db
|
||||
.table('pending_classes')
|
||||
.insert({ tool: 'other', group_id: groupId, klass: 16, probed: 99, named: 0, waiting: 1 })
|
||||
const last = await pending(
|
||||
'00000000000000000000000000000041',
|
||||
[{ kind: 'lone', messageId: '<z@x>', file: 'z' }],
|
||||
{ klass: 16 }
|
||||
)
|
||||
const [probe] = await PendingLadder.claim(1)
|
||||
assert.equal(probe.releaseKey, last)
|
||||
await PendingLadder.record([{ probe, status: 'missing' }])
|
||||
assert.include(
|
||||
await db.from('pending_classes').where({ group_id: groupId, klass: 16 }).firstOrFail(),
|
||||
{ probed: 100, named: 0, dropped: true }
|
||||
)
|
||||
const waiting = await pending(
|
||||
'00000000000000000000000000000042',
|
||||
[{ kind: 'lone', messageId: '<w@x>', file: 'w' }],
|
||||
{ klass: 16 }
|
||||
)
|
||||
const other = await pending(
|
||||
'00000000000000000000000000000043',
|
||||
[{ kind: 'lone', messageId: '<o@x>', file: 'o' }],
|
||||
{ klass: 15 }
|
||||
)
|
||||
await db.rawQuery(
|
||||
`UPDATE pending_classes SET waiting = waiting + 1 WHERE group_id = ? AND klass = 16`,
|
||||
[groupId]
|
||||
)
|
||||
await db.table('rule_sets').insert({
|
||||
version: 98,
|
||||
rules: JSON.stringify({ samplePercent: 0, droppedSignatures: [] }),
|
||||
note: 'test',
|
||||
})
|
||||
assert.equal(await PendingLadder.discard(), 1)
|
||||
const statusOf = async (key: string) =>
|
||||
(
|
||||
await db
|
||||
.from('pending_releases')
|
||||
.where('release_key', Buffer.from(key, 'hex'))
|
||||
.firstOrFail()
|
||||
).status
|
||||
assert.equal(await statusOf(waiting), PENDING.discarded)
|
||||
assert.equal(await statusOf(other), PENDING.waiting)
|
||||
assert.equal(
|
||||
(await db.from('pending_classes').where({ group_id: groupId, klass: 16 }).firstOrFail())
|
||||
.waiting,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('a twin by content names a release whose own probe did not', async ({ assert }) => {
|
||||
// A catalog release with a readable name holds the same first 16 KiB and size.
|
||||
const [poster] = await db.table('posters').insert({ poster: 'Twin <twin@x>' }).returning('id')
|
||||
const [set] = await db
|
||||
.table('group_sets')
|
||||
.insert({ groups: JSON.stringify(['alt.binaries.classes']) })
|
||||
.returning('id')
|
||||
const [twin] = await db
|
||||
.table('releases')
|
||||
.insert({
|
||||
release_key: Buffer.from('aa'.repeat(20), 'hex'),
|
||||
identity_key: Buffer.from('bb'.repeat(20), 'hex'),
|
||||
group_id: groupId,
|
||||
group_set_id: set.id,
|
||||
poster_id: poster.id,
|
||||
name: 'Some.Movie.2016.1080p.BluRay-GRP',
|
||||
source: 3,
|
||||
posted_at: new Date('2026-10-01T00:00:00Z'),
|
||||
first_article: 1,
|
||||
last_article: 2,
|
||||
files: 2,
|
||||
segments: 2,
|
||||
bytes: 1000,
|
||||
readable: true,
|
||||
})
|
||||
.returning('id')
|
||||
await db.table('file_hashes').insert({
|
||||
candidate_id: 777_001,
|
||||
release_id: twin.id,
|
||||
hash16k: 'deadbeef'.repeat(4),
|
||||
size: 1000,
|
||||
})
|
||||
const key = await pending('00000000000000000000000000000051', [
|
||||
{ kind: 'lone', messageId: '<t@x>', file: '00000000000000000000000000000051' },
|
||||
])
|
||||
const [probe] = await PendingLadder.claim(1)
|
||||
assert.equal(probe.releaseKey, key)
|
||||
await PendingLadder.record([
|
||||
{
|
||||
probe,
|
||||
status: 'done',
|
||||
inspection: {
|
||||
...inspection('00000000000000000000000000000051'),
|
||||
hash16k: 'deadbeef'.repeat(4),
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.include(
|
||||
await db.from('pending_releases').where('release_key', Buffer.from(key, 'hex')).firstOrFail(),
|
||||
{
|
||||
status: PENDING.naming,
|
||||
named: 'Some.Movie.2016.1080p.BluRay-GRP',
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { deflateSync } from 'node:zlib'
|
||||
import { PendingNamer } from '#services/pending_namer'
|
||||
import { PENDING } from '#services/pending_ladder'
|
||||
import { RangeIndexer } from '#lib/edge/index'
|
||||
import { DEFAULT_RULES } from '#lib/rules/index'
|
||||
import { decodeChunk } from '#lib/chunks/index'
|
||||
import { identityKey, releaseKey } from '#lib/release/keys'
|
||||
|
||||
const GROUP = 'alt.binaries.namer'
|
||||
|
||||
/** One overview line as XOVER gives it (the corpus stores the reply's text). */
|
||||
function line(n: number, subject: string, minutes: number) {
|
||||
const date = new Date(Date.UTC(2016, 0, 4, 10, minutes)).toUTCString()
|
||||
return `${n}\t${subject}\tp <p@x>\t${date}\t<u${n}@test>\t\t1000\t10\tXref: news.example ${GROUP}:${n}`
|
||||
}
|
||||
|
||||
test.group('Pending namer', (group) => {
|
||||
let dir = ''
|
||||
group.each.setup(async () => {
|
||||
const rollback = await testUtils.db().withGlobalTransaction()
|
||||
dir = await mkdtemp(join(tmpdir(), 'namer-'))
|
||||
return rollback
|
||||
})
|
||||
group.each.teardown(() => rm(dir, { recursive: true, force: true }))
|
||||
|
||||
test('a named pending release is read again from the corpus and written to the catalog with its chunk and candidates', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const groupId = Number(
|
||||
(await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id'))[0].id
|
||||
)
|
||||
// The range stored in the corpus: one chunk with a random-named upload (4 parts and its par2) among others.
|
||||
const lines = [
|
||||
line(5, 'Some.Show [1/1] - "Some.Show.mkv" yEnc (1/1)', 0),
|
||||
...[1, 2, 3, 4].map((i) =>
|
||||
line(9 + i, `[1/2] - "q3B9OrpKiCxUnzgtUGnp.part01.rar" yEnc (${i}/4)`, i)
|
||||
),
|
||||
line(14, '[2/2] - "q3B9OrpKiCxUnzgtUGnp.par2" yEnc (1/1)', 5),
|
||||
line(20, 'Other.Thing [1/1] - "Other.Thing.mkv" yEnc (1/1)', 6),
|
||||
]
|
||||
await mkdir(join(dir, GROUP, '1-200000'), { recursive: true })
|
||||
await writeFile(
|
||||
join(dir, GROUP, '1-200000', '0000-1-100000.gz'),
|
||||
deflateSync(Buffer.from(lines.join('\r\n') + '\r\n', 'latin1'))
|
||||
)
|
||||
await db.table('ranges').insert({
|
||||
group_id: groupId,
|
||||
start_id: 1,
|
||||
end_id: 200_000,
|
||||
next_id: 200_001,
|
||||
status: 'done',
|
||||
stored_at: new Date(),
|
||||
capture: true,
|
||||
})
|
||||
|
||||
// The key the edge gave it: the same identity rules, run here over the same lines.
|
||||
const ix = new RangeIndexer({
|
||||
group: GROUP,
|
||||
start: 1,
|
||||
end: 200_000,
|
||||
rules: { ...DEFAULT_RULES, droppedSignatures: [], priorities: { [GROUP]: 1 } },
|
||||
})
|
||||
ix.addText(lines.join('\r\n'), 200_001)
|
||||
ix.finish()
|
||||
const edge = ix.take().releases.find((r) => !r.readable)!
|
||||
assert.equal(edge.firstArticle, 10)
|
||||
const key = releaseKey(edge.key, groupId, 10)
|
||||
await db.table('pending_releases').insert({
|
||||
release_key: key,
|
||||
identity_key: identityKey(edge.key),
|
||||
group_id: groupId,
|
||||
name: 'q3B9OrpKiCxUnzgtUGnp',
|
||||
poster: 'p <p@x>',
|
||||
first_article: 10,
|
||||
last_article: 14,
|
||||
files: 2,
|
||||
segments: 5,
|
||||
bytes: 5000,
|
||||
file_total: 2,
|
||||
tool: 'other',
|
||||
candidates: JSON.stringify([
|
||||
{ kind: 'archive', messageId: 'u10@test', file: 'q3B9OrpKiCxUnzgtUGnp.part01.rar' },
|
||||
]),
|
||||
status: PENDING.naming,
|
||||
named: 'Some.Movie.2016.1080p.BluRay.x264-GRP.mkv',
|
||||
})
|
||||
|
||||
const namer = new PendingNamer(dir)
|
||||
const [row] = await namer.take(5)
|
||||
assert.equal(row.named, 'Some.Movie.2016.1080p.BluRay.x264-GRP.mkv')
|
||||
await namer.move(row)
|
||||
|
||||
const release = await db.from('releases').where('release_key', key).firstOrFail()
|
||||
assert.include(release, {
|
||||
name: 'Some.Movie.2016.1080p.BluRay.x264-GRP.mkv',
|
||||
inner_name: 'Some.Movie.2016.1080p.BluRay.x264-GRP.mkv',
|
||||
source: 7,
|
||||
readable: true,
|
||||
files: 2,
|
||||
segments: '5',
|
||||
file_total: 2,
|
||||
complete: true,
|
||||
first_article: '10',
|
||||
last_article: '14',
|
||||
})
|
||||
assert.isNotNull(release.settled_at)
|
||||
const chunk = await db.from('release_chunks').where('release_id', release.id).firstOrFail()
|
||||
assert.deepEqual(
|
||||
decodeChunk(chunk.payload)
|
||||
.map((f) => [f.name, f.segments.length])
|
||||
.sort(),
|
||||
[
|
||||
['q3B9OrpKiCxUnzgtUGnp.par2', 1],
|
||||
['q3B9OrpKiCxUnzgtUGnp.part01.rar', 4],
|
||||
]
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await db.from('probe_candidates').where('release_id', release.id).orderBy('kind')).map(
|
||||
(c) => [c.kind, c.ready, c.unreadable]
|
||||
),
|
||||
[
|
||||
['archive', true, false],
|
||||
['par2', true, false],
|
||||
]
|
||||
)
|
||||
assert.deepEqual(
|
||||
await db.from('release_texts').where('release_id', release.id).select('kind', 'text'),
|
||||
[{ kind: 1, text: 'Some.Movie.2016.1080p.BluRay.x264-GRP.mkv' }]
|
||||
)
|
||||
assert.equal(
|
||||
(await db.from('pending_releases').where('release_key', key).firstOrFail()).status,
|
||||
PENDING.named
|
||||
)
|
||||
// Nothing else of the chunk got in.
|
||||
assert.equal(Number((await db.from('releases').count('* as n').first())!.n), 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import Planner from '#services/planner'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
|
||||
const TAIL = coordinatorConfig.tail.priority
|
||||
|
||||
test.group('Planner / tail ranges', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('applyTail grows an open range, or adds the slice when the range was taken meanwhile', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const [g] = await db
|
||||
.table('groups')
|
||||
.insert({
|
||||
name: 'alt.binaries.tail',
|
||||
priority: 1,
|
||||
tail: true,
|
||||
planned_at: new Date('2026-10-27T08:00:00Z'),
|
||||
})
|
||||
.returning('id')
|
||||
const [open] = await db
|
||||
.table('ranges')
|
||||
.insert({ group_id: g.id, start_id: 1, end_id: 300, next_id: 1, priority: TAIL })
|
||||
.returning('id')
|
||||
const [taken] = await db
|
||||
.table('ranges')
|
||||
.insert({
|
||||
group_id: g.id,
|
||||
start_id: 301,
|
||||
end_id: 400,
|
||||
next_id: 301,
|
||||
priority: TAIL,
|
||||
status: 'leased',
|
||||
node_id: 1,
|
||||
})
|
||||
.returning('id')
|
||||
const now = new Date('2026-10-27T09:00:00Z')
|
||||
const planned = await Planner.applyTail(
|
||||
{
|
||||
rows: [
|
||||
{ group_id: Number(g.id), start_id: 2001, end_id: 2500, next_id: 2001, priority: TAIL },
|
||||
],
|
||||
extended: [
|
||||
{ id: Number(open.id), group_id: Number(g.id), from: 301, end_id: 2000 },
|
||||
{ id: Number(taken.id), group_id: Number(g.id), from: 401, end_id: 600 },
|
||||
],
|
||||
},
|
||||
now
|
||||
)
|
||||
assert.deepEqual(planned, { groups: 1, ranges: 2, extended: 1 })
|
||||
const ranges = await db
|
||||
.from('ranges')
|
||||
.where('group_id', g.id)
|
||||
.orderBy('start_id')
|
||||
.select('start_id', 'end_id', 'status', 'priority')
|
||||
assert.deepEqual(
|
||||
ranges.map((r) => [Number(r.start_id), Number(r.end_id), r.status, Number(r.priority)]),
|
||||
[
|
||||
[1, 2000, 'planned', TAIL],
|
||||
[301, 400, 'leased', TAIL],
|
||||
[401, 600, 'planned', TAIL],
|
||||
[2001, 2500, 'planned', TAIL],
|
||||
]
|
||||
)
|
||||
const updated = await db.from('groups').where('id', g.id).firstOrFail()
|
||||
assert.equal(Number(updated.high), 2500)
|
||||
assert.equal(new Date(updated.planned_at).toISOString(), now.toISOString())
|
||||
})
|
||||
|
||||
test('mergeTail packs adjacent planned tail ranges and leaves taken, spooled, capture and history ranges alone', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const saved = coordinatorConfig.rangeArticles
|
||||
coordinatorConfig.rangeArticles = 1000
|
||||
try {
|
||||
const [g] = await db
|
||||
.table('groups')
|
||||
.insert({ name: 'alt.binaries.tail', priority: 1, tail: true })
|
||||
.returning('id')
|
||||
const gid = Number(g.id)
|
||||
const range = (start_id: number, end_id: number, extra: Record<string, unknown> = {}) => ({
|
||||
group_id: gid,
|
||||
start_id,
|
||||
end_id,
|
||||
next_id: start_id,
|
||||
priority: TAIL,
|
||||
...extra,
|
||||
})
|
||||
await db.table('ranges').multiInsert([
|
||||
// History, then a run of five slivers (the fifth is spooled: a break), two more, a capture range, the tail's rest.
|
||||
range(1, 1000, { priority: 500, status: 'done' }),
|
||||
range(1001, 1100),
|
||||
range(1101, 1200),
|
||||
range(1201, 1300),
|
||||
range(1301, 1400),
|
||||
range(1401, 1500, { spooled_chunks: 1 }),
|
||||
range(1501, 1600),
|
||||
range(1601, 1700),
|
||||
range(1701, 1800, { capture: true }),
|
||||
range(1801, 2600),
|
||||
range(2601, 2700),
|
||||
range(2701, 2800, { status: 'leased', node_id: 1 }),
|
||||
])
|
||||
assert.deepEqual(await Planner.mergeTail(true), { candidates: 8, merged: 3, dropped: 5 })
|
||||
assert.equal(
|
||||
(await db.from('ranges').where('group_id', gid).count('* as n').first())!.n,
|
||||
'12'
|
||||
)
|
||||
assert.deepEqual(await Planner.mergeTail(), { candidates: 8, merged: 3, dropped: 5 })
|
||||
const ranges = await db
|
||||
.from('ranges')
|
||||
.where('group_id', gid)
|
||||
.orderBy('start_id')
|
||||
.select('start_id', 'end_id', 'next_id', 'status', 'priority', 'capture')
|
||||
assert.deepEqual(
|
||||
ranges.map((r) => [
|
||||
Number(r.start_id),
|
||||
Number(r.end_id),
|
||||
Number(r.next_id),
|
||||
r.status,
|
||||
Number(r.priority),
|
||||
r.capture,
|
||||
]),
|
||||
[
|
||||
[1, 1000, 1, 'done', 500, false],
|
||||
[1001, 1400, 1001, 'planned', TAIL, false],
|
||||
[1401, 1500, 1401, 'planned', TAIL, false],
|
||||
[1501, 1700, 1501, 'planned', TAIL, false],
|
||||
[1701, 1800, 1701, 'planned', TAIL, true],
|
||||
[1801, 2700, 1801, 'planned', TAIL, false],
|
||||
[2701, 2800, 2701, 'leased', TAIL, false],
|
||||
]
|
||||
)
|
||||
assert.deepEqual(await Planner.mergeTail(), { candidates: 3, merged: 0, dropped: 0 })
|
||||
} finally {
|
||||
coordinatorConfig.rangeArticles = saved
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,55 @@ test.group('ReleaseJudge', (group) => {
|
||||
assert.isNull(await db.from('release_verdicts').where('release_id', locked).first())
|
||||
})
|
||||
|
||||
test('judges tiny single-article files under release-like names', async ({ assert }) => {
|
||||
let n = 0
|
||||
const settled = new Date(Date.now() - 3 * 86400_000)
|
||||
const release = async (name: string, extra: Record<string, unknown> = {}) => {
|
||||
n++
|
||||
const key = `jt${String(n).padStart(6, '0')}`.padEnd(40, '0')
|
||||
return Number(
|
||||
await insertRelease({
|
||||
release_key: key,
|
||||
identity_key: key,
|
||||
name,
|
||||
source: 'stem',
|
||||
groups: ['alt.binaries.boneless'],
|
||||
files: 1,
|
||||
segments: 1,
|
||||
bytes: 15_000,
|
||||
posted_at: settled,
|
||||
readable: true,
|
||||
settled_at: settled,
|
||||
...extra,
|
||||
})
|
||||
)
|
||||
}
|
||||
const dotted = await release('Velvet.S02E20.GERMAN.720p.HDTV.x264-ACED')
|
||||
const spaced = await release('Pacific Rim (2013) BDRip DVDr-sailo1')
|
||||
const subs = await release('Some.Film.2019.German.Subs-XYZ')
|
||||
const ebook = await release('Some.Book.2019.epub-GRP')
|
||||
const music = await release('Some.Album.2019.FLAC-GRP', { groups: ['alt.binaries.sounds.flac'] })
|
||||
const big = await release('Big.Film.2019.1080p-GRP', { bytes: 500_000 })
|
||||
const twoFiles = await release('Two.Films.2019.1080p-GRP', { files: 2, segments: 2 })
|
||||
const plain = await release('Holiday photo')
|
||||
const sidecar = await release('Side.Car.2019.1080p-GRP', { sidecar_only: true })
|
||||
const unreadable = await release('Xk9v.2019.Qpz-ab', { readable: false })
|
||||
|
||||
const round = await new ReleaseJudge().full()
|
||||
const verdicts = new Map(
|
||||
(await db.from('release_verdicts').select('release_id', 'rule')).map((v) => [
|
||||
Number(v.release_id),
|
||||
v.rule,
|
||||
])
|
||||
)
|
||||
assert.equal(verdicts.get(dotted), 'tiny')
|
||||
assert.equal(verdicts.get(spaced), 'tiny')
|
||||
for (const id of [subs, ebook, music, big, twoFiles, plain, sidecar, unreadable]) {
|
||||
assert.notEqual(verdicts.get(id), 'tiny')
|
||||
}
|
||||
assert.equal(round.rules.tiny.added, 2)
|
||||
})
|
||||
|
||||
test('judges the releases whose facts changed, and those a hash of theirs tipped over', async ({
|
||||
assert,
|
||||
}) => {
|
||||
|
||||
@@ -138,4 +138,26 @@ test.group('Releases API', (group) => {
|
||||
const list = (await client.get('/api/v1/releases?q=locked movie')).body() as { releases: { passwordKnown: boolean }[] }
|
||||
assert.isTrue(list.releases[0].passwordKnown)
|
||||
})
|
||||
|
||||
test('a word with a leading ^ matches at a word start only; without it anywhere', async ({ client, assert }) => {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
const [n] = await db.table('nodes').insert({ name: 'api-node' }).returning('id')
|
||||
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id')
|
||||
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
|
||||
indexer.add([
|
||||
...file('Zed.Dead.2019.1080p.mkv', 'Zed.Dead.2019 [1/1]', 2, 100),
|
||||
...file('The_Zed_Files.S01E01.mkv', 'The_Zed_Files.S01E01 [1/1]', 2, 200),
|
||||
...file('Babe.Facialized.2017.mkv', 'Babe.Facialized.2017 [1/1]', 2, 300),
|
||||
], 1001)
|
||||
indexer.finish()
|
||||
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
||||
|
||||
const names = async (q: string) =>
|
||||
((await client.get(`/api/v1/releases?q=${encodeURIComponent(q)}`)).body() as { releases: { name: string }[] }).releases.map((x) => x.name).sort()
|
||||
assert.lengthOf(await names('zed'), 3)
|
||||
assert.deepEqual(await names('^zed'), ['The_Zed_Files S01E01', 'Zed Dead 2019'])
|
||||
assert.deepEqual(await names('^zed dead'), ['Zed Dead 2019'])
|
||||
// Regex characters in the word are literal.
|
||||
assert.deepEqual(await names('^ze+d'), [])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import Settler from '#services/settler'
|
||||
import { insertRelease } from '#tests/functional/helpers/releases'
|
||||
|
||||
const hoursAgo = (h: number) => new Date(Date.now() - h * 3600_000)
|
||||
|
||||
test.group('Settler', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('a complete provisional release quiet for six hours is settled, its waiting candidates made claimable', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const base = {
|
||||
groups: ['alt.binaries.test'],
|
||||
poster: 'p@x',
|
||||
first_article: 1,
|
||||
posted_at: hoursAgo(30),
|
||||
last_posted_at: hoursAgo(30),
|
||||
}
|
||||
// Complete and old: settled.
|
||||
const old = await insertRelease({
|
||||
...base,
|
||||
name: 'old',
|
||||
files: 3,
|
||||
file_total: 3,
|
||||
complete: true,
|
||||
readable: true,
|
||||
first_seen_at: hoursAgo(7),
|
||||
})
|
||||
// Complete but seen an hour ago: its window may still be carried.
|
||||
const recent = await insertRelease({
|
||||
...base,
|
||||
name: 'recent',
|
||||
files: 3,
|
||||
file_total: 3,
|
||||
complete: true,
|
||||
first_seen_at: hoursAgo(1),
|
||||
})
|
||||
// Complete, old, but its last part posted an hour ago: still arriving.
|
||||
const posting = await insertRelease({
|
||||
...base,
|
||||
name: 'posting',
|
||||
files: 3,
|
||||
file_total: 3,
|
||||
complete: true,
|
||||
first_seen_at: hoursAgo(7),
|
||||
last_posted_at: hoursAgo(1),
|
||||
})
|
||||
// Incomplete, and without a counter: the continuation's.
|
||||
const partial = await insertRelease({
|
||||
...base,
|
||||
name: 'partial',
|
||||
files: 2,
|
||||
file_total: 3,
|
||||
complete: false,
|
||||
first_seen_at: hoursAgo(7),
|
||||
})
|
||||
const uncounted = await insertRelease({
|
||||
...base,
|
||||
name: 'uncounted',
|
||||
files: 2,
|
||||
file_total: null,
|
||||
complete: null,
|
||||
first_seen_at: hoursAgo(7),
|
||||
})
|
||||
// Settled already: left alone.
|
||||
const settled = await insertRelease({
|
||||
...base,
|
||||
name: 'settled',
|
||||
files: 3,
|
||||
file_total: 3,
|
||||
complete: true,
|
||||
first_seen_at: hoursAgo(7),
|
||||
settled_at: hoursAgo(6),
|
||||
})
|
||||
await db.table('probe_candidates').multiInsert([
|
||||
{ release_id: old, kind: 'par2', message_id: '<old@x>', file: 'x.par2', status: 'waiting' },
|
||||
{
|
||||
release_id: partial,
|
||||
kind: 'par2',
|
||||
message_id: '<partial@x>',
|
||||
file: 'x.par2',
|
||||
status: 'waiting',
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(await Settler.settleComplete(), 1)
|
||||
const rows = await db
|
||||
.from('releases')
|
||||
.whereIn('id', [old, recent, posting, partial, uncounted, settled])
|
||||
.select('id', 'settled_at')
|
||||
const settledIds = rows
|
||||
.filter((r) => r.settled_at !== null)
|
||||
.map((r) => Number(r.id))
|
||||
.sort()
|
||||
assert.deepEqual(settledIds, [old, settled].sort())
|
||||
const candidates = await db
|
||||
.from('probe_candidates')
|
||||
.whereIn('release_id', [old, partial])
|
||||
.select('release_id', 'ready', 'unreadable')
|
||||
assert.deepEqual(
|
||||
candidates.map((c) => [Number(c.release_id), c.ready, c.unreadable]).sort(),
|
||||
[
|
||||
[old, true, false],
|
||||
[partial, false, false],
|
||||
].sort()
|
||||
)
|
||||
assert.equal(await Settler.settleComplete(), 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import coordinatorConfig from '#config/coordinator'
|
||||
import StatusService from '#services/status'
|
||||
|
||||
test.group('Status', (group) => {
|
||||
let dir = ''
|
||||
let snapshotDir = ''
|
||||
group.each.setup(async () => {
|
||||
const rollback = await testUtils.db().withGlobalTransaction()
|
||||
dir = await mkdtemp(join(tmpdir(), 'snapshots-'))
|
||||
snapshotDir = coordinatorConfig.snapshotDir
|
||||
coordinatorConfig.snapshotDir = dir
|
||||
return rollback
|
||||
})
|
||||
group.each.teardown(async () => {
|
||||
coordinatorConfig.snapshotDir = snapshotDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('the ranges by band, the held groups with their snapshots, the backlogs', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const [g] = await db
|
||||
.table('groups')
|
||||
.insert({ name: 'alt.binaries.status', priority: 1 })
|
||||
.returning('id')
|
||||
const [n] = await db
|
||||
.table('nodes')
|
||||
.insert({ name: 'status-node', last_seen_at: new Date() })
|
||||
.returning('id')
|
||||
const ranges = [
|
||||
{ start_id: 1, end_id: 100, next_id: 1, priority: -3 },
|
||||
{ start_id: 101, end_id: 200, next_id: 101, priority: -1 },
|
||||
{
|
||||
start_id: 201,
|
||||
end_id: 300,
|
||||
next_id: 301,
|
||||
priority: 0,
|
||||
capture: true,
|
||||
status: 'done',
|
||||
done_at: new Date(),
|
||||
},
|
||||
{
|
||||
start_id: 301,
|
||||
end_id: 400,
|
||||
next_id: 351,
|
||||
priority: 0,
|
||||
capture: true,
|
||||
status: 'leased',
|
||||
node_id: n.id,
|
||||
spooled_chunks: 2,
|
||||
},
|
||||
{
|
||||
start_id: 401,
|
||||
end_id: 500,
|
||||
next_id: 401,
|
||||
priority: 2,
|
||||
capture: true,
|
||||
stored_at: new Date(),
|
||||
},
|
||||
{ start_id: 501, end_id: 600, next_id: 501, priority: 500 },
|
||||
{ start_id: 601, end_id: 700, next_id: 601, priority: 4200, status: 'failed' },
|
||||
]
|
||||
const ids = await db
|
||||
.table('ranges')
|
||||
.multiInsert(ranges.map((r) => ({ group_id: g.id, ...r })))
|
||||
.returning('id')
|
||||
await db.table('group_leases').multiInsert([
|
||||
{
|
||||
group_id: g.id,
|
||||
lane: 'history',
|
||||
node_id: n.id,
|
||||
leased_at: new Date(),
|
||||
heartbeat_at: new Date(),
|
||||
range_id: ids[3].id,
|
||||
snapshot_next: 301,
|
||||
snapshot_at: new Date(Date.now() - 600_000),
|
||||
ranges_done: 1,
|
||||
headers: 100,
|
||||
},
|
||||
{
|
||||
group_id: g.id,
|
||||
lane: 'tail',
|
||||
node_id: null,
|
||||
heartbeat_at: new Date(Date.now() - 3600_000),
|
||||
snapshot_next: 1,
|
||||
snapshot_at: new Date(Date.now() - 3600_000),
|
||||
},
|
||||
])
|
||||
await writeFile(join(dir, `${g.id}.gz`), Buffer.alloc(2_000_000))
|
||||
await writeFile(join(dir, `${g.id}-tail.gz`), Buffer.alloc(1000))
|
||||
// A group planned from its low watermark 1000: its ancient range counts in no band and is never leased.
|
||||
const [old] = await db
|
||||
.table('groups')
|
||||
.insert({ name: 'alt.binaries.ancient', priority: 2 })
|
||||
.returning('id')
|
||||
await db.table('ranges').multiInsert([
|
||||
{ group_id: old.id, start_id: 1, end_id: 999, next_id: 1, priority: 0, status: 'ancient' },
|
||||
{ group_id: old.id, start_id: 1000, end_id: 1099, next_id: 1000, priority: 0 },
|
||||
])
|
||||
|
||||
const s = await StatusService.collect()
|
||||
assert.deepEqual(
|
||||
s.bands.map((b) => [
|
||||
b.band,
|
||||
b.planned,
|
||||
b.plannedArticles,
|
||||
b.leased,
|
||||
b.done,
|
||||
b.doneArticles,
|
||||
b.failed,
|
||||
]),
|
||||
[
|
||||
['tail', 1, 100, 0, 0, 0, 0],
|
||||
['raised', 1, 100, 0, 0, 0, 0],
|
||||
['window 0', 0, 0, 1, 1, 100, 0],
|
||||
['window 2', 1, 100, 0, 0, 0, 0],
|
||||
['history 0-999', 2, 200, 0, 0, 0, 0],
|
||||
['history 4000+', 0, 0, 0, 0, 0, 1],
|
||||
]
|
||||
)
|
||||
assert.deepEqual(s.ancient, { groups: 1, articles: 999 })
|
||||
assert.deepEqual(s.lastHour, {
|
||||
indexedArticles: 100,
|
||||
doneRanges: 1,
|
||||
storedArticles: 100,
|
||||
storedRanges: 1,
|
||||
})
|
||||
assert.deepEqual(s.corpus, { pendingRanges: 1, pendingArticles: 100 })
|
||||
assert.deepEqual(s.spool, { ranges: 1, chunks: 2 })
|
||||
assert.deepEqual(s.tail, { groups: 1, ranges: 1, articles: 100 })
|
||||
assert.lengthOf(s.held, 1)
|
||||
assert.include(s.held[0], {
|
||||
node: 'status-node',
|
||||
group: 'alt.binaries.status',
|
||||
lane: 'history',
|
||||
snapshotNext: 301,
|
||||
snapshotMb: 2,
|
||||
rangesDone: 1,
|
||||
headers: 100,
|
||||
})
|
||||
assert.deepEqual(s.held[0].range, { start: 301, end: 400, next: 351 })
|
||||
assert.isAtLeast(s.held[0].snapshotSeconds!, 599)
|
||||
assert.equal(s.snapshots.count, 2)
|
||||
assert.deepEqual(
|
||||
s.snapshots.largest.map((f) => [f.group, f.lane, f.mb]),
|
||||
[
|
||||
['alt.binaries.status', 'history', 2],
|
||||
['alt.binaries.status', 'tail', 0.001],
|
||||
]
|
||||
)
|
||||
assert.equal(s.releases.provisional, 0)
|
||||
assert.deepEqual(s.probes, { ready: 0, waiting: 0, pendingWaiting: 0, pendingNamed: 0 })
|
||||
assert.equal(s.nodes.find((x) => x.name === 'status-node')?.lastSeenSeconds, 0)
|
||||
assert.isAbove(s.catalog.gb, 0)
|
||||
|
||||
const text = StatusService.render(s)
|
||||
assert.include(text, 'window 0')
|
||||
assert.include(text, 'alt.binaries.status history status-node 301-400 351')
|
||||
assert.include(text, 'Snapshots: 2 files')
|
||||
assert.include(text, 'Ancient: 999 articles below the low watermark of 1 groups')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { historyRows } from '#services/planner'
|
||||
|
||||
test.group('Planner / history rows', () => {
|
||||
test('a group planned for the first time: the ancient range below the low watermark, then the slices', ({
|
||||
assert,
|
||||
}) => {
|
||||
const rows = historyRows(7, { low: 1001, high: 3500 }, 0, 1000)
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.start_id, r.end_id, r.next_id, r.priority, r.status ?? 'planned']),
|
||||
[
|
||||
[1, 1000, 1, 0, 'ancient'],
|
||||
[1001, 2000, 1001, 0, 'planned'],
|
||||
[2001, 3000, 2001, 400, 'planned'],
|
||||
[3001, 3500, 3001, 800, 'planned'],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('no ancient range when the group starts at article 1, nor when planning again', ({
|
||||
assert,
|
||||
}) => {
|
||||
assert.deepEqual(
|
||||
historyRows(7, { low: 1, high: 1500 }, 0, 1000).map((r) => [r.start_id, r.end_id, r.status]),
|
||||
[
|
||||
[1, 1000, undefined],
|
||||
[1001, 1500, undefined],
|
||||
]
|
||||
)
|
||||
// Planned before up to 3500: only the new articles, no second ancient range.
|
||||
assert.deepEqual(
|
||||
historyRows(7, { low: 1200, high: 4100 }, 3500, 1000).map((r) => [r.start_id, r.end_id]),
|
||||
[[3501, 4100]]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { queueLevels } from '#services/prober'
|
||||
|
||||
test.group('Prober / queue levels', () => {
|
||||
test('the next claim starts with eight pipeline rounds of every lane still queued, and fills to twice that', ({
|
||||
assert,
|
||||
}) => {
|
||||
assert.deepEqual(queueLevels(22, 8, 400), { low: 1408, target: 2816 })
|
||||
assert.deepEqual(queueLevels(7, 8, 400), { low: 448, target: 896 })
|
||||
})
|
||||
|
||||
test('few lanes: at least the configured batch', ({ assert }) => {
|
||||
assert.deepEqual(queueLevels(2, 8, 400), { low: 400, target: 800 })
|
||||
assert.deepEqual(queueLevels(0, 8, 400), { low: 400, target: 800 })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { tailRanges, wildmatOf } from '#services/planner'
|
||||
import { tailMerges, tailRanges, wildmatOf } from '#services/planner'
|
||||
|
||||
const options = { rangeArticles: 1000, minArticles: 100, maxDelayMinutes: 15, priority: -1 }
|
||||
const now = new Date('2026-10-09T10:00:00Z')
|
||||
@@ -7,7 +7,7 @@ const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000)
|
||||
|
||||
test.group('coordinator / live tail', () => {
|
||||
test('a chunk of new articles, or a long enough wait, makes tail ranges', ({ assert }) => {
|
||||
const rows = tailRanges(
|
||||
const { rows, extended } = tailRanges(
|
||||
[
|
||||
// Enough new articles: planned at once, cut at rangeArticles.
|
||||
{ id: 1, plannedTo: 5000, plannedAt: minutesAgo(2), high: 7500 },
|
||||
@@ -32,6 +32,94 @@ test.group('coordinator / live tail', () => {
|
||||
[3, 101, 103, 101, -1],
|
||||
]
|
||||
)
|
||||
assert.deepEqual(extended, [])
|
||||
})
|
||||
|
||||
test('an open last range grows up to rangeArticles before rows are added', ({ assert }) => {
|
||||
const { rows, extended } = tailRanges(
|
||||
[
|
||||
// Room in the open range for everything new.
|
||||
{
|
||||
id: 1,
|
||||
plannedTo: 5300,
|
||||
plannedAt: minutesAgo(20),
|
||||
high: 5350,
|
||||
open: { id: 11, start: 5001 },
|
||||
},
|
||||
// The open range fills up, the rest is cut as rows.
|
||||
{
|
||||
id: 2,
|
||||
plannedTo: 5300,
|
||||
plannedAt: minutesAgo(2),
|
||||
high: 7500,
|
||||
open: { id: 12, start: 5001 },
|
||||
},
|
||||
// An open range already full: rows only.
|
||||
{
|
||||
id: 3,
|
||||
plannedTo: 6000,
|
||||
plannedAt: minutesAgo(2),
|
||||
high: 6500,
|
||||
open: { id: 13, start: 5001 },
|
||||
},
|
||||
// Not open (taken, spooled, another priority): rows.
|
||||
{ id: 4, plannedTo: 5300, plannedAt: minutesAgo(20), high: 5350, open: null },
|
||||
],
|
||||
now,
|
||||
options
|
||||
)
|
||||
assert.deepEqual(
|
||||
extended.map((e) => [e.id, e.group_id, e.from, e.end_id]),
|
||||
[
|
||||
[11, 1, 5301, 5350],
|
||||
[12, 2, 5301, 6000],
|
||||
]
|
||||
)
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.group_id, r.start_id, r.end_id]),
|
||||
[
|
||||
[2, 6001, 7000],
|
||||
[2, 7001, 7500],
|
||||
[3, 6001, 6500],
|
||||
[4, 5301, 5350],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('adjacent ranges of a group and priority pack into rangeArticles; gaps and other priorities break a run', ({
|
||||
assert,
|
||||
}) => {
|
||||
const r = (id: number, group_id: number, start_id: number, end_id: number, priority = -3) => ({
|
||||
id,
|
||||
group_id,
|
||||
start_id,
|
||||
end_id,
|
||||
priority,
|
||||
})
|
||||
const merges = tailMerges(
|
||||
[
|
||||
// 1–300 + 301–600 + 601–900 fill 1000 with 901–1000; 1001–1100 starts the next run alone.
|
||||
r(1, 1, 1, 300),
|
||||
r(2, 1, 301, 600),
|
||||
r(3, 1, 601, 900),
|
||||
r(4, 1, 901, 1000),
|
||||
r(5, 1, 1001, 1100),
|
||||
// A gap, then two adjacent at another priority.
|
||||
r(6, 1, 1200, 1250),
|
||||
r(7, 1, 1251, 1300, -1),
|
||||
r(8, 1, 1301, 1400, -1),
|
||||
// Another group; one that would overflow stays alone.
|
||||
r(9, 2, 1, 900),
|
||||
r(10, 2, 901, 1100),
|
||||
r(11, 2, 1101, 1150),
|
||||
],
|
||||
1000
|
||||
)
|
||||
assert.deepEqual(merges, [
|
||||
{ id: 1, end_id: 1000, drop: [2, 3, 4] },
|
||||
{ id: 7, end_id: 1400, drop: [8] },
|
||||
{ id: 10, end_id: 1150, drop: [11] },
|
||||
])
|
||||
})
|
||||
|
||||
test('LIST ACTIVE patterns by the first two name parts', ({ assert }) => {
|
||||
|
||||
@@ -87,6 +87,38 @@ test.group('edge / RangeIndexer', () => {
|
||||
assert.deepEqual(batch.counts.map((c) => [c.rule, c.articles, c.files]), [['incomplete', 2, 1]])
|
||||
assert.lengthOf(batch.releases, 0)
|
||||
})
|
||||
|
||||
test("partialFiles: a file short of parts is a partial release, as the Rust window writes it; one article of a random multi-part file isn't", ({ assert }) => {
|
||||
const indexer = new RangeIndexer({ group: GROUP, start: 100, end: 200, rules, idleChunks: 0, edgeArticles: 0, partialFiles: true })
|
||||
const name = '8c21f5c36450f9e2ccc6ff387d159d4d'
|
||||
indexer.add([
|
||||
// Parts 1, 3 and 5 of 6 (the others gone from the server), from a re-send's copy of part 3 too.
|
||||
...post(100, name, 6, { poster: 'Bob <bob@home.me>' }).filter((_, i) => i % 2 === 0),
|
||||
...post(150, name, 6, { poster: 'Bob <bob@home.me>' }).slice(2, 3),
|
||||
...post(160, 'Pq4ZmXc8VbN2LkJh6GfDs3', 4).slice(1, 2),
|
||||
], 201)
|
||||
indexer.finish()
|
||||
const batch = indexer.take()
|
||||
assert.lengthOf(batch.releases, 1)
|
||||
assert.include(batch.releases[0], { name, files: 1, segments: 3, partial: true, firstArticle: 100, lastArticle: 104 })
|
||||
assert.deepEqual(decodeChunk(batch.releases[0].payload)[0].segments.map((s) => s[0]), [1, 3, 5])
|
||||
assert.deepEqual(batch.fragments, [])
|
||||
assert.deepEqual(batch.counts.map((c) => [c.rule, c.articles, c.files]), [['per-article', 1, 0]])
|
||||
})
|
||||
|
||||
test('partialFiles: one article of a multi-part file under a scrambled name is per-article before the end, a partial release at it', ({ assert }) => {
|
||||
const indexer = new RangeIndexer({ group: GROUP, start: 100, end: 1_000_000, rules, idleChunks: 1, edgeArticles: 0, partialFiles: true })
|
||||
// Part 8 of 67, a random poster's: given up mid-range.
|
||||
indexer.add(post(100, 'nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr', 67, { poster: 'guAym5TqSlp SCTLKQ0R <8nGc3Rk@OrjrYqFkp.z>' }).slice(7, 8), 200)
|
||||
indexer.add([], 300)
|
||||
indexer.add([], 400)
|
||||
// The same shape still open when the block ends: the rest can be in the next one.
|
||||
indexer.add(post(999_990, 'bslXlwgPiqZadhRa', 5).slice(1, 2), 1_000_001)
|
||||
indexer.finish()
|
||||
const batch = indexer.take()
|
||||
assert.deepEqual(batch.releases.map((r) => [r.name, r.partial]), [['bslXlwgPiqZadhRa', true]])
|
||||
assert.deepEqual(batch.counts.map((c) => [c.rule, c.articles, c.files]), [['per-article', 1, 0]])
|
||||
})
|
||||
})
|
||||
|
||||
test.group('edge / taking over a range', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { crc32 } from 'node:zlib'
|
||||
import yencode from 'yencode'
|
||||
import { crc32Hex } from '#lib/yenc/index'
|
||||
import { inspect, innerName, MAX_NAMES } from '#lib/probe/index'
|
||||
import { inspect, innerName, MAX_NAMES, usableName } from '#lib/probe/index'
|
||||
|
||||
/** A single-part yEnc article of `data`. */
|
||||
function article(data: Buffer, name: string) {
|
||||
@@ -76,6 +76,19 @@ function rar5File(name: string, size: number, opts: { packed: number; headerFlag
|
||||
return rar5Block(2, opts.headerFlags ?? 0, fields, { extra, data: opts.packed })
|
||||
}
|
||||
|
||||
test.group('probe / usable name (the pending ladder)', () => {
|
||||
test('a par2 or listing that names a real file names the release; random volumes and packaging do not', ({ assert }) => {
|
||||
const base = { size: 1000, type: null }
|
||||
assert.equal(usableName({ ...base, yencName: 'x.par2', names: ['q3B9OrpKiCxUnzgtUGnp.part01.rar', 'Some.Movie.2016.mkv'] }, 'par2'), 'Some.Movie.2016.mkv')
|
||||
assert.isNull(usableName({ ...base, yencName: 'q3B9OrpKiCxUnzgtUGnp.par2', names: ['q3B9OrpKiCxUnzgtUGnp.part01.rar'] }, 'par2'))
|
||||
assert.equal(usableName({ ...base, yencName: 'x.rar', names: ['Folder/Real.Name.S01E01.mkv'] }, 'archive'), 'Real.Name.S01E01.mkv')
|
||||
assert.isNull(usableName({ ...base, yencName: 'x.rar', names: [], passworded: true }, 'archive'))
|
||||
assert.equal(usableName({ ...base, yencName: 'brothalovers.20.02.16.hazel.moore.mp4' }, 'lone'), 'brothalovers.20.02.16.hazel.moore.mp4')
|
||||
assert.isNull(usableName({ ...base, yencName: 'c3453cdd2a3645a8f3cf3d22319abe63' }, 'lone'))
|
||||
assert.isNull(usableName({ ...base, yencName: 'c3453cdd2a3645a8f3cf3d22319abe63.part01.rar' }, 'lone'))
|
||||
})
|
||||
})
|
||||
|
||||
test.group('probe / inspect', () => {
|
||||
test('a par2 index: its files, largest first', ({ assert }) => {
|
||||
const setId = Buffer.alloc(16, 3)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { classKey, describeClass, pendingClass } from '#lib/release/index'
|
||||
|
||||
test.group('release / pendingClass', () => {
|
||||
test('size bucket × name shape × partial, as names.rs pending_class', ({ assert }) => {
|
||||
// The same cases as the Rust test.
|
||||
assert.equal(pendingClass('c04212a389cf69edc54767355ab5285f', 5_000_000, false), 15)
|
||||
assert.equal(pendingClass('c04212a389cf69edc54767355ab5285f', 5_000_000, true), 16)
|
||||
assert.equal(pendingClass('Zx5AjNaK-mh7CFg6Hf8uu', 500_000_000, false), 3 * 14 + 5 * 2 + 1)
|
||||
assert.equal(pendingClass('1650777167', 100, false), 3)
|
||||
assert.equal(
|
||||
pendingClass('mrrtmzrzmi4dgmrzmyytqmlbztczbx', 2_000_000_000, false),
|
||||
4 * 14 + 2 * 2 + 1
|
||||
)
|
||||
assert.equal(pendingClass('PsNdnYiugfZ.rar', 20_000_000, false), 2 * 14 + 3 * 2 + 1)
|
||||
assert.equal(pendingClass('n5d1ueIf34X', 20_000_000, true), 2 * 14 + 4 * 2 + 2)
|
||||
assert.equal(pendingClass('sgkra vU7u2yuw9LDLCjDu04pOez', 999, false), 13)
|
||||
assert.equal(describeClass(16), '1-10M hex partial')
|
||||
assert.equal(describeClass(3 * 14 + 5 * 2 + 1), '100M-1G separators')
|
||||
assert.equal(classKey('nyuu', 'alt.binaries.boneless', 16), 'nyuu\talt.binaries.boneless\t16')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { fileStem, releaseIdentity, runOn, sizeAfterName, subjectRemainder } from '#lib/release/index'
|
||||
import { displayName, fileStem, releaseIdentity, runOn, sizeAfterName, subjectRemainder } from '#lib/release/index'
|
||||
import { looksLikeFilename, weakFileKey } from '#lib/headers/index'
|
||||
|
||||
test.group('release / fileStem', () => {
|
||||
@@ -604,3 +604,15 @@ test.group('release / par2 names', () => {
|
||||
assert.equal(id('[2/6] - "x.jpg" yEnc', 'x.jpg').key, id('[1/6] - "x.part01.rar" yEnc', 'x.part01.rar').key)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('release / displayName', () => {
|
||||
test('brackets the counters left unmatched leave the name, the key keeps them', ({ assert }) => {
|
||||
const subject = (i: number) => `Babe with Plump Bubble Butt Fucked and Facialized ${String(i).padStart(3, '0')}/477] - 0${176 + i}.jpg`
|
||||
const id = (i: number) => releaseIdentity({ poster: 'webmaster@hdbangvidz.com (Porn4You)', weak: false, subject: subject(i), name: `0${176 + i}.jpg` })!
|
||||
assert.equal(id(2).name, 'Babe with Plump Bubble Butt Fucked and Facialized')
|
||||
assert.equal(id(2).key, id(3).key)
|
||||
assert.equal(displayName('[Group] Show (2020)'), '[Group] Show (2020)')
|
||||
assert.equal(displayName('( Show [x] name'), 'Show [x] name')
|
||||
assert.equal(displayName(']'), ']')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { isUnreadableName, tokenKind } from '#lib/release/index'
|
||||
|
||||
test.group('release / isUnreadableName', () => {
|
||||
test('scrambles with separators, spaces, random extensions, packaging words and hex with an addon are unreadable', ({
|
||||
assert,
|
||||
}) => {
|
||||
for (const n of [
|
||||
'Zx5AjNaK-mh7CFg6Hf8uu.part01.rar',
|
||||
'yWjfnT2ujG4wJj3fG46o0FLzsgkvhmrTsAfW2.E0WsOU',
|
||||
'PsNdnYiugfZ.rar',
|
||||
'phfplC9y07U2yrj',
|
||||
'n5d1ueIf34X',
|
||||
'tJqr1tq7xK',
|
||||
'Description - Lt5nHjlo3RiuBb3C.par2',
|
||||
'bd5b4a59f3aa87114dcf75c0-addon.rar',
|
||||
'mrrtmzrzmi4dgmrzmyytqmlbztczbx',
|
||||
'AA-NaR16302830434zxP32108309f3Ab',
|
||||
'_QBs01_MqZYnYwM_iAyRquhc-',
|
||||
'sgkra vU7u2yuw9LDLCjDu04pOez',
|
||||
'Jk5Pud.zQdNj',
|
||||
'7uKsD_NRgT-G_Uc_p8',
|
||||
'NtpUgCmS_Xa1B2Pk',
|
||||
'{E11BF8E9-E2C9-4DAE-A1B2-8027BC465F3F}',
|
||||
'IkYovjrtZrItjQ.mkv',
|
||||
'ff75ce3f16c746c18b2d78cf.6c52f',
|
||||
'prvjr7hapd0i1ny14pz4',
|
||||
]) {
|
||||
assert.isTrue(isUnreadableName(n), n)
|
||||
}
|
||||
})
|
||||
|
||||
test('scene tags, CamelCase, codes, leet, acronyms and a hash beside words are readable', ({
|
||||
assert,
|
||||
}) => {
|
||||
for (const n of [
|
||||
'Dark.Matter.2024.S02E10.No.Quiet.Life.1080p.ATVP.WEB-DL.DDP5.1.Atmos.H.264-Kitsune',
|
||||
'Paul McCartney - Band on the Run',
|
||||
'ShesSoWild - Ester Ana 1080p',
|
||||
'MyDadsHotGirlfriend.13.02.01.Luna.Star.XXX.1080p.x264-SEXORS',
|
||||
'cFosSpeed 11.0 Build 2420 x86 x64 incl Patch.rar',
|
||||
'Rocketship-Hurt_Feelings-WEB-2026-PreMaxXxing',
|
||||
'Drum-and-BassDJ_Samurai-Sound_With_Fire_Artemis-(FQY026)-WEB-2006-PRESCRiPTiON',
|
||||
'Kleverig-H4rd-N12uk3b18-luc16037-720p-smpwr',
|
||||
'Eclipse_IDE_2023-12_R_win32_x86_64.zip',
|
||||
'PartsHPGL 1.06c',
|
||||
'Go4Customer Fax Machine v1.2b',
|
||||
'AdBeGone 1.2',
|
||||
'3DMark03 320',
|
||||
'COD4MW2REMASTERED',
|
||||
'Movie.2020.1080p-GRP [a3f9e2b1]',
|
||||
'EPL__NZBSPLIT__f1fdb433772a1a7771d6b73c875009ff__NZBSPLIT__EPL.2026.Liverpool.vs.Brighton.1080pEN50fps',
|
||||
'iPhone',
|
||||
'Some.Show.S01E01.1080p.mkv',
|
||||
'FastFilms 2.04',
|
||||
'BestCrypt NP v4.0',
|
||||
'EasyHTML 2.35 German',
|
||||
'SchiffsModell - September 2026',
|
||||
'CrossStitcher - Issue 440, October 2026',
|
||||
'Laura15Juli2026',
|
||||
'IHaveAWife - Isa Bella',
|
||||
'DickHDDaily - Santana Red',
|
||||
'1904-SmithsWork-Hasluck',
|
||||
]) {
|
||||
assert.isFalse(isUnreadableName(n), n)
|
||||
}
|
||||
})
|
||||
|
||||
test('token kinds', ({ assert }) => {
|
||||
assert.equal(tokenKind('Champions'), 'word')
|
||||
assert.equal(tokenKind('PRESCRiPTiON'), 'word')
|
||||
assert.equal(tokenKind('GarciaLive'), null)
|
||||
assert.equal(tokenKind('S02E10'), null)
|
||||
assert.equal(tokenKind('1080p'), null)
|
||||
assert.equal(tokenKind('addon'), null)
|
||||
assert.equal(tokenKind('NtpUgCmS'), 'random')
|
||||
assert.equal(tokenKind('tBWTuz'), 'random')
|
||||
assert.equal(tokenKind('Lt5nHjlo3RiuBb3C'), 'random')
|
||||
assert.equal(tokenKind('a3f9e2b1'), 'random')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
test.group('release text kinds', () => {
|
||||
test('names and codes map both ways; the codes are fixed', ({ assert }) => {
|
||||
assert.deepEqual(TEXT_KINDS, { name: 1, file: 2, nfo: 3, par2: 4, media: 5 })
|
||||
assert.deepEqual(TEXT_KINDS, { name: 1, file: 2, nfo: 3, par2: 4, media: 5, member: 6 })
|
||||
for (const [name, code] of Object.entries(TEXT_KINDS)) {
|
||||
assert.equal(textKindCode(name), code)
|
||||
assert.equal(textKindName(code), name)
|
||||
|
||||
@@ -9,6 +9,18 @@ const file = (name: string, totalParts: number, firstMessageId: string, group =
|
||||
groups: [group],
|
||||
firstMessageId,
|
||||
poster,
|
||||
segments: totalParts,
|
||||
bytes: totalParts * 700_000,
|
||||
})
|
||||
/** A single archive of `segments` articles and `bytes` in all, posted to `groups` groups. */
|
||||
const archive = (name: string, poster: string, groups: number, segments: number, bytes: number, firstMessageId = NYUU) => ({
|
||||
name,
|
||||
totalParts: segments,
|
||||
groups: Array.from({ length: groups }, (_, i) => `alt.binaries.g${i}`),
|
||||
firstMessageId,
|
||||
poster,
|
||||
segments,
|
||||
bytes,
|
||||
})
|
||||
const NGPOST = 'b94268bc702040f7b811d5318f727e4f@ngPost'
|
||||
const NYUU = 'FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu'
|
||||
@@ -50,6 +62,79 @@ test.group('rules / verdicts', () => {
|
||||
assert.include(rules.judge('k', [file('Hs7dKq2LmZ9xWv3RtYp1Qa.part01.rar', 50, NYUU, 'alt.binaries.misc')]), { rule: 'garbage', key: 'nyuu alt.binaries.misc' })
|
||||
assert.deepEqual(rules.judge('k', [file('Hs7dKq2LmZ9xWv3RtYp1Qa.part01.rar', 50, NYUU)]), { rule: null })
|
||||
})
|
||||
|
||||
test('stub archives: a settled single archive in 2+ groups with 250 kB articles, or 4+ groups, or a webmail poster', ({ assert }) => {
|
||||
// Power-Post's 250 kB articles, two groups, 12 MB: the 2016 wave; the article-size leg alone without webmail.
|
||||
assert.include(rules.judge('k', [archive('Chat Watch 1.2.1.zip', 'jahnunsinos007@gmail.com (Jahnunsinos007)', 2, 48, 12_000_000)]), { rule: 'stub-archive', key: 'AW' })
|
||||
assert.include(rules.judge('k', [archive('Some.Movie.2014.720p.BluRay-GRP.rar', 'Yenc@power-post.org (Yenc-PP-A&A)', 2, 48, 12_000_000)]), { rule: 'stub-archive', key: 'A' })
|
||||
// Five groups, 319 kB in two articles: the 2015 a.b.cracks wave (under 1 MB, so the groups leg alone).
|
||||
assert.include(rules.judge('k', [archive('Big Hero 6 (2014) 720p BDRip [COMPRESSED].rar', 'Yenc@power-post.org', 5, 2, 319_000)]), { rule: 'stub-archive', key: 'X' })
|
||||
// A webmail poster with 650 kB articles: the 2014 "leaked Sex Video" wave, but only under 100 MB without the article-size leg.
|
||||
assert.include(rules.judge('k', [archive('Leaked Sex Video.rar', 'first.last88@gmail.com', 2, 100, 65_000_000)]), { rule: 'stub-archive', key: 'W' })
|
||||
assert.deepEqual(rules.judge('k', [archive('Leaked Sex Video.rar', 'first.last88@gmail.com', 2, 400, 260_000_000)]), { rule: null })
|
||||
// Not settled: a par2 can still join.
|
||||
assert.deepEqual(rules.judge('k', [archive('Chat Watch 1.2.1.zip', 'jahnunsinos007@gmail.com', 2, 48, 12_000_000)], false), { rule: null })
|
||||
})
|
||||
|
||||
test('stub archives: small, single-group, split and large hash-named archives are kept', ({ assert }) => {
|
||||
// An e-book: a one- or two-article release always has small articles (LOG.md (126)).
|
||||
assert.deepEqual(rules.judge('k', [archive('Maeve Binchy - Tara Road (epub).rar', 'kie@home.now (KiE)', 2, 2, 521_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Chat Watch 1.2.1.zip', 'jahnunsinos007@gmail.com', 1, 48, 12_000_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Some.Movie.2014.720p.BluRay-GRP.rar', 'poster@example.org', 2, 100, 70_000_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Some.Movie.2014.part01.rar', 'a@gmail.com', 2, 48, 12_000_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('cbcd32359460df1fa9b7d01d8720.rar', 'b648de0d1c0f7a9a77be273d@gmail.com', 2, 3163, 2_340_000_000)]), { rule: null })
|
||||
// ROT13-named volumes of passworded videos in a.b.erotica, 258 kB articles, two groups, 1-270 MB; and the 100 MB cap on every leg (LOG.md (128)).
|
||||
assert.deepEqual(rules.judge('k', [archive('Ghful.17.12.07.Qnvvfl.Fgbar.cneg3.rar', 'nospam@nospam.nl (EvH)', 2, 5, 1_147_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Ghful.16.07.05.Wbwb.Xvff.cneg1.rar', 'nospam@nospam.nl (EvH)', 2, 1051, 271_000_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Tinasoft EasyCafe 2.2.14 by OmerSalaj.rar', 'Seven7i@mail.com ({Seven7i})', 4, 463, 117_000_000)]), { rule: null })
|
||||
assert.include(rules.judge('k', [archive('Tipard DVD Ripper - 10 0 12 x86 x64 incl Patch.rar', 'Seven7i@mail.com ({Seven7i})', 4, 244, 62_000_000)]), { rule: 'stub-archive', key: 'AXW' })
|
||||
// A karaoke zip crossposted to a.b.test with Power-Post's articles: its group exempts it (LOG.md (127)).
|
||||
assert.deepEqual(rules.judge('k', [{ ...archive('KV-1055806 - Walker Hayes - U Gurl.zip', 'home@home.com (TexasDj)', 2, 13, 3_300_000), groups: ['alt.binaries.sounds.karaoke', 'alt.binaries.test'] }]), { rule: null })
|
||||
// A rar with its par2 is no single archive.
|
||||
assert.deepEqual(rules.judge('k', [archive('Chat Watch 1.2.1.zip', 'a@gmail.com', 2, 48, 12_000_000), file('Chat Watch 1.2.1.par2', 1, NYUU)]), { rule: null })
|
||||
const off = new RuleSet({ ...DEFAULT_RULES, dropStubArchives: false, samplePercent: 0, priorities: {}, droppedSignatures: [] })
|
||||
assert.deepEqual(off.judge('k', [archive('Chat Watch 1.2.1.zip', 'jahnunsinos007@gmail.com', 2, 48, 12_000_000)]), { rule: null })
|
||||
})
|
||||
|
||||
test('nonsense pieces: one article of 100 kB to 1 MB under a release-like name', ({ assert }) => {
|
||||
assert.include(rules.judge('k', [archive('Some.Show.S01E01.1080p.WEB-DL.rar', 'poster@example.org', 1, 1, 790_000)]), { rule: 'nonsense-piece', key: 'nyuu' })
|
||||
// A 3 kB cue/log sidecar rar with a year in its name: under the floor (the tiny-archive rule's outside the music groups). A book (spaces, a format). Two articles, a plain name, a scrambled one: not this rule.
|
||||
assert.deepEqual(rules.judge('k', [{ ...archive('The Smiths-the queen is dead(Remaster)(2011).rar', 'Yenc@power-post.org (Sir Cedrik)', 1, 1, 3_000), groups: ['alt.binaries.sounds.lossless'] }]), { rule: null })
|
||||
assert.include(rules.judge('k', [archive('The Smiths-the queen is dead(Remaster)(2011).rar', 'Yenc@power-post.org (Sir Cedrik)', 1, 1, 3_000)]), { rule: 'tiny-archive' })
|
||||
assert.deepEqual(rules.judge('k', [archive('Kevin Ashman - [Roman 02] - The Rise of Caratacus (2020) (epub).rar', 'fake@address.com (Psychopath)', 2, 1, 303_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('One.Wrong.Turn.Deanna.Lynn.Sletten.epub.rar', 'fake@address.com (Psychopath)', 1, 1, 382_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Vieweg.Teubner.Verlag.-.Numerische.Mathematik.2011.German.RETAIL.eBook-BitBook.rar', 'saggir@domain.com', 1, 1, 113_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Some.Show.S01E01.1080p.WEB-DL.rar', 'poster@example.org', 1, 2, 790_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('holiday snaps.rar', 'poster@example.org', 1, 1, 790_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('nYDeXeF49dieZ1J7flK-JFzZ3j.rar', 'poster@example.org', 1, 1, 790_000)]), { rule: null })
|
||||
const off = new RuleSet({ ...DEFAULT_RULES, dropNonsensePieces: false, samplePercent: 0, priorities: {}, droppedSignatures: [] })
|
||||
assert.deepEqual(off.judge('k', [archive('Some.Show.S01E01.1080p.WEB-DL.rar', 'poster@example.org', 1, 1, 790_000)]), { rule: null })
|
||||
})
|
||||
|
||||
test('tiny archives: one article of 2-100 kB under a release name, spaces allowed', ({ assert }) => {
|
||||
// A RAR holding "<title>.exe": the 2015-17 flood in miniature (LOG.md (131)).
|
||||
assert.include(rules.judge('k', [archive('Humans.S01E04.German.DD20.Dubbed.DL.1080p.BD.x264-TVS.rar', 'Yanc@power-post.org <Yanc@power-post.org>', 1, 1, 15_084)]), { rule: 'tiny-archive', key: 'nyuu' })
|
||||
assert.include(rules.judge('k', [archive('Cicero Magazin fuer politische Kultur Mai 05 2016.rar', 'WarezGeek@WarezGeek.org (WarezGeek)', 1, 1, 17_478)]), { rule: 'tiny-archive', key: 'nyuu' })
|
||||
// A scene name without a year, a tag or a group suffix: not release-like, so kept.
|
||||
assert.deepEqual(rules.judge('k', [archive('[BigTitsAtWork Brazzers] Eva Angelina (Camera Cums In Handy).rar', 'Yanc@power-post.org', 2, 1, 23_449)]), { rule: null })
|
||||
assert.include(rules.judge('k', [archive('Graphisoft.Archicad.v14.0.GERMAN.iSO.X86-PillePalle.rar', 'WarezGeek@WarezGeek.org (WarezGeek)', 1, 1, 7_869)]), { rule: 'tiny-archive', key: 'nyuu' })
|
||||
// Subtitles, a crack, a trainer, a cover, an e-book: the real tiny archives.
|
||||
assert.deepEqual(rules.judge('k', [archive('The.Big.Bang.Theory.S04E13.720p.HDTV.subs.rar', 'henk@example.org', 1, 1, 38_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Tipard.DVD.Ripper.v10.0.12.Crack-XXX.rar', 'poster@example.org', 1, 1, 60_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Far.Cry.4.v1.7.0.Plus.12.Trainer-FLiNG.rar', 'poster@example.org', 1, 1, 45_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Some Band - Album (2011) covers.rar', 'poster@example.org', 1, 1, 45_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('One.Wrong.Turn.Deanna.Lynn.Sletten.epub.rar', 'fake@address.com (Psychopath)', 1, 1, 38_000)]), { rule: null })
|
||||
// A karaoke zip in its group; a plain name; a scrambled one; two articles; under the floor; above 100 kB is the nonsense-piece rule's; not settled.
|
||||
assert.deepEqual(rules.judge('k', [{ ...archive('KV-1055806 - Walker Hayes - U Gurl (2017).zip', 'home@home.com', 1, 1, 60_000), groups: ['alt.binaries.sounds.karaoke'] }]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('holiday snaps.rar', 'poster@example.org', 1, 1, 20_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('nYDeXeF49dieZ1J7flK-JFzZ3j.rar', 'poster@example.org', 1, 1, 20_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar', 'poster@example.org', 1, 2, 20_000)]), { rule: null })
|
||||
assert.deepEqual(rules.judge('k', [archive('Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar', 'poster@example.org', 1, 1, 1_500)]), { rule: null })
|
||||
assert.include(rules.judge('k', [archive('Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar', 'poster@example.org', 1, 1, 150_000)]), { rule: 'nonsense-piece' })
|
||||
assert.deepEqual(rules.judge('k', [archive('Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar', 'poster@example.org', 1, 1, 20_000)], false), { rule: null })
|
||||
const off = new RuleSet({ ...DEFAULT_RULES, dropTinyArchives: false, samplePercent: 0, priorities: {}, droppedSignatures: [] })
|
||||
assert.deepEqual(off.judge('k', [archive('Humans.S01E04.German.DL.1080p.BD.x264-TVS.rar', 'poster@example.org', 1, 1, 20_000)]), { rule: null })
|
||||
})
|
||||
})
|
||||
|
||||
test.group('rules / upload tools', () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Why the pending namer's move fails: for sample pending releases (a JSON dump of rows, the rule set and the
|
||||
* priorities), the stored corpus chunks of their span through the TypeScript indexer, once with the rule set as
|
||||
* stored and once without its drop rules; prints whether the pending identity comes out and what does.
|
||||
*
|
||||
* node --import=@poppinss/ts-exec tools/namer_check.ts sample.json /corpus
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { unzipSync } from 'node:zlib'
|
||||
import { RangeIndexer } from '#lib/edge/index'
|
||||
import { DEFAULT_RULES, type Rules } from '#lib/rules/index'
|
||||
import { identityKey } from '#lib/release/keys'
|
||||
|
||||
const [file, corpus] = process.argv.slice(2)
|
||||
const dump = JSON.parse(readFileSync(file, 'utf8'))
|
||||
const { version: _v, ...defaults } = DEFAULT_RULES
|
||||
const stored: Rules = { ...defaults, ...dump.rules, version: dump.version, priorities: dump.priorities, samplePercent: 0 }
|
||||
const lenient: Rules = { ...stored, droppedTools: [], droppedSignatures: [], droppedPosters: [], dropExecutables: false }
|
||||
|
||||
function text(row: any): string | null {
|
||||
const parts: string[] = []
|
||||
for (const r of row.ranges) {
|
||||
const dir = join(corpus, row.grp, `${r.start}-${r.end}`)
|
||||
let names: string[]
|
||||
try {
|
||||
names = readdirSync(dir).filter((n) => n.endsWith('.gz')).sort()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
for (const n of names) {
|
||||
const [, from, to] = n.slice(0, -3).split('-').map(Number)
|
||||
if (to >= row.first_article && from <= row.last_article) parts.push(unzipSync(readFileSync(join(dir, n))).toString('latin1'))
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join('') : null
|
||||
}
|
||||
|
||||
function run(row: any, overview: string, rules: Rules) {
|
||||
const ix = new RangeIndexer({ group: row.grp, start: row.first_article, end: row.last_article, rules, idleChunks: 0, edgeArticles: 0, partialFiles: true })
|
||||
ix.addText(overview, row.last_article + 1)
|
||||
ix.finish()
|
||||
const batch = ix.take()
|
||||
const found = batch.releases.some((r) => identityKey(r.key).toString('hex') === row.identity)
|
||||
const near = batch.releases.filter((r) => r.firstArticle !== undefined && Math.abs(Number(r.firstArticle) - row.first_article) < 50)
|
||||
return { found, releases: batch.releases.length, near: near.map((r) => `${identityKey(r.key).toString('hex').slice(0, 12)} ${r.name} @${r.firstArticle}`), counts: batch.counts }
|
||||
}
|
||||
|
||||
for (const row of dump.rows) {
|
||||
const overview = text(row)
|
||||
console.log(`\n${row.grp} tries ${row.tries} ${row.first_article}–${row.last_article} "${row.name}" by ${row.poster}, ${row.segments} segments; identity ${row.identity.slice(0, 12)}`)
|
||||
if (!overview) {
|
||||
console.log(' no corpus text')
|
||||
continue
|
||||
}
|
||||
for (const [label, rules] of [['stored rules', stored], ['no drop rules', lenient]] as const) {
|
||||
const r = run(row, overview, rules)
|
||||
console.log(` ${label}: found ${r.found}; ${r.releases} releases; near: ${r.near.join(' | ') || '-'}; counts ${JSON.stringify(r.counts)}`)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user