Skip to content

Commit 90a8350

Browse files
committed
test: reproduce mixed export snapshots and validation schema races
1 parent 790051c commit 90a8350

2 files changed

Lines changed: 288 additions & 6 deletions

File tree

‎tests/test_read_only_export.py‎

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
python -m pytest tests/test_read_only_export.py
66
The fixture creates and removes its own database and SELECT-only user.
77
"""
8+
import json
89
import os
910
import sqlite3
1011
from uuid import uuid4
@@ -43,6 +44,9 @@ def seeded_catalog(request, tmp_path):
4344
with source_engine.connect() as connection:
4445
dataset = connection.execute(select(logical.dataset)).mappings().one()
4546
if request.param == "sqlite":
47+
with sqlite3.connect(path) as connection:
48+
connection.execute("PRAGMA journal_mode = WAL")
49+
4650
def snapshot():
4751
with sqlite3.connect(path) as connection:
4852
return tuple(connection.iterdump())
@@ -119,7 +123,8 @@ def read_catalog(seeded_catalog):
119123
forbidden = []
120124

121125
def check(_connection, _cursor, statement, _parameters, _context, _many):
122-
if statement.lstrip().split()[0].upper() not in {"SELECT", "SHOW", "PRAGMA", "DESCRIBE"}:
126+
normalized = " ".join(statement.split()).upper()
127+
if normalized != "BEGIN" and normalized.split()[0] not in {"SELECT", "SHOW", "PRAGMA", "DESCRIBE"}:
123128
forbidden.append(statement)
124129
raise AssertionError("Read operation attempted a non-read SQL statement")
125130

@@ -151,7 +156,7 @@ def test_export_and_reads_leave_no_database_trace(read_catalog, tmp_path, suffix
151156
assert not list(tmp_path.glob(".*.staging.*"))
152157

153158

154-
@pytest.mark.parametrize("failure", ["loss", "writer", "publish", "restore", "staging", "backup"])
159+
@pytest.mark.parametrize("failure", ["read", "loss", "writer", "publish", "restore", "staging", "backup"])
155160
def test_failed_export_never_writes_database(read_catalog, tmp_path, monkeypatch, failure):
156161
url, snapshot, _source = read_catalog
157162
before = snapshot()
@@ -161,7 +166,16 @@ def test_failed_export_never_writes_database(read_catalog, tmp_path, monkeypatch
161166
def fail(*_args, **_kwargs):
162167
raise OSError("injected failure")
163168

164-
if failure == "loss":
169+
readers = []
170+
171+
def fail_read(connection, _cursor, statement, _parameters, _context, _many):
172+
if "FROM variable " in " ".join(statement.split()):
173+
readers.append(connection)
174+
raise OSError("injected read failure")
175+
176+
if failure == "read":
177+
event.listen(Engine, "after_cursor_execute", fail_read)
178+
elif failure == "loss":
165179
monkeypatch.setattr(sav, "_export_loss_report", lambda *_a, **_k: (
166180
{"code": "test-loss", "detail": "Requires consent", "details": {}},
167181
))
@@ -188,8 +202,14 @@ def fail_backup(self, *args, **kwargs):
188202
return unlink(self, *args, **kwargs)
189203

190204
monkeypatch.setattr(type(output), "unlink", fail_backup)
191-
with pytest.raises((OSError, UnsupportedOperationError)):
192-
openstatspec.export_sav(database_url=url, dataset_id="sample", destination=output)
205+
try:
206+
with pytest.raises((OSError, UnsupportedOperationError), match="injected read failure" if failure == "read" else None):
207+
openstatspec.export_sav(database_url=url, dataset_id="sample", destination=output)
208+
finally:
209+
if failure == "read":
210+
event.remove(Engine, "after_cursor_execute", fail_read)
211+
if failure == "read":
212+
assert readers and all(connection.closed for connection in readers)
193213
assert snapshot() == before
194214
if failure in {"restore", "backup"}:
195215
# If filesystem recovery itself fails, preserve the previous bytes in
@@ -205,6 +225,117 @@ def fail_backup(self, *args, **kwargs):
205225
assert snapshot() == before
206226

207227

228+
@pytest.mark.parametrize("seeded_catalog", ["sqlite"], indirect=True)
229+
@pytest.mark.parametrize("operation", ["descriptor", "export"])
230+
def test_reads_use_one_snapshot(read_catalog, tmp_path, monkeypatch, operation):
231+
url, snapshot, source = read_catalog
232+
path = make_url(url).database
233+
readers = []
234+
catalog_transactions = []
235+
committed = False
236+
237+
def observe_catalog(connection, _cursor, statement, _parameters, _context, _many):
238+
if "catalog_identity" in statement and connection not in readers:
239+
readers.append(connection)
240+
catalog_transactions.append(connection.connection.driver_connection.in_transaction)
241+
242+
def interleave(connection, _cursor, statement, _parameters, _context, _many):
243+
nonlocal committed
244+
sql = " ".join(statement.split())
245+
if committed or "FROM variable " not in sql or "ORDER BY variable.source_ordinal" not in sql:
246+
return
247+
assert not connection.closed
248+
# Raw connection deliberately bypasses SQLAlchemy's query-only listener.
249+
with sqlite3.connect(path, timeout=1) as writer:
250+
table, dataset_id, operation_id = writer.execute(
251+
"SELECT physical_table_name, dataset_id, (SELECT operation_id FROM operation) FROM dataset"
252+
).fetchone()
253+
writer.execute(f'UPDATE "{table}" SET answer = 3 WHERE __case_ordinal = 1')
254+
writer.execute("UPDATE variable SET variable_label = 'New answer' WHERE source_name = 'answer'")
255+
writer.execute("UPDATE value_label SET label = 'New code' WHERE numeric_code = 1")
256+
writer.execute("UPDATE dataset SET dataset_label = 'New dataset'")
257+
writer.execute(
258+
"INSERT INTO fidelity_event VALUES (?, ?, ?, 'import', 'warning', 'snapshot-loss', NULL, '{}', CURRENT_TIMESTAMP)",
259+
(str(uuid4()), operation_id, dataset_id),
260+
)
261+
committed = True
262+
assert not connection.closed # writer committed without waiting for reader release
263+
264+
real_writer = sav._write_with_dictionary_bridge
265+
266+
def write_after_read(*args, **kwargs):
267+
assert readers and all(connection.closed for connection in readers)
268+
return real_writer(*args, **kwargs)
269+
270+
monkeypatch.setattr(sav, "_write_with_dictionary_bridge", write_after_read)
271+
event.listen(Engine, "before_cursor_execute", observe_catalog)
272+
event.listen(Engine, "after_cursor_execute", interleave)
273+
try:
274+
if operation == "descriptor":
275+
dataset, variables, rows = wide.read_wide_dataset(database_url=url, dataset_id="sample")
276+
assert rows[0]["answer"] == 1
277+
assert variables[0]["label"] == "Answer"
278+
assert json.loads(variables[0]["value_labels"])["1.0"] == "Yes"
279+
assert dataset["file_label"] == ""
280+
else:
281+
output = tmp_path / "snapshot.sav"
282+
result = openstatspec.export_sav(database_url=url, dataset_id="sample", destination=output)
283+
assert not result.diagnostics
284+
expected, expected_meta = pyspssio.read_sav(str(source), include_user_missing=True)
285+
actual, actual_meta = pyspssio.read_sav(str(output), include_user_missing=True)
286+
pd.testing.assert_frame_equal(actual, expected)
287+
for key in ("var_labels", "var_value_labels", "file_label"):
288+
assert actual_meta[key] == expected_meta[key]
289+
finally:
290+
event.remove(Engine, "after_cursor_execute", interleave)
291+
event.remove(Engine, "before_cursor_execute", observe_catalog)
292+
assert committed
293+
assert catalog_transactions and all(catalog_transactions), "first catalog read needs native BEGIN"
294+
assert all(connection.closed for connection in readers)
295+
after_writer = snapshot()
296+
dataset, variables, rows = wide.read_wide_dataset(database_url=url, dataset_id="sample")
297+
assert rows[0]["answer"] == 3
298+
assert variables[0]["label"] == "New answer"
299+
assert json.loads(variables[0]["value_labels"])["1.0"] == "New code"
300+
assert dataset["file_label"] == "New dataset"
301+
if operation == "export":
302+
with pytest.raises(UnsupportedOperationError, match="snapshot-loss"):
303+
openstatspec.export_sav(database_url=url, dataset_id="sample", destination=output)
304+
result = openstatspec.export_sav(
305+
database_url=url, dataset_id="sample", destination=output, allow_loss=["snapshot-loss"],
306+
)
307+
assert {d.code for d in result.diagnostics} == {"snapshot-loss"}
308+
assert snapshot() == after_writer
309+
310+
311+
@pytest.mark.parametrize("seeded_catalog", ["sqlite"], indirect=True)
312+
def test_validation_reflects_the_same_schema_snapshot(read_catalog):
313+
url, snapshot, _source = read_catalog
314+
with sqlite3.connect(make_url(url).database) as connection:
315+
table = connection.execute("SELECT physical_table_name FROM dataset").fetchone()[0]
316+
readers = []
317+
318+
def rename_after_values(connection, _cursor, statement, _parameters, _context, _many):
319+
if readers or f"FROM {table} " not in " ".join(statement.split()):
320+
return
321+
readers.append(connection)
322+
with sqlite3.connect(make_url(url).database, timeout=1) as writer:
323+
writer.execute("BEGIN")
324+
writer.execute(f'ALTER TABLE "{table}" RENAME COLUMN answer TO renamed_answer')
325+
writer.execute("UPDATE variable SET physical_name = 'renamed_answer' WHERE source_name = 'answer'")
326+
assert not connection.closed
327+
328+
event.listen(Engine, "after_cursor_execute", rename_after_values)
329+
try:
330+
assert openstatspec.validate(database_url=url, dataset_id="sample")["valid"]
331+
finally:
332+
event.remove(Engine, "after_cursor_execute", rename_after_values)
333+
assert readers and all(connection.closed for connection in readers)
334+
after_writer = snapshot()
335+
assert openstatspec.validate(database_url=url, dataset_id="sample")["valid"]
336+
assert snapshot() == after_writer
337+
338+
208339
def test_dolt_reads_do_not_enable_undeclared_writes(monkeypatch):
209340
url = "mysql+pymysql://reader@host/dataset"
210341
active = {

‎tests/test_sql_services.py‎

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Real-service writes, round trips, failure boundaries, and candidate probes."""
22

