Skip to content

Commit 053a9b4

Browse files
authored
fix(query)!: stream results instead of buffering them (#300)
* fix(query): stream results instead of buffering them `hotdata query` held every fetched result in memory: the IPC bytes, the decoded batches, and a heap-allocated JSON cell per value, all at once. Peak RSS ran to ~190 bytes per row for a single BIGINT column, so a 10M-row result was SIGKILLed on a 2GB host after minutes of apparent hanging, leaving an empty file and no error — the kernel kills the process, so nothing can be printed. csv and json now render batch by batch as they arrive, so peak memory is one record batch whatever the result size. Output is unchanged: the streamed renderers are held byte-for-byte against the buffered ones in tests, over nulls, embedded commas/quotes/newlines, nested lists and 38-digit decimals. Measured on a 4M-row result: 353MB -> 23MB, and twice as fast, since a buffered writer replaces a syscall per row. `-o table` fetches a bounded window with `?limit=`, so the rest is never downloaded. A window smaller than the result is reported by the existing incomplete-preview footer and exit code rather than looking complete. A result the server is still writing is now waited for. Fetching once and giving up handed back the bounded preview with a non-zero exit while the real result finished writing a moment later — data loss wearing an exit code. The wait honours the server's Retry-After and is bounded by the same 5 minutes the async path already allows. * chore(deps): take hotdata 0.18.0 from crates.io * fix(query): derive the table cap from the rows, not a header `fetch_capped` decided truncation from `total_row_count`, which `arrow_result_to_query_response` backfills from the rows in hand when the response carries no `X-Total-Row-Count`. A window then had total == row_count, so a 10,000-row slice of a million-row result printed with a plain footer and exit 0 — the silent truncation this command exists to avoid, reintroduced on the table path. The fetch now asks for one row past the window. That row's presence is the proof there is more, needs nothing from the server but the rows, and is dropped before rendering. The reported total is used only when the server actually sent one, so an unheadered window shows `?` rather than a total derived from the window itself. `stream_json` also wrote `truncated: false` unconditionally while the process exited non-zero on a short body. The field is written after the rows, so it now carries what the body turned out to be. * fix(query): trust the server's next-page link for the table cap The probe row only proves there is more when `?limit=` is honoured exactly. A server free to clamp the limit to its own maximum returns the window full and no further row, so the probe never arrives, and a window with no X-Total-Row-Count printed as a complete table again — the same silent truncation through a different door. `next_link` is the server stating there is another page, and it was already being constructed and dropped. It now counts alongside the probe row, so either signal alone is enough to mark the window a preview.
1 parent 9d529da commit 053a9b4

5 files changed

Lines changed: 1109 additions & 222 deletions

File tree

‎Cargo.lock‎

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ path = "src/main.rs"
1616
# behind a shared multi-thread tokio runtime. The `arrow` feature backs result
1717
# decode; `upload_file` runs the presigned direct-to-storage upload flow
1818
# (see src/sdk.rs::Api::upload).
19-
hotdata = { version = "0.17.0", features = ["arrow"] }
19+
hotdata = { version = "0.18.0", features = ["arrow"] }
2020
# Shared multi-thread runtime for the sync wrapper; block_on is called
2121
# concurrently from rayon worker threads (see src/indexes.rs). The presigned
2222
# upload (src/sdk.rs::Api::upload) drives the SDK's async `upload_file` on this

‎src/client/sdk.rs‎

Lines changed: 195 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,12 @@ async fn apply_seam_headers(
483483
req
484484
}
485485

486+
/// How often to re-check a result the server is still writing.
487+
const RESULT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
488+
/// Upper bound on a single wait, so a generous `Retry-After` cannot stall the
489+
/// poll past the point of being responsive.
490+
const RESULT_POLL_MAX_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
491+
486492
impl Api {
487493
/// Build an [`Api`], reproducing `ApiClient::new`'s auth-source precedence
488494
/// by selecting the [`AuthMode`] the installed provider will serve. Exits
@@ -877,20 +883,77 @@ impl Api {
877883
})
878884
}
879885

