Skip to content

Commit b7a6d4a

Browse files
committed
fix: scope results and query runs to a database
1 parent 789e7c5 commit b7a6d4a

8 files changed

Lines changed: 161 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- feat: support async table loads and append mode
1313
- feat(uploads): support streaming uploads with on-demand part URLs
14+
- **Breaking:** results and query runs are now scoped to a database via the
15+
required `X-Database-Id` header. The generated `ResultsApi` / `QueryRunsApi`
16+
methods (`get_result`, `list_results`, `get_query_run`, `list_query_runs`)
17+
gain a required `x_database_id` argument, and the hand-written ergonomic
18+
helpers match: `hotdata.arrow.ResultsApi.get_result_arrow` /
19+
`stream_result_arrow` and `hotdata.query.QueryApi.wait_for_result` take the
20+
database id, and `QueryApi.query`'s truncation auto-follow forwards the
21+
query's database scope (the `X-Database-Id` header, or the request-body
22+
`database_id` when no header is set) to its result and query-run fetches.
1423

1524
## [0.5.0] - 2026-06-28
1625

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,21 @@ from hotdata.arrow import ResultsApi
7777
with ApiClient(Configuration(api_key="...", workspace_id="...")) as client:
7878
results = ResultsApi(client)
7979

80+
# Results are scoped to a database via the required `X-Database-Id` header,
81+
# so pass the id of the database the query ran in.
82+
database_id = "your_database_id"
83+
8084
# Buffered: returns a pyarrow.Table.
81-
table = results.get_result_arrow(result_id)
85+
table = results.get_result_arrow(result_id, database_id)
8286

