Skip to content

sbx-mgr: route every SQLite statement through db/ and fence the rest with cargo arch - #561

Merged
ac-pplx merged 1 commit into
mainfrom
ac/sbxmgr-store-1-sql-into-db
Oct 19, 2026
Merged

ac-pplx merged 1 commit into
mainfrom
ac/sbxmgr-store-1-sql-into-db

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Milestone 1 of 3 toward a single owner for sbx-mgr's node-local SQLite database: sandboxes and every other sbx-mgr table, not the control-plane Postgres. After this PR:

  • every SQL statement in sbx-mgr's crates lives in crates/libs/sbx/mgr/server/src/db;
  • two cargo arch ratchets count what remains and trip on the common ways of adding a new crossing. They are lexical tripwires; the compile-time guarantee is milestone 3's.

What moved

Callers keep their transactions, locks and error mapping; the statements and their row decoding move into named db/ functions.

From To Notes
auto_lifecycle_delete_receipts/db.rs (whole file) and its tests db/auto_lifecycle_delete_receipts.rs, db/auto_lifecycle_delete_receipts/tests.rs The parent module keeps only the receipt types (and the one type-only test), so it is now a leaf module.
OpenRouteActivationClaim and its statements db/route_activation_claims.rs validate_cluster_fenced_activation and its error stay in route_activation_claims.rs and depend on db/ one way.
local_artifacts.rs statements, PendingBackup, ArtifactState, NamedArtifactPublication db/local_artifact_leases.rs LocalArtifacts, both leases, their IMMEDIATE transactions and mutex poisoning stay put. The types are re-exported, so callers are unchanged. Identical statements (pin check, deleting rollback/delete) now share one function each; see "Hidden observable changes" for the pin check.
sandbox_eviction scans, counts and evidence reads db/eviction_scan.rs The wrappers in sandbox_eviction keep their names and map rows to the eviction types, so its tests still drive them.
watchdog guest-health and probe-state reads db/guest_health_scan.rs The probe lookup still prepares once. A prepare failure still drops the whole lookup; a per-id failure still logs and continues. New state_labels_follow_the_ids_and_report_missing_rows_as_none covers it. Dropping .optional() (as .map(Some)) fails it with QueryReturnedNoRows.
snapshot-upload jobs statements and the marker backfill's db/snapshot_upload_jobs.rs The backfill visits rows through the open cursor (a visitor closure), so marker writes still interleave with the read. The helper takes the backfill's timestamp after binding the read and before the first row, as before, and passes it to the visitor.
cross-node recovery's SELECT state existing db::current_state_label Reuse rather than a second copy.
SNAPSHOT_UPLOAD_JOB_KIND (the constant) db/snapshot_upload_jobs.rs main's retired-teardown release claim in db/eviction.rs (#331) cancels the refused capture's upload job by this kind. snapshot_upload_jobs re-exports it, so its callers are unchanged and db/ still depends only on leaf modules.

Afterwards, db/ production code depends only on the leaf type modules state, auto_lifecycle, auto_lifecycle_delete_receipts, endpoint_proof and restore_operation. Nothing it imports imports db/.

The fence: lexical tripwires and migration counters

These two cargo arch instances are lexical tripwires and migration counters, not a guarantee. They match single lines of text, so they count the known crossings this stack removes and catch the common ways of adding one. They cannot see aliases, wrapped imports or calls through names imported earlier. The guarantee is milestone 3's compile-time boundary, where the store becomes its own crate:

  • the server crate has no rusqlite dependency;
  • no rusqlite-typed value is reachable from server production code through the store's API.

archcheck's pattern_ratchet gains an optional paths = [...] (globs of the same form as allow) that limits an instance to the files it matches.

  • Without it, an instance checks every production file, as before.
  • An empty paths is rejected (exit 2); an empty allow stays legal.
  • A baseline entry for a file outside paths is stale.
  • Covered by pattern_ratchet_paths and documented in the archcheck README.
  • main has since gained node-manager: prevent signal-driven btrfs send restarts #591's optional include, which also narrows an instance's files. Its edges differ: an empty include measures nothing (tested), and a baseline entry outside it gets the ordinary stale wording. Both keys are kept unchanged, so neither side's tested behaviour moves; an instance that gives both checks only the files matching both. The sbxmgr_* instances use paths. Folding the two keys into one would change one of them, so it is left for a follow-up.

