Skip to content

fix(query): drain the result socket while rendering - #302

Merged
anoop-narang merged 3 commits into
mainfrom
fix/drain-result-socket
Sep 15, 2026
Merged

anoop-narang merged 3 commits into
mainfrom
fix/drain-result-socket

Conversation

@anoop-narang

Copy link
Copy Markdown
Contributor

Warning

0.34.0 is released with this bug. A large result over a real network is slower than the buffering client it replaced, and can fail outright. This needs a 0.34.1.

The bug

0.34.0 renders each batch on the same thread that reads the socket, so the connection is idle for the whole of every render.

Locally that is invisible. A size sweep against a local server shows the render is not the bottleneck and scales fine:

rows        wall_s   us_per_row
500,000       1.10          2.2
1,000,000     1.12          1.1
2,000,000     1.19          0.6
4,000,000     1.35          0.3

Over a WAN link it is a slow consumer. Throughput collapses against the receive window, and a connection held open for minutes is one an intermediary is entitled to drop. Measured against a deployed server on a 20 M-row result:

                     wall      peak RSS   rows
0.33.0 (buffering)  41.46s      1691 MB   20,000,001  complete
0.34.0 (streaming) 301.61s        21 MB    1,966,081  FAILED

error: the result above is incomplete — the download failed partway / transport error: error decoding response body, after exactly 240 batches of 8192 rows.

Note what this means: on a large result 0.34.0 is worse than the client it replaced. The buffering client looks naive by comparison — .bytes() drains greedily — but that is exactly why it gets full bandwidth.

The fix

Reading and rendering are decoupled. Api::stream_result_batches spawns a reader on the shared runtime that drains the socket into a four-batch bounded queue; the renderer pulls from the queue with blocking_recv. Reading never waits on rendering, and peak memory stays bounded by the queue rather than the result — the property the streaming change exists for.

The channel is tokio::sync::mpsc, not std::sync::mpsc: the reader is async and its send has to yield when the queue is full rather than block a runtime worker.

Measured after

Same query, same deployed server:

                     wall      peak RSS   rows
fixed               17.20s        26 MB   20,000,001  complete

2.4x faster than the buffering client at 1/65th its memory, and the rows are an identical multiset to the 0.33.0 output.

Why it shipped

The streaming work was measured at 4 M rows against a local server. The size in the original report was 20 M, which is the one size never run — and the failure only appears over a real network, so a local test could not have found it whatever the size. It was caught by deploying the server PR image to a scratch workspace and driving released binaries at it.

Tests

525 passing, clippy clean. The renderers now take the queue rather than the stream, so the existing byte-equality tests (streamed_csv_matches_the_buffered_render, streamed_json_matches_the_buffered_envelope, the zero-row case) exercise the new path unchanged — they assert output is identical to the buffered renderer, which is what must not move.

No unit test covers the slow-consumer behaviour itself: it needs a real network with a real receive window, and a mock server on loopback cannot reproduce it. That gap is why the workspace run is the evidence here.

0.34.0 renders each batch on the thread that reads the socket, so the
connection sits idle for the whole of every render. Locally that is
invisible — rendering keeps up at 0.3us/row — but over a WAN link it is a
slow consumer: throughput collapses against the receive window, and a
connection held open for minutes is one an intermediary drops.

Measured against a deployed server on a 20M-row result: 301s and
`error decoding response body` after 1,966,081 of 20,000,001 rows, where
the buffering client 0.34.0 replaced completes the same query in 41s.

A reader on the runtime now drains the socket into a four-batch bounded
queue and the renderer pulls from it, so reading never waits on
rendering. Peak memory stays bounded by the queue rather than the result,
which is the property the streaming change exists for.

Same query after the change: 17.2s, 26MB, all 20,000,001 rows — 2.4x
faster than the buffering client at 1/65th its memory.
@anoop-narang
anoop-narang requested a review from a team as a code owner September 15, 2026 11:33
@anoop-narang
anoop-narang requested review from rohan-hotdata and removed request for a team September 15, 2026 11:33
claude[bot]
claude Bot previously approved these changes Sep 15, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff plus surrounding context in src/client/sdk.rs and src/commands/query.rs. The decoupling is correct: rt() is a multi-thread runtime, so the spawned reader makes progress while the renderer blocks on blocking_recv, and the renderer runs on a non-runtime thread as the module invariant requires.

Moving total_row_count() before the drain is safe. fetch_capped already reads that value pre-drain at line 833 and documents it as the server-reported header, so the short-body check keeps its meaning.

Three non-blocking comments left inline.

CI note: CI / test, Integration Tests / integration and the Aikido check were still pending when the review started, so this approval does not rest on test results.

Comment thread src/client/sdk.rs Outdated
Comment thread src/commands/query.rs
Comment thread src/client/sdk.rs Outdated
@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.54054% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/commands/query.rs 89.09% 6 Missing ⚠️
src/client/sdk.rs 94.73% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

The reader task signalled the end of the stream by dropping its sender,
which is also what a panic looks like: `spawn` stores the panic in a
JoinHandle this code drops, so a reader that dies inside arrow decoding
is invisible except for its silence. The renderer read that silence as
the end of the result, and with no X-Total-Row-Count the row-count guard
never fired — partial rows on stdout and exit 0.

The stream now ends with an explicit `BatchMessage::Done`. A queue that
closes without one is reported as an incomplete read.

Covers the mid-stream failure path with a test that serves a body cut
inside a batch and no row-count header, so nothing but the stream error
can reveal the truncation.
Comment thread src/commands/query.rs
claude[bot]
claude Bot previously approved these changes Sep 15, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four prior threads are resolved in the code: the explicit BatchMessage::Done marker, the reader-stopped error in next_queued, the mid-stream decode test, and the doc wording.

Two disclosures about the scope of this approval. CI / test had not reported when this review ran, so the new assertions are unverified here. The hotdata SDK is an external crate and is not vendored in the checkout, so the arrow/ipc substring assertion at src/commands/query.rs:2390 depends on SDK error text that cannot be read from this checkout. A patch bump of hotdata 0.18.x that rewords that error would break the assertion.

@anoop-narang
anoop-narang merged commit 28b43df into main Sep 15, 2026
14 checks passed
@anoop-narang
anoop-narang deleted the fix/drain-result-socket branch September 15, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant