Repository navigation
Gated database migrations - #16
Open
carbolymer wants to merge 14 commits into
Open
carbolymer wants to merge 14 commits into
carbolymer wants to merge 14 commits into
Conversation
carbolymer
force-pushed
the
mgalazyn/feature/gated-migrations
branch
2 times, most recently
from
November 13, 2026 10:30
1aaa00c to
804b7de
Compare
carbolymer
marked this pull request as ready for review
November 13, 2026 12:23
Pure move, no change. The migration runner that the next commits add needs withTransaction, and SQLite.hs will import that runner, so the function cannot stay in SQLite.hs without an import cycle.
Add the types that describe a migration: a stable id such as v2.10.0/001, a one-line summary and its action. The action says when a migration may run. An automatic migration runs at startup. A blocking one runs at startup only on a fresh database, and otherwise needs the operator. Both run SQL. A repair fixes existing data, and never runs at startup. A repair runs in batches. It has a precondition, a cheap estimate, and a function that repairs one batch from a cursor. Nothing uses these types yet.
planMigrations decides what runs, from the migration list and what the database records about it. It never touches the database, so the unit tests cover each case without one. On a fresh database, it applies automatic and blocking migrations, and records repairs as skipped. On an existing database, it applies automatic migrations, registers repairs as pending, and stops at the first blocking migration. The operator mode applies blocking migrations too. A repair is skipped when the same plan applies the migration that introduced its data, because there is nothing to repair yet. Until the database has a registry, the first user_version migrations count as applied. Nothing calls the planner yet.
Mechanical. Each SQL file under db/ gets a module that embeds it, with a stable id, a kind (all automatic) and a one-line summary. Kupo.App.Database.Migration lists them in the same order as the list in SQLite.hs, which still runs its own copy until the next commit. The tests pin the ids and their order: the position of a migration in the list is its user_version, so the list is append only.
SQLite.hs now calls the runner in Kupo.App.Database.Migration, and its own list and runner go away. The runner reads the database state once, plans, and runs each planned step in its own transaction, together with the new user_version. Each migration's SQL now runs as one script through SQLite's parser (direct-sqlite's exec), so a ; inside a statement no longer splits it. When a step fails, the runner logs the migration and the error, then rethrows. Once a migrations table exists, the runner also records each step in it, and backfills the earlier migrations in the step that creates the table. The next commit adds that table. Until then, the runner applies the same statements and sets the same user_version values as before. After the steps, the runner logs a warning about pending repairs, and refuses to go past a pending blocking migration: it logs the migrations that need the operator and throws. No shipped migration is a repair or blocking yet. copy now passes its tracer to the runner, so it logs the same messages as Kupo does at startup.
Add migration v2.12.0/001, which creates the migrations table. Each row holds the state of one migration (applied, pending or skipped), the cutoff slot and cursor of a data repair, and when and by which version Kupo wrote it. The runner from the previous commit fills the table: the step that creates it also adds an applied row for each earlier migration, without a version or an apply time. db/README.md documents the table. The runner tests come with this commit because they read the registry: the backfill, a repair skipped on a fresh database and pending with a cutoff slot on an existing one, a blocking migration that stops the runner, a second run that changes nothing, a trigger body that holds a semicolon, and a failing migration, which rolls back and which the runner logs.
A read-only replica never migrates. It now plans like the writer when it creates its pool, in a read-only transaction, and writes nothing. It logs the migrations that the writer has not run yet and carries on, logs the same warning about pending repairs, and refuses the same databases as the writer: those with a pending blocking migration.
The planner now lists the registry ids that this version does not know. They come from a newer Kupo, and an older one must not touch what those migrations changed. Before it writes anything, the runner logs the ids and throws. The replica check does the same, and so does copy, through the runner.
When the database layer refuses a database, it has already logged why, so Kupo now stops without logging an unexpected error on top. It exits with code 3 when a blocking migration is pending, so that scripts and supervisors can tell this case apart, and with code 1 when a newer Kupo migrated the database. Read-only replicas check migrations while they create their pool, before the handler that stops Kupo applies, so pool creation now handles both refusals itself.
migrateDatabase is what the operator runs. It applies every migration that the plan allows to an operator, blocking ones included, then runs each pending repair from its stored cursor, and returns the repairs that stay pending. A repair first checks its precondition, which gets the cutoff slot recorded at registration and whether the operator confirms a complete index. When the precondition fails, the repair stays pending and the runner logs why. Otherwise the repair runs in batches, and each batch commits together with its cursor, so an interrupted repair resumes without repeating a batch. The last batch marks the repair as applied and returns a summary for the log. When a batch fails, the runner logs the repair and the error, then rethrows. No command calls it yet.
readStatus reads what the registry records about each known migration, in one read-only transaction, and plans as Kupo would at startup. A repair that is not done also gets an estimate of the work left, from cheap statistics, and the cursor it would resume from. renderStatus prints one aligned line per migration, the details of a repair below its line, and hints at the end. statusExitCode gives the exit code that Kupo would have at startup: 1 when a newer Kupo migrated the database, and 3 when a blocking migration is pending. No command calls them yet.
databaseStatus prints the status report and returns the exit code that Kupo would have at startup. It opens the database read-only, never creates it, and fails while kupo db migrate holds its lock. databaseMigrate first takes an exclusive lock, so it fails while Kupo or any other connection has the database open, and keeps them out until it is done. It then runs migrateDatabase, and returns 1 when a repair stays pending. Both print any failure and return 1. The next commit adds them to the command line.
Add both actions to the command line. Each takes --workdir, the directory that holds the database. db migrate also takes --confirm-complete-index, which tells repairs that the index matches * from origin, a fact that the database cannot prove. db migrate logs from the Info level up. The man page documents both commands, the new flag and the exit codes: 3 when a blocking migration is pending, and 1 in the new failure cases.
Sum up for operators what the previous commits change: the registry, the startup gate and its exit code, the warning about pending repairs, the two db commands, and the refusal of databases that a newer Kupo migrated.
carbolymer
force-pushed
the
mgalazyn/feature/gated-migrations
branch
from
November 13, 2026 12:46
804b7de to
3fd8a76
Compare
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.
Summary
Closes #15.
Until now, Kupo ran every database migration silently at startup, however long it took. A heavy migration could keep the server busy for hours with no warning, and nothing recorded which migrations had run. This PR gives migrations a registry and a kind, and moves the heavy ones out of startup into an explicit operator command.
The fix for the spent input index, #14, stacks on this PR and uses the new data repair kind for its repair.
What changes for operators
At startup, Kupo now checks the registry before it writes anything:
kupo db migrate, then start Kupo again.kupo copy.Two new commands:
db statuslists every migration with its kind, state and summary, plus an estimate and a resume cursor for a repair that is not done. It never writes to the database. It exits with code 3 when a blocking migration is pending, and with code 1 when a newer Kupo migrated the database, like Kupo at startup.db migrateapplies every pending migration, blocking ones included, then runs the pending data repairs. It exits with code 1 when a repair stays pending because its precondition failed.--confirm-complete-indextells repairs that the index matches*and was synchronised from origin, which the database cannot prove by itself.Both commands work only on a SQLite database given with
--workdir. They do not support--in-memoryor a remote (PostgreSQL) database.Design
Kupo.App.Database.Migrationholds the ordered list and the runner. Each migration's SQL now runs as one script through SQLite's own parser, so a;inside a statement (a trigger body, a string literal) no longer splits it. Transactions anduser_versionvalues are unchanged.AutomaticandBlockingcarry SQL, andRepaircarries a resumable batch repair. A repair with SQL, or a schema migration with a batch, therefore cannot be written.migrationstable records each migration as applied, pending or skipped. The migration that creates it adds an applied row for every earlier migration.Automaticruns at startup.Blockingstops startup until the operator runskupo db migrate.Repairis a batch data repair: a precondition, a cheap estimate, and a function that repairs one batch from a cursor.kupo db migratetakes an exclusive lock on the database, so it refuses to run while Kupo is running.Failure handling
user_versionbump and its registry row. Migrations before it stay applied, and later ones do not run.DatabaseMigrationFailed(orDatabaseRepairFailedfor a failed repair batch) at Error level, naming the migration.kupo db migrateresumes from the last committed batch.Testing
db statusreports the pending migrations,db migrateapplies them, and a second run reports the database up to date.Not in this PR
/health.kupo db migrateneeds Kupo to be stopped.postgresbuild flag is an unfinished copy of the SQLite backend that keeps its own older migration list, so it gets none of this (no registry, gate, repair orkupo dbsupport).References
kupo db statusandkupo db migrate) #15