Migration Runner: Version Order, Drift, Undo and Repeatables
MediumAI-assisted coding · AI-Assisted Debugging
Migration Runner: Ordering, Drift, Undo, Baselines and Repeatables
You've joined the platform team that owns dbmigrate, the tool every service uses to roll schema changes forward. It works like Flyway or Alembic, except that the database is simulated: running a migration just means "record it in the history table". Last week staging applied V2_10__add_audit *before* V2_9__add_index and nobody noticed until the audit table's index was missing in production. That incident is Part 1. Parts 2 and 3 are the roadmap behind it.
Codebase
| File | What it does |
|---|---|
dbmigrate/api.py | run(operations) entry point and the Dispatcher that routes each operation |
dbmigrate/runner.py | MigrationRunner: migrate and repair (Parts 2 and 3 add more here) |
dbmigrate/planner.py | Decides what a run must do: drift, pending files, out-of-order detection |
dbmigrate/history.py | The history table: rows, ranks, lookups |
dbmigrate/source.py | The migrations directory: files by name, duplicate-version protection |
dbmigrate/versions.py | File-name grammar, canonical versions, version ordering |
dbmigrate/checksum.py | Normalization and the checksum function |
dbmigrate/executor.py | The simulated database (fails a file on a marker line) |
dbmigrate/errors.py | Every message the runner reports |
dbmigrate/config.py | Hash constants, failure marker, row type letters |
tests/ | Existing example tests (one currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against an empty directory and an empty history, and returns one result per operation. Every operation is [name, *args].
| Operation | Arguments | Returns |
|---|---|---|
add_file | filename, sql | the file's checksum; creates the file, or replaces one with the same file name (an edit) |
remove_file | filename | true |
generate | major, count | count; bulk helper that adds V<major>_<i>__gen_<major>_<i>.sql and its down file U<major>_<i>__gen_<major>_<i>.sql for i = 1..count (their bodies are filler; do not depend on them) |
migrate | optional allow_out_of_order (a bool, default false) | {"applied": [labels], "error": null or message} |
repair | none | list of the labels of the failed rows it removed, in rank order |
history | none | list of history rows, in rank order (see below) |
undo | target | Part 2 |
baseline | version | Part 3 |
- Errors of
add_file,remove_file,generate(andbaseline) are returned as"error: <message>".migrateandundonever return a bare error string: their failure message goes in the result's"error"field (no"error: "prefix) next to whatever was applied or undone before the failure. "error: unknown operation"for an unknown name."error: invalid arguments"for a wrong number of arguments, a wrong argument type, a non-list operation or an empty list. (true/falseare not integers;allow_out_of_ordermust be a real bool;generateneedsmajor >= 0andcount >= 1.)add_filechecks in this order: arguments,invalid file name <name>,duplicate version <v>.remove_fileof a file that is not there returns"error: no such file <name>".- A label names a migration in results and messages:
V2.3for a versioned migration (Vplus its canonical version),R__viewsfor a repeatable one. - A history row is
{"rank", "type", "version", "description", "checksum", "success"}.typeis"V"for now;versionis the canonical version string.
File names and versions. V2_3__add_index.sql is the up migration of version 2.3 and U2_3__add_index.sql its down migration. R__views.sql is a repeatable migration. The description is [A-Za-z0-9_]+; any other file name is invalid. Versions are integer components separated by _ in file names and . everywhere else, and are compared component by component as integers, so 2.10 is newer than 2.9. A version's *canonical text* drops leading zeros and trailing zero components (V02_0 and V2 are both "2"; V1_10_0 is "1.10"; V1_05 is "1.5"). Two V files, or two U files, with the same canonical version cannot coexist (duplicate version 2). dbmigrate/versions.py has the grammar. R and U files exist in every Part but migrate ignores them until Parts 2 and 3 give them meaning.
Checksums. A file's checksum is the rolling hash in dbmigrate/checksum.py of its *normalized* text: \r\n becomes \n, trailing spaces and tabs are removed from every line, and trailing blank lines are removed. Leading whitespace and blank lines at the start are significant, and so is everything in the middle. "" hashes to "00000000", "a" to "00000061", "ab" to "00000c21".
Simulated failure. If any line of a file (after stripping it) starts with --!fail, executing that file fails and the detail is the rest of that line (statement rejected if it is empty). The raw text is inspected, not the normalized text.
---
Part 1: Make migrate trustworthy
migrate must apply every pending versioned migration, in version order, and refuse to do so when the directory and the history disagree. The current code gets several of the rules below wrong; the rest are rules you need to preserve. Find out which is which by reading the code and the failing test.
- Failed rows block. If the history has any failed row,
migrateapplies nothing and returnsblocked: <label of the lowest-rank failed row> failed, run repair. Check this first. - Drift. For every successful applied row whose file still exists, the file's current checksum must equal the checksum recorded when it was applied. Otherwise
migrateapplies nothing and returnsdrift: V<version> (<description>) was applied with checksum <recorded> but the file now has <current>for the drifted file with the lowest version. An applied file that was deleted is not drift. - Pending means a
Vfile with no successful row for its version. - Out of order. *Latest applied* is the highest version among successful rows (not the most recent row). If any pending file is older than it,
migrateapplies nothing and returnsout of order: V<lowest such pending version> is older than applied V<latest>, unlessallow_out_of_orderistrue. - Pending files are applied one at a time in version order (late ones included, so with
allow_out_of_orderthe late files go first). Each application appends a row with the file's checksum at that moment. If the file fails, its row is recorded withsuccess: false,migratestops and returns the labels applied so far plus the error<label> failed: <detail>, e.g.V2 failed: duplicate column. - Checks 1 to 4 run in that order and the first one that triggers wins; nothing is applied in those cases.
- Ranks are 1, 2, 3, ... in the order rows were appended and are never reused, even after
repairremoved the row that held one. repairremoves every failed row (so the failed version is pending again and the nextmigrateretries it) and returns their labels in rank order. Editing a file whose row failed is not drift.
Worked example (checksums are shown so you can verify your hash)
[["add_file", "V2_10__add_audit.sql", "CREATE TABLE audit (id INT);"],
["add_file", "V2_9__add_index.sql", "CREATE INDEX ix ON t (id);"],
["add_file", "V1__init.sql", "CREATE TABLE t (id INT);"],
["migrate"],
["history"]]returns
["7bbff389", "bee6ff47", "10b471e2",
{"applied": ["V1", "V2.9", "V2.10"], "error": null},
[{"rank": 1, "type": "V", "version": "1", "description": "init", "checksum": "10b471e2", "success": true},
{"rank": 2, "type": "V", "version": "2.9", "description": "add_index", "checksum": "bee6ff47", "success": true},
{"rank": 3, "type": "V", "version": "2.10", "description": "add_audit", "checksum": "7bbff389", "success": true}]]And after ["migrate"] applied V1__init.sql, replacing it with CREATE TABLE t (id BIGINT); and migrating again returns {"applied": [], "error": "drift: V1 (init) was applied with checksum 10b471e2 but the file now has 02871c20"}.
---
Part 2: Undo
Releases sometimes have to be rolled back. Teams ship a down migration next to each up migration (U2_3__add_index.sql undoes V2_3__add_index.sql). Add ["undo", target], where target is a dotted version like "2.9" (leading zeros and trailing zero components are fine: "02.09", "2.10.0"), which reverts every applied migration newer than target. ["undo", "0"] reverts everything. It returns {"undone": [labels, in the order they were undone], "error": null or message}.
- A target that is not a dotted version (
"x","2.","") returns{"undone": [], "error": "invalid version <target>"}; a non-string argument is"error: invalid arguments". - If the history has a failed row:
blocked: <label> failed, run repair(same rule asmigrate). - The migrations to revert are the successful
Vrows with a version greater than the target, reverted in descending version order. (With out-of-order history, rank order is not version order.) - Every one of them needs a down file with the same canonical version. If any is missing, nothing is run and the result is
missing down migration for V<version>, naming the first missing one in revert order (the highest). Undo checks 1 to 4 in the order given. - Each down file runs in turn. On success the version's history row is deleted (ranks of deleted rows are not reused) and its label is added to
undone. If a down file fails, that version's row is kept but flipped tosuccess: false, undo stops, and the error isundo of V<version> failed: <detail>; the earlier reverts stay done. As for any failed row,migrateis then blocked untilrepair, which removes the row, so the version counts as not applied. - Undo does not look at up files at all: drift in them and missing up files are irrelevant. Down files are read when the undo runs, and their checksums are never recorded.
- After an undo, versions are pending again, and
migrate's rules from Part 1 apply to the shrunken history (the latest applied version may now be lower).
Worked example
[["add_file", "V1__init.sql", "CREATE TABLE t (id INT);"],
["add_file", "V2__users.sql", "CREATE TABLE users (id INT);"],
["add_file", "V3__orders.sql", "CREATE TABLE orders (id INT);"],
["add_file", "U2__users.sql", "DROP TABLE users;"],
["add_file", "U3__orders.sql", "DROP TABLE orders;"],
["migrate"], ["undo", "1"], ["history"], ["migrate"], ["history"]]The undo returns {"undone": ["V3", "V2"], "error": null} and the first history is only the rank-1 row for V1. The second migrate applies ["V2", "V3"] and the final history lists V1 at rank 1, V2 at rank 4 and V3 at rank 5. With only U3 present (no U2), ["undo", "0"] returns {"undone": [], "error": "missing down migration for V2"} and changes nothing, while ["undo", "2"] succeeds.
---
Part 3: Baselines, repeatables, and big repos
Two requirements from the teams adopting the tool, and a scale problem that came with them.
Baseline. Teams with an existing database want to adopt dbmigrate without replaying history. ["baseline", version] records "everything up to this version is already in the database".
versionis dotted like theundotarget. Not a dotted version:"error: invalid version <version>"(non-string: invalid arguments). If the history already has any row at all:"error: history not empty". Checked in that order. Success returnstrueand appends the row{"type": "B", "version": <canonical>, "description": "baseline", "checksum": null, "success": true}.- Every
Vfile whose version is at or below the baseline is considered applied without ever running:migratenever makes it pending, never reports it as out of order and never checks it for drift, no matter when the file is added or edited. It can never be undone. undowith a target below the baseline returnscannot undo below baseline <baseline>(checked after theblockedcheck, before the missing-down check). A target at or above it is fine.
Repeatable migrations. R__views.sql holds things like views and stored procedures that should be re-created whenever their definition changes.
- After all pending versioned migrations of a
migratecall have succeeded, every repeatable that is *pending* is applied, in order of description (plain string comparison, soB_xcomes beforea_x). A repeatable is pending if it has no successful row or its current checksum differs from the checksum of its newest successful row. Applied repeatables appear inappliedasR__<description>after the versioned labels. Their rows havetype: "R"andversion: null. - If anything stops the run earlier (blocked, drift, out of order, a failing versioned migration) no repeatable runs. A failing repeatable is recorded as a failed row, ends the run with
R__<description> failed: <detail>, and blocks later runs like any failed row (blocked: R__views failed, run repair); the repeatables after it do not run.repairremoves it; the nextmigrateretries it. A changed repeatable file is never drift. A deleted one is ignored. - Repeatable and baseline rows are not versioned migrations: they must not count as "latest applied", as drift candidates, or as something
undocan revert. - Undo invalidates repeatables. Views depend on tables, so whenever an
undoreverted at least one version (including one that then stopped on a failing down file), the recorded checksum of everyRrow is set tonullin the history, which makes all repeatables pending again. An undo that reverted nothing changes nothing. - Scale. Real repos have hundreds of files and
migrateruns on every deploy. A run that applies nothing new must cost little: do not re-hash every file or re-scan the whole history for every file on each call. The suite includes a repo with 500 applied migrations and dozens of deploys, and the whole suite has to finish in a few seconds.
Everything from Parts 1 and 2 still holds, with the new row types in the history.
Worked example
[["add_file", "V1__init.sql", "CREATE TABLE t (id INT);"],
["add_file", "R__views.sql", "CREATE VIEW v AS SELECT 1;"],
["migrate"], ["migrate"],
["add_file", "R__views.sql", "CREATE VIEW v AS SELECT 2;"],
["migrate"], ["history"]]returns {"applied": ["V1", "R__views"], "error": null}, then {"applied": [], "error": null}, then (after the edit) {"applied": ["R__views"], "error": null}, and a history of the V1 row (rank 1), an R row for views with checksum ca2c9a81 (rank 2) and another with ca2c9aa0 (rank 3).
Baselining: with V1, V2, V3 in the directory, ["baseline", "2"] returns true, the next migrate returns {"applied": ["V3"], "error": null}, and the history is the baseline row at rank 1 and V3 at rank 2.
Example 1
- Input:
- [["add_file","V2_10__add_audit.sql","CREATE TABLE audit (id INT);"],["add_file","V2_9__add_index.sql","CREATE INDEX ix ON t (id);"],["add_file","V1__init.sql","CREATE TABLE t (id INT);"],["migrate"]]
- Output:
- ["7bbff389","bee6ff47","10b471e2",{"applied":["V1","V2.9","V2.10"],"error":null}]
- Explanation
- Versions compare numerically per component, so 2.9 is applied before 2.10. add_file returns each file's checksum.
Example 2
- Input:
- [V1, V2, V3 with down files U2, U3 added, ["migrate"], ["undo","1"], ["migrate"]]
- Output:
- [..., {"applied":["V1","V2","V3"],"error":null}, {"undone":["V3","V2"],"error":null}, {"applied":["V2","V3"],"error":null}]
- Explanation
- Undo reverts everything newer than version 1, highest version first, deleting those history rows; the next migrate re-applies them with fresh ranks.
Example 3
- Input:
- [V1__init.sql, R__views.sql, ["migrate"], ["migrate"], R__views.sql edited, ["migrate"]]
- Output:
- [..., {"applied":["V1","R__views"],"error":null}, {"applied":[],"error":null}, ..., {"applied":["R__views"],"error":null}]
- Explanation
- A repeatable migration runs after the versioned ones and again only when its checksum changes.
Guidelines
- Python 3 standard library only
- No real database: executing a migration means recording a history row, and a line starting with --!fail makes the file fail
- Versions compare component by component as integers; the canonical text drops leading and trailing zero components
- Every operation returns exactly one value; migrate and undo report failures in their result's "error" field, file-store operations return "error: <message>" strings
- Part 3 includes a repo with 500 applied migrations and dozens of deploys; the suite must finish in a few seconds