Skip to content

feat(migrations): embed migrations in the binary with a mode switch - #76

Open
tsdk02 wants to merge 1 commit into
mainfrom
migrations-sqlx
Open

tsdk02 wants to merge 1 commit into
mainfrom
migrations-sqlx

Conversation

@tsdk02

@tsdk02 tsdk02 commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

Rebased onto 04082026-release after #66, #46 and #77 merged.

#77 overlaps this PR and lands first. It reached the same diagnosis of
20260322000000_txn_based_pickup.sql independently and turned the file into a
documented no-op rather than deleting it, because the filename is referenced
from the justfile, scripts/docker-prod.sh, the README and the docs site. That
call stands; this PR rebases onto it and no longer touches the file.

Migrations could not run in a cluster

The SQL never entered the image — the Dockerfile copies only the compiled binary
— the runner was hardcoded psql -f lines in the justfile needing just,
psql, the repo and a .env, and neither binary migrated on startup. A fresh
install had no tables and the API 500'd on every request. Nothing recorded what
had been applied, so ordering lived in whoever remembered to add a justfile line.

sqlx::migrate! embeds each file's SQL at compile time, so the set travels
inside the binary. One version coordinate — the image tag — and no way to deploy
code without the migrations it expects. A ConfigMap or a separate migration image
would reintroduce two artifacts to keep in lockstep.

app migrate applies them and exits, so the same image can run as a pre-upgrade
hook Job. The exec-form ENTRYPOINT means args: ["migrate"] appends cleanly.

INVOKR_DB_MIGRATION_MODE Behaviour
none (default) Do nothing
run Apply pending migrations
dry-run Print the SQL a run would execute, without applying it

Defaulting to none keeps when migrations apply a deployment decision, while
what ships stays coupled to the code. dry-run writes pure SQL to stdout — the
summary goes to stderr — so it pipes straight into psql, and is how an existing
database gets baselined.

Dev and CI run the same code path

just db-migrate now invokes the binary rather than a shell loop, so developers
exercise the mechanism production uses and adding a migration no longer means
editing the justfile. e2e migrates through the binary too, exercising ordering,
advisory locking and the _sqlx_migrations bookkeeping.

The migrations job keeps its psql loop as the fast check that the SQL itself
applies, and gains assertions so the invariant is checked rather than printed:
public must hold exactly the four control-plane tables, and cron.job must be
queryable. The first assertion would have caught the stale migration the day #47
landed.

workspace_v1.sql is deliberately not part of the set — it is a runtime
template applied per workspace with {p} substituted. A test asserts no embedded
migration executes an unsubstituted placeholder; it strips -- comments first,
because #77's no-op quotes the template's index definition in its explanation.

sqlx stays at 0.7.4. The migrate feature is already on by default, and with
#77 turning txn_based_pickup into a no-op nothing needs the 0.8-only
-- no-transaction escape hatch.

Images gain arm64

The docker job becomes a two-dimensional matrix — arch × image — so it
expands to the full 2×4 cross-product, each architecture built on a native
runner
(ubuntu-latest, ubuntu-24.04-arm) and pushed to an arch-suffixed
tag. A create-manifest job then joins each pair into the version and
latest tags.

Deliberately not platforms: linux/amd64,linux/arm64 on one runner — that
cross-builds arm64 under QEMU, which for a Rust release build is punishingly
slow.

The Dockerfile already handled both architectures (its Tailwind download
branches on dpkg --print-architecture), so only the workflow changed. Cache
scopes are per-arch; a shared scope would have each architecture evicting the
other's layers on every release.

Before deploying to an existing database

Production has the schema applied by hand and no _sqlx_migrations table, so
a first run would try to apply everything again.

  1. Run the image with INVOKR_DB_MIGRATION_MODE=dry-run
  2. Apply only the INSERT INTO _sqlx_migrations statements it emits — discard the DDL
  3. Re-run dry-run and confirm it reports no pending migrations

Test plan

