Skip to content

Commit 258859a

Browse files
committed
fix(managed): keep waiting on an unknown status, and name it
Reverses the status inversion from the previous commit, and takes the belt-and-braces route on the run-readiness question rather than arguing it a third time. The inversion traded one failure mode for a worse one. Enumerating the in-flight statuses does make a missing status surface in one round trip instead of a timeout -- but it also means a status added upstream fails every query the moment it appears, where the arrangement it replaced cost one slow call. That asymmetry was not worth the diagnosis, and the actual defect in the `interrupted` case was never the waiting: it was that the timeout said only "did not finish", never which status it had been waiting on. Both timeouts now carry it, in both clients, so the next missing status costs one slow call and names itself in the error. Separately, `_fetch_result_arrow` now waits out a result that reports itself not ready. A run reports `succeeded` only once its result is saved and ready, so this is unreachable, and the review was right that nothing in this package demonstrated that. Tolerating it is cheap and settles the question in code: the Arrow endpoint answers a result which is not ready with a small refusal rather than with data, so waiting there costs one tiny request -- which is the whole difference from waiting on the JSON result body, and the reason this is not a return to what was removed.
1 parent 21730a3 commit 258859a

5 files changed

Lines changed: 152 additions & 51 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
Costs one extra round trip on a query that would have answered synchronously,
3434
in exchange for not transferring the result twice.
3535

36+
The Arrow fetch now also waits out a result that reports itself not ready, in
37+
case that ordering ever stops holding. It should be unreachable, and it is
38+
cheap to keep: that endpoint answers a result which is not ready with a small
39+
refusal rather than with data, which is exactly what made waiting on the JSON
40+
result body expensive and waiting here not.
41+
3642
- fix(managed): recognise `interrupted`, and drop a run status the API never sends.
3743

3844
Both `ManagedDatabaseClient` and `HotdataClient` treated `failed` and
@@ -48,11 +54,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4854
caller-raised transient error to terminal. `HotdataClient.execute_sql` now
4955
fails fast on it with the run's own message.
5056

51-
Both polls now enumerate the statuses that mean *still in flight* rather than
52-
the ones that mean *finished*, for query runs and for results alike. Listing
53-
the terminal side treats an unrecognised status as "keep waiting", which is
54-
precisely how `interrupted` came to be waited out; listing the in-flight side
55-
makes the next status the API adds fail on the first pass, naming itself.
57+
Both polls keep enumerating the statuses that mean *finished*, and an
58+
unrecognised status still waits. Calling an unknown status terminal would make
59+
the omission easier to diagnose and much worse to live with: one status added
60+
upstream would fail every read at once, where waiting costs a single slow call.
61+
What made `interrupted` expensive was not the waiting — it was that the
62+
timeout never said which status it had been waiting on. Both timeouts now name
63+
it.
5664

5765
## [0.13.0] - 2026-08-27
5866

‎hotdata_framework/client.py‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,17 @@
7777
_INDEX_TYPES = frozenset(get_args(IndexType))
7878
_VECTOR_METRICS = frozenset(get_args(VectorMetric))
7979

80-
# Enumerate the in-flight statuses, not the terminal ones. A poll that lists
81-
# what is terminal treats anything it does not recognise as "keep waiting", so a
82-
# status the API adds later -- or one that was simply missed -- costs the full
83-
# timeout before surfacing. Listing what is still in flight makes an unknown
84-
# status raise on the first pass, naming itself. `interrupted` was missed by
85-
# exactly the other arrangement, and `cancelled` was listed here without being a
86-
# status this API sends.
87-
_RUN_IN_FLIGHT = frozenset({"running"})
88-
_RESULT_IN_FLIGHT = frozenset({"pending", "processing"})
80+
# Query-run statuses that mean the run is over. `interrupted` belongs here --
81+
# omitting it is what made an interrupted run wait out the full timeout -- and
82+
# `cancelled`, listed here for a long time, is not a status this API sends.
83+
#
84+
# Enumerating the terminal side rather than the in-flight side is deliberate. An
85+
# unrecognised status then keeps polling and costs one slow call, where treating
86+
# it as terminal would fail every query the moment a status is added upstream.
87+
# The timeout names the status it last saw, so a missing one is diagnosable
88+
# without being dangerous.
89+
_RUN_TERMINAL = frozenset({"succeeded", "failed", "interrupted"})
90+
_RESULT_FAILURE = frozenset({"failed"})
8991
# Jobs have no "cancelled" state; "partially_succeeded" carries an error_message.
9092
_JOB_TERMINAL = frozenset({"succeeded", "partially_succeeded", "failed"})
9193

