Skip to content

Commit b017161

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 b017161

3 files changed

Lines changed: 90 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
than a preview, and readiness comes from `GET /v1/query-runs/{id}`, which
2828
carries no rows at any size. `result_id` is read off the run rather than off
2929
the query reply, because a run can succeed having saved nothing and the run is
30-
what reports that. Arrow stays the only path the data travels, so column types
31-
come from the server's schema rather than being inferred from JSON.
30+
what reports that — and that case now raises rather than reading as an empty
31+
table. `fetch_table` answered `None` for it, which `fetch_table_rows` turns
32+
into `[]`, the same answer both give for a table that is not synced. A
33+
read-modify-write load would have read no existing rows and written only its
34+
new batch, dropping every row already there. Arrow stays the only path the
35+
data travels, so column types come from the server's schema rather than being
36+
inferred from JSON.
3237

3338
Costs one extra round trip on a query that would have answered synchronously,
3439
in exchange for not transferring the result twice.

‎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)