Verified against a live pg_cron-enabled database, post-rebase:

  • All four migrations apply to an empty database, including fix: unbreak fresh-database setup and false-green e2e assertions #77's comment-only no-op, and are recorded in _sqlx_migrations
  • public holds exactly organizations, region_heartbeats, region_status, workspaces
  • cron.job queryable — pg_cron loaded, not merely created
  • dry-run prints the migrations plus their bookkeeping INSERTs and applies nothing
  • dry-run piped into psql produces a fully migrated, correctly bookkept database
  • run twice leaves the same rows; dry-run then reports no pending migrations
  • mode=none does nothing; an invalid mode is rejected naming the valid values
  • Server still starts with no subcommand — GET /health → 200
  • cargo fmt, cargo clippy --workspace --all-targets, cargo test --workspace clean
  • Release workflow parses as valid YAML; the docker matrix expands to 8 jobs (4 images × 2 arches) on the intended runners, with create-manifest producing 4 multi-arch tags
  • First real release exercises the arm64 runner and the joined manifest
  • Baseline production per the steps above, before the first deploy there

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: cec82552-ab7f-4f6b-b615-f284b7690e63


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tsdk02 tsdk02 self-assigned this Sep 7, 2026
@tsdk02
tsdk02 force-pushed the migrations-sqlx branch 2 times, most recently from bad6094 to 0f8b1ac Compare September 7, 2026 11:22
Migrations could not run in a cluster. The SQL never entered the image — the
Dockerfile copies only the compiled binary — the runner was hardcoded `psql -f`
lines in the justfile needing just, psql, the repo and a .env, and neither
binary migrated on startup. A fresh install had no tables and the API 500'd on
every request. Nothing recorded what had been applied, so ordering lived in
whoever remembered to add a justfile line.

`sqlx::migrate!` embeds each file's SQL at compile time, so the migration set
travels inside the binary. There is one version coordinate — the image tag — and
no way to deploy code without the migrations it expects. A ConfigMap or a
separate migration image would reintroduce two artifacts to keep in lockstep.

`app migrate` applies them and exits, so the same image can run as a pre-upgrade
Job; the exec-form ENTRYPOINT means `args: ["migrate"]` appends cleanly.
INVOKR_DB_MIGRATION_MODE selects the behaviour:

  none     do nothing (default, so no deployment gains behaviour it lacked)
  run      apply pending migrations
  dry-run  print the SQL a run would execute, without applying it

Defaulting to none keeps *when* migrations apply a deployment decision, while
*what* ships stays coupled to the code. dry-run writes pure SQL to stdout — the
summary goes to stderr — so it pipes straight into psql, and is how an existing
database gets baselined: keep the _sqlx_migrations INSERTs for migrations
already applied by hand, discard the DDL.

just db-migrate now runs the same code path rather than a shell loop, so
developers exercise the mechanism production uses, and adding a migration no
longer means editing the justfile. e2e likewise migrates through the binary,
exercising ordering, advisory locking and bookkeeping. The migrations job keeps
its psql loop as the fast check that the SQL itself applies, and gains
assertions so the invariant is checked rather than printed: public must hold
exactly the four control-plane tables, and cron.job must be queryable. The
first would have caught the stale txn_based_pickup migration (#77) the day #47
landed.

crates/common/migrations/workspace_v1.sql is deliberately not part of the set:
it is a runtime template applied per workspace with {p} substituted. A test
asserts no embedded migration executes an unsubstituted placeholder, stripping
`--` comments first, since #77's no-op quotes the template in its explanation.

sqlx stays at 0.7.4 — the migrate feature is already enabled by default, and
with #77 turning txn_based_pickup into a no-op nothing needs the 0.8-only
`-- no-transaction` escape.

Images also gain arm64. The docker job becomes a two-dimensional matrix, arch x
image, so it expands to the full 2x4 cross-product, with each architecture built
on a native runner (ubuntu-latest, ubuntu-24.04-arm) and pushed to an
arch-suffixed tag. A create-manifest job then joins each pair into the version
and latest tags. Deliberately not `platforms: linux/amd64,linux/arm64` on one
runner: that cross-builds arm64 under QEMU, which for a Rust release build is
punishingly slow. The Dockerfile already handled both architectures — its
Tailwind download branches on dpkg --print-architecture — so only the workflow
needed changing. Cache scopes are per-arch, or each architecture would evict the
other's layers on every release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tsdk02
tsdk02 changed the base branch from 04082026-release to main September 25, 2026 11:09

This branch has not been deployed

No deployments
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