Skip to content

Commit 7c878c5

Browse files
committed
Bind export audits and fail closed on catalog init
1 parent 600ac98 commit 7c878c5

2 files changed

Lines changed: 138 additions & 37 deletions

File tree

‎src/openstatspec/sql/wide.py‎

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -685,18 +685,23 @@ def initialize_wide_catalog(
685685
audit_relations=audit_relations, phase="catalog initialization",
686686
) as connection:
687687
inspector = inspect(connection)
688+
tables = set(inspector.get_table_names())
688689
views = set(inspector.get_view_names())
689-
if views and not inspector.has_table(normative.catalog_identity.name):
690-
raise UnsupportedOperationError(
691-
"The selected database catalog is foreign; initialization is not permitted."
692-
)
693-
try:
694-
create_normative_catalog(connection, normative)
695-
except RuntimeError as error:
696-
raise UnsupportedOperationError(
697-
"The selected database catalog is foreign or incompatible; "
698-
"initialization is not permitted."
699-
) from error
690+
if tables or views:
691+
if normative.catalog_identity.name not in tables:
692+
raise UnsupportedOperationError(
693+
"The selected database catalog is foreign; "
694+
"initialization is not permitted."
695+
)
696+
require_verified_catalog(connection)
697+
else:
698+
try:
699+
create_normative_catalog(connection, normative)
700+
except RuntimeError as error:
701+
raise UnsupportedOperationError(
702+
"The selected database catalog is foreign or incompatible; "
703+
"initialization is not permitted."
704+
) from error
700705
require_verified_catalog(connection)
701706
return {"profile": profile.name, "catalog": "verified"}
702707

