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:
@@ -131,6 +131,13 @@ async def apply_migrations(
|
|||||||
migrations (database already current) is a no-op that logs at debug
|
migrations (database already current) is a no-op that logs at debug
|
||||||
level and opens no write transaction.
|
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
|
Raises whatever the failing migration raised, with the database
|
||||||
rolled back to its prior version and prior rows untouched.
|
rolled back to its prior version and prior rows untouched.
|
||||||
"""
|
"""
|
||||||
@@ -153,25 +160,46 @@ async def apply_migrations(
|
|||||||
return current
|
return current
|
||||||
|
|
||||||
for target in range(current + 1, latest + 1):
|
for target in range(current + 1, latest + 1):
|
||||||
statements = migrations[target - 1]
|
applied = False
|
||||||
async with database.transaction():
|
async with database.transaction():
|
||||||
for sql in statements:
|
# Re-read the version INSIDE the write transaction. The
|
||||||
await database.execute(sql)
|
# version read before the loop is advisory (it sizes the
|
||||||
# UPDATE keyed on the singleton row (id = 1): a fresh file
|
# loop and enables the byte-identical no-op fast path), but
|
||||||
# has no row yet, but migration 1 is the only migration a
|
# two runners on one connection interleave between their
|
||||||
# version-0 database can run, so the row is seeded by the
|
# reads: the wrapper serializes individual statements, not
|
||||||
# same statement batch when needed. UPDATE alone would
|
# the read-decide-apply decision. Re-reading under the
|
||||||
# silently match zero rows there, so seed-if-absent first.
|
# transaction's lock means the loser of the race sees the
|
||||||
await database.execute(
|
# winner's stamp and skips instead of re-applying a
|
||||||
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, ?) "
|
# non-idempotent migration with a stale version.
|
||||||
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
|
in_tx_version = await read_schema_version(database)
|
||||||
(target,),
|
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
|
return latest
|
||||||
@@ -574,9 +574,16 @@ async def test_options_reload_reopens_and_rechecks_version(
|
|||||||
async def test_setup_entry_failure_closes_database(
|
async def test_setup_entry_failure_closes_database(
|
||||||
hass, make_entry, monkeypatch
|
hass, make_entry, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""A failing migration closes the opened connection before re-raising.
|
||||||
|
|
||||||
|
Spies on the real wrapper instance: records whether close() ran and
|
||||||
|
whether the connection is left disconnected, rather than inferring
|
||||||
|
from hass.data (a failed setup never stores a runtime record).
|
||||||
|
"""
|
||||||
import pytest as pytest_module
|
import pytest as pytest_module
|
||||||
|
|
||||||
import custom_components.nestquest as nestquest_module
|
import custom_components.nestquest as nestquest_module
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import (
|
from custom_components.nestquest.migrations import (
|
||||||
MIGRATIONS as _MIGRATIONS,
|
MIGRATIONS as _MIGRATIONS,
|
||||||
)
|
)
|
||||||
@@ -587,12 +594,22 @@ async def test_setup_entry_failure_closes_database(
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
nestquest_module, "apply_migrations", _failing, raising=True
|
nestquest_module, "apply_migrations", _failing, raising=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
close_calls: list[bool] = []
|
||||||
|
original_close = NestQuestDatabase.close
|
||||||
|
|
||||||
|
async def _spy_close(self):
|
||||||
|
close_calls.append(True)
|
||||||
|
return await original_close(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||||
|
|
||||||
entry = make_entry()
|
entry = make_entry()
|
||||||
with pytest_module.raises(RuntimeError, match="boom"):
|
with pytest_module.raises(RuntimeError, match="boom"):
|
||||||
await _setup_entry(hass, entry, hass.registry)
|
await _setup_entry(hass, entry, hass.registry)
|
||||||
# Setup closed the connection before re-raising: no leaked handle.
|
|
||||||
# The record only lands in hass.data after the listener is added, so
|
assert close_calls == [True], (
|
||||||
# on failure the connection must be gone from the wrapper itself.
|
"failed setup must close the connection it opened"
|
||||||
|
)
|
||||||
record = hass.data["nestquest"].get(entry.entry_id)
|
record = hass.data["nestquest"].get(entry.entry_id)
|
||||||
if record is not None:
|
assert record is None, "failed setup must not store runtime data"
|
||||||
assert not record.database.connected
|
|
||||||
Reference in New Issue
Block a user