880-
/// Fetch `/v1/results/{id}` as Arrow IPC and decode it through the SDK's
881-
/// `get_result_arrow`, returning the fully-buffered [`hotdata::ArrowResult`].
886+
/// Open a persisted result once it is ready, waiting if it is still being
887+
/// written.
888+
///
889+
/// A result becomes fetchable only after the server finishes draining the
890+
/// query to storage, so a large result is routinely still `processing` when
891+
/// the response that named it arrives. Fetching once and giving up would
892+
/// hand back the bounded preview and call it a day, which is data loss
893+
/// wearing an exit code — so this polls, honouring the server's
894+
/// `Retry-After` where it sends one.
895+
///
896+
/// `ArrowError::NotReady` is matched as a typed variant here rather than
897+
/// after [`ApiError::from_arrow`] has collapsed it into a message, so the
898+
/// retry decision never depends on error text.
882899
///
883-
/// The SDK owns transport (same reqwest client, bearer via the
884-
/// `token_provider`, `X-Workspace-Id`) and decode. Results are
885-
/// database-scoped, so the active database is forwarded as the required
886-
/// `X-Database-Id`; [`require_database`](Self::require_database) exits with a
887-
/// hint when none is set. Its `ArrowError` (the Arrow-path error type, which
888-
/// is not an `Error<T>`) is mapped to [`ApiError`] via
889-
/// [`from_arrow`](ApiError::from_arrow) so callers keep the same `.exit()`
890-
/// handling.
891-
pub fn get_result_arrow(&self, id: &str) -> Result<hotdata::ArrowResult, ApiError> {
900+
/// Returns the last not-ready state as an error once `deadline` passes; the
901+
/// caller decides whether it has something to fall back on.
902+
pub fn open_result_arrow_when_ready(
903+
&self,
904+
id: &str,
905+
limit: Option<i64>,
906+
deadline: std::time::Duration,
907+
) -> Result<hotdata::ArrowResultStream, ApiError> {
892908
let database_id = self.require_database();
893-
rt().block_on(self.client.get_result_arrow(id, database_id, None, None))
909+
let start = std::time::Instant::now();
910+
let mut waiting: Option<indicatif::ProgressBar> = None;
911+
912+
let outcome = loop {
913+
match rt().block_on(self.client.open_result_arrow(id, database_id, None, limit)) {
914+
Ok(stream) => break Ok(stream),
915+
Err(hotdata::ArrowError::NotReady { retry_after, .. }) => {
916+
let elapsed = start.elapsed();
917+
if elapsed >= deadline {
918+
break Err(ApiError::Transport(format!(
919+
"result {id} was still being written after {}s; \
920+
retrieve it later with `hotdata databases results get {id}`",
921+
deadline.as_secs()
922+
)));
923+
}
924+
// The wait is visible: a large result can take a while to
925+
// finish writing, and a silent pause reads as a hang.
926+
if waiting.is_none() {
927+
waiting = Some(crate::util::spinner("waiting for the full result..."));
928+
}
929+
// Honour the server's hint, but keep the poll responsive:
930+
// a generous `Retry-After` should not outlast the deadline.
931+
let hint = retry_after
932+
.map(std::time::Duration::from_secs)
933+
.unwrap_or(RESULT_POLL_INTERVAL)
934+
.clamp(RESULT_POLL_INTERVAL, RESULT_POLL_MAX_INTERVAL);
935+
std::thread::sleep(hint.min(deadline - elapsed));
936+
}
937+
Err(e) => break Err(ApiError::from_arrow(e)),
938+
}
939+
};
940+
941+
if let Some(pb) = waiting {
942+
pb.finish_and_clear();
943+
}
944+
outcome
945+
}
946+
947+
/// Pull the next batch from a stream opened by
948+
/// [`open_result_arrow`](Self::open_result_arrow).
949+
///
950+
/// Kept on `Api` rather than exposed as a raw async call so the renderer
951+
/// stays synchronous and every `block_on` in the CLI goes through one seam.
952+
pub fn next_result_batch(
953+
&self,
954+
stream: &mut hotdata::ArrowResultStream,
955+
) -> Result<Option<arrow::array::RecordBatch>, ApiError> {
956+
rt().block_on(stream.next_batch())
894957
.map_err(ApiError::from_arrow)
895958
}
896959

@@ -1394,7 +1457,7 @@ mod tests {
13941457
}
13951458

13961459
#[test]
1397-
fn get_result_arrow_fetches_decodes_and_forwards_headers() {
1460+
fn open_result_arrow_decodes_and_forwards_headers() {
13981461
use arrow::array::{Int64Array, RecordBatch, StringArray};
13991462
use arrow::datatypes::{DataType, Field, Schema};
14001463
use arrow::ipc::writer::StreamWriter;
@@ -1419,8 +1482,9 @@ mod tests {
14191482
writer.finish().unwrap();
14201483
}
14211484

1422-
// The SDK's get_result_arrow carries the bearer + X-Workspace-Id like
1423-
// every SDK call and negotiates Arrow via ?format=arrow + Accept.
1485+
// The streaming opener carries the bearer + X-Workspace-Id like every
1486+
// SDK call and negotiates Arrow via ?format=arrow + Accept, the same as
1487+
// the buffered fetch it replaced.
14241488
let mut server = mockito::Server::new();
14251489
let m = server
14261490
.mock("GET", "/v1/results/res_1")
@@ -1438,18 +1502,117 @@ mod tests {
14381502
.create();
14391503

14401504
let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("db-1"));
1441-
let result = api
1442-
.get_result_arrow("res_1")
1443-
.expect("get_result_arrow should succeed");
1444-
assert_eq!(result.num_rows(), 3);
1445-
assert_eq!(result.schema.fields().len(), 2);
1446-
assert_eq!(result.schema.field(0).name(), "id");
1447-
assert_eq!(result.schema.field(1).name(), "name");
1505+
let mut stream = api
1506+
.open_result_arrow_when_ready("res_1", None, Duration::from_secs(30))
1507+
.expect("open_result_arrow should succeed");
1508+
1509+
// The schema is known before any batch is pulled.
1510+
let schema = stream.schema();
1511+
assert_eq!(schema.fields().len(), 2);
1512+
assert_eq!(schema.field(0).name(), "id");
1513+
assert_eq!(schema.field(1).name(), "name");
1514+
1515+
let mut rows = 0;
1516+
while let Some(batch) = api
1517+
.next_result_batch(&mut stream)
1518+
.expect("batch decode should succeed")
1519+
{
1520+
rows += batch.num_rows();
1521+
}
1522+
assert_eq!(rows, 3);
14481523
m.assert();
14491524
}
14501525

1526+
use std::time::Duration;
1527+
1528+
/// A result the server is still writing must be waited for, not abandoned.
1529+
///
1530+
/// Fetching once and giving up is what made a bounded preview look like the
1531+
/// whole answer: the caller got the preview rows and a non-zero exit while
1532+
/// the real result finished writing a moment later.
14511533
#[test]
1452-
fn get_result_arrow_maps_not_found_to_status() {
1534+
fn open_result_arrow_waits_while_a_result_is_still_processing() {
1535+
let mut server = mockito::Server::new();
1536+
let m = server
1537+
.mock("GET", "/v1/results/pending")
1538+
.match_query(mockito::Matcher::Any)
1539+
.with_status(202)
1540+
.with_header("content-type", "application/json")
1541+
.with_body(r#"{"status":"processing","result_id":"pending"}"#)
1542+
.expect_at_least(2)
1543+
.create();
1544+
1545+
let api = Api::test_new_scoped(&server.url(), "test-jwt", None, Some("db-1"));
1546+
let started = std::time::Instant::now();
1547+
let err = api
1548+
.open_result_arrow_when_ready("pending", None, Duration::from_millis(300))
1549+
.expect_err("a result that never becomes ready must not hang forever");
1550+
1551+
// It polled rather than returning on the first 202 ...
1552+
m.assert();
1553+
// ... and it respected the deadline instead of waiting indefinitely.
1554+
assert!(
1555+
started.elapsed() < Duration::from_secs(5),
1556+
"gave up far later than the deadline: {:?}",
1557+
started.elapsed()
1558+
);
1559+
let msg = err.message();
1560+
assert!(
1561+
msg.contains("still being written"),
1562+
"the error should say the result was not finished, got {msg:?}"
1563+
);
1564+
assert!(
1565+
msg.contains("results get"),
1566+
"the error should name the command that retrieves it later, got {msg:?}"
1567+
);
1568+
}
1569+
1570+
/// A result that is already ready is returned on the first call, with no
1571+
/// poll delay.
1572+
#[test]
1573+
fn open_result_arrow_when_ready_returns_a_ready_result_immediately() {
1574+
use arrow::array::{Int64Array, RecordBatch};
1575+
use arrow::datatypes::{DataType, Field, Schema};
1576+
use arrow::ipc::writer::StreamWriter;
1577+
1578+
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1579+
let batch = RecordBatch::try_new(
1580+
schema.clone(),
1581+
vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
1582+
)
1583+
.unwrap();
1584+
let mut ipc: Vec<u8> = Vec::new();
1585+
{
1586+
let mut writer = StreamWriter::try_new(&mut ipc, &schema).unwrap();
1587+
writer.write(&batch).unwrap();
1588+
writer.finish().unwrap();
1589+
}
1590+
1591+
let mut server = mockito::Server::new();
1592+
let _m = server
1593+
.mock("GET", "/v1/results/ready")
1594+
.match_query(mockito::Matcher::Any)
1595+
.with_status(200)
1596+
.with_header("content-type", "application/vnd.apache.arrow.stream")
1597+
.with_body(ipc)
1598+
.expect(1)
1599+
.create();
1600+
1601+
let api = Api::test_new_scoped(&server.url(), "test-jwt", None, Some("db-1"));
1602+
let started = std::time::Instant::now();
1603+
let stream = api
1604+
.open_result_arrow_when_ready("ready", None, Duration::from_secs(300))
1605+
.expect("a ready result opens on the first try");
1606+
assert_eq!(stream.schema().fields().len(), 1);
1607+
assert!(
1608+
started.elapsed() < Duration::from_millis(400),
1609+
"a ready result must not pay a poll interval; took {:?}",
1610+
started.elapsed()
1611+
);
1612+
}
1613+
1614+
#[test]
1615+
fn open_result_arrow_maps_not_found_to_status() {
14531616
// A 404 surfaces as ApiError::Status so the CLI's 4xx re-auth hint path
14541617
// still fires.
14551618
let mut server = mockito::Server::new();
@@ -1461,7 +1624,10 @@ mod tests {
14611624
.create();
14621625

14631626
let api = Api::test_new_scoped(&server.url(), "test-jwt", None, Some("db-1"));
1464-
match api.get_result_arrow("missing").unwrap_err() {
1627+
match api
1628+
.open_result_arrow_when_ready("missing", None, Duration::from_secs(30))
1629+
.unwrap_err()
1630+
{
14651631
ApiError::Status { status, .. } => {
14661632
assert_eq!(status, reqwest::StatusCode::NOT_FOUND);
14671633
}
@@ -1470,7 +1636,7 @@ mod tests {
14701636
}
14711637

14721638
#[test]
1473-
fn get_result_arrow_preserves_generic_http_status() {
1639+
fn open_result_arrow_preserves_generic_http_status() {
14741640
// Statuses outside the SDK's explicitly-mapped set (404/400/409/202)
14751641
// come back as ArrowError::Http; from_arrow must preserve them so a
14761642
// 401/403 still reaches the CLI's 4xx re-auth hint. 403 stands in for
@@ -1484,7 +1650,10 @@ mod tests {
14841650
.create();
14851651

14861652
let api = Api::test_new_scoped(&server.url(), "test-jwt", None, Some("db-1"));
1487-
match api.get_result_arrow("forbidden").unwrap_err() {
1653+
match api
1654+
.open_result_arrow_when_ready("forbidden", None, Duration::from_secs(30))
1655+
.unwrap_err()
1656+
{
14881657
ApiError::Status { status, body } => {
14891658
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
14901659
assert_eq!(body, "forbidden");

0 commit comments

Comments
 (0)