fix(installer): enlist table retirement transaction

This commit is contained in:
2026-07-21 16:44:20 +02:00
parent 77f8d15d17
commit 713afdb39b
2 changed files with 35 additions and 5 deletions

View File

@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names)) warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
def executor(execute_session: object, _module_id: str) -> None: def executor(execute_session: object, _module_id: str) -> None:
if not hasattr(execute_session, "get_bind"): if not hasattr(execute_session, "connection"):
raise RuntimeError("No database session is available for destructive table retirement.") raise RuntimeError("No database session is available for destructive table retirement.")
execute_bind = execute_session.get_bind() # type: ignore[attr-defined] # Enlist schema retirement in the caller's active transaction. An
live_inspector = inspect(execute_bind) # Engine returned by Session.get_bind() may acquire a second
# connection, separating the DROP from module-specific secret
# scrubbing/audit writes and deadlocking on their uncommitted locks.
execute_connection = execute_session.connection() # type: ignore[attr-defined]
live_inspector = inspect(execute_connection)
live_tables = [table for table in tables if live_inspector.has_table(table.name)] live_tables = [table for table in tables if live_inspector.has_table(table.name)]
if not live_tables: if not live_tables:
return return
metadata = live_tables[0].metadata metadata = live_tables[0].metadata
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True) metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
return MigrationRetirementPlan( return MigrationRetirementPlan(
supported=True, supported=True,

View File

@@ -20,7 +20,8 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import Column, Integer, MetaData, Table, inspect from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
from sqlalchemy.orm import Session
# Keep the default app import side effect from bootstrapping a development DB. # Keep the default app import side effect from bootstrapping a development DB.
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-")) _TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-"))
@@ -2370,6 +2371,31 @@ finally:
self.assertEqual("example", record["retirements"][0]["module_id"]) self.assertEqual("example", record["retirements"][0]["module_id"])
self.assertIn("database_backup", record["snapshot"]) self.assertIn("database_backup", record["snapshot"])
def test_drop_table_retirement_is_atomic_with_session_writes(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-retirement-transaction-", dir=_TEST_ROOT))
engine = create_engine(
f"sqlite:///{root / 'retirement.db'}",
connect_args={"timeout": 0.1},
)
metadata = MetaData()
audit_table = Table("retirement_audit", metadata, Column("id", Integer, primary_key=True))
owned_table = Table("retirement_owned", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(bind=engine)
owned_model = type("OwnedModel", (), {"__table__": owned_table})
with Session(engine) as session:
session.execute(insert(audit_table).values(id=1))
plan = drop_table_retirement_provider(owned_model, label="Owned")(session, "owned")
self.assertIsNotNone(plan.destroy_data_executor)
plan.destroy_data_executor(session, "owned") # type: ignore[misc]
self.assertFalse(inspect(session.connection()).has_table("retirement_owned"))
session.rollback()
self.assertTrue(inspect(engine).has_table("retirement_owned"))
with engine.connect() as connection:
self.assertEqual([], connection.execute(audit_table.select()).all())
engine.dispose()
def test_module_installer_dry_run_writes_run_record_without_applying(self) -> None: def test_module_installer_dry_run_writes_run_record_without_applying(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-dry-run-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-installer-dry-run-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)