Port Sentinel-Sync-Service from Python to Rust - #11
Merged
Merged
Conversation
axum + sqlx, replacing FastAPI + SQLAlchemy. The wire contract does not change: Command Center's sync_client.py pushes here and its scripts/restore_from_cloud.py reads back, and neither can notice. WHY, precisely. This service scales to zero, which makes startup time a product property rather than a footnote. Fly's proxy gives an auto-started machine roughly 8s to bind its port. Measured on the live machine, the Python service became reachable in 4.5s of that, and the boot log shows where it went: runner: Machine started in 1.175s <- Firecracker ...3.4s of silence... <- Python interpreter + imports proxy: machine became reachable in 4.548s About 1.5s of headroom on an 8s budget, with the interpreter as the dominant term. Same measurement locally, both containers, same method: Python 2.287s mean container-start -> /health Rust 0.194s mean Note the local Python figure understates the platform's, exactly as it did for the agent earlier this month; the Fly number is the real one. Image also drops 339 MB -> 139 MB, and the runtime image needs neither libpq nor curl because sqlx is a pure-Rust driver and TLS is rustls. NOT for memory safety. Python is memory-safe; this service's exposure is authorization, tenant isolation and SQL, all of which port over unchanged. The rewrite buys startup time, and that is the whole claim. HOW IT WAS VERIFIED. Both implementations were run against one Postgres and a stubbed License-Service, and 28 cases were fired at each with the responses diffed: 28/28 status codes match, 25/28 bodies byte-identical. The three that differ are validation-error bodies, where FastAPI emitted Pydantic's structured error array and this emits a plain message — no caller reads it, and the 422 status is reproduced exactly. The cases that mattered all came back identical: tombstone and revive, the known_ids None-vs-[] distinction, keyset paging and next_cursor, include_deleted, upsert-on-conflict, and the 401/403/502 split. So did timestamp rendering, which was the biggest silent-breakage risk — restore_from_cloud.py parses these with datetime.fromisoformat, and an offset-aware Rust type would have appended a "+00:00" the old service never sent. NaiveDateTime renders 2026-09-01T09:00:00.123456 identically. Those cases now live in tests/wire_contract.rs (12 integration tests) rather than in a throwaway script, because the Python they were diffed against is gone in this commit. Migrations move from an Alembic release_command into sqlx at startup, under a Postgres advisory lock. The one migration is CREATE TABLE / INDEX IF NOT EXISTS matching what Alembic built, and was verified to no-op against a database Alembic had already migrated. 19 tests pass, clippy clean, fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught RUSTSEC-2023-0071 — the Marvin timing attack in `rsa`, which
has no fixed version. Worth chasing rather than waiving on sight, so:
`rsa` is not compiled into this binary. It reaches Cargo.lock through
`sqlx-mysql`, an OPTIONAL dependency of the sqlx facade that this crate
never enables. Cargo.lock records the union of a crate's optional
dependencies regardless of features, and `cargo audit` reads the lockfile
rather than the built graph — so it flags a crate that is not there:
$ cargo tree -e normal | grep sqlx-
sqlx-core, sqlx-macros, sqlx-macros-core, sqlx-postgres
(no sqlx-mysql, no rsa)
I tried removing it for real first. Dropping sqlx's `macros` and
`migrate` features and hand-rolling the schema bring-up as embedded
idempotent SQL under an explicit advisory lock did not help — the entry
survives lockfile regeneration, because it is a property of sqlx's
manifest, not of our feature selection. There is no feature combination
that removes it while using sqlx.
So that experiment is reverted, and on its own merits: sqlx's migration
framework keeps a version table, which means the next schema change is a
numbered migration rather than hand-edited DDL. Degrading the migration
story to dodge a false-positive advisory would have been a bad trade.
The waiver is narrow (one advisory id) and carries its reasoning at the
call site, including what to re-check. The alternative was a permanently
red gate, which is worse than a documented waiver because it trains
everyone to ignore the column.
Verified: `cargo audit` exits 1, `cargo audit --ignore RUSTSEC-2023-0071`
exits 0. 19 tests pass, clippy and fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
axum + sqlx, replacing FastAPI + SQLAlchemy. The wire contract does not
change — Command Center's
sync_client.pypushes here andscripts/restore_from_cloud.pyreads back, and neither can notice.Why — startup time, and nothing else
This service scales to zero, which makes startup a product property rather
than a footnote. Fly's proxy gives an auto-started machine roughly 8s to
bind its port. The live boot log shows where the old 4.5s went:
~1.5s of headroom on an 8s budget, with the interpreter as the dominant term.
Same measurement locally, both containers, same method:
/health(The local Python figure understates the platform's, exactly as it did for
the agent earlier this month — the Fly number is the real one.)
Explicitly not for memory safety. Python is memory-safe; this service's
exposure is authorization, tenant isolation and SQL, all of which port over
unchanged. The rewrite buys startup time. That's the whole claim.
How it was verified
Both implementations run against one Postgres and a stubbed
License-Service, 28 cases fired at each, responses diffed:
The three that differ are validation-error bodies, where FastAPI emitted
Pydantic's structured error array and this emits a plain message. No caller
reads it, and the 422 status is reproduced exactly (axum's own
Queryrejection is a 400, so that needed deliberate work).
Everything semantically load-bearing came back identical:
known_idsNonevs[]next_cursorpresent-but-nullbody.get("next_cursor")ending the walk earlyinclude_deleteddefaultrestore_from_cloud.pyusesdatetime.fromisoformat, and an offset-aware Rust type would append a+00:00the old service never sent.NaiveDateTimerenders2026-09-01T09:00:00.123456identically.Those cases now live in
tests/wire_contract.rs(12 integration tests) ratherthan a throwaway script — the Python they were diffed against is deleted in
this PR, so the contract needs pinning by something that survives.
Migrations
Moved from an Alembic
release_commandinto sqlx at startup, under a Postgresadvisory lock so concurrent machines can't race. The single migration is
CREATE TABLE / INDEX IF NOT EXISTSmatching what Alembic built, and wasverified to no-op against a database Alembic had already migrated — the
exact production scenario:
Also
Runtime image needs neither
libpqnorcurl(sqlx is a pure-Rust driver,TLS is rustls), and runs as an unprivileged user rather than root.
19 tests pass, clippy clean,
cargo fmt --checkclean.License-Service is not in this PR — it's the riskier of the two (a bug
locks out paying customers), so it follows once this pattern is proven in
production.
🤖 Generated with Claude Code