Skip to content

Commit 7c67a2b

Browse files
committed
fix(managed): raise when a succeeded run saved no result
A run succeeds with no result id when its rows came back inline but the result could not be saved for later retrieval. `fetch_table` answered `None` for that, `fetch_table_rows` turns `None` into `[]`, and `[]` is also the answer both give for a table that is not synced -- so the two states were indistinguishable to a caller. A read-modify-write load would therefore read no existing rows and write only its new batch, dropping every row already in the table. Silent data loss is the worst outcome on offer, and raising costs nothing if the state never occurs. Terminal rather than transient: re-running the query cannot save a result that was already discarded. The run's `warning_message` goes into the error, since it is the only thing that explains why. The test this replaces asserted the old behaviour -- it pinned `fetch_table` returning `None` for exactly this case, which committed the data-loss path to the suite rather than catching it.
1 parent 258859a commit 7c67a2b

2 files changed

Lines changed: 83 additions & 10 deletions

File tree

‎hotdata_framework/managed_client.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,22 @@ def _await_query_run(self, query_run_id: str, *, database_id: str) -> str | None
185185
run = runs.get_query_run(query_run_id, x_database_id=database_id)
186186
last_status = run.status
187187
if run.status == "succeeded":
188+
if run.result_id is None:
189+
# A run succeeds with no result id when its rows were
190+
# returned inline but the result could not be saved for
191+
# later retrieval. Returning nothing here would surface as
192+
# an empty table -- `fetch_table` answers `None`, and
193+
# `fetch_table_rows` turns that into `[]`, which is the same
194+
# answer it gives for a table that does not exist. A
195+
# read-modify-write load would then read no existing rows
196+
# and write only its new batch, dropping what was there.
197+
# Terminal rather than transient: re-running the query
198+
# cannot save a result that was already discarded.
199+
raise RuntimeError(
200+
f"Query run {query_run_id} succeeded but its result was not "
201+
f"saved, so the table cannot be read"
202+
+ (f": {run.warning_message}" if run.warning_message else "")
203+
)
188204
return run.result_id
189205
if run.status == "interrupted":
190206
# Terminal, but the server lost the run rather than rejecting

‎tests/test_managed_client.py‎

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -302,15 +302,21 @@ def get_query_run(self, query_run_id: str, **kwargs: Any) -> Any:
302302
assert calls.count("query") == 1
303303

304304

305-
def test_result_id_is_read_off_the_run_not_the_query_reply(
305+
def test_a_succeeded_run_that_saved_no_result_raises_rather_than_reading_empty(
306306
monkeypatch: pytest.MonkeyPatch,
307307
) -> None:
308-
"""A ``succeeded`` run reports ``result_id: null`` when every row came back
309-
inline but the result could not be saved for later retrieval.
310-
311-
The query reply's own ``result_id`` is optimistic -- it names a result that
312-
may never resolve -- so the run's is the one to believe. Believing the reply
313-
means fetching Arrow against an id the server will 404.
308+
"""The one state where an empty answer would be a wrong answer.
309+
310+
A run succeeds with no `result_id` when its rows came back inline but the
311+
result could not be saved. Answering `None` puts that on the same footing as
312+
a table that does not exist -- `fetch_table_rows` turns both into `[]` -- so
313+
a read-modify-write load would read no existing rows and write only its new
314+
batch, dropping the rows already in the table. Silent data loss is the worst
315+
outcome available here, so this raises, and raises terminally: re-running
316+
cannot save a result that was already discarded.
317+
318+
The query reply carries an id of its own, and it must not be believed over
319+
the run's.
314320
"""
315321
arrow_calls: list[str] = []
316322

@@ -328,7 +334,12 @@ def __init__(self, api: object) -> None:
328334

329335
def get_query_run(self, query_run_id: str, **kwargs: Any) -> Any:
330336
# ...that the run says was never saved.
331-
return SimpleNamespace(status="succeeded", result_id=None, error_message=None)
337+
return SimpleNamespace(
338+
status="succeeded",
339+
result_id=None,
340+
error_message=None,
341+
warning_message="result row creation failed; result not persisted",
342+
)
332343

333344
class FakeArrowResultsApi:
334345
def __init__(self, api: object) -> None:
@@ -341,20 +352,66 @@ def get_result_arrow(self, result_id: str, **kwargs: Any) -> pa.Table:
341352
monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
342353
monkeypatch.setattr(mc, "QueryRunsApi", FakeQueryRunsApi)
343354
monkeypatch.setattr(mc, "ArrowResultsApi", FakeArrowResultsApi)
355+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
344356

345357
client = mc.ManagedDatabaseClient(
346358
api_key="k",
347359
workspace_id="w",
348360
api_base_url="https://example.test",
349-
max_retries=1,
361+
max_retries=3,
350362
retry_backoff_seconds=0.0,
351363
)
352364
client._runtime = _fake_runtime()
353365

354-
assert client.fetch_table(database="mydb", schema="public", table="orders") is None
366+
with pytest.raises(HotdataTerminalError, match="result was not saved"):
367+
client.fetch_table(database="mydb", schema="public", table="orders")
368+
369+
# The reply's id was never used.
355370
assert arrow_calls == []
356371

357372

373+
def test_fetch_table_rows_cannot_turn_an_unsaved_result_into_no_rows(
374+
monkeypatch: pytest.MonkeyPatch,
375+
) -> None:
376+
"""`fetch_table_rows` is where an empty answer does the damage.
377+
378+
It maps `None` to `[]`, which is also its answer for a table that is not
379+
synced, so the unsaved-result case must not be able to reach that mapping.
380+
"""
381+
382+
class FakeQueryApi:
383+
def __init__(self, api: object) -> None:
384+
pass
385+
386+
def query(self, request: object, *, x_database_id: str) -> QueryResponse:
387+
return _query_response("rslt1")
388+
389+
class FakeQueryRunsApi:
390+
def __init__(self, api: object) -> None:
391+
pass
392+
393+
def get_query_run(self, query_run_id: str, **kwargs: Any) -> Any:
394+
return SimpleNamespace(
395+
status="succeeded", result_id=None, error_message=None, warning_message=None
396+
)
397+
398+
monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
399+
monkeypatch.setattr(mc, "QueryRunsApi", FakeQueryRunsApi)
400+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
401+
402+
client = mc.ManagedDatabaseClient(
403+
api_key="k",
404+
workspace_id="w",
405+
api_base_url="https://example.test",
406+
max_retries=1,
407+
retry_backoff_seconds=0.0,
408+
)
409+
client._runtime = _fake_runtime()
410+
411+
with pytest.raises(HotdataTerminalError):
412+
client.fetch_table_rows(database="mydb", schema="public", table="orders")
413+
414+
358415
def _load_recording_runtime(calls: list[str], uploads: list[str] | None = None) -> SimpleNamespace:
359416
"""A runtime whose ``load_managed_table`` records each mode and always fails
360417
with a transient error, so retry behaviour is observable via ``calls``.

0 commit comments

Comments
 (0)