ADR-0060: Migrations run as a one-shot container before the app
Status: Accepted · Date: 2026-10-01 · Amended: 2026-10-03 (ADR-0062), 2026-10-05 (ADR-0064) · Amends: ADR-0011, ADR-0058 · Decision Makers: Vladislav Aleshaev
Context
A deployment needs a step that brings the schema up to date without dropping the database, and that survives a database that is not up yet. EF's migrator does not: it opens its connection and takes its lock outside the execution strategy, so a server that comes up late fails the run. It reads a 4060 as "the database does not exist" and answers with CREATE DATABASE, which on Azure SQL would create a second database at the service's default size. And it reports a database that holds a migration the build does not know as up to date. The commands' variables and exit codes are in the Seeder's README.
Decision
The schema is migrated by a one-shot that ends before the app starts, never by the API as it starts: compose runs it locally, and a deployment runs it as a job and moves the app only after it exits 0, because a failed migration is then a job that says why.
The one-shot is the Seeder's
migratecommand, throughAddInfrastructure, so that it opens with the connection limits and the retry budget every host has (ADR-0058, whose second precondition this meets), in a pool of 5. It needs the connection string and no other secret, never the PIN pepper, and ships in one tools image that holds the Seeder alone.migratewaits for the database before it hands it to EF: up to 60 s (--wait-seconds, 0 to 600), one open every 2 s and a Warning for each that failed, because EF's migrator does not retry its first open and answers a 4060 withCREATE DATABASE.The open Then succeeds go on the server did not answer (class 20 or above, or number -2) wait the login was refused (18456) wait; the third time in a row ends the run the database cannot be opened (4060) ask master: there and not online, wait; online and still not opened, end the run the third time in a row; not there, EF creates it, except on an Azure SQL nameanything else, SQL Server's or not off Azure, go on to EF; on an Azure SQL name, wait: Azure SQL answers 40613 and 40197 (class 17) and 49918 (class 16) while a database is not available (Microsoft's table of transient faults), so there only an open that succeeded hands the database to EF Three exit codes: 0, done, and a rerun changes nothing; 1, failed after the server was contacted, or the command line was wrong; 2, refused, and nothing was written. All 2s but two come before any connection is opened: a missing pepper or connection string; a string SqlClient cannot parse (a fixed sentence, because the parser's message quotes the string) or that names no database (
migratewould build the schema in the login's default one); formigrate, a connect timeout of 0, which SqlClient reads as no limit; a demo setting out of range or unreadable as its type;seedandresetin demo mode;seed-poolandrecyclewith the demo off. Two 2s come after a connection was opened:seedandresetrefuse a database that holds the demo pool's rows. The pool's commands also end with a code from 10 to 15 (ADR-0062, decision 12).On an Azure SQL server name
seedandresetare refused (exit 2), andmigratenever creates a database there:seed's four users have a password and a PIN that are in the repository,resetdrops the database, and a missing database endsmigratewith exit 1, because the infrastructure creates databases. The rule goes by the server's name, on the five suffixes SqlClient itself treats as Azure SQL. Note:seed-poolandrecyclerun on an Azure SQL name too, because the public demo's database is one (ADR-0062). That is safe whereseedandresetare not: the users they write have no password, their deletes are keyed on a copy, on a database with users and no pool row they write nothing, and neither drops, creates or migrates a database. They run as the app's database user, never the migration's (on Azureazurebank_app, notazurebank_migrator), because every statement reads or writes rows, and the migration's user would give a scheduled job the right to change the schema.A database that holds a migration the build does not know is refused, exit 1, checked before and after
MigrateAsync, because an older build must not move an app onto a schema it does not know. EF's ownApplying migrationlines are the record of what was applied.SIGTERM cancels the command: the token goes to every call to the database, Identity's managers included, and the command says what was cut short, whether a rerun is safe, and exits 1.
The design-time factory gets the limits and the budget, and reads no environment variable. The API's settings are optional to it.
dotnet ef … --connectionsets its string after the factory has run, so such a run keeps the retry budget and opens with its own string's limits.CI checks the model against the migrations, in the job that builds, which opens no database (
dotnet ef migrations has-pending-model-changes), so that a model change with no migration fails there and not in the jobs that create a database.compose runs
sqlserver(healthy) →migrate→seed→api. Both one-shots run again on everyup, soseedexits 0 on a database people have used, and 1 on a half-seeded one: its final check asks for at least the ledger's 26 rows and for the five demo accounts by number, closed ones included, because the app never changes an account's number or removes its row.
Rejected
- Rejected: an EF migrations bundle, because it fails the same outage and its fix is a shell script.
- Rejected: migrating as the API starts, because two replicas would both migrate, and the app's login would need the right to change the schema.
- Rejected: adding 0 and 258 to EF's retry list, because the failing open is outside the strategy.
- Rejected: a Warning for a database ahead of the build, because the deployment moves the app next.
- Rejected: reading the engine edition once connected, because nothing in the repository reaches Azure SQL under another name, and that branch cannot be produced on a local SQL Server.
- Rejected: an environment variable as a source for the design-time factory, because it would aim
dotnet ef database dropat whatever a shell still holds. - Rejected: hiding an argument the parser does not know, because no secret goes in the arguments.
Consequences
- A database a newer build migrated stops
migrate, and with it an older commit's deployment before the app moves; locally, untildocker compose down -v. migrateleaves a schema with no rows: the roles come fromseed, or from the demo pool's first run (ADR-0062), and a registration before either is answered 500.- The SQL Server tests skip on an Azure SQL name, because several create and drop databases.
- Not covered: the Azure SQL rule does not see an alias or an IP address.
- Not covered: a database that stops answering after the wait's open still reaches EF, which answers with
CREATE DATABASE: the loginmigrateis given must not be able to create one. - Not covered: on an Azure SQL name an answer that waiting cannot change takes the whole wait.
- Not covered: a seed that was cut short is not completed by a second run:
resetstarts over.
Revisit when
- A migration's statement runs longer than the 30 s command timeout: a timeout of its own.
- A rollback must pass over a newer schema: a flag, with its own record.
- A server is reached under a name that is not Azure's: the engine-edition check, measured there.
- 60 s does not cover the wait where it is deployed: the default moves, with the measurement.
Verified by
DatabaseGateTests,ConnectionTargetTests,SeederCommandTests,DesignTimeFactoryTests.- On SQL Server:
MigrateCommandSqlServerTests,SeedCommandSqlServerTests,SeederProcessTests.
Related
ADR-0011, ADR-0058, ADR-0061, ADR-0062, ADR-0064.