@@ -925,7 +927,7 @@ def _poll_query_run(
925927
last = None
926928
while time.monotonic() < deadline:
927929
last = runs.get_query_run(query_run_id)
928-
if last.status not in _RUN_IN_FLIGHT:
930+
if last.status in _RUN_TERMINAL:
929931
return last
930932
time.sleep(interval_s)
931933
raise TimeoutError(
@@ -1034,7 +1036,7 @@ def _wait_result_ready(
10341036
last = results.get_result(result_id)
10351037
if last.status == "ready":
10361038
return last
1037-
if last.status not in _RESULT_IN_FLIGHT:
1039+
if last.status in _RESULT_FAILURE:
10381040
raise RuntimeError(last.error_message or f"Result {last.status}")
10391041
time.sleep(interval_s)
10401042
raise TimeoutError(

‎hotdata_framework/managed_client.py‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import pyarrow as pa
1616
from hotdata.api.query_api import QueryApi
1717
from hotdata.api.query_runs_api import QueryRunsApi
18+
from hotdata.arrow import ResultNotReadyError
1819
from hotdata.arrow import ResultsApi as ArrowResultsApi
1920
from hotdata.models.async_query_response import AsyncQueryResponse
2021
from hotdata.models.query_request import QueryRequest
@@ -40,11 +41,6 @@ class ManagedDatabaseClient:
4041
database lifecycle.
4142
"""
4243

43-
# The only status a query run reports while still in flight. Listed this way
44-
# round so an unknown status raises immediately rather than being polled to
45-
# the timeout; see `_await_query_run`.
46-
_RUN_IN_FLIGHT = frozenset({"running"})
47-
4844
_QUERY_TIMEOUT_SECONDS = 300.0
4945
_POLL_INTERVAL_SECONDS = 0.4
5046
_MAX_BACKOFF_SECONDS = 30.0
@@ -124,9 +120,22 @@ def _fetch_result_arrow(self, result_id: str, *, database_id: str) -> pa.Table:
124120
0.6.0 SDK exposes (and requires) ``x_database_id`` on the Arrow
125121
helper directly.
126122
"""
127-
return ArrowResultsApi(self._runtime.api).get_result_arrow(
128-
result_id, x_database_id=database_id
129-
)
123+
arrow = ArrowResultsApi(self._runtime.api)
124+
deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
125+
while True:
126+
try:
127+
return arrow.get_result_arrow(result_id, x_database_id=database_id)
128+
except ResultNotReadyError:
129+
# Waiting on the run should already have made this unreachable:
130+
# a run reports `succeeded` only once its result is saved and
131+
# ready. Tolerating it anyway costs nothing and removes the need
132+
# to take that ordering on trust. The Arrow endpoint answers a
133+
# result that is not ready with a small refusal rather than with
134+
# data, so waiting here is cheap in the way waiting on the JSON
135+
# result body -- which is what this change removed -- is not.
136+
if time.monotonic() >= deadline:
137+
raise
138+
time.sleep(self._POLL_INTERVAL_SECONDS)
130139

131140
def _query_database_scoped(self, sql: str, *, database_id: str) -> str | None:
132141
raw = QueryApi(self._runtime.api).query(
@@ -170,9 +179,11 @@ def _await_query_run(self, query_run_id: str, *, database_id: str) -> str | None
170179
"""
171180
runs = QueryRunsApi(self._runtime.api)
172181
deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
182+
last_status: str | None = None
173183
while time.monotonic() < deadline:
174184
# Runs (like results) of database-scoped queries are database-scoped.
175185
run = runs.get_query_run(query_run_id, x_database_id=database_id)
186+
last_status = run.status
176187
if run.status == "succeeded":
177188
return run.result_id
178189
if run.status == "interrupted":
@@ -183,15 +194,19 @@ def _await_query_run(self, query_run_id: str, *, database_id: str) -> str | None
183194
raise HotdataTransientError(
184195
run.error_message or f"Query run {query_run_id} was interrupted"
185196
)
186-
# Anything not still in flight is terminal, whether or not this
187-
# client has heard of it. Enumerating the terminal statuses instead
188-
# would poll an unrecognised one to the timeout -- which is exactly
189-
# how `interrupted` came to be waited out for five minutes.
190-
if run.status not in self._RUN_IN_FLIGHT:
191-
raise RuntimeError(run.error_message or f"Query run {query_run_id} {run.status}")
197+
if run.status == "failed":
198+
raise RuntimeError(run.error_message or f"Query run {query_run_id} failed")
199+
# Any other status keeps polling, including one this client has never
200+
# seen. Treating an unrecognised status as terminal is the cheaper
201+
# failure to diagnose and by far the more expensive one to suffer: a
202+
# single status added upstream would then fail every query at once,
203+
# where waiting costs one slow call. What made `interrupted`
204+
# expensive was not the waiting, it was that the timeout never said
205+
# which status it had waited on -- so the message now carries it.
192206
time.sleep(self._POLL_INTERVAL_SECONDS)
193207
raise TimeoutError(
194-
f"Query run {query_run_id} did not finish within {self._QUERY_TIMEOUT_SECONDS}s"
208+
f"Query run {query_run_id} did not finish within "
209+
f"{self._QUERY_TIMEOUT_SECONDS}s (last status: {last_status})"
195210
)
196211

197212
def fetch_table_rows(self, *, database: str, schema: str, table: str) -> list[dict[str, Any]]:

‎tests/test_client.py‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,28 @@ def test_list_qualified_table_names_passes_connection_id():
245245
assert it.call_args.kwargs["connection_id"] == "conn_a"
246246

247247

248-
def test_wait_result_ready_raises_on_any_status_that_is_not_in_flight():
249-
"""A result neither `ready` nor still being saved is terminal.
248+
def test_wait_result_ready_raises_on_a_failed_result():
249+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
250+
251+
class FakeResultsApi:
252+
def get_result(self, result_id: str):
253+
return SimpleNamespace(status="failed", error_message="out of memory")
254+
255+
with (
256+
patch.object(client, "_results_api", return_value=FakeResultsApi()),
257+
pytest.raises(RuntimeError, match="out of memory"),
258+
):
259+
client._wait_result_ready("res_1", timeout_s=0.1, interval_s=0)
250260

251-
The poll enumerates the in-flight statuses rather than the terminal ones, so
252-
a status this client has never heard of raises on the first pass instead of
253-
being waited out. `cancelled` -- which this poll listed as terminal, and
254-
which the API does not send -- is as good a stand-in as any.
261+
262+
def test_unknown_result_status_times_out_and_names_the_status():
263+
"""An unrecognised status keeps polling rather than being called terminal.
264+
265+
Failing fast on an unknown status would be easier to debug, and far worse to
266+
live with: one status added upstream would fail every read at once, where
267+
waiting costs a single slow call. The timeout names what it waited on, which
268+
is what makes the omission findable -- and what was missing when
269+
`interrupted` went unrecognised.
255270
"""
256271
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
257272

@@ -261,9 +276,9 @@ def get_result(self, result_id: str):
261276

262277
with (
263278
patch.object(client, "_results_api", return_value=FakeResultsApi()),
264-
pytest.raises(RuntimeError, match="something_new"),
279+
pytest.raises(TimeoutError, match="something_new"),
265280
):
266-
client._wait_result_ready("res_1", timeout_s=0.1, interval_s=0)
281+
client._wait_result_ready("res_1", timeout_s=0.05, interval_s=0)
267282

268283

269284
def test_poll_query_run_returns_promptly_on_interrupted():

‎tests/test_managed_client.py‎

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77

88
import pyarrow as pa
99
import pytest
10+
from hotdata.arrow import ResultNotReadyError
1011
from hotdata.models.async_query_response import AsyncQueryResponse
1112
from hotdata.models.query_response import QueryResponse
1213
from hotdata.rest import ApiException
1314

1415
import hotdata_framework.managed_client as mc
15-
from hotdata_framework.errors import HotdataTerminalError
16+
from hotdata_framework.errors import HotdataTerminalError, HotdataTransientError
1617

1718

1819
def _query_response(result_id: str) -> QueryResponse:
@@ -749,16 +750,19 @@ def get_result_arrow(self, result_id: str, **kwargs: Any) -> pa.Table:
749750
]
750751

751752

752-
def test_unknown_run_status_fails_fast_instead_of_polling_to_the_timeout(
753+
def test_unknown_run_status_keeps_polling_and_the_timeout_names_it(
753754
monkeypatch: pytest.MonkeyPatch,
754755
) -> None:
755-
"""Only `running` means "still in flight"; anything else is terminal.
756-
757-
Enumerating the terminal statuses is what let `interrupted` be polled for
758-
five minutes, so the test runs the other way round: a status this client has
759-
never seen raises on the first pass and names itself.
756+
"""An unrecognised run status waits rather than being called terminal.
757+
758+
Treating an unknown status as terminal is the cheaper failure to diagnose
759+
and much the more expensive one to suffer: a single status added upstream
760+
would fail every read at once, where waiting costs one slow call. So the
761+
poll enumerates what it knows is terminal, and the timeout carries the
762+
status it last saw -- which is precisely what was missing while
763+
`interrupted` went unrecognised, and what would have made that a one-line
764+
diagnosis instead of a mystery.
760765
"""
761-
calls: list[str] = []
762766

763767
class FakeQueryApi:
764768
def __init__(self, api: object) -> None:
@@ -772,7 +776,6 @@ def __init__(self, api: object) -> None:
772776
pass
773777

774778
def get_query_run(self, query_run_id: str, **kwargs: Any) -> Any:
775-
calls.append("get_query_run")
776779
return SimpleNamespace(status="evicted", result_id=None, error_message=None)
777780

778781
monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
@@ -787,9 +790,67 @@ def get_query_run(self, query_run_id: str, **kwargs: Any) -> Any:
787790
retry_backoff_seconds=0.0,
788791
)
789792
client._runtime = _fake_runtime()
793+
monkeypatch.setattr(client, "_QUERY_TIMEOUT_SECONDS", 0.05)
790794

791-
with pytest.raises(HotdataTerminalError, match="evicted"):
795+
# TimeoutError classifies as transient, so the retry wrapper re-raises it
796+
# as such once the budget is spent -- the status still has to reach the text.
797+
with pytest.raises(HotdataTransientError, match="evicted"):
792798
client.fetch_table(database="mydb", schema="public", table="orders")
793799

794-
# One poll, not a timeout's worth.
795-
assert len(calls) == 1
800+
801+
def test_arrow_fetch_waits_out_a_result_that_is_not_ready_yet(
802+
monkeypatch: pytest.MonkeyPatch,
803+
) -> None:
804+
"""Belt and braces over the run wait.
805+
806+
A run reports `succeeded` only once its result is saved and ready, so this
807+
should not happen. Tolerating it costs nothing and removes the need to take
808+
that ordering on trust: the Arrow endpoint answers a result that is not ready
809+
with a small refusal rather than with data, so waiting here is cheap in the
810+
way waiting on the JSON result body is not.
811+
"""
812+
attempts: list[str] = []
813+
814+
class FakeQueryApi:
815+
def __init__(self, api: object) -> None:
816+
pass
817+
818+
def query(self, request: object, *, x_database_id: str) -> AsyncQueryResponse:
819+
return _async_query_response()
820+
821+
class FakeQueryRunsApi:
822+
def __init__(self, api: object) -> None:
823+
pass
824+
825+
def get_query_run(self, query_run_id: str, **kwargs: Any) -> Any:
826+
return SimpleNamespace(status="succeeded", result_id="rslt1", error_message=None)
827+
828+
class FakeArrowResultsApi:
829+
def __init__(self, api: object) -> None:
830+
pass
831+
832+
def get_result_arrow(self, result_id: str, **kwargs: Any) -> pa.Table:
833+
attempts.append(result_id)
834+
if len(attempts) < 3:
835+
raise ResultNotReadyError(status="processing", result_id=result_id)
836+
return pa.table({"id": [1, 2]})
837+
838+
monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
839+
monkeypatch.setattr(mc, "QueryRunsApi", FakeQueryRunsApi)
840+
monkeypatch.setattr(mc, "ArrowResultsApi", FakeArrowResultsApi)
841+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
842+
843+
client = mc.ManagedDatabaseClient(
844+
api_key="k",
845+
workspace_id="w",
846+
api_base_url="https://example.test",
847+
max_retries=1,
848+
retry_backoff_seconds=0.0,
849+
)
850+
client._runtime = _fake_runtime()
851+
852+
table = client.fetch_table(database="mydb", schema="public", table="orders")
853+
854+
assert table is not None
855+
assert table.num_rows == 2
856+
assert len(attempts) == 3

0 commit comments

Comments
 (0)