The Rust worker is the product: edge/, built and checked in CI
User: Rust is the product (option a). tools/edge-bench-rs moves to edge/
(crate edge; binaries edge-node, the worker, and edge-bench, the replay).
CI's new job edge runs cargo test and edge/check-fixtures.sh: both
RangeIndexers on three committed captures (edge/fixtures, 25k overview
lines each of a.b.models, a.b.ath and a.b.boneless in 5 chunks, 3.5 MB),
compared field by field with check.ts; deploy waits for it. The
TypeScript RangeIndexer stays as check.ts's reference. README: what it
is now; the benchmark findings as history.
Random names: looksRandom missed long tokens of capitals and digits
("51294P1CUKR2R35X", release 74539068, readable and a Newznab title).
12+ capitals and digits switching 6+ times between them are random
(COD4MW2REMASTERED switches 4 times, BEATLES1964REMASTER twice); both
implementations, identical on the fixtures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48f4e982f9
commit
5109481d67
38 files changed
+138
-54
No files matched your search
@@ -1 +0,0 @@
|
||||
target/
|
||||
Generated
-959
@@ -1,959 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "edge-bench-rs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"libdeflater",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"mimalloc",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"signal-hook",
|
||||
"ureq",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libdeflate-sys"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7870e5fbd2766179a937c725fb11f4ca0ef025d982beb61bd3ce755425bd19c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libdeflater"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cbb8285d2e2bd4b8d4155840ae81929354b4ba0d7b3c272c84761ffa2141e1f"
|
||||
dependencies = [
|
||||
"libdeflate-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "edge-bench-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "The edge worker's indexing (lib/edge RangeIndexer) in Rust, to compare with edge:bench on captured ranges"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = "0.4"
|
||||
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
|
||||
libdeflater = "1.26.1"
|
||||
md-5 = "0.10"
|
||||
memchr = "2.8.3"
|
||||
mimalloc = "0.1.52"
|
||||
regex = "1"
|
||||
rustc-hash = "2"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
sha1 = "0.10"
|
||||
signal-hook = "0.4.5"
|
||||
ureq = "3.4.2"
|
||||
zstd = "0.13"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
@@ -1,234 +0,0 @@
|
||||
# edge-bench-rs: the edge worker's indexing in Rust
|
||||
|
||||
Since 2026-10-09 this crate is also the production worker: `edge-node`
|
||||
(`src/bin/edge-node.rs`) leases ranges from the coordinator and posts
|
||||
batches like `node ace edge:node`, with the same indexing as the
|
||||
benchmark (`src/lib.rs`). On homelab it indexed 122M headers in 10
|
||||
minutes with 0.37 cores and 565 MB, next to the TypeScript workers' 24M
|
||||
with 0.61 cores and 11.3 GB (8 threads vs 8 processes): about 8× less
|
||||
CPU per header and 20× less memory. See "The worker" below.
|
||||
|
||||
A measurement, not a product. It answers whether a Rust edge worker would be
|
||||
worth a second implementation of the TypeScript one (`edge:node`, REBUILD.md
|
||||
part 2). It ports the path that `edge:bench` replays over a captured range:
|
||||
|
||||
- inflate each XOVER chunk;
|
||||
- scan the overview lines (no object per line);
|
||||
- apply the owner rule from the Xref;
|
||||
- build segments (file key, release identity);
|
||||
- aggregate segments into files, and cut off idle ones as fragments,
|
||||
per-article or incomplete files;
|
||||
- every N chunks, judge the completed releases (rules: executables,
|
||||
readable names, garbage tools and signatures, per-article random
|
||||
subjects), encode their chunks (format v2) and pick probe candidates.
|
||||
|
||||
It does not port NNTP, the coordinator API, batch posting or anything on the
|
||||
server side.
|
||||
|
||||
## Findings (2026-10-09)
|
||||
|
||||
Same machine (the workstation, otherwise idle), same captures, 2M headers
|
||||
each:
|
||||
|
||||
| Capture | TypeScript (`edge:bench`) | Rust (`edge-bench-rs`) | Less CPU |
|
||||
|---|---|---|---|
|
||||
| a.b.boneless 2026 (160950000000–160951999999) | 11.1 CPU-s (~215k headers/s), 0.70 GB peak | 2.9 CPU-s (~690k/s), 0.24 GB | 3.8× |
|
||||
| a.b.boneless 2026 (160952000000–160953999999) | 11.8 CPU-s (~205k/s), 0.70 GB | 3.1 CPU-s (~640k/s), 0.24 GB | 3.8× |
|
||||
| a.b.ath 2013 (2549992990–2551992989) | 7.5 CPU-s (~320k/s), 0.74 GB | 2.15 CPU-s (~930k/s), 0.20 GB | 3.5× |
|
||||
| a.b.models (2017215–4017214; image sets, re-uploads) | 16.7 CPU-s (~139k/s) | 5.0 CPU-s (~400k/s) | 3.3× |
|
||||
|
||||
**Comparing the numbers:**
|
||||
- **CPU seconds are the fair comparison.** `edge:bench`'s headers/s leaves
|
||||
out the time Node's GC threads use; the Rust binary is single-threaded, so
|
||||
its wall time is its CPU time.
|
||||
- **Homelab's cores** (Xeon E5-1650 v4) run the TypeScript ~10% slower than
|
||||
the workstation's.
|
||||
|
||||
**The Rust optimizations,** measured on boneless 2026 (all checked identical
|
||||
after each step):
|
||||
|
||||
| Step | Headers/s |
|
||||
|---|---|
|
||||
| Straight port: regex crate for the patterns, chrono for dates, zlib-rs, std allocator | 360k |
|
||||
| File stems and subject remainders by hand: their 12 + 5 regexes were most of a new file's description (333k per boneless range, many of them per-article random subjects) | |
|
||||
| mimalloc for the many small strings | 495k |
|
||||
| Upload tools, release-name checks and the common Date formats by hand | 500k |
|
||||
| libdeflate: inflates a chunk whole in half of zlib's time (1.09 → 0.51 s per range) | |
|
||||
| memchr (SIMD) for line ends and tabs | ~700k |
|
||||
| Fewer allocations in `describe` (hashing the parts without joining them): no measurable gain, reverted | |
|
||||
|
||||
Rows without a figure weren't measured on their own; each figure covers the
|
||||
rows above it.
|
||||
|
||||
**What's left** is spread out: scanning and building segments, two sha1s per
|
||||
new file, aggregation, cut-off and chunk encoding. `perf` isn't available on
|
||||
the workstation (`perf_event_paranoid` = 3), so these come from coarse
|
||||
timers. The binary prints them as "of which aggregate …, evict+cutoff …,
|
||||
judge+encode …" on stderr.
|
||||
|
||||
**What it means for the full target** (~1.25M articles/s with 50 NNTP
|
||||
connections):
|
||||
- **Worker cores:** ~1.5–2 instead of ~6–7. Spreading workers over other
|
||||
machines (batcave, poweredge-kantoor) becomes mostly unnecessary.
|
||||
- **Total speed:** unchanged. The account's NNTP connections set it (~30k
|
||||
articles/s each), not the worker.
|
||||
- **The cost:** a second implementation of `lib/headers`, `lib/release`,
|
||||
`lib/rules`, `lib/files` (aggregation, volumes) and the chunk format.
|
||||
Every rule change has to land in both and be checked identical (below)
|
||||
for as long as both exist.
|
||||
|
||||
## Running it
|
||||
|
||||
You need a captured range. `edge:bench --capture` saves a range's compressed
|
||||
XOVER chunks once (it needs `NNTP_*` in the environment and one NNTP
|
||||
connection):
|
||||
|
||||
```sh
|
||||
node ace edge:bench --capture --dir /path/to/capture --group alt.binaries.boneless --start 160950000000
|
||||
# 2M articles in chunks of 50k (--articles, --chunk), plus range.json
|
||||
```
|
||||
|
||||
Benchmark both on it:
|
||||
|
||||
```sh
|
||||
node ace edge:bench --dir /path/to/capture # TypeScript: headers/s, CPU, inflate/index, peak RSS
|
||||
cd tools/edge-bench-rs && cargo build --release
|
||||
./target/release/edge-bench-rs /path/to/capture # Rust: the same summary
|
||||
```
|
||||
|
||||
Both take results every 10 chunks (`--batch-chunks`) and give a file up after
|
||||
5 idle chunks (`--idle`). Keep these equal on both sides when comparing.
|
||||
The rules are `DEFAULT_RULES` with only the range's group indexed and no
|
||||
dropped signatures, as in `edge:bench`.
|
||||
|
||||
The two "kept: …" summary lines should already agree: release chunks,
|
||||
files, segments, payload size, candidates, fragments and samples. The
|
||||
summary can hide compensating differences, so compare the outputs
|
||||
themselves.
|
||||
|
||||
## Comparing the output
|
||||
|
||||
```sh
|
||||
./target/release/edge-bench-rs /path/to/capture --dump /tmp/out.jsonl
|
||||
cd ../.. && node --import=@poppinss/ts-exec tools/edge-bench-rs/check.ts /path/to/capture /tmp/out.jsonl
|
||||
```
|
||||
|
||||
`--dump` writes every output of every batch as JSON lines:
|
||||
|
||||
| Line `t` | Contents |
|
||||
|---|---|
|
||||
| `b` | batch boundaries: from, next, headers |
|
||||
| `r` | releases: every ReleaseChunk field, the payload in base64 |
|
||||
| `s` | samples: dropped releases kept in full, with their rule |
|
||||
| `c` | probe candidates |
|
||||
| `n` | rule counts |
|
||||
| `f` | fragments |
|
||||
|
||||
`check.ts` runs the TypeScript `RangeIndexer` (from `lib/edge`, not a copy)
|
||||
on the same capture with the same batching. It matches items by batch and
|
||||
key: release key, candidate (release, kind, message-id), rule and key,
|
||||
fragment file key. Then it compares them field by field:
|
||||
|
||||
```
|
||||
same batches: 4 TypeScript, 4 Rust
|
||||
same releases: 1055 TypeScript, 1055 Rust
|
||||
same samples: 2 TypeScript, 2 Rust
|
||||
same candidates: 1090 TypeScript, 1090 Rust
|
||||
same rule counts: 34 TypeScript, 34 Rust
|
||||
same fragments: 130 TypeScript, 130 Rust
|
||||
payload bytes: 13728351 TypeScript, 13727247 Rust
|
||||
```
|
||||
|
||||
It exits 0 only when everything matches. Otherwise each kind says how many
|
||||
items are only on one side or differ, and shows the first differing fields.
|
||||
|
||||
- **Payloads are compared decoded.** Both sides go through `decodeChunk`
|
||||
from `lib/chunks`, and their files are compared, ordered by name and
|
||||
subject. Node's built-in zstd and the zstd crate frame the same data a
|
||||
little differently, so the compressed sizes differ by about 0.1%. The
|
||||
checker prints them, but they don't count as a difference. Decoding the
|
||||
Rust payloads with the TypeScript decoder also checks the Rust encoder.
|
||||
- **Order doesn't count.** Order within a batch isn't compared (the two
|
||||
hash maps iterate differently). Grouping into batches, the order of a
|
||||
release's files and the order of a fragment's segments are compared,
|
||||
because they're part of the values.
|
||||
|
||||
Run it after any change on either side. All four captures above are
|
||||
identical as of cfe85ab. The captures are kept on homelab in
|
||||
`/mnt/bulk/usenet-rebuild/bench/` (boneless-2026, boneless-2026b,
|
||||
ath-2013, models-a). models-a is a bottom-500 group (rank 501–1000 by
|
||||
span) with what the other three lack: image sets (38,630 release chunks
|
||||
for 412k files), documents, and re-uploads (521 chunks of a second or
|
||||
later upload of a key in a batch). Its chunk 26 is the plain fetch of a
|
||||
range whose compressed reply Eweka cuts short (LOG.md 2026-10-09 (11)).
|
||||
|
||||
## JavaScript semantics the port keeps (`src/js.rs`, `src/names.rs`)
|
||||
|
||||
Differences found with `check.ts` or the tests, kept as JavaScript has
|
||||
them:
|
||||
|
||||
- **Regexes without the `u` flag:** `\d` is [0-9], `\b` an ASCII word
|
||||
boundary, `/i` ASCII-only. They run as `(?-u)` byte regexes, or by hand.
|
||||
The hand-written versions (file stems, subject remainders, upload tools,
|
||||
release-name and file-counter checks) are tested against the regex
|
||||
versions, which are kept for that (`*_re`).
|
||||
- **JS `\s` and `trim()`** include Unicode spaces (NBSP, U+2000–200A,
|
||||
U+3000, BOM…); `split(/\s+/)` keeps leading and trailing empty words.
|
||||
- **`lastIndexOf(c, position)`** finds a match at or before the position
|
||||
(the owner rule, the counter and filename scans). **`.length`** is in
|
||||
UTF-16 units (`len16`). **`parseInt`** reads leading digits.
|
||||
- **`Date.parse`:** chrono follows RFC 5322 and rejects "UTC", which
|
||||
`Date.parse` takes. It ends about a quarter of boneless's Date headers, so
|
||||
it's mapped to GMT; check.ts found this. The common formats are parsed by
|
||||
hand:
|
||||
|
||||
```
|
||||
[Day, ]D[D] Mon YY[YY] HH:MM:SS (GMT|UTC|UT|±hhmm)
|
||||
```
|
||||
|
||||
They ignore the weekday as `Date.parse` does (chrono rejects a weekday
|
||||
that doesn't fit). Anything else goes to chrono. Rare forms `Date.parse`
|
||||
takes but chrono doesn't would show up in check.ts as a `postedAt`
|
||||
difference.
|
||||
- **Invalid UTF-8** becomes U+FFFD on both sides (`toString('utf8')`,
|
||||
`from_utf8_lossy`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `src/main.rs` | arguments, the replay loop, timing, `--dump` |
|
||||
| `src/indexer.rs` | scan.ts (SegmentScanner, scanOverviewText), aggregator.ts, range_indexer.ts |
|
||||
| `src/names.rs` | lib/release (identity, random), lib/files/volumes, lib/rules (upload tools, judge) |
|
||||
| `src/codec.rs` | lib/chunks encodeChunk, format v2 |
|
||||
| `src/js.rs` | JavaScript semantics, sha1 |
|
||||
| `check.ts` | the comparison |
|
||||
|
||||
## The worker
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M]
|
||||
```
|
||||
|
||||
- **One range per thread** (default one per core): one thread reads the
|
||||
coordinator's stream and inflates a frame ahead, another posts the
|
||||
batches in order (one may wait), the indexer goes on meanwhile.
|
||||
- **As `commands/edge_node.ts`:** retries for network trouble and 5xx;
|
||||
409 means the lease is lost (the range stops, isn't given back); a
|
||||
batch once 500k articles or 100k files wait (a picture group's batches
|
||||
went over the server's 256 MB limit); a chunk that doesn't inflate is
|
||||
reported (the coordinator fetches it plain next time); SIGTERM/SIGINT
|
||||
give each range back after its current chunk; `--max-mbit` is the
|
||||
node's download cap, shared by its threads. Plus a heartbeat when no
|
||||
batch went out for a minute.
|
||||
- **Wire format:** `src/wire.rs` writes a batch as `toWire` does.
|
||||
`edge-bench-rs --wire` and `check.ts --wire` compare it with the
|
||||
TypeScript's, field by field.
|
||||
- **End to end:** both workers indexed the a.b.models capture (spooled
|
||||
as a range) through the web server; what the server stored was the
|
||||
same (releases with every column, chunks, candidates, rule counts,
|
||||
fragments with their JSON).
|
||||
- **On homelab:** container `usenet-rebuild-edge-rs` runs
|
||||
`/mnt/bulk/usenet-rebuild/bin/edge-node --threads 8` in
|
||||
node:22-bookworm-slim (the binary needs glibc 2.34+).
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Compares edge-bench-rs's output (--dump) with the TypeScript RangeIndexer
|
||||
* on the same captured range, item by item: releases (every field, and the
|
||||
* payload decoded with lib/chunks), samples, probe candidates, rule counts,
|
||||
* fragments and batch boundaries.
|
||||
*
|
||||
* node --import=@poppinss/ts-exec tools/edge-bench-rs/check.ts <dir> <dump.jsonl> [--wire batches.jsonl] [--batch-chunks 10] [--idle 5]
|
||||
*
|
||||
* With --wire (edge-bench-rs --wire): also each batch as the Rust worker
|
||||
* posts it, against toWire of the TypeScript batch, field by field.
|
||||
*/
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { unzipSync } from 'node:zlib'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { RangeIndexer, toWire, type EdgeBatch } from '#lib/edge/index'
|
||||
import { DEFAULT_RULES, type Rules } from '#lib/rules/index'
|
||||
import { decodeChunk } from '#lib/chunks/index'
|
||||
|
||||
const [dir, dumpPath] = process.argv.slice(2)
|
||||
const flag = (name: string, fallback: number) => {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? fallback : Number(process.argv[i + 1])
|
||||
}
|
||||
const batchChunks = flag('--batch-chunks', 10)
|
||||
const idle = flag('--idle', 5)
|
||||
|
||||
const { group, start, end } = JSON.parse(await readFile(join(dir, 'range.json'), 'utf8'))
|
||||
const rules: Rules = { ...DEFAULT_RULES, priorities: { [group]: 1 }, droppedSignatures: [] }
|
||||
const indexer = new RangeIndexer({ group, start, end, rules, idleChunks: idle })
|
||||
const files = (await readdir(dir)).filter((f) => f.endsWith('.gz')).sort()
|
||||
const batches: EdgeBatch[] = []
|
||||
for (const [i, file] of files.entries()) {
|
||||
const next = Number(file.split('-')[2].replace('.gz', '')) + 1
|
||||
indexer.addText(unzipSync(await readFile(join(dir, file))).toString('utf8'), next)
|
||||
const last = i === files.length - 1
|
||||
if (last) indexer.finish()
|
||||
if (last || (i + 1) % batchChunks === 0) batches.push(indexer.take())
|
||||
}
|
||||
|
||||
/** Canonical lines per kind, in a stable order, from both sides. */
|
||||
type Item = Record<string, unknown>
|
||||
const sortFiles = (decoded: ReturnType<typeof decodeChunk>) => [...decoded].sort((a, b) => (a.name + a.subject < b.name + b.subject ? -1 : 1))
|
||||
const payloadBytes = { ts: 0, rs: 0 }
|
||||
let side: 'ts' | 'rs' = 'ts'
|
||||
const release = (r: Item, b: number) => {
|
||||
const { payload, ...rest } = r as Item & { payload: Buffer }
|
||||
// The compressed size isn't compared (node's zstd and the zstd crate frame alike data a little
|
||||
// differently); what the payload decodes to is.
|
||||
payloadBytes[side] += payload.length
|
||||
return { b, ...rest, decoded: sortFiles(decodeChunk(payload)) }
|
||||
}
|
||||
const ts: Record<string, Item[]> = { b: [], r: [], s: [], c: [], n: [], f: [] }
|
||||
batches.forEach((batch, b) => {
|
||||
ts.b.push({ b, from: batch.from, next: batch.next, headers: batch.headers })
|
||||
for (const r of batch.releases) ts.r.push(release({ upload: r.upload, key: r.key, name: r.name, source: r.source, fileTotal: r.fileTotal, poster: r.poster, groups: r.groups, postedAt: r.postedAt, postedLast: r.postedLast, files: r.files, segments: r.segments, bytes: r.bytes, firstArticle: r.firstArticle, lastArticle: r.lastArticle, readable: r.readable, hasPar2: r.hasPar2, payload: r.payload }, b))
|
||||
for (const s of batch.samples) ts.s.push({ b, key: s.key, upload: s.upload, rule: s.rule, ruleKey: s.ruleKey, segments: s.segments })
|
||||
for (const c of batch.candidates) ts.c.push({ b, upload: c.upload, releaseKey: c.releaseKey, kind: c.kind, messageId: c.messageId, file: c.file })
|
||||
for (const n of batch.counts) ts.n.push({ b, rule: n.rule, key: n.key, articles: n.articles, files: n.files })
|
||||
for (const f of batch.fragments) ts.f.push({ b, fileKey: f.fileKey, name: f.name, totalParts: f.totalParts, postedAt: f.postedAt, segments: f.segments })
|
||||
})
|
||||
|
||||
const rs: Record<string, Item[]> = { b: [], r: [], s: [], c: [], n: [], f: [] }
|
||||
side = 'rs'
|
||||
let b = -1
|
||||
for (const line of (await readFile(dumpPath, 'utf8')).split('\n')) {
|
||||
if (!line) continue
|
||||
const { t, ...item } = JSON.parse(line)
|
||||
if (t === 'b') b++
|
||||
if (t === 'r') rs.r.push(release({ ...item, payload: Buffer.from(item.payload, 'base64') }, b))
|
||||
else if (t === 's') rs.s.push({ b, key: item.key, upload: item.upload, rule: item.rule, ruleKey: item.ruleKey, segments: item.segments })
|
||||
else rs[t].push({ b, ...item })
|
||||
}
|
||||
|
||||
const keyOf: Record<string, (i: Item) => string> = {
|
||||
b: (i) => String(i.b),
|
||||
r: (i) => `${i.b} ${i.key} ${i.upload}`,
|
||||
s: (i) => `${i.b} ${i.key} ${i.upload}`,
|
||||
c: (i) => `${i.b} ${i.releaseKey} ${i.upload} ${i.kind} ${i.messageId}`,
|
||||
n: (i) => `${i.b} ${i.rule} ${i.key}`,
|
||||
f: (i) => `${i.b} ${i.fileKey}`,
|
||||
}
|
||||
const names: Record<string, string> = { b: 'batches', r: 'releases', s: 'samples', c: 'candidates', n: 'rule counts', f: 'fragments' }
|
||||
let failed = false
|
||||
for (const kind of Object.keys(keyOf)) {
|
||||
const a = new Map(ts[kind].map((i) => [keyOf[kind](i), i]))
|
||||
const z = new Map(rs[kind].map((i) => [keyOf[kind](i), i]))
|
||||
const missing = [...a.keys()].filter((k) => !z.has(k))
|
||||
const extra = [...z.keys()].filter((k) => !a.has(k))
|
||||
const differ = [...a.keys()].filter((k) => z.has(k) && !isDeepStrictEqual(a.get(k), z.get(k)))
|
||||
const ok = missing.length === 0 && extra.length === 0 && differ.length === 0
|
||||
failed ||= !ok
|
||||
console.log(`${ok ? 'same' : 'DIFF'} ${names[kind]}: ${a.size} TypeScript, ${z.size} Rust${ok ? '' : `; ${missing.length} only in TypeScript, ${extra.length} only in Rust, ${differ.length} differ`}`)
|
||||
for (const k of [...missing.slice(0, 2), ...extra.slice(0, 2)]) console.log(` only one side: ${k}`)
|
||||
for (const k of differ.slice(0, 2)) {
|
||||
const x = a.get(k)!
|
||||
const y = z.get(k)!
|
||||
const fields = Object.keys(x).filter((f) => !isDeepStrictEqual(x[f], y[f]))
|
||||
console.log(` ${k}: ${fields.map((f) => `${f}: ${JSON.stringify(x[f]).slice(0, 160)} vs ${JSON.stringify(y[f]).slice(0, 160)}`).join('; ')}`)
|
||||
}
|
||||
}
|
||||
console.log(`payload bytes: ${payloadBytes.ts} TypeScript, ${payloadBytes.rs} Rust`)
|
||||
|
||||
const wireIndex = process.argv.indexOf('--wire')
|
||||
if (wireIndex !== -1) {
|
||||
// Order within a batch doesn't count; payloads by what they decode to.
|
||||
const by = (f: (x: any) => string) => (a: any, b: any) => (f(a) < f(b) ? -1 : f(a) > f(b) ? 1 : 0)
|
||||
const normal = (w: any) => ({
|
||||
...w,
|
||||
releases: w.releases.map((r: any) => ({ ...r, payload: sortFiles(decodeChunk(Buffer.from(r.payload, 'base64'))) })).sort(by((r) => `${r.key} ${r.upload}`)),
|
||||
samples: w.samples.map((r: any) => ({ ...r, payload: sortFiles(decodeChunk(Buffer.from(r.payload, 'base64'))) })).sort(by((r) => `${r.key} ${r.upload}`)),
|
||||
candidates: [...w.candidates].sort(by((c) => JSON.stringify(c))),
|
||||
counts: [...w.counts].sort(by((c) => `${c.rule} ${c.key}`)),
|
||||
fragments: [...w.fragments].sort(by((f) => `${f.fileKey} ${f.segments[0]?.[1]}`)),
|
||||
})
|
||||
const rust = (await readFile(process.argv[wireIndex + 1], 'utf8')).split('\n').filter(Boolean).map((l) => normal(JSON.parse(l)))
|
||||
const typescript = batches.map((b) => normal(JSON.parse(JSON.stringify(toWire(b)))))
|
||||
let wireSame = rust.length === typescript.length
|
||||
const firstDiff = (x: any, y: any, path: string): string | null => {
|
||||
if (isDeepStrictEqual(x, y)) return null
|
||||
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null) return `${path}: ${JSON.stringify(x)?.slice(0, 120)} vs ${JSON.stringify(y)?.slice(0, 120)}`
|
||||
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
|
||||
const d = firstDiff(x[k], y[k], `${path}.${k}`)
|
||||
if (d) return d
|
||||
}
|
||||
return path
|
||||
}
|
||||
for (let i = 0; i < Math.max(rust.length, typescript.length); i++) {
|
||||
const d = firstDiff(typescript[i], rust[i], `batch ${i}`)
|
||||
if (d) {
|
||||
wireSame = false
|
||||
console.log(`DIFF wire: ${d}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (wireSame) console.log(`same wire batches: ${typescript.length} TypeScript, ${rust.length} Rust`)
|
||||
failed ||= !wireSame
|
||||
}
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -1,562 +0,0 @@
|
||||
//! 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-rs, kept identical to
|
||||
//! the TypeScript by check.ts) and posts the batches. One range per thread.
|
||||
//!
|
||||
//! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--batch-articles 500000] [--ranges 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).
|
||||
|
||||
use edge_bench_rs::indexer::{Batch, RangeIndexer};
|
||||
use edge_bench_rs::names::Rules;
|
||||
use edge_bench_rs::wire;
|
||||
use flate2::write::GzEncoder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, Receiver};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
/// A batch is posted once this many files (completed, or cut at an edge) wait, whatever its articles (commands/edge_node.ts).
|
||||
const MAX_PENDING_FILES: usize = 100_000;
|
||||
const RETRIES: u32 = 8;
|
||||
const IDLE: Duration = Duration::from_secs(30);
|
||||
/// Without a post for this long, the range's lease is kept alive by a heartbeat.
|
||||
const HEARTBEAT: Duration = Duration::from_secs(60);
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
rules
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StoredJson {
|
||||
releases: u64,
|
||||
candidates: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ApiError {
|
||||
/// 409: the range is no longer this node's (or the batch was stale): stop it, don't give it back.
|
||||
LeaseLost(String),
|
||||
/// 413: the batch was over the server's body limit; the range is tried again in smaller batches.
|
||||
TooLarge(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
ApiError::LeaseLost(m) | ApiError::TooLarge(m) | ApiError::Other(m) => f.write_str(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The coordinator's node API, with retries for network trouble and 5xx (as the TypeScript Api).
|
||||
struct Api {
|
||||
base: String,
|
||||
token: String,
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
fn new(base: &str, token: &str) -> Self {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_connect(Some(Duration::from_secs(30)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(600)))
|
||||
.build()
|
||||
.into();
|
||||
Api { base: base.trim_end_matches('/').to_string(), token: token.to_string(), agent }
|
||||
}
|
||||
|
||||
/// A request, retried; `body`: JSON bytes (gzipped when `gzip`).
|
||||
fn request(&self, method: &str, path: &str, body: Option<&[u8]>, gzip: bool, accept: &str) -> Result<ureq::http::Response<ureq::Body>, ApiError> {
|
||||
let url = format!("{}/api/v1/node/{path}", self.base);
|
||||
let auth = format!("Bearer {}", self.token);
|
||||
for attempt in 1..=RETRIES {
|
||||
let result = match method {
|
||||
"GET" => self.agent.get(&url).header("Authorization", &auth).header("Accept", accept).call(),
|
||||
_ => {
|
||||
let mut request = self.agent.post(&url).header("Authorization", &auth).header("Accept", accept).header("Content-Type", "application/json");
|
||||
if gzip {
|
||||
request = request.header("Content-Encoding", "gzip");
|
||||
}
|
||||
request.send(body.unwrap_or(b"{}"))
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(mut response) => {
|
||||
let status = response.status().as_u16();
|
||||
if status == 409 {
|
||||
let text = response.body_mut().read_to_string().unwrap_or_default();
|
||||
let message = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v["error"].as_str().map(str::to_string)).unwrap_or_else(|| "lease lost".into());
|
||||
return Err(ApiError::LeaseLost(message));
|
||||
}
|
||||
if status < 500 {
|
||||
return Ok(response);
|
||||
}
|
||||
if attempt == RETRIES {
|
||||
return Err(ApiError::Other(format!("{path}: HTTP {status}")));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if attempt == RETRIES {
|
||||
return Err(ApiError::Other(format!("{path}: {error}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(60_000.min(1000 * 2u64.pow(attempt))));
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn lease(&self) -> Result<Option<LeaseJson>, ApiError> {
|
||||
let mut response = self.request("POST", "lease", 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!("lease: {e}")))?;
|
||||
serde_json::from_str(&text).map(Some).map_err(|e| ApiError::Other(format!("lease: {e}")))
|
||||
}
|
||||
status => Err(ApiError::Other(format!("lease: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn stream(&self, range: i64) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
let response = self.request("GET", &format!("ranges/{range}/stream"), None, false, "application/octet-stream")?;
|
||||
if response.status().as_u16() != 200 {
|
||||
return Err(ApiError::Other(format!("stream: HTTP {}", response.status())));
|
||||
}
|
||||
Ok(Box::new(response.into_body().into_reader()))
|
||||
}
|
||||
|
||||
/// Posts a batch (gzipped JSON): the releases and candidates stored, and the bytes posted.
|
||||
fn batch(&self, range: i64, batch: &Batch) -> Result<(u64, u64, usize), ApiError> {
|
||||
let json = wire::to_json(batch);
|
||||
let mut gz = GzEncoder::new(Vec::with_capacity(json.len() / 3), flate2::Compression::new(1));
|
||||
gz.write_all(&json).unwrap();
|
||||
let body = gz.finish().unwrap();
|
||||
let mut response = self.request("POST", &format!("ranges/{range}/batches"), Some(&body), true, "application/json")?;
|
||||
if response.status().as_u16() == 413 {
|
||||
return Err(ApiError::TooLarge(format!("batch: HTTP {} ({} MB gzipped, {} MB of JSON)", response.status(), body.len() / 1_000_000, json.len() / 1_000_000)));
|
||||
}
|
||||
if response.status().as_u16() != 200 {
|
||||
return Err(ApiError::Other(format!("batch: HTTP {}", response.status())));
|
||||
}
|
||||
let text = response.body_mut().read_to_string().map_err(|e| ApiError::Other(format!("batch: {e}")))?;
|
||||
let stored: StoredJson = serde_json::from_str(&text).map_err(|e| ApiError::Other(format!("batch: {e}")))?;
|
||||
Ok((stored.releases, stored.candidates, body.len()))
|
||||
}
|
||||
|
||||
fn call(&self, range: i64, action: &str) -> Result<(), ApiError> {
|
||||
let response = self.request("POST", &format!("ranges/{range}/{action}"), None, false, "application/json")?;
|
||||
match response.status().as_u16() {
|
||||
200..=299 => Ok(()),
|
||||
status => Err(ApiError::Other(format!("{action}: 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")?;
|
||||
match response.status().as_u16() {
|
||||
200..=299 => Ok(()),
|
||||
status => Err(ApiError::Other(format!("corrupt: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of the range's stream: a chunk inflated (or why it didn't), or a keep-alive.
|
||||
enum Frame {
|
||||
Chunk { from: i64, to: i64, text: Result<Vec<u8>, String> },
|
||||
KeepAlive,
|
||||
End,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Reads frames (8 B from, 8 B to, 4 B length, data) and inflates them, one frame ahead of the indexer.
|
||||
fn read_frames(mut stream: Box<dyn Read + Send>, bytes_per_second: Option<f64>) -> Receiver<Frame> {
|
||||
let (tx, rx) = sync_channel::<Frame>(2);
|
||||
std::thread::spawn(move || {
|
||||
let mut decompressor = libdeflater::Decompressor::new();
|
||||
let started = Instant::now();
|
||||
let mut read_bytes = 0f64;
|
||||
loop {
|
||||
let mut head = [0u8; 20];
|
||||
match read_full(&mut stream, &mut head) {
|
||||
Ok(0) => {
|
||||
let _ = tx.send(Frame::End);
|
||||
return;
|
||||
}
|
||||
Ok(20) => {}
|
||||
Ok(n) => {
|
||||
let _ = tx.send(Frame::Failed(format!("stream ended inside a frame ({n} bytes)")));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Frame::Failed(format!("stream: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let from = u64::from_be_bytes(head[0..8].try_into().unwrap()) as i64;
|
||||
let to = u64::from_be_bytes(head[8..16].try_into().unwrap()) as i64;
|
||||
let length = u32::from_be_bytes(head[16..20].try_into().unwrap()) as usize;
|
||||
let mut data = vec![0u8; length];
|
||||
match read_full(&mut stream, &mut data) {
|
||||
Ok(n) if n == length => {}
|
||||
Ok(n) => {
|
||||
let _ = tx.send(Frame::Failed(format!("stream ended inside a frame ({} of {length} bytes)", n)));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Frame::Failed(format!("stream: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// --max-mbit: read no faster than the cap (TCP holds the coordinator back).
|
||||
if let Some(rate) = bytes_per_second {
|
||||
read_bytes += (20 + length) as f64;
|
||||
let due = Duration::from_secs_f64(((read_bytes - rate) / rate).max(0.0));
|
||||
let elapsed = started.elapsed();
|
||||
if due > elapsed {
|
||||
std::thread::sleep(due - elapsed);
|
||||
}
|
||||
}
|
||||
let frame = if length == 0 && from == 0 {
|
||||
Frame::KeepAlive
|
||||
} else {
|
||||
Frame::Chunk { from, to, text: inflate(&data, &mut decompressor) }
|
||||
};
|
||||
if tx.send(frame).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// Reads until `buf` is full or the stream ends: the bytes read.
|
||||
fn read_full(stream: &mut dyn Read, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut at = 0;
|
||||
while at < buf.len() {
|
||||
match stream.read(&mut buf[at..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => at += n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// A chunk as the news server sent it (zlib; gzip too), whole.
|
||||
fn inflate(raw: &[u8], d: &mut libdeflater::Decompressor) -> Result<Vec<u8>, String> {
|
||||
let mut out = vec![0u8; raw.len() * 4 + 4096];
|
||||
loop {
|
||||
let result = if raw.starts_with(&[0x1f, 0x8b]) { d.gzip_decompress(raw, &mut out) } else { d.zlib_decompress(raw, &mut out) };
|
||||
match result {
|
||||
Ok(n) => {
|
||||
out.truncate(n);
|
||||
return Ok(out);
|
||||
}
|
||||
Err(libdeflater::DecompressionError::InsufficientSpace) => out.resize(out.len() * 2, 0),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Config {
|
||||
batch_articles: i64,
|
||||
bytes_per_second: Option<f64>,
|
||||
/// Ranges whose batch was too large (413), and how much smaller their batches are now (halved each time).
|
||||
smaller: Mutex<HashMap<i64, i64>>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Articles and pending files a batch of this range is posted at.
|
||||
fn limits(&self, range: i64) -> (i64, usize) {
|
||||
let divisor = self.smaller.lock().unwrap().get(&range).copied().unwrap_or(1);
|
||||
(self.batch_articles / divisor, MAX_PENDING_FILES / divisor as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Totals {
|
||||
headers: u64,
|
||||
releases: u64,
|
||||
candidates: u64,
|
||||
bytes: usize,
|
||||
post_ms: u128,
|
||||
}
|
||||
|
||||
/// One leased range, start to finish (commands/edge_node.ts runRange).
|
||||
fn run_range(api: &Arc<Api>, lease: LeaseJson, config: &Config, stopping: &AtomicBool) {
|
||||
let range = lease.range;
|
||||
let started = Instant::now();
|
||||
let mut indexer = RangeIndexer::new(&range.group, range.start, range.end, range.next, lease.rules.rules(), 5);
|
||||
let totals = Arc::new(Mutex::new(Totals::default()));
|
||||
let post_error: Arc<Mutex<Option<ApiError>>> = Arc::new(Mutex::new(None));
|
||||
let last_contact = Arc::new(AtomicI64::new(0));
|
||||
let since = Instant::now();
|
||||
// Posts in order on their own thread; the indexer goes on meanwhile (one batch may wait).
|
||||
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!("Range {} failed: {e}; giving it back.", range.id));
|
||||
let _ = api.call(range.id, "release");
|
||||
return;
|
||||
}
|
||||
};
|
||||
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;
|
||||
for frame in frames.iter() {
|
||||
if stopping.load(Ordering::Relaxed) || post_error.lock().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
// Keep the lease while no batch goes out (slow ranges wait for the spool).
|
||||
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.call(range.id, "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;
|
||||
if reached_end {
|
||||
indexer.finish();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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: reported, fetched plain next time.
|
||||
log("warn", &format!("Range {}: chunk {from}–{to} doesn't inflate ({e}); reported, the range goes back", range.id));
|
||||
let _ = api.corrupt(range.id, from);
|
||||
return;
|
||||
}
|
||||
match post_error {
|
||||
Some(ApiError::LeaseLost(m)) => {
|
||||
log("warn", &format!("Range {}: {m}", range.id));
|
||||
return;
|
||||
}
|
||||
Some(ApiError::TooLarge(m)) => {
|
||||
// Batches already stored stay; from the resume point on, half the size (a batch is at least a chunk).
|
||||
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!("Range {}: {m}; giving it back, its batches 1/{divisor} the size from now on.", range.id));
|
||||
let _ = api.call(range.id, "release");
|
||||
return;
|
||||
}
|
||||
Some(ApiError::Other(m)) => {
|
||||
log("error", &format!("Range {} failed: {m}; giving it back.", range.id));
|
||||
let _ = api.call(range.id, "release");
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(e) = failure {
|
||||
log("error", &format!("Range {} failed: {e}; giving it back.", range.id));
|
||||
let _ = api.call(range.id, "release");
|
||||
return;
|
||||
}
|
||||
if !reached_end {
|
||||
log("info", &format!("Range {}: giving it back.", range.id));
|
||||
let _ = api.call(range.id, "release");
|
||||
return;
|
||||
}
|
||||
match api.call(range.id, "finish") {
|
||||
Ok(()) => {
|
||||
let t = totals.lock().unwrap();
|
||||
let seconds = started.elapsed().as_secs_f64();
|
||||
log(
|
||||
"info",
|
||||
&format!(
|
||||
"{} {}–{}: {} headers in {:.0} s ({:.0}/s), {} release chunks, {} probe candidates; posted {:.1} MB in {:.1} s.",
|
||||
range.group,
|
||||
range.start,
|
||||
range.end,
|
||||
t.headers,
|
||||
seconds,
|
||||
t.headers as f64 / seconds,
|
||||
t.releases,
|
||||
t.candidates,
|
||||
t.bytes as f64 / 1e6,
|
||||
t.post_ms as f64 / 1000.0
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(e) => log("warn", &format!("Range {}: {e}", range.id)),
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let url = std::env::var("COORDINATOR_URL").expect("COORDINATOR_URL is needed");
|
||||
let token = std::env::var("NODE_TOKEN").expect("NODE_TOKEN is needed");
|
||||
let threads: usize = flag("--threads")
|
||||
.or_else(|| std::env::var("EDGE_PROCESSES").ok())
|
||||
.map(|v| v.parse().unwrap())
|
||||
.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());
|
||||
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),
|
||||
// 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()),
|
||||
});
|
||||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
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()));
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
let (api, config, stopping) = (api.clone(), config.clone(), stopping.clone());
|
||||
std::thread::spawn(move || {
|
||||
let mut done = 0;
|
||||
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),
|
||||
Err(e) => {
|
||||
log("warn", &format!("Lease failed: {e}"));
|
||||
idle(&stopping);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for worker in workers {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
fn idle(stopping: &AtomicBool) {
|
||||
let until = Instant::now() + IDLE;
|
||||
while Instant::now() < until && !stopping.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! lib/chunks/codec.ts encodeChunk, format v2: a version byte, the length of
|
||||
//! the compressed part, zstd(JSON head + text ids), then the nyuu and hex ids
|
||||
//! as raw bytes.
|
||||
|
||||
use crate::js::json_str;
|
||||
|
||||
pub struct ChunkFile<'a> {
|
||||
pub name: &'a str,
|
||||
pub poster: &'a str,
|
||||
pub subject: &'a str,
|
||||
pub posted_at: Option<i64>,
|
||||
pub groups: &'a [String],
|
||||
/// [part, message-id, bytes]
|
||||
pub segments: Vec<(u32, &'a str, i64)>,
|
||||
}
|
||||
|
||||
fn is_nyuu(id: &str, suffix: &str) -> bool {
|
||||
let b = id.as_bytes();
|
||||
if b.len() != 38 + suffix.len() || b[24] != b'-' || !id.ends_with(suffix) {
|
||||
return false;
|
||||
}
|
||||
(0..24).step_by(2).all(|i| b[i].is_ascii_uppercase() && b[i + 1].is_ascii_lowercase()) && b[25..38].iter().all(u8::is_ascii_digit)
|
||||
}
|
||||
|
||||
fn is_hex(id: &str, suffix: &str) -> bool {
|
||||
let b = id.as_bytes();
|
||||
b.len() == 32 + suffix.len() && id.ends_with(suffix) && b[..32].iter().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c))
|
||||
}
|
||||
|
||||
fn pack_letters(id: &[u8], out: &mut Vec<u8>) {
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits = 0;
|
||||
for i in (0..24).step_by(2) {
|
||||
acc = (acc << 10) | ((id[i] - b'A') as u32 * 26 + (id[i + 1] - b'a') as u32);
|
||||
bits += 10;
|
||||
while bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xff) as u8);
|
||||
}
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_val(c: u8) -> u8 {
|
||||
if c.is_ascii_digit() {
|
||||
c - b'0'
|
||||
} else {
|
||||
c - b'a' + 10
|
||||
}
|
||||
}
|
||||
|
||||
fn push_num(out: &mut String, n: i64) {
|
||||
use std::fmt::Write;
|
||||
write!(out, "{n}").unwrap();
|
||||
}
|
||||
|
||||
pub fn encode_chunk(files: &[ChunkFile]) -> Vec<u8> {
|
||||
let mut head = String::with_capacity(256 * files.len());
|
||||
let mut text: Vec<&str> = Vec::new();
|
||||
let mut raw: Vec<u8> = Vec::new();
|
||||
head.push('[');
|
||||
for (fi, f) in files.iter().enumerate() {
|
||||
let ids = &f.segments;
|
||||
let code = match ids.first() {
|
||||
None => 't',
|
||||
Some(&(_, first, _)) => {
|
||||
let nyuu = first.get(38..).filter(|s| s.starts_with('@'));
|
||||
let hex = first.get(32..).filter(|s| s.starts_with('@'));
|
||||
if nyuu.is_some_and(|suffix| ids.iter().all(|&(_, id, _)| is_nyuu(id, suffix))) {
|
||||
'n'
|
||||
} else if hex.is_some_and(|suffix| ids.iter().all(|&(_, id, _)| is_hex(id, suffix))) {
|
||||
'h'
|
||||
} else {
|
||||
't'
|
||||
}
|
||||
}
|
||||
};
|
||||
if fi > 0 {
|
||||
head.push(',');
|
||||
}
|
||||
head.push_str("{\"n\":");
|
||||
json_str(f.name, &mut head);
|
||||
head.push_str(",\"p\":");
|
||||
json_str(f.poster, &mut head);
|
||||
head.push_str(",\"j\":");
|
||||
json_str(f.subject, &mut head);
|
||||
head.push_str(",\"d\":");
|
||||
match f.posted_at {
|
||||
Some(t) => push_num(&mut head, t),
|
||||
None => head.push_str("null"),
|
||||
}
|
||||
head.push_str(",\"g\":[");
|
||||
for (i, g) in f.groups.iter().enumerate() {
|
||||
if i > 0 {
|
||||
head.push(',');
|
||||
}
|
||||
json_str(g, &mut head);
|
||||
}
|
||||
head.push_str("],\"s\":[");
|
||||
for (i, &(_, _, b)) in ids.iter().enumerate() {
|
||||
if i > 0 {
|
||||
head.push(',');
|
||||
}
|
||||
push_num(&mut head, b);
|
||||
}
|
||||
head.push(']');
|
||||
if !ids.iter().enumerate().all(|(k, &(n, _, _))| n as usize == k + 1) {
|
||||
head.push_str(",\"k\":[");
|
||||
for (i, &(n, _, _)) in ids.iter().enumerate() {
|
||||
if i > 0 {
|
||||
head.push(',');
|
||||
}
|
||||
push_num(&mut head, n as i64);
|
||||
}
|
||||
head.push(']');
|
||||
}
|
||||
match code {
|
||||
'n' => {
|
||||
head.push_str(",\"c\":\"n\",\"x\":");
|
||||
json_str(&ids[0].1[38..], &mut head);
|
||||
head.push_str(",\"t\":[");
|
||||
let mut previous = 0i64;
|
||||
for (i, &(_, id, _)) in ids.iter().enumerate() {
|
||||
pack_letters(id.as_bytes(), &mut raw);
|
||||
let t: i64 = id[25..38].parse().unwrap();
|
||||
if i > 0 {
|
||||
head.push(',');
|
||||
}
|
||||
push_num(&mut head, t - previous);
|
||||
previous = t;
|
||||
}
|
||||
head.push(']');
|
||||
}
|
||||
'h' => {
|
||||
head.push_str(",\"c\":\"h\",\"x\":");
|
||||
json_str(&ids[0].1[32..], &mut head);
|
||||
for &(_, id, _) in ids {
|
||||
let b = id.as_bytes();
|
||||
for i in (0..32).step_by(2) {
|
||||
raw.push(hex_val(b[i]) << 4 | hex_val(b[i + 1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => text.extend(ids.iter().map(|&(_, id, _)| id)),
|
||||
}
|
||||
head.push('}');
|
||||
}
|
||||
head.push(']');
|
||||
let mut plain = head.into_bytes();
|
||||
for id in text {
|
||||
plain.push(b'\n');
|
||||
plain.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
let packed = zstd::bulk::compress(&plain, 3).unwrap();
|
||||
let mut out = Vec::with_capacity(5 + packed.len() + raw.len());
|
||||
out.push(2);
|
||||
out.extend_from_slice(&(packed.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&packed);
|
||||
out.extend_from_slice(&raw);
|
||||
out
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
//! lib/edge/scan.ts (SegmentScanner, scanOverviewText), lib/files/aggregator.ts
|
||||
//! and lib/edge/range_indexer.ts (RangeIndexer, chunkOf, candidatesOf), in the
|
||||
//! same order of operations, so the outputs can be compared one to one.
|
||||
|
||||
use crate::codec::{encode_chunk, ChunkFile};
|
||||
use crate::js::{self, sha1_hex};
|
||||
use crate::names::{self, is_random_file, JudgedFile, Release, Rules, Verdict};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
pub static mut PROF: [u64; 8] = [0; 8];
|
||||
|
||||
pub struct FileMeta {
|
||||
pub file_key: String,
|
||||
pub name: String,
|
||||
pub poster: String,
|
||||
pub subject: String,
|
||||
pub total_parts: u32,
|
||||
pub groups: Vec<String>,
|
||||
pub release: Release,
|
||||
}
|
||||
|
||||
pub struct Seg {
|
||||
pub file: Rc<FileMeta>,
|
||||
pub number: u32,
|
||||
pub message_id: String,
|
||||
pub bytes: i64,
|
||||
pub article: i64,
|
||||
pub posted_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct ReleaseChunk {
|
||||
pub key: String,
|
||||
pub upload: usize,
|
||||
pub name: String,
|
||||
pub source: &'static str,
|
||||
pub file_total: Option<i64>,
|
||||
pub poster: String,
|
||||
pub groups: Vec<String>,
|
||||
pub posted_at: Option<i64>,
|
||||
pub posted_last: Option<i64>,
|
||||
pub files: usize,
|
||||
pub segments: usize,
|
||||
pub bytes: i64,
|
||||
pub first_article: i64,
|
||||
pub last_article: i64,
|
||||
pub readable: bool,
|
||||
pub has_par2: bool,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct Candidate {
|
||||
pub release_key: String,
|
||||
pub upload: usize,
|
||||
pub kind: &'static str,
|
||||
pub message_id: String,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
pub struct Fragment {
|
||||
pub file_key: String,
|
||||
pub name: String,
|
||||
pub poster: String,
|
||||
pub subject: String,
|
||||
pub total_parts: u32,
|
||||
pub groups: Vec<String>,
|
||||
pub release: Release,
|
||||
pub posted_at: Option<i64>,
|
||||
pub segments: Vec<(u32, String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Batch {
|
||||
pub from: i64,
|
||||
pub next: i64,
|
||||
pub headers: u64,
|
||||
pub releases: Vec<ReleaseChunk>,
|
||||
pub samples: Vec<(ReleaseChunk, &'static str, String)>,
|
||||
pub candidates: Vec<Candidate>,
|
||||
/// (rule, key) → (articles, files)
|
||||
pub counts: Vec<(&'static str, String, u64, u64)>,
|
||||
pub fragments: Vec<Fragment>,
|
||||
}
|
||||
|
||||
struct Counter {
|
||||
index: usize,
|
||||
length: usize,
|
||||
segment: i64,
|
||||
total: i64,
|
||||
}
|
||||
|
||||
/// The last "(k/N)" after `after`, scanning back from the end (findCounter).
|
||||
fn find_counter(subject: &[u8], after: usize) -> Option<Counter> {
|
||||
let mut open = subject.len();
|
||||
loop {
|
||||
// lastIndexOf('(', open - 1): at most open - 1.
|
||||
if open == 0 {
|
||||
return None;
|
||||
}
|
||||
open = subject[..open].iter().rposition(|&c| c == b'(')?;
|
||||
if open <= after {
|
||||
return None;
|
||||
}
|
||||
let mut i = open + 1;
|
||||
let mut segment: i64 = 0;
|
||||
let mut digits = 0;
|
||||
while let Some(c) = subject.get(i).filter(|c| c.is_ascii_digit()) {
|
||||
segment = segment * 10 + (c - b'0') as i64;
|
||||
digits += 1;
|
||||
i += 1;
|
||||
}
|
||||
if digits == 0 || subject.get(i) != Some(&b'/') {
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
let mut total: i64 = 0;
|
||||
digits = 0;
|
||||
while let Some(c) = subject.get(i).filter(|c| c.is_ascii_digit()) {
|
||||
total = total * 10 + (c - b'0') as i64;
|
||||
digits += 1;
|
||||
i += 1;
|
||||
}
|
||||
if digits == 0 || subject.get(i) != Some(&b')') {
|
||||
continue;
|
||||
}
|
||||
return Some(Counter { index: open, length: i + 1 - open, segment, total });
|
||||
}
|
||||
}
|
||||
|
||||
fn xref_groups(xref: &str, group: &str) -> Vec<String> {
|
||||
let mut groups = Vec::new();
|
||||
for token in js::split_spaces(js::trim(xref)).into_iter().skip(1) {
|
||||
if let Some(colon) = token.rfind(':') {
|
||||
if colon > 0 {
|
||||
groups.push(token[..colon].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if groups.is_empty() {
|
||||
groups.push(group.to_string());
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Scanner {
|
||||
files: FxHashMap<String, Option<Rc<FileMeta>>>,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl Scanner {
|
||||
fn owner<'a>(&self, xref: &'a str, group: &'a str, priorities: &FxHashMap<String, u32>) -> &'a str {
|
||||
let b = xref.as_bytes();
|
||||
let Some(mut at) = xref.find(' ') else { return group };
|
||||
let mut owner: Option<&str> = None;
|
||||
let mut best = u32::MAX;
|
||||
loop {
|
||||
while b.get(at) == Some(&b' ') {
|
||||
at += 1;
|
||||
}
|
||||
if at >= b.len() {
|
||||
break;
|
||||
}
|
||||
let end = xref[at..].find(' ').map(|i| i + at);
|
||||
// lastIndexOf(':', end ?? length): a colon at or before `end`.
|
||||
let limit = end.unwrap_or(b.len());
|
||||
let colon = xref[..limit.min(b.len())].rfind(':');
|
||||
if let Some(colon) = colon.filter(|&c| c > at) {
|
||||
let name = &xref[at..colon];
|
||||
if let Some(&p) = priorities.get(name) {
|
||||
if p < best {
|
||||
best = p;
|
||||
owner = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
match end {
|
||||
Some(e) => at = e,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
owner.unwrap_or(group)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn segment(&mut self, article: i64, subject: &str, from: &str, date: &str, message_id: &str, bytes: &str, xref: &str, group: &str) -> Option<Seg> {
|
||||
if subject.is_empty() || message_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sb = subject.as_bytes();
|
||||
let name_end = subject.rfind('"')?;
|
||||
if name_end == 0 {
|
||||
return None;
|
||||
}
|
||||
let counter = find_counter(sb, name_end)?;
|
||||
self.key.clear();
|
||||
self.key.push_str(from);
|
||||
self.key.push('\u{1}');
|
||||
self.key.push_str(&subject[..counter.index]);
|
||||
self.key.push('\u{0}');
|
||||
self.key.push_str(&subject[counter.index + counter.length..]);
|
||||
let file = match self.files.get(self.key.as_str()) {
|
||||
Some(f) => f.clone(),
|
||||
None => {
|
||||
let f = describe(subject, name_end, &counter, from, xref, group).map(Rc::new);
|
||||
self.files.insert(self.key.clone(), f.clone());
|
||||
f
|
||||
}
|
||||
};
|
||||
let file = file?;
|
||||
if counter.segment < 1 || counter.segment > file.total_parts as i64 {
|
||||
return None;
|
||||
}
|
||||
let id = if message_id.starts_with('<') { message_id.get(1..message_id.len().saturating_sub(1)).unwrap_or("") } else { message_id };
|
||||
Some(Seg {
|
||||
number: counter.segment as u32,
|
||||
message_id: id.to_string(),
|
||||
bytes: js::parse_int(bytes).unwrap_or(0),
|
||||
article,
|
||||
posted_at: js::parse_date(date),
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(subject: &str, name_end: usize, counter: &Counter, from: &str, xref: &str, group: &str) -> Option<FileMeta> {
|
||||
let name_start = subject[..name_end].rfind('"')?;
|
||||
let name = &subject[name_start + 1..name_end];
|
||||
if name.is_empty() || counter.total < 1 {
|
||||
return None;
|
||||
}
|
||||
let joined = format!("{}{}", &subject[..counter.index], &subject[counter.index + counter.length..]);
|
||||
let base = js::trim(&js::collapse_spaces(&joined)).to_string();
|
||||
let file_key = sha1_hex(&format!("{from}\n{base}\n{}", counter.total));
|
||||
let release = names::release_identity(from, &base, name);
|
||||
Some(FileMeta {
|
||||
file_key,
|
||||
name: name.to_string(),
|
||||
poster: from.to_string(),
|
||||
subject: base,
|
||||
total_parts: counter.total as u32,
|
||||
groups: xref_groups(xref, group),
|
||||
release,
|
||||
})
|
||||
}
|
||||
|
||||
/// One line of an XOVER reply: number, subject, from, date, message-id, bytes, xref (scanOverviewText).
|
||||
fn scan_overview_text(text: &str, mut line: impl FnMut(Option<i64>, &str, &str, &str, &str, &str, &str)) {
|
||||
let b = text.as_bytes();
|
||||
let mut start = 0;
|
||||
while start < b.len() {
|
||||
let end = memchr::memchr(b'\n', &b[start..]).map(|i| i + start).unwrap_or(b.len());
|
||||
let stop = if end > start && b[end - 1] == b'\r' { end - 1 } else { end };
|
||||
if stop > start {
|
||||
let l = &text[start..stop];
|
||||
let lb = l.as_bytes();
|
||||
let mut tabs = [usize::MAX; 9];
|
||||
let mut n = 0;
|
||||
for i in memchr::memchr_iter(b'\t', lb) {
|
||||
tabs[n] = i;
|
||||
n += 1;
|
||||
if n == 9 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
let field = |a: usize, b: usize| -> &str {
|
||||
if a == usize::MAX {
|
||||
""
|
||||
} else {
|
||||
&l[a + 1..if b == usize::MAX { l.len() } else { b }]
|
||||
}
|
||||
};
|
||||
let mut xref = if n >= 8 { field(tabs[7], tabs[8]) } else { "" };
|
||||
if let Some(rest) = xref.strip_prefix("Xref:") {
|
||||
xref = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
}
|
||||
line(js::parse_int(&l[..tabs[0]]), &l[tabs[0] + 1..tabs[1]], field(tabs[1], tabs[2]), field(tabs[2], tabs[3]), field(tabs[3], tabs[4]), field(tabs[5], tabs[6]), xref);
|
||||
}
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// A re-upload's copy of a file is kept apart (FileAggregator, uploadGapMs).
|
||||
pub const UPLOAD_GAP_MS: i64 = 3 * 60 * 60 * 1000;
|
||||
|
||||
struct OpenFile {
|
||||
parts: Vec<Seg>,
|
||||
seen: FxHashSet<u32>,
|
||||
total: u32,
|
||||
touched: u64,
|
||||
/// Posting times seen.
|
||||
earliest: Option<i64>,
|
||||
latest: Option<i64>,
|
||||
}
|
||||
|
||||
impl OpenFile {
|
||||
fn distance(&self, t: Option<i64>) -> i64 {
|
||||
match (t, self.earliest, self.latest) {
|
||||
(Some(t), Some(e), Some(l)) => 0.max(e - t).max(t - l),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aggregator {
|
||||
/// Per file key its open copies, oldest first.
|
||||
open: FxHashMap<String, Vec<OpenFile>>,
|
||||
batch: u64,
|
||||
}
|
||||
|
||||
impl Aggregator {
|
||||
fn add(&mut self, segments: Vec<Seg>) -> Vec<Vec<Seg>> {
|
||||
self.batch += 1;
|
||||
let batch = self.batch;
|
||||
let mut touched: Vec<String> = Vec::new();
|
||||
let mut touched_set: FxHashSet<String> = FxHashSet::default();
|
||||
for s in segments {
|
||||
let key = s.file.file_key.clone();
|
||||
if !touched_set.contains(key.as_str()) {
|
||||
touched_set.insert(key.clone());
|
||||
touched.push(key.clone());
|
||||
}
|
||||
let copies = self.open.entry(key).or_default();
|
||||
let t = s.posted_at;
|
||||
// A copy lacking the part, the nearest in time.
|
||||
let mut chosen: Option<usize> = None;
|
||||
let mut best = i64::MAX;
|
||||
for (i, copy) in copies.iter().enumerate() {
|
||||
if copy.seen.contains(&s.number) {
|
||||
continue;
|
||||
}
|
||||
let d = copy.distance(t);
|
||||
if d < best {
|
||||
best = d;
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
let index = match chosen {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if let Some(near) = copies.iter_mut().find(|c| c.distance(t) <= UPLOAD_GAP_MS) {
|
||||
near.touched = batch;
|
||||
continue;
|
||||
}
|
||||
copies.push(OpenFile { parts: Vec::new(), seen: FxHashSet::default(), total: s.file.total_parts, touched: batch, earliest: None, latest: None });
|
||||
copies.len() - 1
|
||||
}
|
||||
};
|
||||
let file = &mut copies[index];
|
||||
file.seen.insert(s.number);
|
||||
if let Some(t) = t {
|
||||
if file.earliest.is_none_or(|e| t < e) {
|
||||
file.earliest = Some(t);
|
||||
}
|
||||
if file.latest.is_none_or(|l| t > l) {
|
||||
file.latest = Some(t);
|
||||
}
|
||||
}
|
||||
file.touched = batch;
|
||||
file.parts.push(s);
|
||||
}
|
||||
let mut complete = Vec::new();
|
||||
for key in touched {
|
||||
let copies = self.open.get_mut(&key).unwrap();
|
||||
let mut i = 0;
|
||||
while i < copies.len() {
|
||||
if (copies[i].parts.len() as u32) < copies[i].total {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut f = copies.remove(i);
|
||||
f.parts.sort_by_key(|s| s.number);
|
||||
complete.push(f.parts);
|
||||
}
|
||||
if copies.is_empty() {
|
||||
self.open.remove(&key);
|
||||
}
|
||||
}
|
||||
complete
|
||||
}
|
||||
|
||||
/// Files idle for `idle` batches (all when 0): their segments, per file key (its copies one after another).
|
||||
fn evict(&mut self, idle: u64) -> Vec<Vec<Seg>> {
|
||||
let batch = self.batch;
|
||||
let mut out = Vec::new();
|
||||
let keys: Vec<String> = self.open.keys().cloned().collect();
|
||||
for key in keys {
|
||||
let copies = self.open.get_mut(&key).unwrap();
|
||||
let mut gone: Vec<Seg> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < copies.len() {
|
||||
if idle > 0 && batch - copies[i].touched < idle {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
gone.extend(copies.remove(i).parts);
|
||||
}
|
||||
if copies.is_empty() {
|
||||
self.open.remove(&key);
|
||||
}
|
||||
if !gone.is_empty() {
|
||||
out.push(gone);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// uploadsOf: a key's files split where posting pauses for more than UPLOAD_GAP_MS after the latest article so far.
|
||||
fn uploads_of(files: Vec<Vec<Seg>>) -> Vec<Vec<Vec<Seg>>> {
|
||||
if files.len() < 2 {
|
||||
return vec![files];
|
||||
}
|
||||
let first: Vec<Option<i64>> = files.iter().map(|f| posted_of(f)).collect();
|
||||
let last: Vec<Option<i64>> = files.iter().map(|f| f.iter().filter_map(|s| s.posted_at).max()).collect();
|
||||
let mut dated: Vec<usize> = (0..files.len()).filter(|&i| first[i].is_some()).collect();
|
||||
if dated.len() < 2 {
|
||||
return vec![files];
|
||||
}
|
||||
dated.sort_by_key(|&i| (first[i].unwrap(), i));
|
||||
let mut upload = vec![0usize; files.len()];
|
||||
let mut current = 0;
|
||||
let mut end = last[dated[0]].unwrap();
|
||||
for &i in &dated[1..] {
|
||||
if first[i].unwrap() - end > UPLOAD_GAP_MS {
|
||||
current += 1;
|
||||
end = last[i].unwrap();
|
||||
} else if last[i].unwrap() > end {
|
||||
end = last[i].unwrap();
|
||||
}
|
||||
upload[i] = current;
|
||||
}
|
||||
if current == 0 {
|
||||
return vec![files];
|
||||
}
|
||||
let mut out: Vec<Vec<Vec<Seg>>> = (0..=current).map(|_| Vec::new()).collect();
|
||||
for (i, f) in files.into_iter().enumerate() {
|
||||
out[upload[i]].push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct RangeIndexer {
|
||||
group: String,
|
||||
rules: Rules,
|
||||
start: i64,
|
||||
end: i64,
|
||||
priorities: FxHashMap<String, u32>,
|
||||
idle: u64,
|
||||
chunk_articles: i64,
|
||||
scanner: Scanner,
|
||||
aggregator: Aggregator,
|
||||
batch: Batch,
|
||||
counts: FxHashMap<(&'static str, String), (u64, u64)>,
|
||||
count_order: Vec<(&'static str, String)>,
|
||||
completed: FxHashMap<String, Vec<Vec<Seg>>>,
|
||||
completed_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl RangeIndexer {
|
||||
/// `resume_at`: where this worker starts (a range taken over midway); its first batch starts there.
|
||||
pub fn new(group: &str, start: i64, end: i64, resume_at: i64, rules: Rules, idle: u64) -> Self {
|
||||
RangeIndexer {
|
||||
group: group.to_string(),
|
||||
start,
|
||||
end,
|
||||
priorities: rules.priorities.clone(),
|
||||
rules,
|
||||
idle,
|
||||
chunk_articles: 0,
|
||||
scanner: Scanner::default(),
|
||||
aggregator: Aggregator::default(),
|
||||
batch: Batch { from: resume_at, next: resume_at, ..Default::default() },
|
||||
counts: FxHashMap::default(),
|
||||
count_order: Vec::new(),
|
||||
completed: FxHashMap::default(),
|
||||
completed_order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_text(&mut self, text: &str, next: i64) {
|
||||
let mut segments: Vec<Seg> = Vec::new();
|
||||
let mut crossposts: Vec<String> = Vec::new();
|
||||
let mut obfuscated = 0u64;
|
||||
let mut headers = 0u64;
|
||||
{
|
||||
let group = self.group.clone();
|
||||
let scanner = &mut self.scanner;
|
||||
let priorities = &self.priorities;
|
||||
scan_overview_text(text, |n, subject, from, date, message_id, bytes, xref| {
|
||||
let Some(n) = n else { return };
|
||||
if message_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
headers += 1;
|
||||
let owner = if xref.is_empty() { group.as_str() } else { scanner.owner(xref, &group, priorities) };
|
||||
if owner != group {
|
||||
crossposts.push(owner.to_string());
|
||||
return;
|
||||
}
|
||||
match scanner.segment(n, subject, from, date, message_id, bytes, xref, &group) {
|
||||
Some(s) => segments.push(s),
|
||||
None => obfuscated += 1,
|
||||
}
|
||||
});
|
||||
}
|
||||
self.batch.headers += headers;
|
||||
for owner in crossposts {
|
||||
self.count("crosspost", owner, 1, 0);
|
||||
}
|
||||
if obfuscated > 0 {
|
||||
self.count("obfuscated", String::new(), obfuscated, 0);
|
||||
}
|
||||
self.end_chunk(segments, next);
|
||||
}
|
||||
|
||||
fn end_chunk(&mut self, segments: Vec<Seg>, next: i64) {
|
||||
let t = std::time::Instant::now();
|
||||
let done = self.aggregator.add(segments);
|
||||
unsafe { PROF[3] += t.elapsed().as_nanos() as u64; }
|
||||
self.files(done);
|
||||
if self.chunk_articles == 0 {
|
||||
self.chunk_articles = next - self.batch.from;
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let gone = self.aggregator.evict(self.idle);
|
||||
self.cut_off(gone, false);
|
||||
unsafe { PROF[4] += t.elapsed().as_nanos() as u64; }
|
||||
self.batch.next = next;
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) {
|
||||
let gone = self.aggregator.evict(0);
|
||||
self.cut_off(gone, true);
|
||||
self.batch.next = self.end + 1;
|
||||
}
|
||||
|
||||
/// Files completed and fragments cut since the last take (RangeIndexer.pending).
|
||||
pub fn pending(&self) -> usize {
|
||||
self.batch.fragments.len() + self.completed.values().map(|l| l.len()).sum::<usize>()
|
||||
}
|
||||
|
||||
pub fn take(&mut self) -> Batch {
|
||||
let t = std::time::Instant::now();
|
||||
self.judge_releases();
|
||||
unsafe { PROF[5] += t.elapsed().as_nanos() as u64; }
|
||||
self.scanner.files.clear();
|
||||
let next = self.batch.next;
|
||||
let mut out = std::mem::replace(&mut self.batch, Batch { from: next, next, ..Default::default() });
|
||||
for id in self.count_order.drain(..) {
|
||||
let (articles, files) = self.counts.remove(&id).unwrap();
|
||||
out.counts.push((id.0, id.1, articles, files));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn files(&mut self, files: Vec<Vec<Seg>>) {
|
||||
for file in files {
|
||||
let key = release_key_of(&file[0]);
|
||||
match self.completed.get_mut(&key) {
|
||||
Some(list) => list.push(file),
|
||||
None => {
|
||||
self.completed_order.push(key.clone());
|
||||
self.completed.insert(key, vec![file]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn judge_releases(&mut self) {
|
||||
let order = std::mem::take(&mut self.completed_order);
|
||||
let mut by_release = std::mem::take(&mut self.completed);
|
||||
for key in order {
|
||||
for (upload, list) in uploads_of(by_release.remove(&key).unwrap()).into_iter().enumerate() {
|
||||
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 })
|
||||
.collect();
|
||||
match names::judge(&key, &judged, &self.rules) {
|
||||
Verdict::Keep => {
|
||||
let candidates = candidates_of(&key, &list, upload);
|
||||
self.batch.releases.push(chunk_of(&key, &list, upload));
|
||||
self.batch.candidates.extend(candidates);
|
||||
}
|
||||
Verdict::Drop { rule, key: rule_key, sample } => {
|
||||
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, upload), rule, rule_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cut_off(&mut self, files: Vec<Vec<Seg>>, at_end: bool) {
|
||||
let window = self.idle as i64 * self.chunk_articles.max(1);
|
||||
for list in files {
|
||||
if list.len() == 1 && is_random_file(&list[0].file.name) {
|
||||
let tool = names::upload_tool(&list[0].message_id);
|
||||
self.count("per-article", tool.to_string(), 1, 0);
|
||||
continue;
|
||||
}
|
||||
let min_part = list.iter().map(|s| s.number).min().unwrap();
|
||||
let max_part = list.iter().map(|s| s.number).max().unwrap();
|
||||
let first_article = list.iter().map(|s| s.article).min().unwrap();
|
||||
let cut_at_start = min_part > 1 && first_article < self.start + window;
|
||||
let cut_at_end = at_end && max_part < list[0].file.total_parts;
|
||||
if !cut_at_start && !cut_at_end {
|
||||
self.count("incomplete", String::new(), list.len() as u64, 1);
|
||||
continue;
|
||||
}
|
||||
let f = &list[0].file;
|
||||
let mut fragment = Fragment {
|
||||
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: f.release.clone(),
|
||||
posted_at: None,
|
||||
segments: Vec::with_capacity(list.len()),
|
||||
};
|
||||
for s in &list {
|
||||
fragment.segments.push((s.number, s.message_id.clone(), s.bytes, s.article));
|
||||
if let Some(t) = s.posted_at {
|
||||
if fragment.posted_at.is_none_or(|p| t < p) {
|
||||
fragment.posted_at = Some(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.batch.fragments.push(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
fn count(&mut self, rule: &'static str, key: String, articles: u64, files: u64) {
|
||||
match self.counts.get_mut(&(rule, key.clone())) {
|
||||
Some(c) => {
|
||||
c.0 += articles;
|
||||
c.1 += files;
|
||||
}
|
||||
None => {
|
||||
self.count_order.push((rule, key.clone()));
|
||||
self.counts.insert((rule, key), (articles, files));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn posted_of(segments: &[Seg]) -> Option<i64> {
|
||||
segments.iter().filter_map(|s| s.posted_at).min()
|
||||
}
|
||||
|
||||
fn chunk_of(key: &str, files: &[Vec<Seg>], upload: usize) -> ReleaseChunk {
|
||||
let first = &files[0][0].file;
|
||||
let chunk_files: Vec<ChunkFile> = files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let m = &f[0].file;
|
||||
ChunkFile { name: &m.name, poster: &m.poster, subject: &m.subject, posted_at: posted_of(f), groups: &m.groups, segments: f.iter().map(|s| (s.number, s.message_id.as_str(), s.bytes)).collect() }
|
||||
})
|
||||
.collect();
|
||||
let mut segments = 0;
|
||||
let mut bytes = 0;
|
||||
let mut first_article = i64::MAX;
|
||||
let mut last_article = 0;
|
||||
let mut posted_at: Option<i64> = None;
|
||||
let mut posted_last: Option<i64> = None;
|
||||
for f in files {
|
||||
for s in f {
|
||||
if let Some(t) = s.posted_at {
|
||||
if posted_last.is_none_or(|p| t > p) {
|
||||
posted_last = Some(t);
|
||||
}
|
||||
}
|
||||
segments += 1;
|
||||
bytes += s.bytes;
|
||||
first_article = first_article.min(s.article);
|
||||
last_article = last_article.max(s.article);
|
||||
}
|
||||
if let Some(t) = posted_of(f) {
|
||||
if posted_at.is_none_or(|p| t < p) {
|
||||
posted_at = Some(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
ReleaseChunk {
|
||||
key: key.to_string(),
|
||||
upload,
|
||||
name: first.release.name.clone(),
|
||||
source: first.release.source,
|
||||
file_total: files.iter().find_map(|f| f[0].file.release.file_total_truthy()),
|
||||
poster: first.poster.clone(),
|
||||
groups: first.groups.clone(),
|
||||
posted_at,
|
||||
posted_last,
|
||||
files: files.len(),
|
||||
segments,
|
||||
bytes,
|
||||
first_article,
|
||||
last_article,
|
||||
readable: chunk_files.iter().any(|f| !is_random_file(f.name)),
|
||||
has_par2: chunk_files.iter().any(|f| names::is_par2(f.name)),
|
||||
payload: encode_chunk(&chunk_files),
|
||||
}
|
||||
}
|
||||
|
||||
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() };
|
||||
for f in files {
|
||||
let name = &f[0].file.name;
|
||||
if let Some(volume) = names::seven_zip_volume(name).filter(|&v| v > 1) {
|
||||
if tail.is_none_or(|(v, _)| volume > v) {
|
||||
tail = Some((volume, f));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if names::is_archive_start(name) {
|
||||
out.push(c("archive", &f[0]));
|
||||
} else if names::is_par2(name) && !names::is_later_volume(name) {
|
||||
out.push(c("par2", &f[0]));
|
||||
}
|
||||
}
|
||||
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() });
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GROUP: &str = "alt.binaries.test";
|
||||
|
||||
fn line(n: i64, subject: &str, minutes: i64) -> String {
|
||||
let date = chrono::DateTime::from_timestamp_millis(1_791_194_400_000 + minutes * 60_000).unwrap().format("%a, %d %b %Y %H:%M:%S +0000");
|
||||
format!("{n}\t{subject}\tp <p@x>\t{date}\t<u{n}@test>\t\t1000\t10\tXref: news.example {GROUP}:{n}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploads_split_as_in_the_typescript() {
|
||||
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 mut lines: Vec<String> = (0..5).map(|i| line(100 + i, &format!(r#"Big.Release [1/2] - "Big.Release.mkv" yEnc ({}/5)"#, i + 1), i * 60)).collect();
|
||||
lines.push(line(200, r#"Big.Release [2/2] - "Big.Release.par2" yEnc (1/1)"#, 245));
|
||||
lines.extend((0..5).map(|i| line(300 + i, &format!(r#"Big.Release [1/2] - "Big.Release.mkv" yEnc ({}/5)"#, i + 1), 1440 + i)));
|
||||
ix.add_text(&lines.join("\r\n"), 10_001);
|
||||
ix.finish();
|
||||
let batch = ix.take();
|
||||
let got: Vec<(usize, usize)> = batch.releases.iter().map(|r| (r.upload, r.files)).collect();
|
||||
assert_eq!(got, vec![(0, 2), (1, 1)]);
|
||||
assert_eq!(batch.releases[0].posted_last.unwrap() - batch.releases[0].posted_at.unwrap(), 245 * 60_000);
|
||||
assert_eq!(batch.releases[0].key, batch.releases[1].key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_and_fills_as_in_the_typescript() {
|
||||
let hour = 3_600_000i64;
|
||||
let meta = Rc::new(FileMeta {
|
||||
file_key: "a".into(),
|
||||
name: "a.bin".into(),
|
||||
poster: "p".into(),
|
||||
subject: "s".into(),
|
||||
total_parts: 3,
|
||||
groups: vec![],
|
||||
release: names::release_identity("p", "s", "a.bin"),
|
||||
});
|
||||
let at = |number: u32, h: f64, id: &str| Seg { file: meta.clone(), number, message_id: id.into(), bytes: 100, article: 0, posted_at: Some((h * hour as f64) as i64) };
|
||||
let mut agg = Aggregator::default();
|
||||
assert!(agg.add(vec![at(1, 10.0, "a1"), at(2, 10.0, "a2"), at(1, 10.1, "resend")]).is_empty());
|
||||
assert!(agg.add(vec![at(1, 34.0, "b1"), at(2, 34.0, "b2")]).is_empty());
|
||||
assert_eq!(agg.open.get("a").unwrap().len(), 2);
|
||||
let second = agg.add(vec![at(3, 34.2, "b3")]);
|
||||
assert_eq!(second[0].iter().map(|s| s.message_id.as_str()).collect::<Vec<_>>(), vec!["b1", "b2", "b3"]);
|
||||
let first = agg.add(vec![at(3, 58.0, "a3")]);
|
||||
assert_eq!(first[0].iter().map(|s| s.message_id.as_str()).collect::<Vec<_>>(), vec!["a1", "a2", "a3"]);
|
||||
assert!(agg.open.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
//! JavaScript semantics the TypeScript worker relies on: `\s` and `trim()`,
|
||||
//! UTF-16 lengths, `parseInt`, `Date.parse` (for Usenet dates) and sha1 hex.
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// JS `\s` / `trim()` whitespace (WhiteSpace and LineTerminator).
|
||||
pub fn is_space(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}'
|
||||
)
|
||||
}
|
||||
|
||||
pub fn trim(s: &str) -> &str {
|
||||
s.trim_matches(is_space)
|
||||
}
|
||||
|
||||
/// `s.replace(/\s+/g, ' ')`.
|
||||
pub fn collapse_spaces(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut in_space = false;
|
||||
for c in s.chars() {
|
||||
if is_space(c) {
|
||||
if !in_space {
|
||||
out.push(' ');
|
||||
in_space = true;
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `s.split(/\s+/)`: like JS, a leading or trailing run gives an empty word.
|
||||
pub fn split_spaces(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut in_space = false;
|
||||
let mut run_start = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
if is_space(c) {
|
||||
if !in_space {
|
||||
run_start = i;
|
||||
in_space = true;
|
||||
}
|
||||
} else if in_space {
|
||||
out.push(&s[start..run_start]);
|
||||
start = i;
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
if in_space {
|
||||
out.push(&s[start..run_start]);
|
||||
out.push("");
|
||||
} else {
|
||||
out.push(&s[start..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A string's `.length` in JavaScript (UTF-16 code units).
|
||||
pub fn len16(s: &str) -> usize {
|
||||
if s.is_ascii() {
|
||||
s.len()
|
||||
} else {
|
||||
s.encode_utf16().count()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Number.parseInt(s, 10)`, None for NaN.
|
||||
pub fn parse_int(s: &str) -> Option<i64> {
|
||||
let s = s.trim_start_matches(is_space);
|
||||
let (neg, digits) = match s.as_bytes().first() {
|
||||
Some(b'-') => (true, &s[1..]),
|
||||
Some(b'+') => (false, &s[1..]),
|
||||
_ => (false, s),
|
||||
};
|
||||
let mut n: i64 = 0;
|
||||
let mut any = false;
|
||||
for b in digits.bytes() {
|
||||
if !b.is_ascii_digit() {
|
||||
break;
|
||||
}
|
||||
n = n.wrapping_mul(10).wrapping_add((b - b'0') as i64);
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
Some(if neg { -n } else { n })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 of a proleptic Gregorian date (month 1..=12; a day past the month's end runs on, as in JS).
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// The usual Date headers: [Day, ]D[D] Mon YY[YY] HH:MM:SS (GMT|UTC|UT|±hhmm). None for anything else.
|
||||
fn fast_date(s: &[u8]) -> Option<i64> {
|
||||
let mut i = 0;
|
||||
if s.len() > 4 && s[3] == b',' && s[..3].iter().all(u8::is_ascii_alphabetic) {
|
||||
i = 4;
|
||||
while s.get(i) == Some(&b' ') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let num = |i: &mut usize, min: usize, max: usize| -> Option<i64> {
|
||||
let start = *i;
|
||||
while *i < s.len() && s[*i].is_ascii_digit() && *i - start < max {
|
||||
*i += 1;
|
||||
}
|
||||
if *i - start < min || s.get(*i).is_some_and(u8::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
Some(std::str::from_utf8(&s[start..*i]).ok()?.parse().ok()?)
|
||||
};
|
||||
let expect = |i: &mut usize, c: u8| -> Option<()> {
|
||||
(s.get(*i) == Some(&c)).then(|| *i += 1)
|
||||
};
|
||||
let day = num(&mut i, 1, 2)?;
|
||||
expect(&mut i, b' ')?;
|
||||
const MONTHS: [&[u8]; 12] = [b"jan", b"feb", b"mar", b"apr", b"may", b"jun", b"jul", b"aug", b"sep", b"oct", b"nov", b"dec"];
|
||||
let mon = s.get(i..i + 3)?;
|
||||
let month = MONTHS.iter().position(|m| mon.iter().zip(m.iter()).all(|(a, b)| a.to_ascii_lowercase() == *b))? as i64 + 1;
|
||||
i += 3;
|
||||
expect(&mut i, b' ')?;
|
||||
let year_start = i;
|
||||
let mut year = num(&mut i, 2, 4)?;
|
||||
match i - year_start {
|
||||
2 => year += if year < 50 { 2000 } else { 1900 },
|
||||
4 => {}
|
||||
_ => return None,
|
||||
}
|
||||
expect(&mut i, b' ')?;
|
||||
let hour = num(&mut i, 2, 2)?;
|
||||
expect(&mut i, b':')?;
|
||||
let minute = num(&mut i, 2, 2)?;
|
||||
expect(&mut i, b':')?;
|
||||
let second = num(&mut i, 2, 2)?;
|
||||
expect(&mut i, b' ')?;
|
||||
if day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59 {
|
||||
return None;
|
||||
}
|
||||
let offset_minutes = match &s[i..] {
|
||||
b"GMT" | b"UTC" | b"UT" => 0,
|
||||
[sign @ (b'+' | b'-'), h1, h2, m1, m2] if [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()) => {
|
||||
let minutes = ((h1 - b'0') as i64 * 10 + (h2 - b'0') as i64) * 60 + (m1 - b'0') as i64 * 10 + (m2 - b'0') as i64;
|
||||
if *sign == b'+' { minutes } else { -minutes }
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let days = days_from_civil(year, month, day);
|
||||
Some((((days * 24 + hour) * 60 + minute - offset_minutes) * 60 + second) * 1000)
|
||||
}
|
||||
|
||||
/// `Date.parse` for the Date header (RFC 5322 and the obsolete forms); None for NaN.
|
||||
pub fn parse_date(s: &str) -> Option<i64> {
|
||||
let s = trim(s);
|
||||
if let Some(t) = fast_date(s.as_bytes()) {
|
||||
return Some(t);
|
||||
}
|
||||
parse_date_chrono(s)
|
||||
}
|
||||
|
||||
fn parse_date_chrono(s: &str) -> Option<i64> {
|
||||
// "UTC" isn't an RFC 5322 zone (UT and GMT are), but Date.parse takes it (~a quarter of a.b.boneless).
|
||||
let owned;
|
||||
let s = match s.strip_suffix(" UTC") {
|
||||
Some(rest) => {
|
||||
owned = format!("{rest} GMT");
|
||||
owned.as_str()
|
||||
}
|
||||
None => s,
|
||||
};
|
||||
if let Ok(d) = chrono::DateTime::parse_from_rfc2822(s) {
|
||||
return Some(d.timestamp_millis());
|
||||
}
|
||||
// "Mon, 5 Oct 2026 10:00:00 +0000 (UTC)" and other trailing comments.
|
||||
if let Some(i) = s.find(" (") {
|
||||
if let Ok(d) = chrono::DateTime::parse_from_rfc2822(&s[..i]) {
|
||||
return Some(d.timestamp_millis());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// sha1 of the UTF-8 text, as lowercase hex (node's `hash('sha1', text)`).
|
||||
pub fn sha1_hex(text: &str) -> String {
|
||||
let digest = Sha1::digest(text.as_bytes());
|
||||
let mut out = String::with_capacity(40);
|
||||
for b in digest {
|
||||
out.push(char::from_digit((b >> 4) as u32, 16).unwrap());
|
||||
out.push(char::from_digit((b & 15) as u32, 16).unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// JSON.stringify of a string.
|
||||
pub fn json_str(s: &str, out: &mut String) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0C}' => out.push_str("\\f"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_like_js() {
|
||||
assert_eq!(split_spaces("a b"), vec!["a", "b"]);
|
||||
assert_eq!(split_spaces(" a b "), vec!["", "a", "b", ""]);
|
||||
assert_eq!(split_spaces(""), vec![""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_int_like_js() {
|
||||
assert_eq!(parse_int("123abc"), Some(123));
|
||||
assert_eq!(parse_int(" -5"), Some(-5));
|
||||
assert_eq!(parse_int("x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates() {
|
||||
assert_eq!(parse_date("Mon, 05 Oct 2026 10:00:00 +0000"), Some(1_791_194_400_000));
|
||||
assert_eq!(parse_date("5 Oct 2026 10:00:00 GMT"), Some(1_791_194_400_000));
|
||||
assert_eq!(parse_date("Mon, 05 Oct 26 10:00:00 UTC"), Some(1_791_194_400_000));
|
||||
for d in [
|
||||
"Mon, 05 Oct 2026 10:00:00 +0000", "Mon, 5 Oct 2026 12:00:00 +0200", "05 Oct 2026 10:00:00 GMT", "Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
"Sat, 29 Feb 2020 23:59:59 -0130", "Fri, 31 Dec 99 00:00:00 UTC", "Mon, 05 Oct 2026 10:00:00 UT",
|
||||
] {
|
||||
assert_eq!(fast_date(d.as_bytes()), parse_date_chrono(d), "{d}");
|
||||
}
|
||||
assert_eq!(fast_date(b"Mon, 05 Oct 2026 10:00:00 EST"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//! The edge worker's indexing (lib/edge RangeIndexer and what it uses) in
|
||||
//! Rust, kept identical to the TypeScript (check.ts): the edge-bench-rs
|
||||
//! benchmark and the edge-node worker share it.
|
||||
|
||||
pub mod codec;
|
||||
pub mod indexer;
|
||||
pub mod js;
|
||||
pub mod names;
|
||||
pub mod wire;
|
||||
@@ -1,243 +0,0 @@
|
||||
//! 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 tools/edge-bench-rs/check.ts.
|
||||
//!
|
||||
//! edge-bench-rs <dir> [--batch-chunks 10] [--idle 5] [--dump out.jsonl] [--wire batches.jsonl]
|
||||
|
||||
use base64::Engine;
|
||||
use edge_bench_rs::indexer::{self, Batch, RangeIndexer};
|
||||
use edge_bench_rs::js::json_str;
|
||||
use edge_bench_rs::{names, wire};
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
// Many small strings (message-ids, names): the system allocator was a measurable share.
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
/// A chunk as the news server sent it (zlib; gzip also taken), whole: libdeflate wants the output
|
||||
/// size, so it grows until the text fits (~3.6 times the compressed size on boneless).
|
||||
fn inflate(raw: &[u8], d: &mut libdeflater::Decompressor) -> Vec<u8> {
|
||||
let mut out = vec![0u8; raw.len() * 4 + 4096];
|
||||
loop {
|
||||
let result = if raw.starts_with(&[0x1f, 0x8b]) { d.gzip_decompress(raw, &mut out) } else { d.zlib_decompress(raw, &mut out) };
|
||||
match result {
|
||||
Ok(n) => {
|
||||
out.truncate(n);
|
||||
return out;
|
||||
}
|
||||
Err(libdeflater::DecompressionError::InsufficientSpace) => out.resize(out.len() * 2, 0),
|
||||
Err(e) => panic!("inflate: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_field(json: &str, name: &str) -> String {
|
||||
let at = json.find(&format!("\"{name}\":")).unwrap() + name.len() + 3;
|
||||
let rest = json[at..].trim_start();
|
||||
if let Some(s) = rest.strip_prefix('"') {
|
||||
s[..s.find('"').unwrap()].to_string()
|
||||
} else {
|
||||
rest[..rest.find([',', '}']).unwrap()].trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn peak_rss_mb() -> u64 {
|
||||
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
||||
status.lines().find(|l| l.starts_with("VmHWM:")).and_then(|l| l.split_whitespace().nth(1)).and_then(|k| k.parse::<u64>().ok()).unwrap_or(0) / 1024
|
||||
}
|
||||
|
||||
fn opt_num(n: Option<i64>, out: &mut String) {
|
||||
match n {
|
||||
Some(n) => out.push_str(&n.to_string()),
|
||||
None => out.push_str("null"),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_batch(batch: &Batch, out: &mut impl Write) {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let mut line = String::new();
|
||||
let mut emit = |line: &mut String| {
|
||||
line.push('\n');
|
||||
out.write_all(line.as_bytes()).unwrap();
|
||||
line.clear();
|
||||
};
|
||||
line.push_str(&format!("{{\"t\":\"b\",\"from\":{},\"next\":{},\"headers\":{}}}", batch.from, batch.next, batch.headers));
|
||||
emit(&mut line);
|
||||
let release = |r: &indexer::ReleaseChunk, kind: &str, line: &mut String| {
|
||||
line.push_str(&format!("{{\"t\":\"{kind}\",\"upload\":{},\"key\":", r.upload));
|
||||
json_str(&r.key, line);
|
||||
line.push_str(",\"name\":");
|
||||
json_str(&r.name, line);
|
||||
line.push_str(&format!(",\"source\":\"{}\",\"fileTotal\":", r.source));
|
||||
opt_num(r.file_total, line);
|
||||
line.push_str(",\"poster\":");
|
||||
json_str(&r.poster, line);
|
||||
line.push_str(",\"groups\":[");
|
||||
for (i, g) in r.groups.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(',');
|
||||
}
|
||||
json_str(g, line);
|
||||
}
|
||||
line.push_str("],\"postedAt\":");
|
||||
opt_num(r.posted_at, line);
|
||||
line.push_str(",\"postedLast\":");
|
||||
opt_num(r.posted_last, line);
|
||||
line.push_str(&format!(
|
||||
",\"files\":{},\"segments\":{},\"bytes\":{},\"firstArticle\":{},\"lastArticle\":{},\"readable\":{},\"hasPar2\":{},\"payload\":\"{}\"",
|
||||
r.files,
|
||||
r.segments,
|
||||
r.bytes,
|
||||
r.first_article,
|
||||
r.last_article,
|
||||
r.readable,
|
||||
r.has_par2,
|
||||
b64.encode(&r.payload)
|
||||
));
|
||||
};
|
||||
for r in &batch.releases {
|
||||
release(r, "r", &mut line);
|
||||
line.push('}');
|
||||
emit(&mut line);
|
||||
}
|
||||
for (r, rule, key) in &batch.samples {
|
||||
release(r, "s", &mut line);
|
||||
line.push_str(&format!(",\"rule\":\"{rule}\",\"ruleKey\":"));
|
||||
json_str(key, &mut line);
|
||||
line.push('}');
|
||||
emit(&mut line);
|
||||
}
|
||||
for c in &batch.candidates {
|
||||
line.push_str(&format!("{{\"t\":\"c\",\"upload\":{},\"releaseKey\":", c.upload));
|
||||
json_str(&c.release_key, &mut line);
|
||||
line.push_str(&format!(",\"kind\":\"{}\",\"messageId\":", c.kind));
|
||||
json_str(&c.message_id, &mut line);
|
||||
line.push_str(",\"file\":");
|
||||
json_str(&c.file, &mut line);
|
||||
line.push('}');
|
||||
emit(&mut line);
|
||||
}
|
||||
for (rule, key, articles, files) in &batch.counts {
|
||||
line.push_str(&format!("{{\"t\":\"n\",\"rule\":\"{rule}\",\"key\":"));
|
||||
json_str(key, &mut line);
|
||||
line.push_str(&format!(",\"articles\":{articles},\"files\":{files}}}"));
|
||||
emit(&mut line);
|
||||
}
|
||||
for f in &batch.fragments {
|
||||
line.push_str("{\"t\":\"f\",\"fileKey\":");
|
||||
json_str(&f.file_key, &mut line);
|
||||
line.push_str(",\"name\":");
|
||||
json_str(&f.name, &mut line);
|
||||
line.push_str(&format!(",\"totalParts\":{},\"postedAt\":", f.total_parts));
|
||||
opt_num(f.posted_at, &mut line);
|
||||
line.push_str(",\"segments\":[");
|
||||
for (i, (n, id, bytes, article)) in f.segments.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(',');
|
||||
}
|
||||
line.push_str(&format!("[{n},"));
|
||||
json_str(id, &mut line);
|
||||
line.push_str(&format!(",{bytes},{article}]"));
|
||||
}
|
||||
line.push_str("]}");
|
||||
emit(&mut line);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let dir = args.get(1).expect("usage: edge-bench-rs <dir> [--batch-chunks N] [--idle N] [--dump file]").clone();
|
||||
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
|
||||
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()));
|
||||
|
||||
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();
|
||||
|
||||
let mut priorities = FxHashMap::default();
|
||||
priorities.insert(group.clone(), 1);
|
||||
let mut indexer = RangeIndexer::new(&group, start, end, start, names::Rules::defaults(priorities), idle);
|
||||
|
||||
let (mut inflate_s, mut index_s) = (0.0, 0.0);
|
||||
let (mut compressed, mut text_bytes) = (0usize, 0usize);
|
||||
let mut out = (0u64, 0usize, 0usize, 0usize, 0usize, 0usize, 0usize, 0usize); // headers, releases, files, segments, payload, candidates, fragments, samples
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
let mut decompressor = libdeflater::Decompressor::new();
|
||||
let wall = Instant::now();
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let raw = std::fs::read(format!("{dir}/{file}")).unwrap();
|
||||
compressed += raw.len();
|
||||
let t = Instant::now();
|
||||
let data = inflate(&raw, &mut decompressor);
|
||||
inflate_s += t.elapsed().as_secs_f64();
|
||||
text_bytes += data.len();
|
||||
let t = Instant::now();
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let next: i64 = file.split('-').nth(2).unwrap().trim_end_matches(".gz").parse::<i64>().unwrap() + 1;
|
||||
indexer.add_text(&text, next);
|
||||
let last = i + 1 == files.len();
|
||||
if last {
|
||||
indexer.finish();
|
||||
}
|
||||
if !last && (i + 1) % batch_chunks != 0 {
|
||||
index_s += t.elapsed().as_secs_f64();
|
||||
continue;
|
||||
}
|
||||
let batch = indexer.take();
|
||||
index_s += t.elapsed().as_secs_f64();
|
||||
out.0 += batch.headers;
|
||||
out.1 += batch.releases.len();
|
||||
for r in &batch.releases {
|
||||
keys.insert(r.key.clone());
|
||||
out.2 += r.files;
|
||||
out.3 += r.segments;
|
||||
out.4 += r.payload.len();
|
||||
}
|
||||
out.5 += batch.candidates.len();
|
||||
out.6 += batch.fragments.len();
|
||||
out.7 += batch.samples.len();
|
||||
if let Some(d) = dump.as_mut() {
|
||||
dump_batch(&batch, d);
|
||||
}
|
||||
if let Some(w) = wire_out.as_mut() {
|
||||
w.write_all(&wire::to_json(&batch)).unwrap();
|
||||
w.write_all(b"\n").unwrap();
|
||||
}
|
||||
}
|
||||
let total = inflate_s + index_s;
|
||||
#[allow(static_mut_refs)]
|
||||
let p = unsafe { indexer::PROF };
|
||||
eprintln!(" of which aggregate {:.2} s, evict+cutoff {:.2} s, judge+encode {:.2} s", p[3] as f64 / 1e9, p[4] as f64 / 1e9, p[5] as f64 / 1e9);
|
||||
println!("{group} {start}–{end}: {} chunks, {} MB compressed, {} MB text", files.len(), compressed / 1_000_000, text_bytes / 1_000_000);
|
||||
println!(
|
||||
"{} headers in {:.2} s ({:.0}/s on one core; wall {:.2} s)",
|
||||
out.0,
|
||||
total,
|
||||
out.0 as f64 / total,
|
||||
wall.elapsed().as_secs_f64()
|
||||
);
|
||||
println!(" inflate {inflate_s:.2} s, index {index_s:.2} s; peak RSS {} MB", peak_rss_mb());
|
||||
println!(
|
||||
" kept: {} release chunks ({} releases), {} files, {} segments, payload {:.1} MB ({:.1} B/segment); {} candidates, {} fragments, {} samples",
|
||||
out.1,
|
||||
keys.len(),
|
||||
out.2,
|
||||
out.3,
|
||||
out.4 as f64 / 1e6,
|
||||
out.4 as f64 / out.3.max(1) as f64,
|
||||
out.5,
|
||||
out.6,
|
||||
out.7
|
||||
);
|
||||
}
|
||||
@@ -1,998 +0,0 @@
|
||||
//! lib/release (identity, random), lib/files/volumes and lib/rules/upload_tools.
|
||||
//! Regexes run on bytes without Unicode (`(?-u)`): JS regexes without the `u`
|
||||
//! flag match ASCII classes, `\d` is [0-9] and `\b` is an ASCII boundary. JS
|
||||
//! `\s` (which includes Unicode spaces) is spelled out as SP.
|
||||
|
||||
use crate::js::{self, len16, sha1_hex};
|
||||
use md5::{Digest, Md5};
|
||||
use regex::bytes::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// JS `\s` as a byte pattern.
|
||||
const SP: &str = r"(?:[\t\n\x0B\x0C\r ]|\xC2\xA0|\xE1\x9A\x80|\xE2\x80[\x80-\x8A\xA8\xA9\xAF]|\xE2\x81\x9F|\xE3\x80\x80|\xEF\xBB\xBF)";
|
||||
|
||||
fn re(pattern: &str) -> Regex {
|
||||
Regex::new(&format!("(?-u){}", pattern.replace("SP", SP))).unwrap()
|
||||
}
|
||||
|
||||
/// `s.replace(regex, with)` (first match) or `s.replace(/…/g, with)` (all).
|
||||
fn replace(re: &Regex, s: &str, with: &str, all: bool) -> String {
|
||||
let out = if all { re.replace_all(s.as_bytes(), with.as_bytes()) } else { re.replacen(s.as_bytes(), 1, with.as_bytes()) };
|
||||
// The patterns match whole characters (ASCII, or delimited by ASCII), so this stays UTF-8.
|
||||
String::from_utf8(out.into_owned()).unwrap()
|
||||
}
|
||||
|
||||
fn test(re: &Regex, s: &str) -> bool {
|
||||
re.is_match(s.as_bytes())
|
||||
}
|
||||
|
||||
static SUFFIXES: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
|
||||
vec![
|
||||
(re(r"(?i)\.vol[0-9]+[+-][0-9]+\.par2$"), ""),
|
||||
(re(r"(?i)\.par2$"), ""),
|
||||
(re(r"(?i)\.part[0-9]+\.rar$"), ""),
|
||||
(re(r"(?i)\.r[0-9]{2,3}$"), ""),
|
||||
(re(r"(?i)\.rar$"), ""),
|
||||
(re(r"(?i)\.7z(\.[0-9]{3})?$"), ""),
|
||||
(re(r"(?i)\.zip(\.[0-9]{3})?$"), ""),
|
||||
// JS: (?<=\.(7z|…))\.\d{3}$ — no lookbehind here, so the extension is matched and put back.
|
||||
(re(r"(?i)(\.(?:7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.[0-9]{3}$"), "$1"),
|
||||
(re(r"(?i)\.(nfo|sfv|nzb|srr|srs|txt|jpg|png|md5|sha1|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log)$"), ""),
|
||||
(re(r"(?i)\.tar$"), ""),
|
||||
(re(r"(?i)\.(zst|gz|xz|bz2)$"), ""),
|
||||
(re(r"(?i)\.(mkv|mp4|avi|m4v|ts|iso|img|bin|flac|mp3|epub|pdf)$"), ""),
|
||||
]
|
||||
});
|
||||
|
||||
/// fileStem with the regexes, as in the TypeScript (the reference for file_stem's tests).
|
||||
#[allow(dead_code)]
|
||||
pub fn file_stem_re(name: &str) -> String {
|
||||
let mut stem = js::trim(name).to_string();
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for (suffix, with) in SUFFIXES.iter() {
|
||||
if !suffix.is_match(stem.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
let next = replace(suffix, &stem, with, false);
|
||||
if next != stem && !next.is_empty() {
|
||||
stem = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
stem
|
||||
}
|
||||
|
||||
static QUOTED: LazyLock<Regex> = LazyLock::new(|| re(r#""[^"]*""#));
|
||||
static COUNTERS: LazyLock<Regex> = LazyLock::new(|| re(r"\[[0-9]+/[0-9]+\]|\([0-9]+/[0-9]+\)"));
|
||||
static YENC_END: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\byEnc\b(SP+[0-9]+)?SP*$"));
|
||||
static YENC: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\byEnc\b"));
|
||||
static SEPARATORS: LazyLock<Regex> = LazyLock::new(|| re(r"(?:SP|[-.:|]|\xE2\x80\x93)+"));
|
||||
|
||||
/// subjectRemainder with the regexes, as in the TypeScript (the reference for subject_remainder's tests).
|
||||
#[allow(dead_code)]
|
||||
pub fn subject_remainder_re(subject: &str) -> String {
|
||||
let s = replace("ED, subject, " ", true);
|
||||
let s = replace(&COUNTERS, &s, " ", true);
|
||||
let s = remove_counters_and_sizes(&s);
|
||||
let s = replace(&YENC_END, &s, " ", false);
|
||||
let s = replace(&YENC, &s, " ", true);
|
||||
let s = replace(&SEPARATORS, &s, " ", true);
|
||||
finish_remainder(&s)
|
||||
}
|
||||
|
||||
fn eq_ci(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_ascii_lowercase() == *y)
|
||||
}
|
||||
|
||||
/// Ends in `suffix` (lowercase), ASCII case-insensitively.
|
||||
fn ends_ci(s: &[u8], suffix: &[u8]) -> bool {
|
||||
s.len() >= suffix.len() && eq_ci(&s[s.len() - suffix.len()..], suffix)
|
||||
}
|
||||
|
||||
/// ASCII digits ending at `end`.
|
||||
fn digits_back(s: &[u8], end: usize) -> usize {
|
||||
s[..end].iter().rev().take_while(|c| c.is_ascii_digit()).count()
|
||||
}
|
||||
|
||||
/// `.` + one of `exts` at the end: the length without it.
|
||||
fn strip_ext(s: &[u8], exts: &[&[u8]]) -> Option<usize> {
|
||||
for ext in exts {
|
||||
let n = ext.len() + 1;
|
||||
if s.len() >= n && s[s.len() - n] == b'.' && ends_ci(s, ext) {
|
||||
return Some(s.len() - n);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `\.\d{3}$`: the length without it.
|
||||
fn strip_three_digits(s: &[u8]) -> Option<usize> {
|
||||
let n = s.len();
|
||||
(n >= 4 && s[n - 4] == b'.' && s[n - 3..].iter().all(u8::is_ascii_digit)).then(|| n - 4)
|
||||
}
|
||||
|
||||
const SPLIT_EXTS: [&[u8]; 14] = [b"7z", b"zip", b"rar", b"tar", b"mkv", b"mp4", b"avi", b"m4v", b"ts", b"m2ts", b"iso", b"img", b"bin", b"vob"];
|
||||
const SIDE_EXTS: [&[u8]; 19] = [b"nfo", b"sfv", b"nzb", b"srr", b"srs", b"txt", b"jpg", b"png", b"md5", b"sha1", b"srt", b"sub", b"idx", b"ass", b"ssa", b"m3u", b"m3u8", b"cue", b"log"];
|
||||
const PACK_EXTS: [&[u8]; 4] = [b"zst", b"gz", b"xz", b"bz2"];
|
||||
const MEDIA_EXTS: [&[u8]; 12] = [b"mkv", b"mp4", b"avi", b"m4v", b"ts", b"iso", b"img", b"bin", b"flac", b"mp3", b"epub", b"pdf"];
|
||||
|
||||
/// SUFFIXES[rule] once: the stem's new length when it matches.
|
||||
fn strip_suffix_rule(s: &[u8], rule: usize) -> Option<usize> {
|
||||
let n = s.len();
|
||||
match rule {
|
||||
// \.vol\d+[+-]\d+\.par2$
|
||||
0 => {
|
||||
if !ends_ci(s, b".par2") {
|
||||
return None;
|
||||
}
|
||||
let d2 = digits_back(s, n - 5);
|
||||
let sign = (n - 5).checked_sub(d2 + 1).filter(|_| d2 > 0)?;
|
||||
if !matches!(s[sign], b'+' | b'-') {
|
||||
return None;
|
||||
}
|
||||
let d1 = digits_back(s, sign);
|
||||
let v = sign.checked_sub(d1 + 4).filter(|_| d1 > 0)?;
|
||||
eq_ci(&s[v..v + 4], b".vol").then_some(v)
|
||||
}
|
||||
1 => ends_ci(s, b".par2").then(|| n - 5),
|
||||
// \.part\d+\.rar$
|
||||
2 => {
|
||||
if !ends_ci(s, b".rar") {
|
||||
return None;
|
||||
}
|
||||
let d = digits_back(s, n - 4);
|
||||
let p = (n - 4).checked_sub(d + 5).filter(|_| d > 0)?;
|
||||
eq_ci(&s[p..p + 5], b".part").then_some(p)
|
||||
}
|
||||
// \.r\d{2,3}$
|
||||
3 => {
|
||||
let d = digits_back(s, n);
|
||||
let p = n.checked_sub(d + 2).filter(|_| (2..=3).contains(&d))?;
|
||||
eq_ci(&s[p..p + 2], b".r").then_some(p)
|
||||
}
|
||||
4 => ends_ci(s, b".rar").then(|| n - 4),
|
||||
// \.7z(\.\d{3})?$ and \.zip(\.\d{3})?$
|
||||
5 | 6 => {
|
||||
let ext: &[u8] = if rule == 5 { b".7z" } else { b".zip" };
|
||||
if let Some(m) = strip_three_digits(s) {
|
||||
if ends_ci(&s[..m], ext) {
|
||||
return Some(m - ext.len());
|
||||
}
|
||||
}
|
||||
ends_ci(s, ext).then(|| n - ext.len())
|
||||
}
|
||||
// (?<=\.(7z|…))\.\d{3}$: only the volume number goes.
|
||||
7 => {
|
||||
let m = strip_three_digits(s)?;
|
||||
strip_ext(&s[..m], &SPLIT_EXTS).map(|_| m)
|
||||
}
|
||||
8 => strip_ext(s, &SIDE_EXTS),
|
||||
9 => strip_ext(s, &[b"tar"]),
|
||||
10 => strip_ext(s, &PACK_EXTS),
|
||||
11 => strip_ext(s, &MEDIA_EXTS),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// fileStem: per-file suffixes stripped, repeatedly. The SUFFIXES regexes by hand: with them, a
|
||||
/// file's description was most of the indexing time.
|
||||
pub fn file_stem(name: &str) -> String {
|
||||
let stem = js::trim(name);
|
||||
let b = stem.as_bytes();
|
||||
let mut len = b.len();
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for rule in 0..12 {
|
||||
if let Some(next) = strip_suffix_rule(&b[..len], rule) {
|
||||
if next > 0 && next < len {
|
||||
len = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The suffixes are ASCII: `len` is a character boundary.
|
||||
stem[..len].to_string()
|
||||
}
|
||||
|
||||
fn is_word(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_'
|
||||
}
|
||||
|
||||
/// "yEnc" (any case) at `i` with a word boundary (ASCII, as in JS) on both sides.
|
||||
fn yenc_at(b: &[u8], i: usize) -> bool {
|
||||
i + 4 <= b.len() && eq_ci(&b[i..i + 4], b"yenc") && (i == 0 || !is_word(b[i - 1])) && (i + 4 == b.len() || !is_word(b[i + 4]))
|
||||
}
|
||||
|
||||
/// `(\s+\d+)?\s*$`
|
||||
fn yenc_tail(rest: &str) -> bool {
|
||||
let after_spaces = rest.trim_start_matches(js::is_space);
|
||||
if after_spaces.chars().all(js::is_space) {
|
||||
return true;
|
||||
}
|
||||
if after_spaces.len() == rest.len() {
|
||||
return false;
|
||||
}
|
||||
let after_digits = after_spaces.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
after_digits.len() < after_spaces.len() && after_digits.chars().all(js::is_space)
|
||||
}
|
||||
|
||||
/// subjectRemainder by hand: the same five replacements and trim.
|
||||
pub fn subject_remainder(subject: &str) -> String {
|
||||
let b = subject.as_bytes();
|
||||
// "[^"]*" → ' '
|
||||
let mut s1: Vec<u8> = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'"' {
|
||||
if let Some(j) = b[i + 1..].iter().position(|&c| c == b'"') {
|
||||
s1.push(b' ');
|
||||
i += j + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
s1.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
// \[\d+/\d+\] and \(\d+/\d+\) → ' '
|
||||
let mut s2: Vec<u8> = Vec::with_capacity(s1.len());
|
||||
let mut i = 0;
|
||||
while i < s1.len() {
|
||||
let close = match s1[i] {
|
||||
b'[' => Some(b']'),
|
||||
b'(' => Some(b')'),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(close) = close {
|
||||
let a = s1[i + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
let slash = i + 1 + a;
|
||||
if a > 0 && s1.get(slash) == Some(&b'/') {
|
||||
let d = s1[slash + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
if d > 0 && s1.get(slash + 1 + d) == Some(&close) {
|
||||
s2.push(b' ');
|
||||
i = slash + 2 + d;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
s2.push(s1[i]);
|
||||
i += 1;
|
||||
}
|
||||
// Only ASCII was replaced, between whole characters.
|
||||
let mut s3 = remove_counters_and_sizes(&String::from_utf8(s2).unwrap());
|
||||
// \byEnc\b(\s+\d+)?\s*$ → ' ' (the first place where it fits)
|
||||
let b3 = s3.as_bytes();
|
||||
if let Some(at) = (0..b3.len()).find(|&i| yenc_at(b3, i) && yenc_tail(&s3[i + 4..])) {
|
||||
s3.truncate(at);
|
||||
s3.push(' ');
|
||||
}
|
||||
// \byEnc\b → ' ', then runs of \s, -, –, ., :, | → ' '
|
||||
let b3 = s3.as_bytes();
|
||||
let mut s4 = String::with_capacity(s3.len());
|
||||
let mut separator = false;
|
||||
let mut i = 0;
|
||||
while i < b3.len() {
|
||||
if yenc_at(b3, i) {
|
||||
// Its " " starts or joins a separator run.
|
||||
if !separator {
|
||||
s4.push(' ');
|
||||
separator = true;
|
||||
}
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
let c = s3[i..].chars().next().unwrap();
|
||||
if js::is_space(c) || matches!(c, '-' | '.' | ':' | '|' | '\u{2013}') {
|
||||
if !separator {
|
||||
s4.push(' ');
|
||||
separator = true;
|
||||
}
|
||||
} else {
|
||||
s4.push(c);
|
||||
separator = false;
|
||||
}
|
||||
i += c.len_utf8();
|
||||
}
|
||||
finish_remainder(&s4)
|
||||
}
|
||||
|
||||
static OF_COUNTER: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)[\[(]?\b(?:fileSP+)?([0-9]+)SP*ofSP*([0-9]+)\b[\])]?"));
|
||||
static DASH_COUNTER: LazyLock<Regex> = LazyLock::new(|| re(r"\[([0-9]+)-([0-9]+)\]"));
|
||||
static SLASH_COUNTER: LazyLock<Regex> = LazyLock::new(|| re(r"\b([0-9]+)/([0-9]+)\b"));
|
||||
static ZERO_COUNTER: LazyLock<Regex> = LazyLock::new(|| re(r"\b(0[0-9]*)-([0-9]+)\b"));
|
||||
static SIZES: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b[0-9]+(?:[.,][0-9]+)?SP*(?:[kmgt]i?b|bytes?)\b"));
|
||||
static EMPTY_BRACKETS: LazyLock<Regex> = LazyLock::new(|| re(r" ?(?:\[ ?\]|\( ?\)) ?"));
|
||||
|
||||
fn number(digits: &[u8]) -> f64 {
|
||||
std::str::from_utf8(digits).unwrap().parse::<f64>().unwrap()
|
||||
}
|
||||
|
||||
/// `s.replace(/…/g, counter)`: the match goes when its first number is at most its second.
|
||||
fn replace_counters(re: &Regex, s: &str) -> String {
|
||||
if !re.is_match(s.as_bytes()) {
|
||||
return s.to_string();
|
||||
}
|
||||
let out = re.replace_all(s.as_bytes(), |c: ®ex::bytes::Captures| if number(&c[1]) <= number(&c[2]) { b" ".to_vec() } else { c[0].to_vec() });
|
||||
String::from_utf8(out.into_owned()).unwrap()
|
||||
}
|
||||
|
||||
/// The written-out counters, file sizes and empty brackets subjectRemainder removes after the [i/M] ones.
|
||||
fn remove_counters_and_sizes(s: &str) -> String {
|
||||
let s = replace_counters(&OF_COUNTER, s);
|
||||
let s = replace_counters(&DASH_COUNTER, &s);
|
||||
let s = replace_counters(&SLASH_COUNTER, &s);
|
||||
let s = replace_counters(&ZERO_COUNTER, &s);
|
||||
replace(&SIZES, &s, " ", true)
|
||||
}
|
||||
|
||||
/// The end of subjectRemainder: what's left of brackets, double spaces, trim (after the separators became single spaces).
|
||||
fn finish_remainder(s: &str) -> String {
|
||||
let s = if s.contains(['[', '(']) { replace(&EMPTY_BRACKETS, s, " ", true) } else { s.to_string() };
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if c == ' ' && out.ends_with(' ') {
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
js::trim(&out).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);
|
||||
if let Some(c) = COUNTERS_CAPTURE.captures(s.as_bytes()) {
|
||||
let total = c.get(2).or(c.get(4)).unwrap().as_bytes();
|
||||
return Some(js_number_string(number(total)));
|
||||
}
|
||||
for re in [&*OF_COUNTER_OPEN, &*DASH_COUNTER, &*SLASH_COUNTER, &*ZERO_COUNTER] {
|
||||
for c in re.captures_iter(s.as_bytes()) {
|
||||
if number(&c[1]) <= number(&c[2]) {
|
||||
return Some(js_number_string(number(&c[2])));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
static COUNTERS_CAPTURE: LazyLock<Regex> = LazyLock::new(|| re(r"\[([0-9]+)/([0-9]+)\]|\(([0-9]+)/([0-9]+)\)"));
|
||||
/// The "of" counter without its closing bracket (as counterTotal's pattern).
|
||||
static OF_COUNTER_OPEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)[\[(]?\b(?:fileSP+)?([0-9]+)SP*ofSP*([0-9]+)\b"));
|
||||
|
||||
/// `String(n)` of a counter's number (integers as JS prints them).
|
||||
fn js_number_string(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e21 {
|
||||
format!("{}", n as i128)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
static IMAGE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(jpe?g|png|gif|bmp|webp|tiff?)$"));
|
||||
static AUDIO: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(mp3|flac|m4a|m4b|ogg|opus|wav|aac|wma|ape)(?:$|[^a-z0-9])"));
|
||||
static DOCUMENT: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(pdf|epub|mobi|azw3?|djvu|cbr|cbz|cb7|chm|lit|docx?)$"));
|
||||
|
||||
/// An image set's name without a counter: up to the last number, without numbers.
|
||||
fn image_prefix(stem: &str) -> String {
|
||||
let b = stem.as_bytes();
|
||||
let cut = match b.iter().rposition(u8::is_ascii_digit) {
|
||||
Some(last) => b[..=last].iter().rposition(|c| !c.is_ascii_digit()).map(|i| i + 1).unwrap_or(0),
|
||||
None => b.len(),
|
||||
};
|
||||
let head = &stem[..cut];
|
||||
let mut out = String::with_capacity(head.len());
|
||||
let mut sep = false;
|
||||
for c in head.chars() {
|
||||
if c.is_ascii_digit() || js::is_space(c) || matches!(c, '.' | '_' | '-') {
|
||||
sep = true;
|
||||
} else {
|
||||
if sep && !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(c);
|
||||
sep = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_random_token(token: &str) -> bool {
|
||||
let t = token.strip_prefix('[').unwrap_or(token);
|
||||
let t = t.strip_suffix(']').unwrap_or(t);
|
||||
if len16(t) < 6 {
|
||||
return false;
|
||||
}
|
||||
let digit = t.bytes().any(|b| b.is_ascii_digit());
|
||||
if digit && t.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return true;
|
||||
}
|
||||
digit && t.bytes().any(|b| b.is_ascii_lowercase()) && t.bytes().any(|b| b.is_ascii_uppercase()) && !t.bytes().any(|b| matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
|
||||
pub fn is_post_token(remainder: &str) -> bool {
|
||||
let compact: String = remainder.chars().filter(|&c| !js::is_space(c) && !matches!(c, '[' | ']' | '(' | ')' | '_' | '-')).collect();
|
||||
let n = len16(&compact);
|
||||
(6..=40).contains(&n) && compact.bytes().any(|b| b.is_ascii_digit()) && compact.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
static NAME_LIKE: LazyLock<Regex> = LazyLock::new(|| re(r"[A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,}"));
|
||||
|
||||
pub fn names_release(remainder: &str) -> bool {
|
||||
let words: Vec<&str> = js::split_spaces(remainder).into_iter().filter(|w| !w.is_empty() && !is_random_token(w)).collect();
|
||||
name_like(words.join(" ").as_bytes())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Release {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub source: &'static str,
|
||||
/// The [i/M] total as parsed (0 is falsy in the TypeScript).
|
||||
pub file_total: Option<i64>,
|
||||
}
|
||||
|
||||
impl Release {
|
||||
pub fn file_total_truthy(&self) -> Option<i64> {
|
||||
self.file_total.filter(|&n| n != 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
static FILE_COUNTER: LazyLock<Regex> = LazyLock::new(|| re(r"\[([0-9]+)/([0-9]+)\]"));
|
||||
|
||||
fn hash(parts: &[&str]) -> String {
|
||||
sha1_hex(&parts.join("\n"))
|
||||
}
|
||||
|
||||
/// A name that says what it is: 12+ letters and digits, two words of 3+ letters (`describes`).
|
||||
fn describes(stem: &str) -> bool {
|
||||
let b = stem.as_bytes();
|
||||
if b.iter().filter(|c| c.is_ascii_alphanumeric()).count() < 12 {
|
||||
return false;
|
||||
}
|
||||
// [A-Za-z]{3,}[^A-Za-z0-9]+[A-Za-z]{3,}
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let letters = b[i..].iter().take_while(|c| c.is_ascii_alphabetic()).count();
|
||||
if letters == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let end = i + letters;
|
||||
if letters >= 3 {
|
||||
let gap = b[end..].iter().take_while(|c| !c.is_ascii_alphanumeric()).count();
|
||||
if gap > 0 && b[end + gap..].iter().take_while(|c| c.is_ascii_alphabetic()).count() >= 3 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
const TAG_WORDS: [&str; 21] = [
|
||||
"www", "http", "https", "com", "net", "org", "info", "the", "and", "presents", "posted", "post", "powered", "yenc", "part", "rar", "par2", "nzb", "voor", "for", "from",
|
||||
];
|
||||
|
||||
/// isPosterTag: the text outside the quotes shares no word with the file's stem.
|
||||
pub fn is_poster_tag(remainder: &str, stem: &str) -> bool {
|
||||
let lower = stem.to_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit())).filter(|w| !w.is_empty()).collect();
|
||||
let compact: String = words.concat();
|
||||
let whole: std::collections::HashSet<&str> = words.iter().copied().collect();
|
||||
// A per-post id, also inside a word ("FTD772544").
|
||||
let b = remainder.as_bytes();
|
||||
if b.windows(5).any(|w| w.iter().all(u8::is_ascii_digit)) {
|
||||
return false;
|
||||
}
|
||||
let rem = remainder.to_lowercase();
|
||||
for word in rem.split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit())) {
|
||||
if !word.is_empty() && word.bytes().all(|c| c.is_ascii_digit()) {
|
||||
if word.len() >= 2 && whole.contains(word) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word.len() < 3 || TAG_WORDS.contains(&word) {
|
||||
continue;
|
||||
}
|
||||
if if word.len() >= 4 { compact.contains(word) } else { whole.contains(word) } {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// releaseIdentity for a named (not weak) file: never null.
|
||||
pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release {
|
||||
// The post rule's key takes the [i/M] total; the release's file total any counter's (counterTotal).
|
||||
let post_total = file_counter_total(subject);
|
||||
let file_total = counter_total(subject).and_then(|t| t.parse::<i64>().ok());
|
||||
let stem = file_stem(name);
|
||||
let remainder = subject_remainder(subject);
|
||||
// A document or comic is a release of its own.
|
||||
if test(&DOCUMENT, name) {
|
||||
let shown = replace(&DOCUMENT, &stem, "", false);
|
||||
let name = if shown.is_empty() { stem.clone() } else { shown };
|
||||
return Release { key: hash(&["stem", poster, &stem]), name, source: "stem", file_total };
|
||||
}
|
||||
let image = test(&IMAGE, name);
|
||||
// The poster's constant tag outside the quotes: the file's stem says which upload it is (not for images and audio).
|
||||
let tag = !image && !test(&AUDIO, name) && describes(&stem) && !looks_random(&stem) && is_poster_tag(&remainder, &stem);
|
||||
let prefix = if image { image_prefix(&stem) } else { String::new() };
|
||||
if !tag && names_release(&remainder) {
|
||||
let bound = counter_total(subject).unwrap_or_else(|| prefix.clone());
|
||||
return Release { key: hash(&["subject", poster, &remainder, &bound]), 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 };
|
||||
}
|
||||
}
|
||||
if image {
|
||||
let name = if prefix.is_empty() { stem.clone() } else { prefix.clone() };
|
||||
return Release { key: hash(&["stem", poster, &name]), name, source: "stem", file_total };
|
||||
}
|
||||
Release { key: hash(&["stem", poster, &stem]), name: stem, source: "stem", file_total }
|
||||
}
|
||||
|
||||
static GENERIC: LazyLock<Regex> = LazyLock::new(|| {
|
||||
re(r"(?i)^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples?|subs?|subtitles?|proof|cover|covers|extras?|featurettes?|(cd|dvd|disc|disk)[ ._-]?[0-9]{1,2})$")
|
||||
});
|
||||
|
||||
fn is_generic_name(name: &str) -> bool {
|
||||
test(&GENERIC, js::trim(name))
|
||||
}
|
||||
|
||||
/// looksRandom.
|
||||
pub fn looks_random(name: &str) -> bool {
|
||||
// name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
|
||||
let name = 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,
|
||||
};
|
||||
if is_generic_name(name) {
|
||||
return true;
|
||||
}
|
||||
let b = name.as_bytes();
|
||||
let hex = |s: &[u8]| s.iter().all(|c| c.is_ascii_hexdigit());
|
||||
let digits = |s: &[u8]| s.iter().all(|c| c.is_ascii_digit());
|
||||
if b.len() >= 16 && hex(b) {
|
||||
return true;
|
||||
}
|
||||
if let Some(dot) = name.find('.') {
|
||||
let (h, d) = (&b[..dot], &b[dot + 1..]);
|
||||
if h.len() >= 16 && hex(h) && d.len() >= 9 && digits(d) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if b.len() == 36 && [8, 13, 18, 23].iter().all(|&i| b[i] == b'-') && b.iter().enumerate().all(|(i, c)| [8, 13, 18, 23].contains(&i) || c.is_ascii_hexdigit()) {
|
||||
return true;
|
||||
}
|
||||
{
|
||||
let core = name.strip_suffix(" n").unwrap_or(name).as_bytes();
|
||||
if core.len() >= 6 && digits(core) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let count = |f: fn(&u8) -> bool| b.iter().filter(|c| f(c)).count();
|
||||
let n_digit = count(u8::is_ascii_digit);
|
||||
let n_lower = count(u8::is_ascii_lowercase);
|
||||
let n_upper = count(u8::is_ascii_uppercase);
|
||||
let alnum = b.iter().all(|c| c.is_ascii_alphanumeric());
|
||||
if b.len() >= 12 && b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) && n_digit >= 2 && n_lower >= 4 {
|
||||
return true;
|
||||
}
|
||||
if (8..=15).contains(&b.len()) && alnum && n_digit > 0 && n_lower > 0 && b[1..].iter().filter(|c| c.is_ascii_uppercase()).count() >= 3 {
|
||||
return true;
|
||||
}
|
||||
if !(b.len() >= 16 && alnum) || n_lower == 0 || n_upper == 0 {
|
||||
return false;
|
||||
}
|
||||
n_digit > 0 || n_upper >= 6
|
||||
}
|
||||
|
||||
pub fn is_random_file(name: &str) -> bool {
|
||||
looks_random(&file_stem(name))
|
||||
}
|
||||
|
||||
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$"));
|
||||
static RAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.rar$"));
|
||||
static PART: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part([0-9]+)\.rar$"));
|
||||
static BIN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.bin$"));
|
||||
static OLD_RAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.r[0-9]{2,3}$"));
|
||||
static PAR2_VOL: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.vol[0-9]+[+-][0-9]+\.par2$"));
|
||||
pub static PAR2: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.par2$"));
|
||||
|
||||
pub fn is_par2(name: &str) -> bool {
|
||||
test(&PAR2, name)
|
||||
}
|
||||
|
||||
pub fn seven_zip_volume(name: &str) -> Option<i64> {
|
||||
if let Some(c) = SEVEN_SPLIT.captures(name.as_bytes()) {
|
||||
return js::parse_int(std::str::from_utf8(&c[1]).unwrap());
|
||||
}
|
||||
if test(&SEVEN, name) {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_archive_start(name: &str) -> bool {
|
||||
if test(&BIN, name) {
|
||||
return true;
|
||||
}
|
||||
if matches!(seven_zip_volume(name), Some(0) | Some(1)) {
|
||||
return true;
|
||||
}
|
||||
test(&PART1, name) || (test(&RAR, name) && !test(&PART, name))
|
||||
}
|
||||
|
||||
pub fn is_later_volume(name: &str) -> bool {
|
||||
if let Some(c) = PART.captures(name.as_bytes()) {
|
||||
return js::parse_int(std::str::from_utf8(&c[1]).unwrap()).unwrap_or(0) > 1;
|
||||
}
|
||||
test(&OLD_RAR, name) || test(&PAR2_VOL, name)
|
||||
}
|
||||
|
||||
static TOOLS: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
|
||||
vec![
|
||||
("nyuu", re(r"^(?:[A-Z][a-z]){12}-[0-9]{13}@nyuu$")),
|
||||
("nyuu-host", re(r"^(?:[A-Z][a-z]){12}-[0-9]{13}@[^\n\r]+$")),
|
||||
("ngPost", re(r"^[0-9a-f]{32}@ngPost$")),
|
||||
("yuun", re(r"^[0-9a-f]{32}@[0-9a-f]{32}\.yuun$")),
|
||||
("hex32", re(r"^[0-9a-f]{32}@[A-Za-z0-9.-]+$")),
|
||||
("JBinUp", re(r"@JBin(?:Up|Down)\.local$")),
|
||||
("partNofM", re(r"(?i)^part[0-9]+of[0-9]+\.")),
|
||||
("ts-dollar", re(r"^[0-9]{13}\$[0-9]+\$[0-9]+@localhost$")),
|
||||
("random", re(r"^[A-Za-z0-9]{10,40}@[A-Za-z0-9]{3,12}\.[A-Za-z0-9]{2,6}$")),
|
||||
]
|
||||
});
|
||||
|
||||
/// uploadTool with the regexes, as in the TypeScript (the reference for upload_tool's test).
|
||||
#[allow(dead_code)]
|
||||
pub fn upload_tool_re(message_id: &str) -> &'static str {
|
||||
if message_id.is_empty() {
|
||||
return "other";
|
||||
}
|
||||
for (tool, pattern) in TOOLS.iter() {
|
||||
if test(pattern, message_id) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
"other"
|
||||
}
|
||||
|
||||
fn all(s: &[u8], f: impl Fn(u8) -> bool) -> bool {
|
||||
s.iter().all(|&c| f(c))
|
||||
}
|
||||
|
||||
fn lower_hex(c: u8) -> bool {
|
||||
c.is_ascii_digit() || (b'a'..=b'f').contains(&c)
|
||||
}
|
||||
|
||||
/// `(?:[A-Z][a-z]){12}-\d{13}@`: nyuu's prefix (38 bytes, then '@').
|
||||
fn nyuu_prefix(b: &[u8]) -> bool {
|
||||
b.len() > 38 && (0..24).step_by(2).all(|i| b[i].is_ascii_uppercase() && b[i + 1].is_ascii_lowercase()) && b[24] == b'-' && all(&b[25..38], |c| c.is_ascii_digit()) && b[38] == b'@'
|
||||
}
|
||||
|
||||
/// The posting tool of a Message-ID (TOOLS, in order, by hand: they ran for every per-article file).
|
||||
pub fn upload_tool(message_id: &str) -> &'static str {
|
||||
let b = message_id.as_bytes();
|
||||
if b.is_empty() {
|
||||
return "other";
|
||||
}
|
||||
if nyuu_prefix(b) && b.len() > 39 {
|
||||
// nyuu: "@nyuu" exactly; nyuu-host: any host (`.+`: no line terminators).
|
||||
if &b[39..] == b"nyuu" {
|
||||
return "nyuu";
|
||||
}
|
||||
if !b[39..].iter().any(|&c| c == b'\n' || c == b'\r') && !message_id[39..].contains(['\u{2028}', '\u{2029}']) {
|
||||
return "nyuu-host";
|
||||
}
|
||||
}
|
||||
let hex32 = b.len() > 33 && all(&b[..32], lower_hex) && b[32] == b'@';
|
||||
if hex32 {
|
||||
let host = &b[33..];
|
||||
if host == b"ngPost" {
|
||||
return "ngPost";
|
||||
}
|
||||
if host.len() == 37 && all(&host[..32], lower_hex) && &host[32..] == b".yuun" {
|
||||
return "yuun";
|
||||
}
|
||||
if all(host, |c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-') {
|
||||
return "hex32";
|
||||
}
|
||||
}
|
||||
if message_id.ends_with("@JBinUp.local") || message_id.ends_with("@JBinDown.local") {
|
||||
return "JBinUp";
|
||||
}
|
||||
// (?i)^part\d+of\d+\.
|
||||
if b.len() > 4 && eq_ci(&b[..4], b"part") {
|
||||
let d1 = b[4..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
let of = 4 + d1;
|
||||
if d1 > 0 && b.len() > of + 2 && eq_ci(&b[of..of + 2], b"of") {
|
||||
let d2 = b[of + 2..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
if d2 > 0 && b.get(of + 2 + d2) == Some(&b'.') {
|
||||
return "partNofM";
|
||||
}
|
||||
}
|
||||
}
|
||||
// ^\d{13}\$\d+\$\d+@localhost$
|
||||
if let Some(rest) = message_id.strip_suffix("@localhost") {
|
||||
let r = rest.as_bytes();
|
||||
let parts: Vec<&[u8]> = r.split(|&c| c == b'$').collect();
|
||||
if parts.len() == 3 && parts[0].len() == 13 && parts.iter().all(|p| !p.is_empty() && all(p, |c| c.is_ascii_digit())) {
|
||||
return "ts-dollar";
|
||||
}
|
||||
}
|
||||
// ^[A-Za-z0-9]{10,40}@[A-Za-z0-9]{3,12}\.[A-Za-z0-9]{2,6}$
|
||||
if let Some(at) = b.iter().position(|&c| c == b'@') {
|
||||
let (local, host) = (&b[..at], &b[at + 1..]);
|
||||
if (10..=40).contains(&local.len()) && all(local, |c| c.is_ascii_alphanumeric()) {
|
||||
if let Some(dot) = host.iter().position(|&c| c == b'.') {
|
||||
let (name, tld) = (&host[..dot], &host[dot + 1..]);
|
||||
if (3..=12).contains(&name.len()) && (2..=6).contains(&tld.len()) && all(name, |c| c.is_ascii_alphanumeric()) && all(tld, |c| c.is_ascii_alphanumeric()) {
|
||||
return "random";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"other"
|
||||
}
|
||||
|
||||
/// [A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,} anywhere.
|
||||
fn name_like(b: &[u8]) -> bool {
|
||||
let sep = |c: u8| matches!(c, b'.' | b'_' | b' ' | b'-');
|
||||
(2..b.len()).any(|k| {
|
||||
if !(sep(b[k]) && b[k - 1].is_ascii_alphabetic() && b[k - 2].is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
let m = k + b[k..].iter().take_while(|&&c| sep(c)).count();
|
||||
m + 2 <= b.len() && b[m].is_ascii_alphanumeric() && b[m + 1].is_ascii_alphanumeric()
|
||||
})
|
||||
}
|
||||
|
||||
/// The second number of the first \[(\d+)/(\d+)\].
|
||||
fn file_counter_total(s: &str) -> Option<i64> {
|
||||
let b = s.as_bytes();
|
||||
for (i, &c) in b.iter().enumerate() {
|
||||
if c != b'[' {
|
||||
continue;
|
||||
}
|
||||
let a = b[i + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
let slash = i + 1 + a;
|
||||
if a == 0 || b.get(slash) != Some(&b'/') {
|
||||
continue;
|
||||
}
|
||||
let d = b[slash + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
if d > 0 && b.get(slash + 1 + d) == Some(&b']') {
|
||||
return js::parse_int(&s[slash + 1..slash + 1 + d]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// explored: a release in the sampled share of a rule's drops.
|
||||
pub fn explored(key: &str, percent: f64) -> bool {
|
||||
if percent <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let d = Md5::digest(key.as_bytes());
|
||||
let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]);
|
||||
((n % 10_000) as f64) < percent * 100.0
|
||||
}
|
||||
|
||||
static SIDECAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$"));
|
||||
static EXECUTABLE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(exe|scr|com|pif|bat|cmd|vbs|lnk)SP*$"));
|
||||
|
||||
pub struct JudgedFile<'a> {
|
||||
pub name: &'a str,
|
||||
pub total_parts: u32,
|
||||
pub groups: &'a [String],
|
||||
pub first_message_id: &'a str,
|
||||
}
|
||||
|
||||
pub enum Verdict {
|
||||
Keep,
|
||||
Drop { rule: &'static str, key: String, sample: bool },
|
||||
}
|
||||
|
||||
/// The rules a lease brings (lib/rules Rules): what the worker drops.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rules {
|
||||
pub version: i64,
|
||||
/// Indexed groups and their priority (owner rule).
|
||||
pub priorities: rustc_hash::FxHashMap<String, u32>,
|
||||
/// "tool\tgroup"
|
||||
pub dropped_signatures: 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,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
/// DEFAULT_RULES with these priorities and no dropped signatures (edge:bench's).
|
||||
pub fn defaults(priorities: rustc_hash::FxHashMap<String, u32>) -> Self {
|
||||
Rules {
|
||||
version: 1,
|
||||
priorities,
|
||||
dropped_signatures: 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RuleSet.judge.
|
||||
pub fn judge(release_key: &str, files: &[JudgedFile], rules: &Rules) -> Verdict {
|
||||
let sample = explored(release_key, rules.sample_percent);
|
||||
let content: Vec<&JudgedFile> = files.iter().filter(|f| !test(&SIDECAR, f.name)).collect();
|
||||
if rules.drop_executables && !content.is_empty() && content.iter().all(|f| test(&EXECUTABLE, f.name)) {
|
||||
let c = EXECUTABLE.captures(content[0].name.as_bytes()).unwrap();
|
||||
let key = std::str::from_utf8(&c[1]).unwrap().to_lowercase();
|
||||
return Verdict::Drop { rule: "executable", key, sample };
|
||||
}
|
||||
if files.iter().any(|f| !is_random_file(f.name)) {
|
||||
return Verdict::Keep;
|
||||
}
|
||||
let first = &files[0];
|
||||
let tool = upload_tool(first.first_message_id);
|
||||
let group = first.groups.first().map(String::as_str).unwrap_or("");
|
||||
if rules.dropped_tools.contains(tool) || rules.dropped_signatures.contains(&format!("{tool}\t{group}")) {
|
||||
return Verdict::Drop { rule: "garbage", key: format!("{tool} {group}"), sample };
|
||||
}
|
||||
if files.iter().all(|f| f.total_parts <= rules.per_article_max_parts) {
|
||||
return Verdict::Drop { rule: "per-article", key: tool.to_string(), sample };
|
||||
}
|
||||
Verdict::Keep
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stems() {
|
||||
assert_eq!(file_stem("x.mkv.vol03+04.par2"), "x");
|
||||
assert_eq!(file_stem("x.part07.rar"), "x");
|
||||
assert_eq!(file_stem("Movie.H.264.mkv.001"), "Movie.H.264");
|
||||
assert_eq!(file_stem("a.7z.002"), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remainders() {
|
||||
assert_eq!(subject_remainder(r#"Some.Release [01/45] - "x.rar" yEnc (1/50) 7168"#), "Some Release");
|
||||
assert!(names_release("Some Release"));
|
||||
assert!(!names_release("elys de81bcabef5faf6a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_hand_as_the_regexes() {
|
||||
let names = [
|
||||
"x.mkv.vol03+04.par2", "x.part07.rar", "Movie.H.264.mkv.001", "a.7z.002", "a.7z", "b.zip.001", "B.ZIP", "c.r00", "c.r1234",
|
||||
"d.vol1+2.par2", "d.vol+2.par2", ".par2", "e.tar.gz", "f.mkv", "g.m3u8", "h.bin.001", " spaced.rar ", "i.PART01.RAR", "j.part.rar",
|
||||
"k.nfo.txt", "noext", "ünï.cödé.mkv.001", "l.ts.123", "m.r12.par2", "n.vol12-34.PAR2", "o.7z.1234", "p.123", "q.r1", ".rar.rar",
|
||||
];
|
||||
for n in names {
|
||||
assert_eq!(file_stem(n), file_stem_re(n), "{n}");
|
||||
}
|
||||
let subjects = [
|
||||
r#"Some.Release [01/45] - "x.rar" yEnc (1/50) 7168"#,
|
||||
r#"[1/3] - "a.par2" yEnc (1/1) 123 "#,
|
||||
r#"yEnc "x" yenc yEncx (1/2)"#,
|
||||
r#"Show – S01E01 | 1080p : x "a" "b"#,
|
||||
r#"name (12/3x) [4/] "q" YENC 12 34"#,
|
||||
" lead\u{a0}nbsp yEnc\u{3000}99 ",
|
||||
r#"elys-de81bcabef5faf6a - [Nyh5Q07Y] - "x.mkv" yEnc"#,
|
||||
"a_yEnc b yEnc_c yEnc",
|
||||
"yEnc yEnc 5",
|
||||
"x yEnc 12 yEnc",
|
||||
"",
|
||||
];
|
||||
for s in subjects {
|
||||
assert_eq!(subject_remainder(s), subject_remainder_re(s), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_by_hand_as_the_regexes() {
|
||||
let ids = [
|
||||
"FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu", "FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@", "FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@news.example",
|
||||
"b94268bc702040f7b811d5318f727e4f@ngPost", "b94268bc702040f7b811d5318f727e4f@b94268bc702040f7b811d5318f727e4f.yuun", "b94268bc702040f7b811d5318f727e4f@host-1.example",
|
||||
"B94268bc702040f7b811d5318f727e4f@ngPost", "x@JBinUp.local", "y@JBinDown.local", "part1of69.lOlWzvSHabJCBTVrs0ts@AdultPoster.local", "PART12OF3.x",
|
||||
"1790923856877$1729905757859$1@localhost", "179092385687$1$1@localhost", "t3Y4mV92YCp0McxDc5@zLZlsw.RYqes", "t3Y4mV92YC@zL.RYqes", "t3Y4mV92YCp0McxDc5@zLZlsw.RY.es",
|
||||
"", "a@b", "b94268bc702040f7b811d5318f727e4f@", "b94268bc702040f7b811d5318f727e4f@host_x",
|
||||
];
|
||||
for id in ids {
|
||||
assert_eq!(upload_tool(id), upload_tool_re(id), "{id}");
|
||||
}
|
||||
for t in ["Some Release", "a b", "ab-cd", "ab - c1", "ab.c", "x ab_12", "12 ab", "ab "] {
|
||||
assert_eq!(name_like(t.as_bytes()), test(&NAME_LIKE, t), "{t}");
|
||||
}
|
||||
for t in ["[01/45] x", "x [1/] [2/30]", "[a/1] [3/4]", "no counter"] {
|
||||
let re = FILE_COUNTER.captures(t.as_bytes()).and_then(|c| js::parse_int(std::str::from_utf8(&c[2]).unwrap()));
|
||||
assert_eq!(file_counter_total(t), re, "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poster_tags_as_in_the_typescript() {
|
||||
let kill = "www.ggb.6x.to <darkness@earth.de>";
|
||||
let a = release_identity(kill, r#"[KiLL.dA.FuCkIn.EaStErRaBbIt]---- (03/10) "Cephalgy.-.Weisses.Fleisch.2003[[g.6x.to]].r00" yEnc"#, "Cephalgy.-.Weisses.Fleisch.2003[[g.6x.to]].r00");
|
||||
let nfo = release_identity(kill, r#"[KiLL.dA.FuCkIn.EaStErRaBbIt]---- (01/10) "Cephalgy.-.Weisses.Fleisch.2003[[g.6x.to]].nfo" yEnc"#, "Cephalgy.-.Weisses.Fleisch.2003[[g.6x.to]].nfo");
|
||||
let b = release_identity(kill, r#"[KiLL.dA.FuCkIn.EaStErRaBbIt]---- (03/10) "Shnarph.-.Lautlos.2008[[g.6x.to]].r00" yEnc"#, "Shnarph.-.Lautlos.2008[[g.6x.to]].r00");
|
||||
assert_eq!(a.key, nfo.key);
|
||||
assert_ne!(a.key, b.key);
|
||||
assert_eq!(a.source, "stem");
|
||||
let ftd1 = release_identity("p", r#"FTD769997 [01/23] - "Prison.Break.S04E02.720p.part01.rar" yEnc"#, "Prison.Break.S04E02.720p.part01.rar");
|
||||
let ftd2 = release_identity("p", r#"FTD769997 [23/23] - "Prison.Break.S04E02.720p.part.par2" yEnc"#, "Prison.Break.S04E02.720p.part.par2");
|
||||
assert_eq!(ftd1.key, ftd2.key);
|
||||
let scene = release_identity("p", r#"Some.Movie.2008.DVDRip.XviD-GRP [01/40] - "grp-sm.part01.rar" yEnc"#, "grp-sm.part01.rar");
|
||||
assert_eq!(scene.name, "Some Movie 2008 DVDRip XviD GRP");
|
||||
assert!(is_poster_tag("PowBeertje80 Posted", "Bravo Hits Vol.14 2CD -1996-"));
|
||||
assert!(!is_poster_tag("Audiofiles DIE ZEIT 35", "35_China_dl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_documents_and_audio_as_in_the_typescript() {
|
||||
let id = |subject: &str, name: &str| release_identity("p", subject, name);
|
||||
let a = id(r#"What about this? - File 027 of 132: "DSC_027.jpg" yEnc"#, "DSC_027.jpg");
|
||||
let b = id(r#"What about this? - File 028 of 132: "DSC_028.jpg" yEnc"#, "DSC_028.jpg");
|
||||
let other = id(r#"What about this? - File 001 of 90: "DSC_001.jpg" yEnc"#, "DSC_001.jpg");
|
||||
assert_eq!(a.key, b.key);
|
||||
assert_ne!(a.key, other.key);
|
||||
assert_eq!(a.name, "What about this?");
|
||||
let k1 = id(r#"Proud to be a poster "Kitchen Hottie 1 set233-76-lg.jpg" yEnc"#, "Kitchen Hottie 1 set233-76-lg.jpg");
|
||||
let k2 = id(r#"Proud to be a poster "Kitchen Hottie 1 set233-77-lg.jpg" yEnc"#, "Kitchen Hottie 1 set233-77-lg.jpg");
|
||||
let k3 = id(r#"Proud to be a poster "Office Chair set12-01-lg.jpg" yEnc"#, "Office Chair set12-01-lg.jpg");
|
||||
assert_eq!(k1.key, k2.key);
|
||||
assert_ne!(k1.key, k3.key);
|
||||
for (subject, expected) in [
|
||||
(r#"Minutemen (001/104) "x.cbr" - 76.88 MB - yEnc"#, "Minutemen"),
|
||||
(r#"Babes P A R T 3 ("Charlotte 03 16.jpg" 0097-1574 156.87 kB-273.03 MB) yEnc"#, "Babes P A R T 3"),
|
||||
(r#"*MaryKate* - OfficeChair [23of61] - "OfficeChair023.jpg" yEnc 187924 bytes"#, "*MaryKate* OfficeChair"),
|
||||
(r#"Adventure Comics [1948-12] - "x.rar""#, "Adventure Comics [1948 12]"),
|
||||
] {
|
||||
assert_eq!(subject_remainder(subject), expected, "{subject}");
|
||||
assert_eq!(subject_remainder_re(subject), expected, "{subject}");
|
||||
}
|
||||
let c1 = id(r#"cw-horror&mystery [10/28] "Vampirella Magazine 061.cbr" yEnc"#, "Vampirella Magazine 061.cbr");
|
||||
let c2 = id(r#"cw-horror&mystery [11/28] "Mystery Tales 12.cbr" yEnc"#, "Mystery Tales 12.cbr");
|
||||
assert_ne!(c1.key, c2.key);
|
||||
assert_eq!(c2.name, "Mystery Tales 12");
|
||||
let t1 = id(r#"(Geisterjäger John Sinclair Hörspiele Vol. 2) [04/59] - "042 - 011 - Der unheimliche Bogenschütze.mp3 - by DeckaR" yEnc"#, "042 - 011 - Der unheimliche Bogenschütze.mp3 - by DeckaR");
|
||||
let t2 = id(r#"(Geisterjäger John Sinclair Hörspiele Vol. 2) [34/59] - "072 - 027 - Die Teufelsuhr.MP3 - by DeckaR" yEnc"#, "072 - 027 - Die Teufelsuhr.MP3 - by DeckaR");
|
||||
let par2 = id(r#"(Geisterjäger John Sinclair Hörspiele Vol. 2) [59/59] - "Hörspiele Vol. 2.par2" yEnc"#, "Hörspiele Vol. 2.par2");
|
||||
assert_eq!(t1.key, t2.key);
|
||||
assert_eq!(t1.key, par2.key);
|
||||
// A cover without words around it joins its stem's archive.
|
||||
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 random_names() {
|
||||
assert!(looks_random("prvjr7hapd0i1ny14pz4"));
|
||||
assert!(looks_random("RmISakytFwXSCEzjVEpaKk"));
|
||||
assert!(!looks_random("Some.Show.S01E01.1080p"));
|
||||
assert!(looks_random("VIDEO_TS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools() {
|
||||
assert_eq!(upload_tool("FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu"), "nyuu");
|
||||
assert_eq!(upload_tool("b94268bc702040f7b811d5318f727e4f@ngPost"), "ngPost");
|
||||
assert_eq!(upload_tool("x@y"), "other");
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! A batch as the coordinator takes it (lib/edge/wire.ts toWire): JSON with
|
||||
//! the TypeScript names, chunk payloads in base64.
|
||||
|
||||
use crate::indexer::{Batch, Candidate, Fragment, ReleaseChunk};
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireRelease<'a> {
|
||||
key: &'a str,
|
||||
upload: usize,
|
||||
name: &'a str,
|
||||
source: &'a str,
|
||||
file_total: Option<i64>,
|
||||
poster: &'a str,
|
||||
groups: &'a [String],
|
||||
posted_at: Option<i64>,
|
||||
posted_last: Option<i64>,
|
||||
files: usize,
|
||||
segments: usize,
|
||||
bytes: i64,
|
||||
first_article: i64,
|
||||
last_article: i64,
|
||||
readable: bool,
|
||||
has_par2: bool,
|
||||
payload: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
rule_key: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireCandidate<'a> {
|
||||
release_key: &'a str,
|
||||
upload: usize,
|
||||
kind: &'a str,
|
||||
message_id: &'a str,
|
||||
file: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WireCount<'a> {
|
||||
rule: &'a str,
|
||||
key: &'a str,
|
||||
articles: u64,
|
||||
files: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireFragmentRelease<'a> {
|
||||
key: &'a str,
|
||||
name: &'a str,
|
||||
source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
file_total: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireFragment<'a> {
|
||||
file_key: &'a str,
|
||||
name: &'a str,
|
||||
poster: &'a str,
|
||||
subject: &'a str,
|
||||
total_parts: u32,
|
||||
groups: &'a [String],
|
||||
release: Option<WireFragmentRelease<'a>>,
|
||||
posted_at: Option<i64>,
|
||||
segments: Vec<(u32, &'a str, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WireBatch<'a> {
|
||||
from: i64,
|
||||
next: i64,
|
||||
headers: u64,
|
||||
releases: Vec<WireRelease<'a>>,
|
||||
samples: Vec<WireRelease<'a>>,
|
||||
candidates: Vec<WireCandidate<'a>>,
|
||||
counts: Vec<WireCount<'a>>,
|
||||
fragments: Vec<WireFragment<'a>>,
|
||||
}
|
||||
|
||||
fn release<'a>(r: &'a ReleaseChunk, rule: Option<(&'a str, &'a str)>) -> WireRelease<'a> {
|
||||
WireRelease {
|
||||
key: &r.key,
|
||||
upload: r.upload,
|
||||
name: &r.name,
|
||||
source: r.source,
|
||||
file_total: r.file_total,
|
||||
poster: &r.poster,
|
||||
groups: &r.groups,
|
||||
posted_at: r.posted_at,
|
||||
posted_last: r.posted_last,
|
||||
files: r.files,
|
||||
segments: r.segments,
|
||||
bytes: r.bytes,
|
||||
first_article: r.first_article,
|
||||
last_article: r.last_article,
|
||||
readable: r.readable,
|
||||
has_par2: r.has_par2,
|
||||
payload: base64::engine::general_purpose::STANDARD.encode(&r.payload),
|
||||
rule: rule.map(|(rule, _)| rule),
|
||||
rule_key: rule.map(|(_, key)| key),
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(c: &Candidate) -> WireCandidate<'_> {
|
||||
WireCandidate { release_key: &c.release_key, upload: c.upload, kind: c.kind, message_id: &c.message_id, file: &c.file }
|
||||
}
|
||||
|
||||
fn fragment(f: &Fragment) -> WireFragment<'_> {
|
||||
WireFragment {
|
||||
file_key: &f.file_key,
|
||||
name: &f.name,
|
||||
poster: &f.poster,
|
||||
subject: &f.subject,
|
||||
total_parts: f.total_parts,
|
||||
groups: &f.groups,
|
||||
// A named file always has a release; fileTotal only when truthy, as fragmentOf.
|
||||
release: Some(WireFragmentRelease { key: &f.release.key, name: &f.release.name, source: f.release.source, file_total: f.release.file_total_truthy() }),
|
||||
posted_at: f.posted_at,
|
||||
segments: f.segments.iter().map(|(n, id, bytes, article)| (*n, id.as_str(), *bytes, *article)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// toWire(batch) as JSON bytes.
|
||||
pub fn to_json(batch: &Batch) -> Vec<u8> {
|
||||
let wire = WireBatch {
|
||||
from: batch.from,
|
||||
next: batch.next,
|
||||
headers: batch.headers,
|
||||
releases: batch.releases.iter().map(|r| release(r, None)).collect(),
|
||||
samples: batch.samples.iter().map(|(r, rule, key)| release(r, Some((rule, key.as_str())))).collect(),
|
||||
candidates: batch.candidates.iter().map(candidate).collect(),
|
||||
counts: batch.counts.iter().map(|(rule, key, articles, files)| WireCount { rule, key, articles: *articles, files: *files }).collect(),
|
||||
fragments: batch.fragments.iter().map(fragment).collect(),
|
||||
};
|
||||
serde_json::to_vec(&wire).unwrap()
|
||||
}
|
||||
Reference in new issue
Block a user