[rules.pattern_ratchet.sbxmgr_sqlite] checks crates/libs/sbx/mgr/** and crates/apps/sbx/**. The CLI and svc crates match nothing today. It allows only src/db.rs and src/db/**, and matches lines containing:

  • rusqlite::, use rusqlite (including use ::rusqlite and use rusqlite as x), and extern crate rusqlite;
  • unqualified Connection::open*, Connection::new* and Transaction::new*;
  • the qualified path sql::sqlite (the shared SQLite open and migrate helpers);
  • pplx_space_common_jobs::db, plain or inside a {…} group on the same line;
  • params! and named_params!;
  • method calls .execute(, .execute_batch(, .prepare(, .prepare_cached(, .query_row(, .query_row_and_then(, .query_one(, .query_map(, .query_and_then(, .transaction(, .unchecked_transaction(, .transaction_with_behavior(, .savepoint(, .pragma_update(, .pragma_query( and .pragma_query_value(;
  • immediate_transaction(;
  • .query( or .exists( with an argument on the same line, so path.exists() and uri.query() don't match;
  • SQL statement literals: a string that starts with one of the statements below, or a line that starts one inside a multi-line literal:
    • SELECT, INSERT … INTO;
    • UPDATE <table> SET (or UPDATE <table> ending the line), DELETE FROM;
    • DDL, PRAGMA, a CTE, or a transaction-control statement.

[rules.pattern_ratchet.sbxmgr_sqlite_schema] checks every crate. It allows the store, the migrations crate, and common/core/src/host/linux.rs, where the path is defined. It matches:

  • pplx_space_sbxmgr_migrations and SANDBOX_MANAGER_(DB|MIGRATOR);
  • the migrator's default-path helper sqlite_sandbox_manager;
  • the file name sbx-manager.db;
  • the migrations directory db/sandbox-manager.

This instance is how the fence counts the database migrator. Putting crates/libs/db/migrator/** into the first instance's paths instead would match 37 unrelated migrator lines (bootstrap_roles.rs 25, lib.rs 7, user_password.rs 5): its Postgres SQL and other services' SQLite databases.

Known escape classes. Each of these reaches the database, or a raw handle to it, on lines that neither instance matches:

  • imports that rustfmt wraps or groups across lines (use pplx_space_common_jobs::{ with db on the next line), and grouped sql::{sqlite} imports;
  • crate and module aliases (use pplx_space_common_svc::sql as s;);
  • calls through a name imported earlier (open_rusqlite(path, options)), and constructors imported under another name (use rusqlite::Connection as Db; is counted, but Db::open(path) is not);
  • BackgroundWorker::new(Arc::clone(&self.db), …), which hands the raw connection to common-jobs;
  • methods outside the alternation: pragma, pragma_update_and_check, savepoint_with_name, raw_execute, raw_query and others;
  • arguments that start on the next line, for .query( and .exists(.

Per the owner's decision these regexes are not hardened further; milestone 3's crate boundary closes these escapes for the store's own API.

Pattern checks:

  • On main, the SQL patterns match all 76 statement lines that sat outside db/.
  • In sbx-mgr production code they match nothing beyond real SQLite access.
  • tests/repository.rs loads the repository's own rules:
    • sbxmgr_sqlite: 22 sample lines, each a crossing. They cover the rusqlite imports, the unqualified constructors, a qualified sql::sqlite call, two job-store imports, both macros, .execute(, .query_one(, .query_row_and_then(, .query(/.exists( with an argument, .unchecked_transaction(, immediate_transaction(, and three SQL literal forms. Eight look-alikes are clean. Not every method in the alternation has its own sample line.
    • Two regressions: a listed file that opens an already-imported Connection grows its count, and so does a child module using use super::Connection. Both tests fail against the patterns this PR first shipped.
    • sbxmgr_sqlite_schema, the migrator case: six crossings and five look-alikes in a migrator source file. The crossings are the migrations import, SANDBOX_MANAGER_DB, the sqlite_sandbox_manager() call and definition, the sbx-manager.db path and the db/sandbox-manager directory. The look-alikes are the template-manager equivalents and the SandboxManager subcommand variant. The case fails without this round's three patterns.

Modes: no_crossing, no_raised_caps, listed_no_growth and stale_entries are required. no_new_entries is advisory for this seeding PR, as in rootfs_copy, so CI reports the seeded entries for review.

Not counted:

  • crates/libs/db/migrator opens and migrates SANDBOX_MANAGER_DB itself today. It is counted as an sbxmgr_sqlite_schema baseline entry, 5 lines; milestone 3 moves it onto the store crate's migration entry point.
  • scripts/disk-usage/reclaim-backed-btrfs-send-streams.py and scripts/space-sbx-db-migrate.sh name sbx-manager.db. They are non-Rust operator tools that archcheck does not read; this PR leaves them unchanged as known exceptions.
  • common-jobs' BackgroundWorker runs SQL on sbx-mgr's jobs table through the connection sbx-mgr hands it. That library is outside sbx-mgr's crates, and the hand-off is one of the escape classes above.

Counts (production code outside db/)

main this PR
SQL statement lines (ratchet SQL patterns) 76 lines in 7 files 0
rusqlite:: / use rusqlite lines 279 lines in 34 files 185 lines in 33 files
all sbxmgr_sqlite pattern lines 481 lines in 35 files 203 lines in 34 files
[pattern_ratchet.sbxmgr_sqlite] entries — 34 entries, 203 lines
[pattern_ratchet.sbxmgr_sqlite_schema] entries — 2 entries, 8 lines (run.rs 3, migrator lib.rs 5)

Round 1 widened the patterns, which left the sbxmgr_sqlite total at 203 lines:

  • cleanup_executor.rs 6 → 7, for its job-store import;
  • run.rs 7 → 6, because its migrations import is now counted by sbxmgr_sqlite_schema.

No other file's count changed.

Round 2 added the three migrator patterns to sbxmgr_sqlite_schema. That raised the migrator's lib.rs from 3 to 5 lines (the sqlite_sandbox_manager definition and its call); the baseline is recomputed.

What remains:

  • connection handles (Arc<Mutex<rusqlite::Connection>>) and rusqlite::Result/Error types and matches;
  • 16 transaction openings: 11 matched by the method patterns, and 5 rusqlite::Transaction::new_unchecked in local_artifacts.rs;
  • the cleanup executor's job-store import;
  • run.rs's open and migration.

Milestone 2 replaces them with the store's own handle, transaction and error types. It routes the cleanup executor's job-store calls through db::cleanup_jobs and empties the sbxmgr_sqlite baseline.

file_size baseline:

  • The moved receipts file keeps its 1158 cap under its new path. With the --renames list CI passes (archcheck: accept a renamed baseline entry instead of reporting it as new #596), no_new_entries accepts the moved entry; without it, it reports the rename for review.
  • Caps are lowered where files shrank:
    • sandbox_eviction.rs 1273→1253;
    • db/state_transitions.rs 1018→1016;
    • db/sandboxes.rs 1553→1547.
  • snapshot_upload_jobs.rs, recovery_cross_node.rs and db/cleanup_episodes.rs also shrink, but main had already grown them past their caps (1318 > 1210, 1954 > 1946 and 1845 > 1833), so their caps stay.
  • No cap is raised.

Hidden observable changes

  • Log targets: none renamed. No tracing event moved: the lease Drop warnings, the backfill warning and the eviction and watchdog logs stay in their modules.
  • sql! locators (module_path!():line!() in the SQL text, visible in SQLite profile traces):
    • the named-publication inventory read, the delete-checkout pin check and gc_staging_is_unowned now carry pplx_space_sbx_mgr::db::local_artifact_leases:<line>;
    • the guest-health read now carries pplx_space_sbx_mgr::db::guest_health_scan:<line>;
    • cross-node recovery's state read uses current_state_label, which has no locator.
  • The pin check:
    • checkout_delete_in_transaction's copy was sql!-wrapped; recover_stuck_states' copy had different whitespace and no sql!.
    • Both now run the one sql!-wrapped statement, so the recovery path's SQL text changes in whitespace and gains a locator.
  • Unmoved statements whose locator line shifted. These sql! statements did not move, but shorter db::route_activation_claims paths removed lines above them, so their line!() shifted by −6:
    • db/cleanup_episodes.rs 1784, 1806, 1824, 1925, 1938;
    • db/sandboxes.rs 1177, 1275, 1282.
  • SQL text whitespace: some extracted statements are re-indented with their new functions. Only the whitespace between tokens changed.
  • No file!()-derived values are involved.

Decisions

  • Keep module APIs, move the statements. LocalArtifacts, sandbox_eviction and snapshot_upload_jobs keep their entry points and transaction ownership. Only the receipts' db.rs moves wholesale, since it was already a persistence module.
  • Domain row types follow their statements. These are the receipts' SQL types, OpenRouteActivationClaim, the artifact state, and the watchdog's guest-health row; otherwise db/ would import its callers.
  • A second instance for the schema and path. Its patterns are specific enough to check workspace-wide. Adding the migrator to the first instance's paths would flag its unrelated Postgres and SQLite code.
  • Rebased onto main 825b786 (66 commits past the original base). main's new sbx-mgr SQL all landed inside db/ (db/eviction.rs, db/cleanup_jobs.rs, db/local_artifacts.rs, db/cleanup_episodes.rs, db/state_write.rs and their tests), so no statement had to move. The counts above are recounted on the new base and are unchanged. The one new non-leaf dependency inside db/, crate::snapshot_upload_jobs::SNAPSHOT_UPLOAD_JOB_KIND in db/eviction.rs, is resolved by moving the constant (table above). Conflicts: the archcheck include/paths merge above; the node_manager_commands rule and baseline table, kept beside the two new ones; the README's exit-2 paragraph, which keeps archcheck: accept a renamed baseline entry instead of reporting it as new #596's --renames errors and adds the paths error; and a watchdog import.
  • Rebased onto main c4be884, without conflicts. The five new main commits touch no sbx-mgr, migrator, common or archcheck code, so the counts and caps above are unchanged.
  • The seeding counts differ from the task brief's estimates. The brief's per-file SQL counts (for example grpc_state_snapshot.rs 11) included test modules; in production code the SQL sat in 7 files.

Validation

Check Result
bash ci/scripts/run-rust-checks.sh fmt (includes the Rust error policy) pass
bash ci/scripts/run-rust-checks.sh clippy (CI's clippy mode, including the workspace --all-targets --all-features pass) pass
cargo arch --base-baseline <main> --base-architecture <main> 0 required findings. The advisory ones are pre-existing file_size drift, plus the expected no_new_entries for the two seeded tables and the renamed receipts file.
SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0 1731 passed, 0 failed. The 1 skip is the root-only #[ignore] transfer_directory UID test, not a dependency skip.
cargo test --locked -p pplx-space-archcheck pass: 47 CLI tests including pattern_ratchet_paths and main's include scope test, and 5 repository tests including the two sbxmgr_sqlite pattern tests, the migrator case and main's node_manager_commands case

After the c4be884 rebase, fmt, clippy, cargo arch against main and the archcheck tests re-ran on every head of the stack and passed. Nextest ran once, on #586's head d9f6b99: SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked --all-features -p pplx-space-sbx-mgr -p pplx-space-sbx-mgr-store -p pplx-space-db-migrate -p pplx-space-sqlite -p pplx-space-db-test-fixtures --retries 0 gave 1808 passed, 0 failed (server 1367, store 366, migrator 53, sqlite 12, test fixtures 10). The 1 skip is the root-only #[ignore] transfer_directory UID test. Kind was not re-run, because the rebase changed no code.

Stacked follow-ups:

🤖 Generated with Claude Code

@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-store-1-sql-into-db branch from 1b0f941 to 08c09fe Compare October 16, 2026 05:59
@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-store-1-sql-into-db branch 2 times, most recently from f3165c8 to 6e5769c Compare October 16, 2026 14:43
@ac-pplx
ac-pplx added this pull request to stack #576 October 16, 2026 14:58
@ac-pplx
ac-pplx marked this pull request as ready for review October 16, 2026 19:18
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 3 findings

No correctness findings. I compared the moved SQL text and the error, Option and transaction handling at each converted call site in local_artifacts.rs, sandbox_eviction.rs, snapshot_upload_jobs.rs, watchdog.rs, route_activation_claims.rs and recovery_cross_node.rs, and they match.

Low

  1. ARCHITECTURE.toml:104: the multi-line SQL pattern covers less than the description says.
    The quote-anchored pattern on line 103 covers REPLACE INTO, DDL, PRAGMA and transaction control. The pattern for a line that starts a statement inside a multi-line literal covers only SELECT, INSERT … INTO, UPDATE, DELETE FROM and WITH. I ran both regexes over a let sql = " line followed by REPLACE INTO sandboxes (id) VALUES (?1)";, CREATE TABLE t (x)"; and PRAGMA foreign_keys = OFF";: none of them matches, while SELECT 1 FROM t"; does. The description says the patterns match "a line that starts one inside a multi-line literal" for the whole list. Either add the missing alternatives to the second pattern or narrow the description. Milestone 3's crate boundary makes this a tripwire gap rather than an open door.
  2. service/recovery_cross_node.rs:689: cross-node recovery's state read loses its sql! locator.
    The old read was sql!("SELECT state FROM sandboxes WHERE id = ?1"). db::current_state_label (db/sandboxes.rs) isn't wrapped, so in a SQLite profile trace this read can't be told apart from the other callers' reads. The description mentions this. Wrapping current_state_label in sql! keeps the reuse and labels every caller.
  3. db/local_artifact_leases.rs: local_artifacts statements now live in three db/ modules.
    db/local_artifacts.rs already owns mark_local_artifact_backup_completed, publish_stopped_artifact and the named-artifact reads. This PR adds db/local_artifact_leases.rs, which also holds pending-backup listing, named-publication inventory and the orphan scan, not only leases. It also adds stamp_backup_completed_if_unset and local_artifact_exists to db/snapshot_upload_jobs.rs. A schema change to local_artifacts now means checking three files. Folding the leases module into db/local_artifacts.rs, or naming it for what it holds, would help the single-owner goal. Take it or leave it.

Verified against 6e5769c8ec. Reproduced locally: the two SQL-literal regexes from ARCHITECTURE.toml against sample lines (finding 1), and the base-vs-head diff of each call site (finding 2). I didn't rerun the test suite; CI is green on this head.

@the-gigi-pplx the-gigi-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. 3 non-blocking findings in the review comment above.

@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-store-1-sql-into-db branch from 6e5769c to d887c6a Compare October 19, 2026 21:28
…with cargo arch

sbx-mgr's node-local SQLite database (`sandboxes` and every other sbx-mgr
table) was queried from seven modules outside `src/db`, and `db/` had
two-way dependencies with `auto_lifecycle_delete_receipts` and
`route_activation_claims`. Every statement now lives in a named `db/`
function, and new `cargo arch` ratchets count what is left and trip on
the common ways of adding a crossing. They are line-based tripwires,
not a guarantee: aliases, wrapped imports and calls through names
imported earlier escape them. The compile-time boundary is the store
crate that follows.

Store moves, with callers keeping their transactions, locks and error
mapping:

- `auto_lifecycle_delete_receipts/db.rs` moves to
  `db/auto_lifecycle_delete_receipts.rs`, with its tests. The parent module
  keeps only the receipt types, so it is a leaf `db/` depends on.
- The claim type and statements of `route_activation_claims` move to
  `db/route_activation_claims.rs`; the admission check that reads them
  stays outside and depends on `db/` one way.
- `local_artifacts.rs`'s statements and row types move to
  `db/local_artifact_leases.rs`; `LocalArtifacts` and its leases keep their
  transactions, mutex and poisoning behaviour, and re-export the types.
- `sandbox_eviction`'s scans and evidence reads move to
  `db/eviction_scan.rs`, the watchdog's to `db/guest_health_scan.rs`, and
  the snapshot-upload jobs' to `db/snapshot_upload_jobs.rs`. The marker
  backfill visits rows through the open cursor and samples its timestamp
  after binding the read, as before, so its writes interleave with the
  read in the same order. The watchdog's per-id state lookup gains a test.
- Cross-node recovery reuses `db::current_state_label`.

`db/` now depends only on the leaf type modules `state`,
`auto_lifecycle`, `auto_lifecycle_delete_receipts`, `endpoint_proof` and
`restore_operation`.

archcheck's `pattern_ratchet` gains an optional, non-empty `paths` list
that limits an instance to the files it matches, since other services'
crates use rusqlite for their own databases.

`[rules.pattern_ratchet.sbxmgr_sqlite]` covers `crates/libs/sbx/mgr/**`
and `crates/apps/sbx/**` and allows only `src/db`. It matches rusqlite
paths and imports, connection and transaction constructors, the shared
SQLite open helpers by their qualified path, the shared job store's SQL
functions, parameter
macros, statement and transaction calls, and SQL statement literals.
Its baseline is seeded with the 34 files that still name rusqlite types,
open transactions or import the job store (203 lines); no SQL statement
remains outside `db/`.

`[rules.pattern_ratchet.sbxmgr_sqlite_schema]` matches the sbx-mgr
migrations crate, the database's path, file name, migrator and
migrations directory names, and the database migrator's default-path
helper, in any crate outside the store, the migrations crate and the
path's definition. Its baseline lists `run.rs` and the database
migrator (8 lines).
@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-store-1-sql-into-db branch from d887c6a to 16016bb Compare October 19, 2026 22:14
@ac-pplx
ac-pplx added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 2fc6cc3 Oct 19, 2026
25 checks passed
@ac-pplx
ac-pplx deleted the ac/sbxmgr-store-1-sql-into-db branch October 19, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants