Apply pending migrations at startup instead of asking for a second command
ci / build and test (push) Failing after 1m48s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s

The API deliberately never migrated: it failed readiness while a migration was pending and
named it, and a separate step applied them. That is the right split for a deployment with a
release pipeline and the wrong one for a self-hosted server, where it means an image that
boots, refuses traffic, and waits for somebody to know that dotnet ef exists. The schema and
the code that expects it ship in the same image, so the image is where the two are
reconciled now.

Before RunAsync rather than in the background. A migration racing the first requests would
let them through against a half-applied schema, and the first authenticated request is the
one that provisions accounts. Failing to migrate therefore fails to start, which is the
loudest signal available and the one an orchestrator already acts on.

Concurrent starts take a Postgres advisory lock first. Without it two replicas rolled out
together read the same empty history table, both apply the same migration, and the second
dies on an object that already exists — a crash loop on the day of a schema change, which
is the worst day to have one. The lock is held on a connection of its own because EF opens
and closes one per command, and a session lock belongs to the connection that took it.

The exception is a database that does not exist yet: there is nothing to hold a lock in, so
that path migrates without one and says so. Two instances creating it at once still
converges — one wins, the other restarts into the ordinary locked path — and refusing to
start would leave a fresh deployment stuck on the step this removes.

Database:AutoMigrate turns it off for the deployments that own their schema: a migrator job,
a rollout where new code must run against the old schema first, or a database user denied
DDL. With it off the behaviour is exactly what it was, and the health check now explains
which of the two situations a pending migration means.

Verified against a throwaway PostgreSQL container: an empty database gets all seven
migrations applied before the port opens, the tables land in the dodo schema, and a second
start logs the schema up to date and serves. The API suite passes, which exercises the
startup path once per assembly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:12:22 +02:00
co-authored by Claude Opus 5
parent 73c7e2a1e3
commit 093f3904c1
7 changed files with 260 additions and 19 deletions
+17 -15
View File
@@ -109,7 +109,7 @@ nothing to clean up after — but with no daemon those suites fail rather than s
## Running it
Four commands, in order. The first two are once per machine.
Three commands, in order. The first is once per machine.
**1. The development dependencies** — PostgreSQL and Keycloak, with the `dodossh` realm imported:
@@ -117,27 +117,29 @@ Four commands, in order. The first two are once per machine.
docker compose -f deploy/docker-compose.dev.yml up -d
```
**2. The schema.** The API never migrates anything: it fails readiness while a migration is pending, and
says which one. `dotnet-ef` is pinned in `.config/dotnet-tools.json`, so run `dotnet tool restore` first if
you have not:
```bash
dotnet ef database update --project src/DodoSSH.Infrastructure
```
With nothing else configured this targets the compose stack above. Set `DODOSSH_DESIGN_CONNECTION` to point
it at another database.
**3. The server:**
**2. The server:**
```bash
dotnet run --project src/DodoSSH.Api
```
It listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
It applies any pending migrations before it opens its port, so there is no separate schema step and no
window in which a request meets a half-applied schema. Set `Database:AutoMigrate` to `false` where
something else owns the schema — a migrator job, or a database user denied DDL — and the old behaviour
comes back: readiness fails while a migration is pending, and the log says which one. To apply them by
hand, `dotnet-ef` is pinned in `.config/dotnet-tools.json` (`dotnet tool restore` first if you have not):
```bash
dotnet ef database update --project src/DodoSSH.Infrastructure
```
With nothing else configured that targets the compose stack above. Set `DODOSSH_DESIGN_CONNECTION` to
point it at another database.
The server listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
Development — `/openapi/v1.json`.
**4. The desktop client:**
**3. The desktop client:**
```bash
dotnet run --project src/DodoSSH.Client.App