Skip to content

feat(arrow): stream a result off the socket - #142

Open
anoop-narang wants to merge 5 commits into
mainfrom
feat/streaming-result-reader
Open

feat(arrow): stream a result off the socket#142
anoop-narang wants to merge 5 commits into
mainfrom
feat/streaming-result-reader

Conversation

@anoop-narang

Copy link
Copy Markdown
Contributor

Why

Both existing Arrow entry points buffer the entire response body before decoding:

  • get_result_arrow — "Buffers the entire Arrow IPC stream into memory before returning."
  • stream_result_arrow — decodes lazily, but its own doc notes "the response body is collected once when the stream is created".

So peak memory scales with the result, and neither is usable for a result larger
than memory. The hotdata CLI is OOM-killed (SIGKILL, no error message) around
10 M rows fetching one.

What

open_result_arrow returns an ArrowResultStream that pulls body chunks as
batches are asked for, feeding arrow_ipc::reader::StreamDecoder. Peak memory is
one record batch regardless of result size. bytes::Bytes -> arrow_buffer::Buffer
is a refcount bump, not a copy.

The schema is resolved at open time, so a caller writing a CSV header does not
have to read data first.

Additive. get_result_arrow and stream_result_arrow are untouched,
including their documented "connection released at construction" semantics. The
trade for not buffering is that open_result_arrow holds the pooled connection
until the stream is drained or dropped.

Tests

20 arrow tests pass, 8 of them new. The ones worth noting:

  • streaming_reader_survives_a_split_at_every_offset — splits the IPC body at
    every byte offset in turn and asserts identical batches. Chunk boundaries
    landing inside a message are the whole risk of a push decoder, and a socket
    only produces them occasionally.
  • streaming_reader_matches_the_buffered_reader — byte-for-byte equivalence with
    StreamReader over the same bytes.
  • a_body_cut_short_is_an_error_not_a_short_read — a truncated download must not
    read as a complete result. The buffered path got this for free; a streaming
    reader has to assert it.

The byte source is swappable behind a #[cfg(test)] enum so the pump can be
driven with chosen splits rather than whatever chunking a socket happens to
produce.

Merge order

This releases first — the CLI change that consumes it (hotdata-cli#TBD) is
pinned to a path override until there is a published version.

`get_result_arrow` and `stream_result_arrow` both collect the whole
response body before decoding, so peak memory scales with the size of the
result. Neither is usable for a result larger than memory: the CLI is
OOM-killed around 10M rows fetching one.

`open_result_arrow` returns an `ArrowResultStream` that pulls body chunks
as batches are asked for and feeds them to arrow-ipc's push decoder, so
peak memory is one record batch whatever the result size. A reqwest chunk
converts to an arrow `Buffer` without copying.

Additive: both existing entry points keep their current behaviour and
their documented connection semantics. The trade for not buffering is
that the pooled connection stays checked out until the stream is drained
or dropped.

The schema is resolved when the stream is opened, so a caller writing a
header row does not have to read data to learn the column names.
@anoop-narang
anoop-narang marked this pull request as ready for review September 11, 2026 09:27
@anoop-narang
anoop-narang requested a review from a team as a code owner September 11, 2026 09:27
@anoop-narang
anoop-narang requested review from rohan-hotdata and removed request for a team September 11, 2026 09:27
Comment thread src/arrow.rs Outdated
Comment thread src/arrow.rs
Comment thread src/lib.rs
claude[bot]
claude Bot previously approved these changes Sep 11, 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.

Approved. Three non-blocking nits are inline.

The decode pump is correct. StreamDecoder::decode returns Ok(None) only after draining the buffer it is given, so replacing pending instead of appending never drops bytes. The split-at-every-offset test covers the chunk-boundary risk directly.

`next_batch` marked the stream done before confirming a clean end of
stream, so a caller that logged the decode error and read on was told the
stream had ended normally. A truncated download then reads as a complete
result on the second call.

`finish()` now runs first and `done` is set only once it succeeds. An
exhausted body keeps yielding `None`, so every later call reaches
`finish()` again and repeats the error.

Also documents the new entry point where a reader would look for it: the
module doc still said "two entry points are provided", the README's Arrow
section described only the two buffering methods, and CHANGELOG had no
entry under [Unreleased].
Comment thread src/arrow.rs
Comment thread src/arrow.rs
claude[bot]
claude Bot previously approved these changes Sep 11, 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 three prior threads are resolved in the code: finish() now runs before done is set, the module doc and README list the third entry point, and the CHANGELOG has an [Unreleased] entry. Two nits inline, neither blocking.

Note on CI: at review time the unit-test and integration checks were queued or running, so the test results are not visible here.

`ResultError::TooLarge` told the caller to stream the result with
`Client::stream_result_arrow`. That method collects the whole body before
decoding, so a caller who trips the auto-materialize guard on a huge
result and follows the advice materializes exactly what the guard
refused. It now names `open_result_arrow`, which decodes off the socket.

Also covers `ArrowResultStream::read_all`, which nothing called: a
dropped field there would hand back metadata-free results on every call.
Comment thread src/arrow.rs
Comment on lines +1083 to +1089
/// A truncated body must keep failing. If the stream marked itself done
/// before confirming a clean end, a caller that logged the first error and
/// read on would be told the stream ended normally — a short download
/// silently becoming a complete result.
/// `read_all` must carry every batch *and* the metadata headers into the
/// returned `ArrowResult`. A dropped field here would hand back
/// metadata-free results on every call.

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.

super nit: split the merged doc comment (not blocking).

Two unrelated test docs now sit in one block on streaming_reader_read_all_carries_batches_and_metadata. The first three sentences describe a_cut_short_body_keeps_erroring_on_every_later_call at src/arrow.rs:1122, which is left undocumented. A reader of this block learns the wrong reason for the test below it.

Suggested change
/// A truncated body must keep failing. If the stream marked itself done
/// before confirming a clean end, a caller that logged the first error and
/// read on would be told the stream ended normally — a short download
/// silently becoming a complete result.
/// `read_all` must carry every batch *and* the metadata headers into the
/// returned `ArrowResult`. A dropped field here would hand back
/// metadata-free results on every call.
/// `read_all` must carry every batch *and* the metadata headers into the
/// returned `ArrowResult`. A dropped field here would hand back
/// metadata-free results on every call.

Then move the truncated-body sentences onto a_cut_short_body_keeps_erroring_on_every_later_call.

@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.

Cycle 3 review. Both prior nits are addressed: the TooLarge doc and message now name open_result_arrow with the reason stream_result_arrow is wrong there, and read_all has a test covering batches, schema, total_row_count and next_link. No blocking issues. One super nit inline on a merged test doc comment.

The integration job is still in progress, so this approval does not cover its result.

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