@@ -1345,6 +1350,26 @@ def read_fidelity_events(
13451350
return tuple(result)
13461351

13471352

1353+
@contextmanager
1354+
def _bound_export_audit_transaction(
1355+
*, database_url: str, dolt_conformance_source: Any | None, phase: str,
1356+
):
1357+
"""Open one verified transaction bound to the effective Dolt working set."""
1358+
validate_connection_url(database_url)
1359+
require_persistent_database_url(database_url)
1360+
profile, active = effective_profile(
1361+
database_url, dolt_conformance_source=dolt_conformance_source,
1362+
)
1363+
engine = create_engine(database_url)
1364+
normative = normative_catalog(MetaData())
1365+
with _bound_catalog_transaction(
1366+
engine=engine, profile_name=profile.name, active=active,
1367+
audit_relations={normative.operation.name, normative.fidelity_event.name},
1368+
phase=phase,
1369+
) as connection:
1370+
yield connection, normative
1371+
1372+
13481373
def record_export_operation(
13491374
*, database_url: str, dataset_id: str, destination: str,
13501375
allowed_fidelity_events: Iterable[Mapping[str, Any]],
@@ -1354,14 +1379,12 @@ def record_export_operation(
13541379
) -> str:
13551380
"""Persist a completed export only in the normative audit catalog."""
13561381
operation_details = dict(operation_details or {})
1357-
engine = create_engine(database_url)
1358-
effective_profile(
1359-
database_url, dolt_conformance_source=dolt_conformance_source,
1360-
)
1361-
normative = normative_catalog(MetaData())
13621382
operation_id = str(uuid4())
13631383
events = tuple(allowed_fidelity_events)
1364-
with engine.begin() as connection:
1384+
with _bound_export_audit_transaction(
1385+
database_url=database_url, dolt_conformance_source=dolt_conformance_source,
1386+
phase="export audit creation",
1387+
) as (connection, normative):
13651388
_verify_normative_catalog(connection, normative)
13661389
dataset = _resolve_normative_dataset(connection, normative, dataset_id)
13671390
completed_at = datetime.now(UTC).replace(tzinfo=None)
@@ -1416,11 +1439,10 @@ def finish_export_operation(
14161439
dolt_conformance_source: Any | None = None,
14171440
) -> None:
14181441
"""Mark a started normative export operation as succeeded."""
1419-
effective_profile(
1420-
database_url, dolt_conformance_source=dolt_conformance_source,
1421-
)
1422-
normative = normative_catalog(MetaData())
1423-
with create_engine(database_url).begin() as connection:
1442+
with _bound_export_audit_transaction(
1443+
database_url=database_url, dolt_conformance_source=dolt_conformance_source,
1444+
phase="export audit finalization",
1445+
) as (connection, normative):
14241446
_verify_normative_catalog(connection, normative)
14251447
row = _export_operation_row(connection, normative, operation_id)
14261448
if row["status"] != "started":
@@ -1465,17 +1487,16 @@ def fail_export_operation(
14651487
dolt_conformance_source: Any | None = None,
14661488
) -> None:
14671489
"""Close a started normative export after filesystem compensation."""
1468-
effective_profile(
1469-
database_url, dolt_conformance_source=dolt_conformance_source,
1470-
)
1471-
normative = normative_catalog(MetaData())
14721490
event = {
14731491
"code": "export_failed",
14741492
"detail": "Export publication or finalization failed after audit start.",
14751493
"severity": "error",
14761494
"details": dict(failure_details),
14771495
}
1478-
with create_engine(database_url).begin() as connection:
1496+
with _bound_export_audit_transaction(
1497+
database_url=database_url, dolt_conformance_source=dolt_conformance_source,
1498+
phase="export audit failure",
1499+
) as (connection, normative):
14791500
_verify_normative_catalog(connection, normative)
14801501
row = _export_operation_row(connection, normative, operation_id)
14811502
if row["status"] != "started":
@@ -1497,11 +1518,10 @@ def record_export_backup_retained(
14971518
dolt_conformance_source: Any | None = None,
14981519
) -> None:
14991520
"""Append a warning to a successfully finalized normative export."""
1500-
effective_profile(
1501-
database_url, dolt_conformance_source=dolt_conformance_source,
1502-
)
1503-
normative = normative_catalog(MetaData())
1504-
with create_engine(database_url).begin() as connection:
1521+
with _bound_export_audit_transaction(
1522+
database_url=database_url, dolt_conformance_source=dolt_conformance_source,
1523+
phase="export backup-retention audit",
1524+
) as (connection, normative):
15051525
_verify_normative_catalog(connection, normative)
15061526
row = _export_operation_row(connection, normative, operation_id)
15071527
if row["status"] != "succeeded":
@@ -1532,10 +1552,6 @@ def record_export_cleanup_failure(
15321552
dolt_conformance_source: Any | None = None,
15331553
) -> str:
15341554
"""Persist terminal cleanup failure in the normative audit catalog."""
1535-
effective_profile(
1536-
database_url, dolt_conformance_source=dolt_conformance_source,
1537-
)
1538-
normative = normative_catalog(MetaData())
15391555
operation_id = operation_id or str(uuid4())
15401556
event = {
15411557
"code": "cleanup_failed",
@@ -1551,7 +1567,10 @@ def record_export_cleanup_failure(
15511567
),
15521568
},
15531569
}
1554-
with create_engine(database_url).begin() as connection:
1570+
with _bound_export_audit_transaction(
1571+
database_url=database_url, dolt_conformance_source=dolt_conformance_source,
1572+
phase="export cleanup-failure audit",
1573+
) as (connection, normative):
15551574
_verify_normative_catalog(connection, normative)
15561575
row = connection.execute(
15571576
select(normative.operation).where(

‎tests/test_catalog_persistence_review.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ def test_import_rejects_ephemeral_sqlite_catalogs(database_url):
5151
_create(database_url)
5252

5353

54+
def test_initialization_does_not_modify_an_existing_unverified_catalog(tmp_path):
55+
path = tmp_path / "partial.sqlite"
56+
connection = sqlite3.connect(path)
57+
connection.execute("create table dataset_catalog (legacy_name text)")
58+
connection.commit()
59+
connection.close()
60+
61+
with pytest.raises(UnsupportedOperationError, match="foreign"):
62+
wide.initialize_wide_catalog(database_url=f"sqlite:///{path}")
63+
64+
connection = sqlite3.connect(path)
65+
tables = {
66+
row[0]
67+
for row in connection.execute(
68+
"select name from sqlite_master where type = 'table'"
69+
)
70+
}
71+
connection.close()
72+
assert tables == {"dataset_catalog"}
73+
74+
5475
def test_import_routes_catalog_and_dataset_mutations_through_binding_guard(
5576
tmp_path, monkeypatch,
5677
):
@@ -70,6 +91,67 @@ def observe(**kwargs):
7091
assert phases == ["catalog initialization", "import"]
7192

7293

94+
def test_export_audit_mutations_route_through_binding_guard(tmp_path, monkeypatch):
95+
database_url = f"sqlite:///{tmp_path / 'bound-export.sqlite'}"
96+
_create(database_url)
97+
real_bound_transaction = wide._bound_catalog_transaction
98+
phases = []
99+
100+
@contextmanager
101+
def observe(**kwargs):
102+
phases.append(kwargs["phase"])
103+
with real_bound_transaction(**kwargs) as connection:
104+
yield connection
105+
106+
monkeypatch.setattr(wide, "_bound_catalog_transaction", observe)
107+
failed_id = wide.record_export_operation(
108+
database_url=database_url,
109+
dataset_id="sample",
110+
destination="failed.sav",
111+
allowed_fidelity_events=(),
112+
terminal=False,
113+
)
114+
wide.fail_export_operation(
115+
database_url=database_url,
116+
operation_id=failed_id,
117+
failure_details={"reason": "test"},
118+
)
119+
succeeded_id = wide.record_export_operation(
120+
database_url=database_url,
121+
dataset_id="sample",
122+
destination="succeeded.sav",
123+
allowed_fidelity_events=(),
124+
terminal=False,
125+
)
126+
wide.finish_export_operation(
127+
database_url=database_url, operation_id=succeeded_id,
128+
)
129+
wide.record_export_backup_retained(
130+
database_url=database_url,
131+
operation_id=succeeded_id,
132+
destination="succeeded.sav",
133+
backup="succeeded.sav.backup",
134+
cleanup_error=RuntimeError("test"),
135+
)
136+
wide.record_export_cleanup_failure(
137+
database_url=database_url,
138+
destination="cleanup.sav",
139+
original_error=RuntimeError("export"),
140+
cleanup_error=RuntimeError("cleanup"),
141+
residual_object_inventory={},
142+
deterministic_recovery_evidence={},
143+
)
144+
145+
assert phases == [
146+
"export audit creation",
147+
"export audit failure",
148+
"export audit creation",
149+
"export audit finalization",
150+
"export backup-retention audit",
151+
"export cleanup-failure audit",
152+
]
153+
154+
73155
def test_export_engine_identity_is_persisted_as_non_loss_audit_metadata(tmp_path):
74156
path = tmp_path / "export-audit.sqlite"
75157
database_url = f"sqlite:///{path}"

0 commit comments

Comments
 (0)