Skip to content

Commit 098e45a

Browse files
authored
Merge pull request #155 from hotdata-dev/worktree-synthetic-churning-blanket
Update to hotdata SDK 0.2.0 and follow truncated query results
2 parents 18bfbe3 + 579135b commit 098e45a

3 files changed

Lines changed: 194 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 2 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_stream` carries `content_length` (sized body, not chunked, so
1818
# the server can fast-fail an oversized upload — see src/sdk.rs::Api::upload_stream).
19-
hotdata = { version = "0.1.1", features = ["arrow"] }
19+
hotdata = { version = "0.2.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). `sync` backs the
2222
# mpsc channel that bridges the blocking upload reader into an async byte stream.

src/query.rs

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,45 @@ pub(crate) fn fetch_arrow_result(api: &Api, result_id: &str) -> QueryResponse {
205205
arrow_result_to_query_response(result, result_id.to_owned())
206206
}
207207

208+
/// Resolve an inline (HTTP 200) query response for display.
209+
///
210+
/// A non-truncated response carries the whole result in `rows`, so it's shown
211+
/// as-is. A truncated one (#640) carries only a bounded preview — the full set
212+
/// is persisted under `result_id` — so follow it to the full result via Arrow,
213+
/// the same path the async (202) branch uses. Truncation rides on result *size*
214+
/// while `async_after_ms` gates on *time*, so a fast-completing but large query
215+
/// returns a truncated inline 200; without this follow the CLI would silently
216+
/// print only the preview rows.
217+
///
218+
/// If a truncated response has no `result_id` (persistence could not be
219+
/// initiated — see the SDK's `warning` field), the full result is unfetchable,
220+
/// so fall back to the preview and surface a warning rather than failing.
221+
fn resolve_inline(api: &Api, resp: hotdata::models::QueryResponse) -> QueryResponse {
222+
if !resp.truncated {
223+
return query_response_from_sdk(resp);
224+
}
225+
match resp.result_id.clone().flatten() {
226+
Some(result_id) => {
227+
// The Arrow fetch returns only schema + rows; carry the query-level
228+
// warning and execution time the inline response reported, which
229+
// `arrow_result_to_query_response` otherwise hardcodes to None.
230+
let mut full = fetch_arrow_result(api, &result_id);
231+
full.warning = resp.warning.flatten();
232+
full.execution_time_ms = Some(resp.execution_time_ms.max(0) as u64);
233+
full
234+
}
235+
None => {
236+
let mut preview = query_response_from_sdk(resp);
237+
let note = "result truncated to a preview; full result unavailable (persistence not initiated)";
238+
preview.warning = Some(match preview.warning {
239+
Some(w) => format!("{w}; {note}"),
240+
None => note.to_string(),
241+
});
242+
preview
243+
}
244+
}
245+
}
246+
208247
pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &str) {
209248
let api = Api::new(Some(workspace_id));
210249

@@ -223,9 +262,11 @@ pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &s
223262
spinner.finish_and_clear();
224263

225264
let async_resp = match outcome {
226-
// Completed within async_after_ms — inline results.
265+
// Completed within async_after_ms — inline results. A large result can
266+
// come back truncated to a preview even on this fast path, so follow it
267+
// to the full set (resolve_inline) rather than printing the preview.
227268
hotdata::QueryOutcome::Inline(resp) => {
228-
print_result(&query_response_from_sdk(resp), format);
269+
print_result(&resolve_inline(&api, resp), format);
229270
return;
230271
}
231272
// Still running — poll the query run, then fetch the result as Arrow.
@@ -397,3 +438,151 @@ pub fn print_result(result: &QueryResponse, format: &str) {
397438
_ => unreachable!(),
398439
}
399440
}
441+
442+
#[cfg(test)]
443+
mod tests {
444+
use super::*;
445+
use crate::sdk::Api;
446+
use std::sync::Arc;
447+
448+
/// A truncated inline 200: one preview row standing in for a larger result.
449+
/// `result_id` uses the wire double-option (`Some(None)` = field present but
450+
/// null, i.e. persistence not initiated).
451+
fn truncated_preview(result_id: Option<&str>) -> hotdata::models::QueryResponse {
452+
let mut resp = hotdata::models::QueryResponse::new(
453+
vec!["id".to_string()], // columns
454+
5, // execution_time_ms
455+
vec![false], // nullable
456+
1, // preview_row_count
457+
"qrun_1".to_string(), // query_run_id
458+
1, // row_count (deprecated, == preview)
459+
vec![vec![serde_json::json!(1)]], // rows (preview only)
460+
true, // truncated
461+
);
462+
resp.result_id = Some(result_id.map(|s| s.to_string()));
463+
resp
464+
}
465+
466+
#[test]
467+
fn resolve_inline_follows_truncated_result_to_full_arrow() {
468+
use arrow::array::{Int64Array, RecordBatch};
469+
use arrow::datatypes::{DataType, Field, Schema};
470+
use arrow::ipc::writer::StreamWriter;
471+
472+
// Full result has 3 rows — more than the 1-row inline preview.
473+
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
474+
let batch = RecordBatch::try_new(
475+
schema.clone(),
476+
vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
477+
)
478+
.unwrap();
479+
let mut ipc: Vec<u8> = Vec::new();
480+
{
481+
let mut writer = StreamWriter::try_new(&mut ipc, &schema).unwrap();
482+
writer.write(&batch).unwrap();
483+
writer.finish().unwrap();
484+
}
485+
486+
let mut server = mockito::Server::new();
487+
let m = server
488+
.mock("GET", "/v1/results/res_1")
489+
.match_query(mockito::Matcher::UrlEncoded(
490+
"format".into(),
491+
"arrow".into(),
492+
))
493+
.with_status(200)
494+
.with_header("content-type", "application/vnd.apache.arrow.stream")
495+
.with_body(ipc)
496+
.create();
497+
498+
// The inline response carries a query-level warning and execution time
499+
// (execution_time_ms=5 from `truncated_preview`) that must survive the
500+
// Arrow follow, which otherwise hardcodes them to None.
501+
let mut resp = truncated_preview(Some("res_1"));
502+
resp.warning = Some(Some("approximate aggregate".to_string()));
503+
504+
let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1"));
505+
let resolved = resolve_inline(&api, resp);
506+
507+
// Followed the truncated preview to the full 3-row result.
508+
assert_eq!(resolved.row_count, 3);
509+
assert_eq!(resolved.rows.len(), 3);
510+
assert_eq!(resolved.result_id.as_deref(), Some("res_1"));
511+
// Inline warning + timing carried through, not dropped by the fetch.
512+
assert_eq!(resolved.warning.as_deref(), Some("approximate aggregate"));
513+
assert_eq!(resolved.execution_time_ms, Some(5));
514+
m.assert();
515+
}
516+
517+
#[test]
518+
fn resolve_inline_returns_untruncated_preview_without_fetching() {
519+
// truncated=false short-circuits before any network call; point the Api
520+
// at a server with no mocks so an erroneous fetch would fail loudly.
521+
let server = mockito::Server::new();
522+
let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1"));
523+
524+
let mut resp = hotdata::models::QueryResponse::new(
525+
vec!["x".to_string()],
526+
5,
527+
vec![false],
528+
2,
529+
"qrun_2".to_string(),
530+
2,
531+
vec![vec![serde_json::json!(1)], vec![serde_json::json!(2)]],
532+
false, // not truncated
533+
);
534+
resp.result_id = Some(Some("res_2".to_string()));
535+
536+
let resolved = resolve_inline(&api, resp);
537+
assert_eq!(resolved.row_count, 2);
538+
assert_eq!(
539+
resolved.rows,
540+
vec![vec![serde_json::json!(1)], vec![serde_json::json!(2)]]
541+
);
542+
assert_eq!(resolved.result_id.as_deref(), Some("res_2"));
543+
}
544+
545+
#[test]
546+
fn resolve_inline_truncated_without_result_id_warns_and_keeps_preview() {
547+
// Truncated but persistence never started (result_id is null): the full
548+
// result is unfetchable, so keep the preview and surface a warning.
549+
let server = mockito::Server::new();
550+
let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1"));
551+
552+
let resolved = resolve_inline(&api, truncated_preview(None));
553+
assert_eq!(resolved.row_count, 1);
554+
assert_eq!(resolved.rows.len(), 1);
555+
assert!(
556+
resolved
557+
.warning
558+
.as_deref()
559+
.unwrap_or("")
560+
.contains("truncated")
561+
);
562+
}
563+
564+
#[test]
565+
fn resolve_inline_preserves_existing_warning_when_following_fails() {
566+
// A truncated response with no result_id often arrives with an SDK
567+
// warning explaining why persistence didn't start. The truncation note
568+
// is appended to it, not allowed to clobber it.
569+
let server = mockito::Server::new();
570+
let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1"));
571+
572+
let mut resp = truncated_preview(None);
573+
resp.warning = Some(Some(
574+
"result persistence could not be initiated".to_string(),
575+
));
576+
577+
let resolved = resolve_inline(&api, resp);
578+
let warning = resolved.warning.as_deref().unwrap_or("");
579+
assert!(
580+
warning.contains("result persistence could not be initiated"),
581+
"original warning dropped: {warning:?}"
582+
);
583+
assert!(
584+
warning.contains("truncated"),
585+
"truncation note missing: {warning:?}"
586+
);
587+
}
588+
}

0 commit comments

Comments
 (0)