change CI pipeline to alpine based, improve sync parallelism

This commit is contained in:
Matt Hess
2026-02-27 19:59:57 +00:00
parent 9bf50947dd
commit f5e945a23b
5 changed files with 42 additions and 344 deletions
+21 -15
View File
@@ -15,32 +15,32 @@ jobs:
fmt:
name: Formatting
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: rustup component add rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Lint (clippy)
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: rustup component add clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace --all-targets
compile-wasm:
name: Compile WASM target
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- run: rustup target add wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Check crypto crate compiles for WASM
run: cargo check -p salvium-crypto --target wasm32-unknown-unknown
@@ -50,9 +50,10 @@ jobs:
name: Compile workspace
needs: [fmt, clippy, compile-wasm]
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build all crates
run: cargo build --workspace
@@ -64,9 +65,10 @@ jobs:
name: "Test: types + consensus rules"
needs: [compile]
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: salvium-types
run: cargo test -p salvium-types -- --nocapture
@@ -77,9 +79,10 @@ jobs:
name: "Test: crypto verification"
needs: [compile]
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: salvium-crypto (unit)
run: cargo test -p salvium-crypto --lib -- --nocapture
@@ -90,9 +93,10 @@ jobs:
name: "Test: wallet + transactions + CLI"
needs: [compile]
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: salvium-wallet (unit + integration compile)
run: cargo test -p salvium-wallet -- --nocapture
@@ -105,9 +109,10 @@ jobs:
name: "Test: miner + multisig + RPC"
needs: [compile]
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: salvium-miner
run: cargo test -p salvium-miner -- --nocapture
@@ -120,9 +125,10 @@ jobs:
name: Documentation
needs: [compile]
runs-on: ubuntu-latest
container: rust:alpine
steps:
- run: apk add --no-cache musl-dev perl make git
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Doc tests
run: cargo test --workspace --doc
Generated
+1 -273
View File
@@ -354,22 +354,6 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[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"
@@ -548,15 +532,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "1.0.0"
@@ -580,12 +555,6 @@ dependencies = [
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -636,27 +605,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -774,25 +722,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -802,19 +731,13 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
"hashbrown",
]
[[package]]
@@ -924,7 +847,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -953,22 +875,6 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -987,11 +893,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1108,16 +1012,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -1290,12 +1184,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minicov"
version = "0.3.8"
@@ -1339,23 +1227,6 @@ dependencies = [
"pxfm",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1445,38 +1316,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.5+3.5.5"
@@ -1873,22 +1712,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1899,7 +1733,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
@@ -2278,15 +2111,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2307,29 +2131,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
@@ -2525,27 +2326,6 @@ dependencies = [
"syn",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tempfile"
version = "3.24.0"
@@ -2661,16 +2441,6 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -2681,19 +2451,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -3004,35 +2761,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[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.48.0"
+1 -1
View File
@@ -6,7 +6,7 @@ description = "Async and blocking RPC clients for Salvium daemon and wallet"
[dependencies]
salvium-types = { path = "../salvium-types" }
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+1 -1
View File
@@ -22,7 +22,7 @@ ml-kem = { version = "0.2", features = ["deterministic"] }
hkdf = "0.12"
sha2 = "0.10"
chacha20 = "0.9"
reqwest = { version = "0.12", features = ["json"], default-features = false, optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
hidapi = { version = "2.6", optional = true }
[features]
+18 -54
View File
@@ -52,10 +52,6 @@ struct BatchController {
max_batch: usize,
target_batch_time_ms: u64,
consecutive_errors: u32,
/// Approximate max bytes per batch (mobile-friendly cap).
max_batch_bytes: usize,
/// Last observed bytes per second.
last_bytes_per_sec: f64,
}
impl BatchController {
@@ -66,16 +62,11 @@ impl BatchController {
max_batch: 1000,
target_batch_time_ms: 1000,
consecutive_errors: 0,
max_batch_bytes: 2 * 1024 * 1024, // 2 MB
last_bytes_per_sec: 0.0,
}
}
/// Adjust batch size based on timing and throughput.
///
/// `blocks` is the number of blocks in the batch, `bytes` is the total
/// response size, `elapsed_ms` is how long the batch took.
fn adjust(&mut self, elapsed_ms: u64, had_error: bool, blocks: usize, bytes: usize) {
/// Adjust batch size based on elapsed time per batch.
fn adjust(&mut self, elapsed_ms: u64, had_error: bool) {
if had_error {
self.batch_size = (self.batch_size / 2).max(self.min_batch);
self.consecutive_errors += 1;
@@ -87,26 +78,11 @@ impl BatchController {
self.consecutive_errors = 0;
// Track throughput.
if elapsed_ms > 0 {
self.last_bytes_per_sec = bytes as f64 / (elapsed_ms as f64 / 1000.0);
}
// Time-based scaling.
if elapsed_ms < self.target_batch_time_ms {
self.batch_size = (self.batch_size + self.batch_size / 2).min(self.max_batch);
} else {
self.batch_size = (self.batch_size - self.batch_size / 4).max(self.min_batch);
}
// Byte-cap: if the batch exceeded the byte limit, scale down proportionally.
if blocks > 0 && bytes > self.max_batch_bytes {
let avg_block_bytes = bytes / blocks;
if avg_block_bytes > 0 {
let byte_limited = self.max_batch_bytes / avg_block_bytes;
self.batch_size = self.batch_size.min(byte_limited).max(self.min_batch);
}
}
}
/// Return the batch size for the next round, capped by remaining blocks.
@@ -229,7 +205,7 @@ impl SyncEngine {
let batch_data = match batch_result {
Ok(r) => r,
Err(e) => {
controller.adjust(batch_timer.elapsed().as_millis() as u64, true, 0, 0);
controller.adjust(batch_timer.elapsed().as_millis() as u64, true);
if let Some(tx) = event_tx {
let _ = tx.send(SyncEvent::Error(e.to_string())).await;
}
@@ -275,7 +251,7 @@ impl SyncEngine {
}
current = reorg_start;
controller.adjust(batch_timer.elapsed().as_millis() as u64, true, 0, 0);
controller.adjust(batch_timer.elapsed().as_millis() as u64, true);
continue;
}
}
@@ -283,7 +259,7 @@ impl SyncEngine {
}
if bin_blocks.len() != heights.len() {
controller.adjust(batch_timer.elapsed().as_millis() as u64, true, 0, 0);
controller.adjust(batch_timer.elapsed().as_millis() as u64, true);
if let Some(tx) = event_tx {
let _ = tx
.send(SyncEvent::Error(format!(
@@ -315,12 +291,6 @@ impl SyncEngine {
}
// ── 3. Parallel parse + sequential store ────────────────────
// Estimate batch bytes for throughput tracking.
let batch_bytes: usize = bin_blocks
.iter()
.map(|e| e.block.len() + e.txs.iter().map(|t| t.len()).sum::<usize>())
.sum();
// Phase 1: Parse + scan all blocks in parallel via spawn_blocking.
let scan_ctx_clone = scan_ctx.clone();
let parse_results: Vec<ParsedBlockResult> = {
@@ -521,13 +491,7 @@ impl SyncEngine {
}
// ── 6. Adapt batch size + race nodes ────────────────────────
let n_blocks = bin_blocks.len();
controller.adjust(
batch_timer.elapsed().as_millis() as u64,
false,
n_blocks,
batch_bytes,
);
controller.adjust(batch_timer.elapsed().as_millis() as u64, false);
pool.maybe_race().await;
}
@@ -2470,9 +2434,9 @@ mod tests {
fn test_batch_controller_scale_up() {
let mut ctrl = BatchController::new();
// Fast batch → scale up by 50%
ctrl.adjust(500, false, 64, 100_000); // 500ms < 1s target
ctrl.adjust(500, false); // 500ms < 1s target
assert_eq!(ctrl.batch_size, 96); // 64 + 32
ctrl.adjust(500, false, 96, 150_000);
ctrl.adjust(500, false);
assert_eq!(ctrl.batch_size, 144); // 96 + 48
}
@@ -2481,7 +2445,7 @@ mod tests {
let mut ctrl = BatchController::new();
ctrl.batch_size = 40;
// Slow batch → scale down by 25%
ctrl.adjust(5000, false, 40, 100_000); // 5s > 3s target
ctrl.adjust(5000, false); // 5s > 3s target
assert_eq!(ctrl.batch_size, 30); // 40 - 10
}
@@ -2489,7 +2453,7 @@ mod tests {
fn test_batch_controller_error_halves() {
let mut ctrl = BatchController::new();
ctrl.batch_size = 20;
ctrl.adjust(0, true, 0, 0);
ctrl.adjust(0, true);
assert_eq!(ctrl.batch_size, 10); // 20 / 2
assert_eq!(ctrl.consecutive_errors, 1);
}
@@ -2498,9 +2462,9 @@ mod tests {
fn test_batch_controller_consecutive_errors_drop_to_min() {
let mut ctrl = BatchController::new();
ctrl.batch_size = 50;
ctrl.adjust(0, true, 0, 0); // 25
ctrl.adjust(0, true, 0, 0); // 12
ctrl.adjust(0, true, 0, 0); // 3 consecutive → drops to min
ctrl.adjust(0, true); // 25
ctrl.adjust(0, true); // 12
ctrl.adjust(0, true); // 3 consecutive → drops to min
assert_eq!(ctrl.batch_size, ctrl.min_batch);
assert_eq!(ctrl.consecutive_errors, 3);
}
@@ -2508,10 +2472,10 @@ mod tests {
#[test]
fn test_batch_controller_error_resets_on_success() {
let mut ctrl = BatchController::new();
ctrl.adjust(0, true, 0, 0);
ctrl.adjust(0, true, 0, 0);
ctrl.adjust(0, true);
ctrl.adjust(0, true);
assert_eq!(ctrl.consecutive_errors, 2);
ctrl.adjust(1000, false, 64, 100_000);
ctrl.adjust(1000, false);
assert_eq!(ctrl.consecutive_errors, 0);
}
@@ -2519,7 +2483,7 @@ mod tests {
fn test_batch_controller_respects_max() {
let mut ctrl = BatchController::new();
ctrl.batch_size = 90;
ctrl.adjust(100, false, 90, 100_000); // fast → scale up
ctrl.adjust(100, false); // fast → scale up
assert!(ctrl.batch_size <= ctrl.max_batch);
}
@@ -2527,7 +2491,7 @@ mod tests {
fn test_batch_controller_respects_min() {
let mut ctrl = BatchController::new();
ctrl.batch_size = 2;
ctrl.adjust(10000, false, 2, 50_000); // slow → scale down
ctrl.adjust(10000, false); // slow → scale down
assert!(ctrl.batch_size >= ctrl.min_batch);
}