From 9f2db7cf5c4345847ecfeb4fa6633907962c7453 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:27:02 +0530 Subject: [PATCH 1/2] ci: fix rust caches (wrong target dir, poisoned shared key), adopt nextest Every rust job recompiled all ~490 crates on every run despite "full match: true" cache restores, because rust-cache pointed at the workspace member (apps/backend) while cargo writes artifacts to the repo-root target/. The saved 117MB caches held only the registry. - rust-cache: workspaces ". -> target" everywhere (ci + release) - split lint/test cache keys: caches are immutable and lint always won the save race, freezing clippy check-artifacts into the key the test lanes restored; tests could never save codegen artifacts - cache-on-failure: true so red runs still seed the dep cache - CARGO_PROFILE_DEV_DEBUG=0 in CI: ~30-40% less compile+link, smaller caches; only cost is line numbers in CI backtraces - cargo-nextest in test lanes: one parallel pool across the 4 test binaries (cargo test ran them sequentially - 5.5 min of runtime on the Windows lane alone); pg/mysql keep the 4-thread cap nextest previously broke the suite: it is process-per-test, so the once-per-process leftover-database sweep ran concurrently in every test process and dropped databases of still-running tests. The sweep is now age-gated - the UUIDv7 in each cms_test_* name embeds its creation time, and only databases older than 5 minutes are reclaimed. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 61 +++++++++++++++++++--------- .github/workflows/release.yml | 4 +- apps/backend/tests/common/test_db.rs | 47 +++++++++++++++++---- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f2c65a9..f9ef5192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ permissions: env: CARGO_TERM_COLOR: always + # CI-only: skip debuginfo in dev/test builds. Cuts compile+link time ~30-40% + # and shrinks caches; the only cost is line numbers in CI backtraces. + CARGO_PROFILE_DEV_DEBUG: 0 jobs: # Decide which areas changed so we only run the jobs that matter. Pure doc edits @@ -91,12 +94,16 @@ jobs: toolchain: "1.94" - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - workspaces: apps/backend - # Share the dependency cache with the linux test lanes (deps are - # identical; clippy's final artifacts differ but the heavy 3rd-party - # layer is reused). rust-cache prunes to deps before saving, so a - # single shared cache serves lint + all 3 db lanes. - shared-key: linux + # Workspace root is the REPO root (Cargo.lock + target/ live there); + # cargo just runs from apps/backend. Pointing rust-cache at the member + # dir would cache the nonexistent apps/backend/target. + workspaces: ". -> target" + # Lint has its own key: GH caches are immutable and clippy saves + # check-artifacts that are useless for `cargo test` codegen, so + # sharing a key with the test lanes lets whichever job finishes first + # (always lint) poison the cache for the others. + shared-key: linux-lint + cache-on-failure: true - run: cargo fmt --all -- --check # --no-default-features drops `embed-dashboard`, whose RustEmbed derive needs # apps/dashboard/dist at compile time (not built in CI). @@ -127,9 +134,9 @@ jobs: fail-fast: false matrix: # SQLite runs fully parallel (in-memory, isolated per test). Postgres and - # MySQL run database-per-test against one shared server, so test threads are - # capped to keep the connection demand well under the server's ceiling on - # high-core runners. + # MySQL run database-per-test against one shared server, so concurrent + # tests are capped to keep the connection demand well under the server's + # ceiling on high-core runners. include: - db: sqlite test_database: sqlite @@ -138,11 +145,11 @@ jobs: - db: postgres test_database: postgres test_database_url: postgres://postgres:postgres@localhost:5432/postgres - test_args: "-- --test-threads=4" + test_args: "--test-threads 4" - db: mysql test_database: mysql test_database_url: mysql://root:root@localhost:3306/mysql - test_args: "-- --test-threads=4" + test_args: "--test-threads 4" env: TEST_DATABASE: ${{ matrix.test_database }} TEST_DATABASE_URL: ${{ matrix.test_database_url }} @@ -185,15 +192,24 @@ jobs: toolchain: "1.94" - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - workspaces: apps/backend + # Workspace root is the REPO root (Cargo.lock + target/ live there). + workspaces: ". -> target" # All 3 db lanes compile an identical binary (db is chosen at runtime - # via TEST_DATABASE), so one shared cache lets lanes 2 & 3 restore - # fully warm. Shared with the lint job too (deps are identical). - shared-key: linux + # via TEST_DATABASE), so one shared cache serves all of them; the + # save race between lanes is harmless. NOT shared with lint: clippy + # saves check-artifacts that are useless for test codegen. + shared-key: linux-test + cache-on-failure: true + - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + with: + tool: cargo-nextest + # nextest runs all test binaries' tests in ONE parallel pool (cargo test + # runs the binaries sequentially). Process-per-test also isolates env-var + # mutation between tests. # --no-default-features drops `embed-dashboard` (RustEmbed needs a built # apps/dashboard/dist at compile time, which CI doesn't produce). - - name: cargo test - run: cargo test --no-default-features --locked ${{ matrix.test_args }} + - name: cargo nextest + run: cargo nextest run --no-default-features --locked ${{ matrix.test_args }} # Cross-platform coverage. The binary ships on Windows/macOS/Linux, so the suite # must run off Linux too. Service containers don't run on these runners, so only @@ -224,11 +240,16 @@ jobs: toolchain: "1.94" - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - workspaces: apps/backend + # Workspace root is the REPO root (Cargo.lock + target/ live there). + workspaces: ". -> target" shared-key: ${{ matrix.os }} + cache-on-failure: true + - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + with: + tool: cargo-nextest # --no-default-features drops `embed-dashboard` (no built dashboard/dist in CI). - - name: cargo test - run: cargo test --no-default-features --locked + - name: cargo nextest + run: cargo nextest run --no-default-features --locked frontend: name: Frontend (dashboard) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16698e6d..0aaeaf7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,9 @@ jobs: toolchain: "1.94" - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - workspaces: apps/backend + # Workspace root is the REPO root (Cargo.lock + target/ live there); + # cargo runs from apps/backend but artifacts land in /target. + workspaces: ". -> target" key: release-${{ matrix.os }}-${{ matrix.arch }} # Build cargo directly (dist is already present from the artifact); keep the diff --git a/apps/backend/tests/common/test_db.rs b/apps/backend/tests/common/test_db.rs index c14cafe5..2f4db2c3 100644 --- a/apps/backend/tests/common/test_db.rs +++ b/apps/backend/tests/common/test_db.rs @@ -13,9 +13,10 @@ //! - **SQLite** stays `sqlite::memory:` — no Docker, identical to the original //! behaviour, so a bare `cargo test` keeps working with zero setup. //! - **Postgres / MySQL** get a fresh `cms_test_` database created here -//! and dropped on teardown (best-effort). A one-time startup sweep drops any -//! leftover `cms_test_*` databases from a previous aborted run, so local -//! re-runs are self-healing. +//! and dropped on teardown (best-effort). A startup sweep drops leftover +//! `cms_test_*` databases from a previous aborted run (only ones older than +//! [`SWEEP_MIN_AGE_MS`], so concurrent test processes never sweep each +//! other), keeping local re-runs self-healing. use sqlx::Connection; use tokio::sync::OnceCell; @@ -180,8 +181,38 @@ async fn drop_database(admin_url: &str, db_name: &str, backend: Backend) -> Resu } /// Run the leftover-database sweep exactly once per test process. +/// +/// Under `cargo nextest` every test runs in its own process, so the sweep runs +/// many times concurrently — the age gate in `sweep_leftovers` is what keeps +/// those concurrent sweeps from dropping each other's live databases. static SWEEP: OnceCell<()> = OnceCell::const_new(); +/// Only databases older than this are swept. Live tests never get close (a +/// single test finishes in seconds); anything past it is a leak from an +/// aborted run. +const SWEEP_MIN_AGE_MS: u64 = 5 * 60 * 1000; + +/// Age gate for the sweep: `true` when the database was created more than +/// [`SWEEP_MIN_AGE_MS`] ago. The name embeds a UUIDv7 (`cms_test_`) whose +/// first 12 hex chars are the 48-bit unix-ms creation time. Names that don't +/// parse are legacy leftovers — treat as stale so they still get reclaimed. +fn is_stale(db_name: &str) -> bool { + let Some(ts_hex) = db_name + .strip_prefix(DB_PREFIX) + .and_then(|uuid| uuid.get(..12)) + else { + return true; + }; + let Ok(created_ms) = u64::from_str_radix(ts_hex, 16) else { + return true; + }; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_millis() as u64; + now_ms.saturating_sub(created_ms) > SWEEP_MIN_AGE_MS +} + async fn ensure_swept(admin_url: &str, backend: Backend) { SWEEP .get_or_init(|| async { @@ -192,9 +223,11 @@ async fn ensure_swept(admin_url: &str, backend: Backend) { .await; } -/// Drop every pre-existing `cms_test_*` database — reclaims leaks from a -/// previous run that aborted before teardown. CI containers are ephemeral, so -/// this mainly keeps local dev idempotent. +/// Drop stale `cms_test_*` databases — reclaims leaks from a previous run that +/// aborted before teardown. CI containers are ephemeral, so this mainly keeps +/// local dev idempotent. Only databases older than [`SWEEP_MIN_AGE_MS`] are +/// dropped so concurrent test processes (nextest) can't sweep away databases +/// of tests that are still running. async fn sweep_leftovers(admin_url: &str, backend: Backend) -> Result<(), sqlx::Error> { let names: Vec = match backend { Backend::Postgres => { @@ -223,7 +256,7 @@ async fn sweep_leftovers(admin_url: &str, backend: Backend) -> Result<(), sqlx:: Backend::Sqlite => return Ok(()), }; - for name in names { + for name in names.into_iter().filter(|n| is_stale(n)) { drop_database(admin_url, &name, backend).await?; } Ok(()) From 21930e1164dceedf9d6b656f5d5737c4ca892a4f Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:46:25 +0530 Subject: [PATCH 2/2] fix: rustfmt in test_db.rs; ignore quick-xml advisories pending upstream cargo-deny was already red on main: RUSTSEC-2026-0194/0195 (quick-xml < 0.41 XML-parsing DoS). No upgrade path exists - even object_store 0.14 pins quick-xml ^0.40.1 - and the parser only sees XML responses from the operator-configured S3 endpoint, not user input. Ignored in deny.toml with justification; revisit when object_store allows >= 0.41. Co-Authored-By: Claude Fable 5 --- apps/backend/tests/common/test_db.rs | 5 +---- deny.toml | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/backend/tests/common/test_db.rs b/apps/backend/tests/common/test_db.rs index 2f4db2c3..75a5c9d2 100644 --- a/apps/backend/tests/common/test_db.rs +++ b/apps/backend/tests/common/test_db.rs @@ -197,10 +197,7 @@ const SWEEP_MIN_AGE_MS: u64 = 5 * 60 * 1000; /// first 12 hex chars are the 48-bit unix-ms creation time. Names that don't /// parse are legacy leftovers — treat as stale so they still get reclaimed. fn is_stale(db_name: &str) -> bool { - let Some(ts_hex) = db_name - .strip_prefix(DB_PREFIX) - .and_then(|uuid| uuid.get(..12)) - else { + let Some(ts_hex) = db_name.strip_prefix(DB_PREFIX).and_then(|uuid| uuid.get(..12)) else { return true; }; let Ok(created_ms) = u64::from_str_radix(ts_hex, 16) else { diff --git a/deny.toml b/deny.toml index 38f34711..2391d8e2 100644 --- a/deny.toml +++ b/deny.toml @@ -15,6 +15,14 @@ ignore = [ # vulnerability and there is no safe upgrade available. Revisit when `image` # drops the dependency. "RUSTSEC-2024-0436", + # quick-xml < 0.41 CPU/memory DoS when parsing hostile XML (quadratic + # duplicate-attribute check; unbounded namespace allocations). Pulled in only + # via object_store's S3 client, which parses XML *responses from the + # configured S3 endpoint* — an operator-chosen, trusted-ish peer, not + # arbitrary user input. No upgrade path yet: even object_store 0.14 pins + # quick-xml ^0.40.1 (< 0.41). Revisit when object_store moves to >= 0.41. + "RUSTSEC-2026-0194", + "RUSTSEC-2026-0195", ] [licenses]