perf: stream parameterized query results instead of buffering them - #172
perf: stream parameterized query results instead of buffering them#172lukekim wants to merge 2 commits into
Conversation
`query_with_params` documents that it returns a streaming `pa.RecordBatchReader`, but it read the entire result into a `pa.Table` and handed back a reader over that table. The eager read was forced by closing the ADBC statement in a `finally` block: the statement owns the result stream, so it had to be drained before the statement went away. Keep the statement alive until the stream is drained -- or until the returned reader is closed or garbage collected -- and return a reader that pulls batches off the wire on demand. Measured against a local runtime serving a 10M-row (382 MiB) Arrow accelerated table: time to first batch 0.305s -> 0.021s (14x faster) peak RSS while streaming 579 MB -> 248 MB (2.3x lower) Fully materializing the result (`query_arrow(sql, params=...)`) is unchanged, as expected -- the win is for callers that stream. Adds regression coverage that the result is not drained before the reader is returned, and that the statement is released on drain, on early close, and on error.
There was a problem hiding this comment.
Pull request overview
This PR fixes _ADBCClient.query_with_params() to actually return a streaming pyarrow.RecordBatchReader (as documented) by keeping the underlying ADBC statement alive until the result stream is exhausted or the reader is closed/collected, avoiding eager materialization of the entire result.
Changes:
- Add
_stream_until_closed()to wrap aRecordBatchReaderand ensure the owning ADBC statement is closed when streaming completes or the reader is cleaned up. - Update
query_with_params()to return a streaming reader instead of buffering viaread_all(), while still ensuringstmt.close()on pre-stream failures. - Add unit tests verifying lazy streaming behavior and statement lifecycle cleanup (drain, early close/abandon, and execution error).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| spicepy/_client.py | Switch parameterized query path to true streaming and ensure statement cleanup is deferred until stream completion/close. |
| tests/test_client.py | Add targeted tests asserting streaming (non-buffering) and correct statement cleanup across success/early-close/error cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…tches query_batches is the streaming entry point but only took a plain SQL string, so the newly streaming parameterized path was not reachable through it. Also documents the distinction in the README: which query_* helpers materialize the whole result, and how to stream one that does not fit in memory.
| def batches() -> object: | ||
| for i in range(count): | ||
| pulled.append(i) | ||
| yield pa.record_batch( | ||
| [pa.array([i], type=pa.int64())], schema=cls.SCHEMA | ||
| ) | ||
|
|
||
| return pa.RecordBatchReader.from_batches(cls.SCHEMA, batches()) |
| def batches() -> Iterator[pa.RecordBatch]: | ||
| try: | ||
| yield from reader | ||
| finally: | ||
| reader.close() | ||
| stmt.close() |
|
The red The registry is returning a 152-byte JSON error body under The workflow's retry wrapper can't help — the response is a deterministic 200, not a flake. Filed as spiceai/spiceai#12116 (covers both the registry bug and the CLI's misleading error). Nothing to fix on this branch; the integration jobs should go green on a re-run once the registry is serving archives again. |
|
Update — this blocker has cleared; the red runs just predate the fix. The 279 bytes, valid The failing |
Problem
Client.query_with_params()documents that it returns a streamingpa.RecordBatchReader, but it materialized the whole result before returning:The eager read was forced by the
finally: stmt.close(). The ADBC statement owns the result stream, so closing it there meant the stream had to be drained first.The effect is that a parameterized query over a large table has no streaming behaviour at all: asking for the first batch pays the cost of the entire result, in both time and memory.
Fix
Keep the statement alive until the stream is drained — or until the returned reader is closed or garbage collected — and hand back a reader that pulls batches off the wire on demand.
Measurements
Against a local runtime serving a 10M-row (382 MiB) Arrow-accelerated table,
SELECT * FROM big WHERE id >= $1:Fully materializing the result (
query_arrow(sql, params=[...])) is unchanged at ~0.22 s / ~575 MB, as expected — the win is for callers that stream.For reference, the non-parameterized paths were already streaming correctly and are unchanged by this PR; they measure at parity with a raw
pyarrowFlight client.Tests
Adds
TestADBCClientStreamingcovering that:trunkwithresult was buffered instead of streamed)Full suite: 390 passed, 24 skipped.
ruffreports no new findings (2 pre-existingPLR0917),blackclean,mypyclean,pylint9.26/10.tests/test_main.py::test_local_runtime_refreshfails identically before and after this change — it is an integration test needing a local runtime with ataxi_tripsdataset.Follow-up in this PR: reachable from
query_batches, and documentedquery_batchesis the streaming entry point, but it only accepted a plain SQLstring — so the now-streaming parameterized path could not be reached through
it. It takes
paramsas well:The README now also states which
query_*helpers materialize the whole resultand which one to reach for when it does not fit in memory — previously neither
query_batchesnor the materializing helpers were documented at all.Additional measurements
Peak RSS while streaming a parameterized result is now flat in the result size
rather than proportional to it, measured against a local runtime over an
Arrow-accelerated table (isolated process per run,
ru_maxrss):Time to first batch is likewise flat: 0.30 s → 0.027 s at 10M rows, 0.59 s →
0.030 s at 20M.
Verification
Run against a local runtime with an Arrow-accelerated dataset, so the
integration tests execute rather than skip:
pytest— 392 passed, 24 skipped (cloud tests, no API key)mypy— clean;pylint— 9.26/10ruff check— 2 findings, both pre-existing ontrunkand untouched hereruff format --check— 15 files would be reformatted, identical before andafter this change (the committed formatting predates the configured
line-length); left alone so it does not bury the changetests/test_main.py::test_local_runtime_refreshis order- and timing-dependentagainst a live runtime — it truncates the shared dataset via
refresh_sqlandraces an async refresh — so it can fail on a re-run regardless of this change.