fix: re-read schema version inside each migration transaction; spy on close in setup-failure test

- apply_migrations re-reads the version under the transaction lock
  before applying, so two interleaved runners on one connection cannot
  both apply a non-idempotent migration from a stale read
- setup-failure test now spies on NestQuestDatabase.close and asserts
  it ran and no runtime record was stored (was vacuous)
This commit is contained in:
Joshua King
2026-09-14 07:05:13 -04:00
parent a679004720
commit c0c540c194
2 changed files with 68 additions and 23 deletions
+46 -18
View File
@@ -131,6 +131,13 @@ async def apply_migrations(
migrations (database already current) is a no-op that logs at debug
level and opens no write transaction.
The version is re-read inside each migration transaction before
deciding to apply it: the outer read is advisory only (loop bounds,
no-op fast path), and on one shared connection two interleaved
runners would otherwise both read the same version and both apply
a non-idempotent migration. Under the transaction's lock the
second runner sees the first runner's stamp and skips.
Raises whatever the failing migration raised, with the database
rolled back to its prior version and prior rows untouched.
"""
@@ -153,25 +160,46 @@ async def apply_migrations(
return current
for target in range(current + 1, latest + 1):
statements = migrations[target - 1]
applied = False
async with database.transaction():
for sql in statements:
await database.execute(sql)
# UPDATE keyed on the singleton row (id = 1): a fresh file
# has no row yet, but migration 1 is the only migration a
# version-0 database can run, so the row is seeded by the
# same statement batch when needed. UPDATE alone would
# silently match zero rows there, so seed-if-absent first.
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
(target,),
# Re-read the version INSIDE the write transaction. The
# version read before the loop is advisory (it sizes the
# loop and enables the byte-identical no-op fast path), but
# two runners on one connection interleave between their
# reads: the wrapper serializes individual statements, not
# the read-decide-apply decision. Re-reading under the
# transaction's lock means the loser of the race sees the
# winner's stamp and skips instead of re-applying a
# non-idempotent migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is newer "
f"than this integration understands (latest known: "
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
for sql in migrations[target - 1]:
await database.execute(sql)
# INSERT keyed on the singleton row (id = 1): a fresh
# file has no row yet, and migration 1 is the only
# migration a version-0 database can run, so the row is
# seeded by the same statement batch when needed.
# UPDATE alone would silently match zero rows there.
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = "
"excluded.version",
(target,),
)
applied = True
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d (%d statement%s)",
target,
len(migrations[target - 1]),
"s" if len(migrations[target - 1]) != 1 else "",
)
LOGGER.info(
"NestQuest schema migrated to version %d (%d statement%s)",
target,
len(statements),
"s" if len(statements) != 1 else "",
)
return latest