3+
import json
34
import os
5+
from datetime import datetime, timezone
46
from uuid import uuid4
57

68
import pandas as pd
79
import pyspssio
810
import pytest
9-
from sqlalchemy import MetaData, create_engine, inspect as inspect_database, text
11+
from sqlalchemy import MetaData, Table, create_engine, event, inspect as inspect_database, select, text
12+
from sqlalchemy.engine import Engine
1013
from sqlalchemy.exc import DBAPIError
1114

1215
import openstatspec
@@ -17,6 +20,7 @@
1720
delete_dataset_representation,
1821
)
1922
from openstatspec.sql.wide import create_wide_dataset
23+
from openstatspec.spss import sav
2024
from conformance import compare_sav_semantics, write_supported_semantics_fixture
2125

2226

@@ -111,6 +115,153 @@ def test_live_profile_preserves_supported_sav_semantics(environment_name, datase
111115
)
112116
assert compare_sav_semantics(source, destination) == {"equivalent": True, "differences": []}
113117

118+
@pytest.mark.parametrize("environment_name", [
119+
"OPENSTATSPEC_POSTGRES_URL", "OPENSTATSPEC_MYSQL_URL",
120+
"OPENSTATSPEC_MARIADB_URL", "OPENSTATSPEC_DOLT_URL",
121+
])
122+
def test_live_export_uses_one_snapshot(environment_name, source_sav, tmp_path, monkeypatch, record_property):
123+
database_url = os.environ.get(environment_name)
124+
if not database_url:
125+
pytest.skip(f"{environment_name} is not configured")
126+
imported = openstatspec.import_sav(
127+
source_sav, database_url=database_url, dataset_id="snapshot_" + uuid4().hex,
128+
)
129+
engine = create_engine(database_url)
130+
tables = normative_catalog(MetaData())
131+
readers, isolations, native_transactions = [], [], []
132+
committed = False
133+
134+
def weaker_default(connection):
135+
if connection.engine is not engine:
136+
# Test-only DBAPI setup, before the reader can request its isolation.
137+
# SQLAlchemy's MySQL setter itself emits SET SESSION ... and COMMIT
138+
# outside cursor events; the event SQL allowlist must not allow SET.
139+
connection.dialect.set_isolation_level(
140+
connection.connection.driver_connection, "READ COMMITTED",
141+
)
142+
assert connection.get_isolation_level() == "READ COMMITTED"
143+
144+
def only_reads(connection, _cursor, statement, _parameters, _context, _many):
145+
if connection.engine is engine:
146+
return
147+
sql = " ".join(statement.split()).upper()
148+
assert sql == "BEGIN" or sql.split()[0] in {"SELECT", "SHOW", "DESCRIBE"}, statement
149+
150+
def owned_contents():
151+
with engine.connect() as connection:
152+
return (
153+
tuple(connection.execute(select(physical).order_by(physical.c.__case_ordinal))),
154+
tuple(connection.execute(select(tables.variable).where(
155+
tables.variable.c.dataset_id == imported["dataset_id"],
156+
).order_by(tables.variable.c.source_ordinal))),
157+
tuple(connection.execute(select(tables.value_label).where(
158+
tables.value_label.c.value_label_set_id.in_(select(
159+
tables.value_label_set.c.value_label_set_id,
160+
).where(tables.value_label_set.c.dataset_id == imported["dataset_id"])),
161+
).order_by(tables.value_label.c.value_label_set_id, tables.value_label.c.ordinal))),
162+
tuple(connection.execute(select(tables.fidelity_event).where(
163+
tables.fidelity_event.c.dataset_id == imported["dataset_id"],
164+
).order_by(tables.fidelity_event.c.fidelity_event_id))),
165+
)
166+
167+
after_writer = None
168+
169+
def interleave(connection, _cursor, statement, _parameters, _context, _many):
170+
nonlocal committed, after_writer
171+
if connection.engine is engine: # never recurse through the writer
172+
return
173+
sql = " ".join(statement.split())
174+
if "FROM catalog_identity" in sql:
175+
readers.append(connection)
176+
raw = connection.connection.driver_connection
177+
isolations.append(connection.dialect.get_isolation_level(raw))
178+
native_transactions.append(
179+
raw.info.transaction_status != 0
180+
if connection.dialect.name == "postgresql" else bool(raw.server_status & 1)
181+
)
182+
if committed or "FROM variable " not in sql or "ORDER BY variable.source_ordinal" not in sql:
183+
return
184+
with engine.begin() as writer:
185+
if writer.dialect.name in {"mysql", "mariadb"}:
186+
writer.exec_driver_sql("BEGIN") # Dolt may retain DBAPI autocommit=1.
187+
writer.execute(physical.update().where(physical.c.__case_ordinal == 1).values(age=35))
188+
writer.execute(tables.variable.update().where(
189+
tables.variable.c.dataset_id == imported["dataset_id"],
190+
tables.variable.c.source_name == "age",
191+
).values(variable_label="New age"))
192+
writer.execute(tables.value_label.update().where(
193+
tables.value_label.c.value_label_set_id.in_(select(
194+
tables.value_label_set.c.value_label_set_id,
195+
).where(tables.value_label_set.c.dataset_id == imported["dataset_id"])),
196+
).values(label="New code"))
197+
writer.execute(tables.fidelity_event.insert().values(
198+
fidelity_event_id=str(uuid4()), operation_id=imported["operation_id"],
199+
dataset_id=imported["dataset_id"], direction="import", severity="warning",
200+
event_code="snapshot-loss", detail_json="{}", created_at=datetime.now(timezone.utc),
201+
))
202+
committed = True
203+
assert not connection.closed
204+
after_writer = owned_contents()
205+
206+
real_writer = sav._write_with_dictionary_bridge
207+
208+
def write_after_read(*args, **kwargs):
209+
assert readers and all(connection.closed for connection in readers)
210+
return real_writer(*args, **kwargs)
211+
212+
try:
213+
physical = Table(imported["data_table"], MetaData(), autoload_with=engine)
214+
with engine.connect() as connection:
215+
record_property("server_version", connection.exec_driver_sql("SELECT VERSION()").scalar_one())
216+
monkeypatch.setattr(sav, "_write_with_dictionary_bridge", write_after_read)
217+
event.listen(Engine, "engine_connect", weaker_default)
218+
event.listen(Engine, "before_cursor_execute", only_reads)
219+
event.listen(Engine, "after_cursor_execute", interleave)
220+
try:
221+
destination = tmp_path / "snapshot.sav"
222+
result = openstatspec.export_sav(
223+
database_url=database_url, dataset_id=imported["dataset_id"], destination=destination,
224+
)
225+
finally:
226+
event.remove(Engine, "after_cursor_execute", interleave)
227+
event.remove(Engine, "before_cursor_execute", only_reads)
228+
event.remove(Engine, "engine_connect", weaker_default)
229+
record_property("catalog_isolations", isolations)
230+
record_property("catalog_native_transactions", native_transactions)
231+
record_property("writer_committed", committed)
232+
assert committed
233+
assert not result.diagnostics
234+
expected, expected_meta = pyspssio.read_sav(str(source_sav), include_user_missing=True)
235+
actual, actual_meta = pyspssio.read_sav(str(destination), include_user_missing=True)
236+
pd.testing.assert_frame_equal(actual, expected)
237+
for key in ("var_labels", "var_value_labels"):
238+
assert actual_meta[key] == expected_meta[key]
239+
assert isolations and set(isolations) == {"REPEATABLE READ"}
240+
assert all(native_transactions), "first catalog read must be in a native transaction"
241+
assert all(connection.closed for connection in readers)
242+
_, variables, rows = wide.read_wide_dataset(
243+
database_url=database_url, dataset_id=imported["dataset_id"],
244+
)
245+
assert rows[0]["age"] == 35
246+
assert variables[0]["label"] == "New age"
247+
assert json.loads(variables[0]["value_labels"])["34.0"] == "New code"
248+
with pytest.raises(UnsupportedOperationError, match="snapshot-loss"):
249+
openstatspec.export_sav(
250+
database_url=database_url, dataset_id=imported["dataset_id"], destination=destination,
251+
)
252+
assert owned_contents() == after_writer
253+
finally:
254+
# Never reset a service/catalog or touch another dataset's objects.
255+
with engine.begin() as connection:
256+
delete_dataset_representation(connection, tables, imported["dataset_id"])
257+
quote = connection.dialect.identifier_preparer.quote
258+
connection.exec_driver_sql(f"DROP TABLE {quote(imported['data_table'])}")
259+
connection.execute(tables.operation.delete().where(
260+
tables.operation.c.operation_id == imported["operation_id"],
261+
))
262+
engine.dispose()
263+
264+
114265
def test_live_unknown_dolt_is_read_only_and_rejects_default_writes(
115266
tmp_path,
116267
) -> None:

0 commit comments

Comments
 (0)