Skip to content

Port Sentinel-Sync-Service from Python to Rust - #11

Merged
Sbussiso merged 2 commits into
masterfrom
rust/rewrite
Sep 14, 2026
Merged

Sbussiso merged 2 commits into
masterfrom
rust/rewrite

Conversation

@Sbussiso

Copy link
Copy Markdown
Contributor

axum + sqlx, replacing FastAPI + SQLAlchemy. The wire contract does not
change
— Command Center's sync_client.py pushes here and
scripts/restore_from_cloud.py reads 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:

runner: Machine started in 1.175s          ← Firecracker
        ...3.4s of silence...              ← Python interpreter + imports
proxy:  machine became reachable in 4.548s

~1.5s of headroom on an 8s budget, with the interpreter as the dominant term.
Same measurement locally, both containers, same method:

container start → /health image
Python 2.287s mean 339 MB
Rust 0.194s mean 139 MB

(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:

  • 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 (axum's own Query
rejection is a 400, so that needed deliberate work).

Everything semantically load-bearing came back identical:

behaviour why it would have hurt
tombstone + revive a restore resurrecting deleted cameras
known_ids None vs [] log tables' retention deletes propagating
keyset paging, next_cursor present-but-null body.get("next_cursor") ending the walk early
include_deleted default forensics vs restore reading the same
401 / 403 / 502 split a License blip looking like a revocation
timestamp rendering the biggest riskrestore_from_cloud.py uses datetime.fromisoformat, and an offset-aware Rust type would append 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 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_command into sqlx at startup, under a Postgres
advisory lock so concurrent machines can't race. The single 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 — the
exact production scenario:

INFO sqlx::postgres::notice: relation "synced_rows" already exists, skipping

Also

Runtime image needs neither libpq nor curl (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 --check clean.

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

Sbussiso and others added 2 commits September 13, 2026 22:40
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>
@Sbussiso
Sbussiso merged commit 011f71f into master Sep 14, 2026
5 checks passed
@Sbussiso
Sbussiso deleted the rust/rewrite branch September 14, 2026 06:07
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.

1 participant