8387
# Streaming: yields a pyarrow.RecordBatchStreamReader without
8488
# materializing the full table in memory.
85-
with results.stream_result_arrow(result_id) as reader:
89+
with results.stream_result_arrow(result_id, database_id) as reader:
8690
for batch in reader:
8791
...
8892
```
8993

90-
Both methods accept `offset` and `limit` for pagination. They raise `hotdata.arrow.ResultNotReadyError` if the result is still pending or processing — poll `results.get_result(result_id)` until `status == "ready"` first.
94+
Both methods accept `offset` and `limit` for pagination. They raise `hotdata.arrow.ResultNotReadyError` if the result is still pending or processing — poll `results.get_result(result_id, database_id)` until `status == "ready"` first.
9195

9296
## File uploads
9397

‎hotdata/arrow.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ class ResultsApi(_GeneratedResultsApi):
8787
def get_result_arrow(
8888
self,
8989
id: str,
90+
x_database_id: str,
9091
*,
9192
offset: Optional[int] = None,
9293
limit: Optional[int] = None,
@@ -99,14 +100,18 @@ def get_result_arrow(
99100
iterate batches without materializing the whole table.
100101
101102
:param id: Result ID.
103+
:param x_database_id: Database the result belongs to. Results are scoped
104+
to a database via the required ``X-Database-Id`` header — pass the
105+
same database the query ran in.
102106
:param offset: Rows to skip (default: 0).
103107
:param limit: Maximum rows to return (default: unbounded).
104108
:raises ResultNotReadyError: result is still pending or processing.
105109
:raises hotdata.exceptions.ApiException: for other HTTP errors
106110
(400 invalid params, 404 not found, 409 failed result).
107111
"""
108112
ipc = _import_pyarrow()
109-
response = self._call_arrow(id=id, offset=offset, limit=limit,
113+
response = self._call_arrow(id=id, x_database_id=x_database_id,
114+
offset=offset, limit=limit,
110115
_request_timeout=_request_timeout)
111116
try:
112117
return ipc.open_stream(response).read_all()
@@ -117,6 +122,7 @@ def get_result_arrow(
117122
def stream_result_arrow(
118123
self,
119124
id: str,
125+
x_database_id: str,
120126
*,
121127
offset: Optional[int] = None,
122128
limit: Optional[int] = None,
@@ -132,15 +138,22 @@ def stream_result_arrow(
132138
133139
Example::
134140
135-
with results.stream_result_arrow(result_id) as reader:
141+
with results.stream_result_arrow(result_id, database_id) as reader:
136142
for batch in reader:
137143
process(batch)
138144
145+
:param id: Result ID.
146+
:param x_database_id: Database the result belongs to. Results are scoped
147+
to a database via the required ``X-Database-Id`` header — pass the
148+
same database the query ran in.
149+
:param offset: Rows to skip (default: 0).
150+
:param limit: Maximum rows to return (default: unbounded).
139151
:raises ResultNotReadyError: result is still pending or processing.
140152
:raises hotdata.exceptions.ApiException: for other HTTP errors.
141153
"""
142154
ipc = _import_pyarrow()
143-
response = self._call_arrow(id=id, offset=offset, limit=limit,
155+
response = self._call_arrow(id=id, x_database_id=x_database_id,
156+
offset=offset, limit=limit,
144157
_request_timeout=_request_timeout)
145158
try:
146159
yield ipc.open_stream(response)
@@ -151,16 +164,20 @@ def _call_arrow(
151164
self,
152165
*,
153166
id: str,
167+
x_database_id: str,
154168
offset: Optional[int],
155169
limit: Optional[int],
156170
_request_timeout: Any,
157171
) -> Any:
158172
# Build the request via the generator's private serialize helper so
159173
# path/query/auth handling stays in lockstep with the generated client.
160174
# Override only what we need: the Accept header and the format query.
175+
# `GET /v1/results/{id}` is database-scoped, so the required
176+
# X-Database-Id header flows through the generated serializer too.
161177
headers: Dict[str, Any] = {"Accept": ARROW_STREAM_MEDIA_TYPE}
162178
params = self._get_result_serialize(
163179
id=id,
180+
x_database_id=x_database_id,
164181
offset=offset,
165182
limit=limit,
166183
format=ResultsFormatQuery.ARROW,

‎hotdata/query.py‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -337,11 +337,20 @@ def query(
337337
return response
338338
if not follow or not response.truncated:
339339
return response
340-
return self._materialize_full(response, row_guard, byte_guard)
340+
# Auto-follow re-fetches the persisted result and the query run, both of
341+
# which are scoped to a database via the required X-Database-Id header.
342+
# /v1/query takes exactly one database source, so the effective database
343+
# is the header value when present, otherwise the request-body
344+
# database_id.
345+
effective_db = x_database_id or query_request.database_id or ""
346+
return self._materialize_full(
347+
response, effective_db, row_guard, byte_guard
348+
)
341349

342350
def wait_for_result(
343351
self,
344352
result_id: str,
353+
x_database_id: str,
345354
*,
346355
results_api: Optional[_ResultsApi] = None,
347356
) -> Any:
@@ -355,6 +364,10 @@ def wait_for_result(
355364
every poll, materializing the whole result into memory before the size
356365
guards can act.
357366
367+
:param result_id: Result ID to poll.
368+
:param x_database_id: Database the result belongs to. Results are scoped
369+
to a database via the required ``X-Database-Id`` header — pass the
370+
same database the query ran in.
358371
:raises ResultFailedError: the result failed (delivered as HTTP 409).
359372
:raises ResultTimeoutError: ``ready`` not reached within the poll deadline.
360373
"""
@@ -365,7 +378,7 @@ def wait_for_result(
365378
last_status = "pending"
366379
while True:
367380
try:
368-
result = api.get_result(result_id, limit=0)
381+
result = api.get_result(result_id, x_database_id=x_database_id, limit=0)
369382
except ApiException as exc:
370383
# A failed result is delivered as HTTP 409: the generated client
371384
# raises (response_deserialize raises on any non-2xx) rather than
@@ -449,16 +462,19 @@ def _backoff_delay(self, exc: ApiException, attempt: int) -> float:
449462
def _materialize_full(
450463
self,
451464
preview: QueryResponse,
465+
x_database_id: str,
452466
max_auto_rows: Optional[int],
453467
max_auto_bytes: Optional[int],
454468
) -> QueryResponse:
455469
if preview.result_id is None:
456470
raise ResultUnavailableError(warning=preview.warning)
457471

458472
results_api = _ResultsApi(self.api_client)
459-
self.wait_for_result(preview.result_id, results_api=results_api)
473+
self.wait_for_result(
474+
preview.result_id, x_database_id, results_api=results_api
475+
)
460476

461-
total = self._authoritative_total(preview)
477+
total = self._authoritative_total(preview, x_database_id)
462478
# Auto-follow does extra round-trips (poll + paginate) and materializes
463479
# the full result; log it so the hidden work behind one query() call is
464480
# observable without being noisy (info, not a warning).
@@ -477,7 +493,12 @@ def _materialize_full(
477493
)
478494

479495
rows = self._fetch_all_rows(
480-
preview.result_id, total, max_auto_rows, max_auto_bytes, results_api
496+
preview.result_id,
497+
x_database_id,
498+
total,
499+
max_auto_rows,
500+
max_auto_bytes,
501+
results_api,
481502
)
482503
# Replace the bounded preview with the full row set. truncated /
483504
# total_row_count stay as the server reported them so the caller can
@@ -487,22 +508,30 @@ def _materialize_full(
487508
preview.total_row_count = total
488509
return preview
489510

490-
def _authoritative_total(self, preview: QueryResponse) -> Optional[int]:
511+
def _authoritative_total(
512+
self, preview: QueryResponse, x_database_id: str
513+
) -> Optional[int]:
491514
"""The grand total row count. ``total_row_count`` is null while a
492515
truncated result is still persisting, so fall back to the query-run
493516
record, which carries the authoritative count once the run succeeds.
517+
518+
The query run is scoped to a database via the required ``X-Database-Id``
519+
header, so ``x_database_id`` is forwarded to the lookup.
494520
"""
495521
if preview.total_row_count is not None:
496522
return preview.total_row_count
497523
try:
498-
run = _QueryRunsApi(self.api_client).get_query_run(preview.query_run_id)
524+
run = _QueryRunsApi(self.api_client).get_query_run(
525+
preview.query_run_id, x_database_id=x_database_id
526+
)
499527
except ApiException:
500528
return None
501529
return run.row_count
502530

503531
def _fetch_all_rows(
504532
self,
505533
result_id: str,
534+
x_database_id: str,
506535
total: Optional[int],
507536
max_auto_rows: Optional[int],
508537
max_auto_bytes: Optional[int],
@@ -515,6 +544,7 @@ def _fetch_all_rows(
515544
while True:
516545
page = results_api.get_result(
517546
result_id,
547+
x_database_id=x_database_id,
518548
offset=offset,
519549
limit=page_size,
520550
format=ResultsFormatQuery.JSON,

‎tests/integration/test_query_async_polling.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def test_query_async_polling(
5656
deadline = time.monotonic() + POLL_TIMEOUT_S
5757
run = None
5858
while time.monotonic() < deadline:
59-
run = query_runs_api.get_query_run(query_run_id)
59+
run = query_runs_api.get_query_run(query_run_id, x_database_id=database_id)
6060
if run.status in TERMINAL_STATUSES:
6161
break
6262
time.sleep(POLL_INTERVAL_S)
@@ -70,13 +70,13 @@ def test_query_async_polling(
7070
)
7171
assert run.row_count == 1
7272

73-
runs_listing = query_runs_api.list_query_runs(limit=50)
73+
runs_listing = query_runs_api.list_query_runs(x_database_id=database_id, limit=50)
7474
assert any(r.id == query_run_id for r in runs_listing.query_runs), (
7575
f"query run {query_run_id} not surfaced by list_query_runs"
7676
)
7777

7878
if run.result_id:
79-
result = results_api.get_result(run.result_id)
79+
result = results_api.get_result(run.result_id, x_database_id=database_id)
8080
assert result.result_id == run.result_id
8181
assert result.status in {"ready", "processing"}
8282
if result.status == "ready":
@@ -85,7 +85,7 @@ def test_query_async_polling(
8585

8686
# ResultInfo (list_results) exposes the id as `id`, not `result_id` —
8787
# only GetResultResponse uses `result_id`.
88-
results_listing = results_api.list_results(limit=50)
88+
results_listing = results_api.list_results(x_database_id=database_id, limit=50)
8989
assert any(r.id == run.result_id for r in results_listing.results), (
9090
f"result {run.result_id} not surfaced by list_results"
9191
)

‎tests/integration/test_results_arrow.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def test_results_arrow(
7070
deadline = time.monotonic() + POLL_TIMEOUT_S
7171
run = None
7272
while time.monotonic() < deadline:
73-
run = query_runs_api.get_query_run(query_run_id)
73+
run = query_runs_api.get_query_run(query_run_id, x_database_id=database_id)
7474
if run.status in TERMINAL_STATUSES:
7575
break
7676
time.sleep(POLL_INTERVAL_S)
@@ -85,27 +85,27 @@ def test_results_arrow(
8585
# ResultNotReadyError on 202.
8686
deadline = time.monotonic() + POLL_TIMEOUT_S
8787
while time.monotonic() < deadline:
88-
result = results_api.get_result(result_id)
88+
result = results_api.get_result(result_id, x_database_id=database_id)
8989
if result.status == "ready":
9090
break
9191
time.sleep(POLL_INTERVAL_S)
9292
else:
9393
pytest.fail(f"result {result_id} never became ready")
9494

9595
# Buffered: returns a full pyarrow.Table.
96-
table = results_api.get_result_arrow(result_id)
96+
table = results_api.get_result_arrow(result_id, database_id)
9797
assert isinstance(table, pa.Table)
9898
assert table.num_rows == 2
9999
assert set(table.column_names) == {"x", "msg"}
100100
assert table.column("x").to_pylist() == [1, 2]
101101
assert table.column("msg").to_pylist() == ["hello", "world"]
102102

103103
# Streaming: same data via RecordBatchStreamReader.
104-
with results_api.stream_result_arrow(result_id) as reader:
104+
with results_api.stream_result_arrow(result_id, database_id) as reader:
105105
streamed = pa.Table.from_batches(list(reader), schema=reader.schema)
106106
assert streamed.equals(table)
107107

108108
# Pagination forwards correctly.
109-
sliced = results_api.get_result_arrow(result_id, offset=1, limit=1)
109+
sliced = results_api.get_result_arrow(result_id, database_id, offset=1, limit=1)
110110
assert sliced.num_rows == 1
111111
assert sliced.column("x").to_pylist() == [2]

‎tests/test_arrow.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def test_get_result_arrow_returns_table(monkeypatch: pytest.MonkeyPatch) -> None
148148
_install_fake_response(monkeypatch, fake, captured)
149149

150150
results, _ = _make_results_api()
151-
got = results.get_result_arrow("res_123")
151+
got = results.get_result_arrow("res_123", "db_x")
152152

153153
assert got.equals(table)
154154
# Connection is drained (not just released) so a partially-read body can't
@@ -166,6 +166,8 @@ def test_get_result_arrow_returns_table(monkeypatch: pytest.MonkeyPatch) -> None
166166
assert "format=arrow" in call["url"]
167167
# Accept header overrides the JSON default.
168168
assert call["headers"]["Accept"] == ARROW_STREAM_MEDIA_TYPE
169+
# Results are database-scoped: the required X-Database-Id header is sent.
170+
assert call["headers"]["X-Database-Id"] == "db_x"
169171

170172

171173
def test_get_result_arrow_forwards_offset_and_limit(
@@ -180,7 +182,7 @@ def test_get_result_arrow_forwards_offset_and_limit(
180182
_install_fake_response(monkeypatch, fake, captured)
181183

182184
results, _ = _make_results_api()
183-
results.get_result_arrow("res_123", offset=10, limit=100)
185+
results.get_result_arrow("res_123", "db_x", offset=10, limit=100)
184186

185187
url = captured[0]["url"]
186188
assert "offset=10" in url
@@ -199,7 +201,7 @@ def test_stream_result_arrow_yields_reader(
199201
_install_fake_response(monkeypatch, fake, [])
200202

201203
results, _ = _make_results_api()
202-
with results.stream_result_arrow("res_123") as reader:
204+
with results.stream_result_arrow("res_123", "db_x") as reader:
203205
batches = list(reader)
204206
assert batches, "expected at least one RecordBatch"
205207
roundtrip = pa.Table.from_batches(batches, schema=reader.schema)
@@ -232,7 +234,7 @@ def test_get_result_arrow_raises_when_not_ready(
232234

233235
results, _ = _make_results_api()
234236
with pytest.raises(ResultNotReadyError) as ei:
235-
results.get_result_arrow("res_pending")
237+
results.get_result_arrow("res_pending", "db_x")
236238

237239
assert ei.value.status == "processing"
238240
assert ei.value.result_id == "res_pending"
@@ -252,7 +254,7 @@ def test_get_result_arrow_raises_api_exception_on_404(
252254

253255
results, _ = _make_results_api()
254256
with pytest.raises(ApiException) as ei:
255-
results.get_result_arrow("res_missing")
257+
results.get_result_arrow("res_missing", "db_x")
256258

257259
assert ei.value.status == 404
258260
assert fake.release_conn_called
@@ -278,7 +280,7 @@ def test_get_result_arrow_raises_api_exception_on_409_failed(
278280

279281
results, _ = _make_results_api()
280282
with pytest.raises(ApiException) as ei:
281-
results.get_result_arrow("res_failed")
283+
results.get_result_arrow("res_failed", "db_x")
282284

283285
assert ei.value.status == 409
284286

0 commit comments

Comments
 (0)