arrowbricks 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/PKG-INFO +1 -1
  2. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/pyproject.toml +1 -1
  3. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/Cargo.lock +1 -1
  4. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/Cargo.toml +2 -2
  5. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/README.md +1 -1
  6. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/src/client.rs +202 -27
  7. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/src/lib.rs +1 -1
  8. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/src/pipeline.rs +45 -11
  9. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +211 -0
  10. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/src/arrowbricks/_core.pyi +1 -1
  11. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/src/arrowbricks/client.py +5 -3
  12. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/LICENSE +0 -0
  13. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/README.md +0 -0
  14. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/.gitignore +0 -0
  15. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
  16. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
  17. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/rustfmt.toml +0 -0
  18. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/src/heartbeat.rs +0 -0
  19. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
  20. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_execute_json.py +0 -0
  21. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +0 -0
  22. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
  23. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
  24. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
  25. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
  26. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
  27. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/src/arrowbricks/__init__.py +0 -0
  28. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/src/arrowbricks/_streaming.py +0 -0
  29. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/src/arrowbricks/cursor.py +0 -0
  30. {arrowbricks-1.2.0 → arrowbricks-1.3.0}/src/arrowbricks/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrowbricks
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Requires-Dist: arro3-core>=0.8 ; extra == 'arro3'
5
5
  Provides-Extra: arro3
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arrowbricks"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands you the result as Arrow -- a DB-API-ish Cursor (fetchone/fetchmany/fetchall/fetchall_arrow) or NDJSON streaming. Rust/PyO3 core throughout -- zero required runtime dependencies."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -240,7 +240,7 @@ dependencies = [
240
240
 
241
241
  [[package]]
242
242
  name = "arrowbricks_core"
243
- version = "1.2.0"
243
+ version = "1.3.0"
244
244
  dependencies = [
245
245
  "arrow",
246
246
  "arrow-json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "arrowbricks_core"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -45,7 +45,7 @@ default = ["extension-module"]
45
45
  extension-module = ["pyo3/extension-module"]
46
46
 
47
47
  [dev-dependencies]
48
- tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "test-util"] }
48
+ tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "test-util", "net", "io-util"] }
49
49
  wiremock = "0.6.5"
50
50
 
51
51
  [profile.release]
@@ -65,7 +65,7 @@ unlike the table object itself.
65
65
 
66
66
  ## API
67
67
 
68
- - `Client(host, warehouse_id, *, token=None, token_provider=None, chunk_fetch_concurrency=32, http_timeout=60.0, wait_timeout="30s", warehouse_start_timeout=300.0, warehouse_confirmed_running_ttl_s=30.0, compress_results=True)` -- exactly one of `token`/`token_provider`. `token_provider` is a callable (sync or async) returning a token string, called fresh on every request, no caching. `compress_results` requests LZ4-compressed cloud-fetch chunks (see above); set `False` to opt out.
68
+ - `Client(host, warehouse_id, *, token=None, token_provider=None, chunk_fetch_concurrency=64, http_timeout=60.0, wait_timeout="30s", warehouse_start_timeout=300.0, warehouse_confirmed_running_ttl_s=30.0, compress_results=True)` -- exactly one of `token`/`token_provider`. `token_provider` is a callable (sync or async) returning a token string, called fresh on every request, no caching. `compress_results` requests LZ4-compressed cloud-fetch chunks (see above); set `False` to opt out.
69
69
  - `Client.execute_arrow(statement, *, catalog=None, schema=None, parameters=None) -> Table` -- eager: fetches and assembles the whole result before returning. `parameters` is Databricks' own named-parameter format (`[{"name":..., "value":..., "type":...}]`), passed straight through.
70
70
  - `Client.execute(statement, *, catalog=None, schema=None, parameters=None) -> ResultSet` -- submits and starts background chunk fetching without pulling anything yet.
71
71
  - `ResultSet.fetchmany_arrow(n) -> Table` -- pulls/decodes only as many chunks as needed for `n` rows, buffering the rest; may return fewer than `n` once exhausted.
@@ -84,6 +84,41 @@ struct StatementResponseBody {
84
84
  status: StatementStatusBody,
85
85
  #[serde(default)]
86
86
  manifest: Option<ManifestBody>,
87
+ /// SEA embeds whichever chunks are already ready straight in the
88
+ /// submit/poll response body -- confirmed against a real workspace: a
89
+ /// SUCCEEDED response's `result.external_links` already contained chunk
90
+ /// 0's presigned URL, disposition EXTERNAL_LINKS same as always. Used to
91
+ /// skip that chunk's own `GET .../result/chunks/{i}` resolution request
92
+ /// entirely (see `ChunkMeta::pre_resolved_links`) -- a full round trip
93
+ /// saved for whichever chunks land here, which for a fast/small query is
94
+ /// a meaningful fraction of total latency.
95
+ #[serde(default)]
96
+ result: Option<ResultBody>,
97
+ }
98
+
99
+ #[derive(Deserialize)]
100
+ struct ResultLinkBody {
101
+ // `#[serde(default)]` (not `Option<i64>`) deliberately: an omitempty-style
102
+ // server serializer would drop a zero-valued `chunk_index` field entirely
103
+ // rather than emit `0` -- exactly the chunk this optimization targets
104
+ // most (chunk 0), and exactly the case that would otherwise turn one
105
+ // optional fast-path field into a hard parse failure for the *whole*
106
+ // statement response (`authed_json` fails the entire `StatementResponseBody`
107
+ // deserialize on any missing required field, no retry). Defaulting to 0
108
+ // reconstructs the omitted value correctly either way.
109
+ #[serde(default)]
110
+ chunk_index: i64,
111
+ // Same reasoning, plus an empty link is filtered out where this is
112
+ // consumed (`execute_statement`) rather than trusted -- a link that
113
+ // somehow came through empty is worse than just re-resolving normally.
114
+ #[serde(default)]
115
+ external_link: String,
116
+ }
117
+
118
+ #[derive(Deserialize, Default)]
119
+ struct ResultBody {
120
+ #[serde(default)]
121
+ external_links: Vec<ResultLinkBody>,
87
122
  }
88
123
 
89
124
  #[derive(Deserialize)]
@@ -149,10 +184,38 @@ impl ApiError {
149
184
  }
150
185
  }
151
186
 
152
- fn from_reqwest(e: reqwest::Error) -> Self {
187
+ /// `idempotent` must be `true` only when replaying the *whole* request is
188
+ /// actually safe -- a GET, or a PUT/DELETE whose retried body is byte-for-
189
+ /// byte the same operation either way (`upload_volume_file`'s
190
+ /// overwrite=true PUT, `delete_volume_file`'s DELETE). It must be `false`
191
+ /// for the statement-submit POST: a decode/mid-flight-send failure there
192
+ /// means the server may have already accepted and started executing the
193
+ /// statement before the response broke -- for arbitrary caller SQL
194
+ /// (INSERT/MERGE/COPY INTO), blindly replaying that POST risks a second,
195
+ /// duplicate execution, not just a duplicate read. `is_decode()` alone
196
+ /// isn't the whole idempotent-failure surface either -- `is_request()`
197
+ /// (excluding `is_connect()`/`is_timeout()`, already handled) also covers
198
+ /// hyper's own "connection closed before message completed" pooled-
199
+ /// connection-reuse race, which fires before any body is even read and
200
+ /// is equally safe to retry on an idempotent request.
201
+ fn from_reqwest(e: reqwest::Error, idempotent: bool) -> Self {
202
+ // `is_decode()` (reqwest's `Kind::Decode`) isn't only content-decoding
203
+ // -- `Response::bytes()`/`.text()`/`.json()` also wrap a body read
204
+ // that fails mid-stream (connection reset, truncated transfer) in
205
+ // the same `Kind::Decode`, message "error decoding response body"
206
+ // (see reqwest's `async_impl::response::Response::do_bytes` and
207
+ // `error::decode`). Confirmed against a real workspace: a 400-chunk/
208
+ // 5.6M-row fetch hit exactly this on one of many large concurrent
209
+ // blob downloads, and it reproduced identically before this change
210
+ // existed -- a genuine transient network blip, not a permanent
211
+ // problem with the request. Connect/timeout errors stay non-transient,
212
+ // unchanged from before -- those mean the endpoint genuinely isn't
213
+ // responding, where failing fast on the caller's own timeout is still
214
+ // right.
215
+ let transient = idempotent && (e.is_decode() || (e.is_request() && !e.is_connect() && !e.is_timeout()));
153
216
  Self {
154
217
  message: e.to_string(),
155
- transient: false,
218
+ transient,
156
219
  }
157
220
  }
158
221
 
@@ -181,6 +244,16 @@ pub(crate) fn join_error(e: tokio::task::JoinError) -> ApiError {
181
244
  pub struct ChunkMeta {
182
245
  pub chunk_index: i64,
183
246
  pub row_count: Option<i64>,
247
+ /// Set when the statement submit/poll response already embedded one or
248
+ /// more of this chunk's presigned URLs (see `StatementResponseBody::result`)
249
+ /// -- the fetch worker downloads them directly instead of first resolving
250
+ /// via `GET .../result/chunks/{i}`. `Vec`, not `Option<String>` -- a
251
+ /// `chunk_index` can carry more than one blob (same reason
252
+ /// `fetch_chunk_index` returns `Vec<Bytes>` and `ReorderBuffer` keys on
253
+ /// `VecDeque`, not a single item: collapsing to one would silently drop
254
+ /// every link but the last for the same index). Empty means "not
255
+ /// pre-resolved, fetch it the normal way."
256
+ pub pre_resolved_links: Vec<String>,
184
257
  }
185
258
 
186
259
  /// What submitting a statement gets you before any chunk is fetched:
@@ -233,10 +306,15 @@ pub struct ChunkItem {
233
306
  /// `out` reads every frame without reconstructing a decoder per frame.
234
307
  fn decompress_lz4_frame(compressed: &Bytes) -> Result<Bytes, ApiError> {
235
308
  use std::io::Read;
236
- // Decompressed size is always >= compressed size for real chunk data, so
237
- // this is a real (if approximate) lower bound, not a guess -- avoids
238
- // repeated doubling-and-copy growth across frames.
239
- let mut out = Vec::with_capacity(compressed.len());
309
+ // `compressed.len()` is a real lower bound, but LZ4 on Arrow-IPC data
310
+ // (long dictionary/offset-buffer runs, mostly-repeated bytes) typically
311
+ // compresses several-fold -- estimating just the lower bound means the
312
+ // real decompressed size almost always blows past initial capacity,
313
+ // paying for repeated doubling-and-copy growth on every chunk. `* 4` is
314
+ // a heuristic, not a guarantee (`Vec` still grows normally if it's wrong
315
+ // either way) -- just a better starting point than the guaranteed-too-
316
+ // small lower bound.
317
+ let mut out = Vec::with_capacity(compressed.len() * 4);
240
318
  let mut decoder = lz4_flex::frame::FrameDecoder::new(&compressed[..]);
241
319
  loop {
242
320
  let before = out.len();
@@ -315,18 +393,22 @@ impl DbClient {
315
393
  wait_timeout: "30s".to_string(),
316
394
  // Python's DatabricksClient defaults to 6, tuned for asyncio+GIL
317
395
  // where higher concurrency stops paying off past single digits
318
- // (see its own comment). Ad hoc benchmarking against a mocked
319
- // warehouse (80 chunks, 8ms simulated per-chunk latency) showed
320
- // this Rust core's real OS-thread parallelism keeps improving up
321
- // to ~64 concurrent fetches (119ms@8, 70ms@16, 48ms@32, 42ms@64),
322
- // regressing slightly at 128 once concurrency exceeds the chunk
323
- // count -- not reproducible from a script in this repo, so take
324
- // the specific numbers as directional, not a checked-in
325
- // benchmark. 32 is a reasonable default headroom below that peak
326
- // without chasing a number that's workload-shaped; callers with
327
- // very large chunk counts
396
+ // (see its own comment). This Rust core's real OS-thread
397
+ // parallelism keeps paying off well past that -- measured against
398
+ // a real 400-chunk/5.6M-row/120-column table, repeated runs (full
399
+ // download time, same query, same warehouse): 16=140s,
400
+ // 32=~113s (avg of 3), 64=114s, 96=~102s (avg of 2), 128=122s.
401
+ // 16 is clearly worse and 128 clearly regresses (concurrency
402
+ // outrunning what the warehouse/network can actually keep fed);
403
+ // 32-96 all land in roughly the same band with 96 nominally
404
+ // fastest on this table/warehouse, but the gap over 32 is modest
405
+ // (~10%) and the exact peak is workload/warehouse-shaped, not a
406
+ // fixed constant -- 64 is picked as a safe middle-ground default
407
+ // headroom above the old value with no observed downside, not
408
+ // a claim that 64 is the true optimum. A caller with a very
409
+ // large chunk count or a fast/low-latency link to the warehouse
328
410
  // may still want to raise it further.
329
- chunk_fetch_concurrency: 32,
411
+ chunk_fetch_concurrency: 64,
330
412
  warehouse_start_timeout: Duration::from_secs(300),
331
413
  warehouse_confirmed_running_ttl: Duration::from_secs(30),
332
414
  warehouse_confirmed_running_at: Mutex::new(None),
@@ -394,6 +476,11 @@ impl DbClient {
394
476
  url: &str,
395
477
  body: Option<&Value>,
396
478
  ) -> Result<T, ApiError> {
479
+ // Only a GET is safe to blindly replay on a decode/mid-flight-send
480
+ // failure -- see `ApiError::from_reqwest`'s doc. The only POSTs this
481
+ // crate makes through here are statement submission and
482
+ // warehouse-start, neither of which is safe to risk double-executing.
483
+ let idempotent = method == reqwest::Method::GET;
397
484
  retry_call(|| async {
398
485
  // Fetched fresh on every attempt, not just once before the retry
399
486
  // loop -- matches Python's _bearer_token being called on every
@@ -408,9 +495,9 @@ impl DbClient {
408
495
  if let Some(b) = body {
409
496
  req = req.json(b);
410
497
  }
411
- let resp = req.send().await.map_err(ApiError::from_reqwest)?;
498
+ let resp = req.send().await.map_err(|e| ApiError::from_reqwest(e, idempotent))?;
412
499
  let status = resp.status();
413
- let text = resp.text().await.map_err(ApiError::from_reqwest)?;
500
+ let text = resp.text().await.map_err(|e| ApiError::from_reqwest(e, idempotent))?;
414
501
  if !status.is_success() {
415
502
  return Err(ApiError::from_status(status, &text));
416
503
  }
@@ -439,13 +526,13 @@ impl DbClient {
439
526
  .timeout(self.http_timeout)
440
527
  .send()
441
528
  .await
442
- .map_err(ApiError::from_reqwest)?;
529
+ .map_err(|e| ApiError::from_reqwest(e, true))?;
443
530
  let status = resp.status();
444
531
  if !status.is_success() {
445
532
  let text = resp.text().await.unwrap_or_default();
446
533
  return Err(ApiError::from_status(status, &text));
447
534
  }
448
- let bytes = resp.bytes().await.map_err(ApiError::from_reqwest)?;
535
+ let bytes = resp.bytes().await.map_err(|e| ApiError::from_reqwest(e, true))?;
449
536
  if !compressed {
450
537
  return Ok(bytes);
451
538
  }
@@ -593,10 +680,25 @@ impl DbClient {
593
680
 
594
681
  let manifest = data.manifest.unwrap_or_default();
595
682
  let compressed = manifest.result_compression.as_deref() == Some("LZ4_FRAME");
683
+ // `Vec` per index, not a plain map entry -- see `ChunkMeta::pre_resolved_links`'s
684
+ // doc for why collapsing to one would silently lose rows.
685
+ let mut pre_resolved: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
686
+ if let Some(r) = data.result {
687
+ for link in r.external_links {
688
+ if link.external_link.is_empty() {
689
+ continue;
690
+ }
691
+ pre_resolved
692
+ .entry(link.chunk_index)
693
+ .or_default()
694
+ .push(link.external_link);
695
+ }
696
+ }
596
697
  let chunk_metas = manifest
597
698
  .chunks
598
699
  .into_iter()
599
700
  .map(|c| ChunkMeta {
701
+ pre_resolved_links: pre_resolved.remove(&c.chunk_index).unwrap_or_default(),
600
702
  chunk_index: c.chunk_index,
601
703
  row_count: c.row_count,
602
704
  })
@@ -629,6 +731,16 @@ impl DbClient {
629
731
  Ok(blobs)
630
732
  }
631
733
 
734
+ /// Same shape as `fetch_chunk_index`, but for links the statement submit/
735
+ /// poll response already embedded -- no resolution GET needed first.
736
+ async fn fetch_pre_resolved_links(&self, links: &[String], compressed: bool) -> Result<Vec<Bytes>, ApiError> {
737
+ let mut blobs = Vec::with_capacity(links.len());
738
+ for link in links {
739
+ blobs.push(self.fetch_link_bytes(link, compressed).await?);
740
+ }
741
+ Ok(blobs)
742
+ }
743
+
632
744
  /// Bounded-concurrency worker pool, mirroring
633
745
  /// `_fetch_chunks_with_backpressure`: a fixed pool of workers pulls from
634
746
  /// a shared queue of chunk metas, each pushing its blobs into a bounded
@@ -660,10 +772,16 @@ impl DbClient {
660
772
  loop {
661
773
  let meta = { queue.lock().unwrap().pop_front() };
662
774
  let Some(meta) = meta else { return Ok(()) };
663
- match client
664
- .fetch_chunk_index(&statement_id, meta.chunk_index, compressed)
665
- .await
666
- {
775
+ let fetched = if meta.pre_resolved_links.is_empty() {
776
+ client
777
+ .fetch_chunk_index(&statement_id, meta.chunk_index, compressed)
778
+ .await
779
+ } else {
780
+ client
781
+ .fetch_pre_resolved_links(&meta.pre_resolved_links, compressed)
782
+ .await
783
+ };
784
+ match fetched {
667
785
  Ok(blobs) => {
668
786
  for blob in blobs {
669
787
  let item = ChunkItem {
@@ -713,7 +831,9 @@ impl DbClient {
713
831
  .timeout(self.http_timeout)
714
832
  .send()
715
833
  .await
716
- .map_err(ApiError::from_reqwest)?;
834
+ // `overwrite=true` means retrying this exact PUT is safe --
835
+ // same idempotency reasoning as a GET.
836
+ .map_err(|e| ApiError::from_reqwest(e, true))?;
717
837
  let status = resp.status();
718
838
  if !status.is_success() {
719
839
  let text = resp.text().await.unwrap_or_default();
@@ -738,7 +858,9 @@ impl DbClient {
738
858
  .timeout(self.http_timeout)
739
859
  .send()
740
860
  .await
741
- .map_err(ApiError::from_reqwest)?;
861
+ // DELETE is naturally idempotent here too (404 is already
862
+ // treated as success below).
863
+ .map_err(|e| ApiError::from_reqwest(e, true))?;
742
864
  let status = resp.status();
743
865
  if status == StatusCode::NOT_FOUND || status.is_success() {
744
866
  return Ok(());
@@ -857,4 +979,57 @@ mod tests {
857
979
  err.message
858
980
  );
859
981
  }
982
+
983
+ /// Regression test for a real bug found against a real workspace: a
984
+ /// 400-chunk/5.6M-row fetch failed permanently on one of many large
985
+ /// concurrent blob downloads with reqwest's "error decoding response
986
+ /// body" -- a connection that closes early mid-body (`Kind::Decode`,
987
+ /// see `ApiError::from_reqwest`'s comment), not a genuinely dead
988
+ /// endpoint. Before the fix, `transient` was unconditionally `false` for
989
+ /// every reqwest error, so `retry_call` never got a second attempt and
990
+ /// the whole query failed outright. A raw truncated-response server
991
+ /// (rather than wiremock, which doesn't expose a way to violate its own
992
+ /// Content-Length) reproduces the same client-side error reqwest raised
993
+ /// against the real blob storage endpoint.
994
+ #[tokio::test]
995
+ async fn fetch_link_bytes_retries_after_a_connection_closed_mid_body() {
996
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
997
+ use tokio::net::TcpListener;
998
+
999
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1000
+ let addr = listener.local_addr().unwrap();
1001
+
1002
+ tokio::spawn(async move {
1003
+ let mut attempt = 0u32;
1004
+ loop {
1005
+ let Ok((mut socket, _)) = listener.accept().await else {
1006
+ return;
1007
+ };
1008
+ attempt += 1;
1009
+ let mut buf = [0u8; 1024];
1010
+ let _ = socket.read(&mut buf).await;
1011
+ if attempt == 1 {
1012
+ // Claims 100 bytes, sends 10, then closes -- reqwest's
1013
+ // `.bytes()` surfaces exactly this as `Kind::Decode`.
1014
+ let _ = socket
1015
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789")
1016
+ .await;
1017
+ let _ = socket.shutdown().await;
1018
+ } else {
1019
+ let _ = socket
1020
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
1021
+ .await;
1022
+ let _ = socket.shutdown().await;
1023
+ return;
1024
+ }
1025
+ }
1026
+ });
1027
+
1028
+ let client = DbClient::new(&format!("http://{addr}"), "wh-test", "fake-token");
1029
+ let bytes = client
1030
+ .fetch_link_bytes(&format!("http://{addr}/data"), false)
1031
+ .await
1032
+ .expect("must retry past the truncated first attempt and succeed on the second");
1033
+ assert_eq!(&bytes[..], b"hello");
1034
+ }
860
1035
  }
@@ -213,7 +213,7 @@ impl PyDbClient {
213
213
  warehouse_id,
214
214
  token=None,
215
215
  token_provider=None,
216
- chunk_fetch_concurrency=32,
216
+ chunk_fetch_concurrency=64,
217
217
  http_timeout=60.0,
218
218
  wait_timeout="30s".to_string(),
219
219
  warehouse_start_timeout=300.0,
@@ -361,30 +361,64 @@ fn non_finite_token(v: f64) -> Option<&'static str> {
361
361
  }
362
362
  }
363
363
 
364
+ /// One top-level Float32/Float64 column, downcast once per batch rather than
365
+ /// once per (row, field) -- see `patch_non_finite_floats`.
366
+ enum FloatColumn<'a> {
367
+ F64(usize, &'a arrow::array::Float64Array),
368
+ F32(usize, &'a arrow::array::Float32Array),
369
+ }
370
+
364
371
  /// Rewrites each affected line's `null` (arrow-json's fixed encoding for a
365
372
  /// non-finite float, see `encode_ndjson_lines`) to a `"NaN"`/`"Infinity"`/
366
373
  /// `"-Infinity"` JSON string, in place. Only scans top-level Float32/Float64
367
374
  /// columns -- one row of `lines` per row of the batches, in the same order.
375
+ /// Precomputes each batch's float columns once (schema scan + downcast) up
376
+ /// front instead of redoing both per row -- for a wide, non-float-heavy
377
+ /// schema (this feature's own motivating case: a 120-column real table) that
378
+ /// was a dynamic downcast attempt on every column for every row, the large
379
+ /// majority immediately discarded, and a schema with no float columns at all
380
+ /// still paid for the full row x column scan for nothing.
368
381
  fn patch_non_finite_floats(batches: &[RecordBatch], lines: &mut [String]) {
369
382
  let mut global_row = 0usize;
370
383
  for batch in batches {
371
384
  let schema = batch.schema();
385
+ let float_columns: Vec<FloatColumn> = schema
386
+ .fields()
387
+ .iter()
388
+ .enumerate()
389
+ .filter_map(|(field_index, field)| match field.data_type() {
390
+ DataType::Float64 => Some(FloatColumn::F64(
391
+ field_index,
392
+ batch.column(field_index).as_primitive::<Float64Type>(),
393
+ )),
394
+ DataType::Float32 => Some(FloatColumn::F32(
395
+ field_index,
396
+ batch.column(field_index).as_primitive::<Float32Type>(),
397
+ )),
398
+ _ => None,
399
+ })
400
+ .collect();
401
+
402
+ if float_columns.is_empty() {
403
+ global_row += batch.num_rows();
404
+ continue;
405
+ }
406
+
372
407
  for row_in_batch in 0..batch.num_rows() {
373
- for (field_index, field) in schema.fields().iter().enumerate() {
374
- let token = match field.data_type() {
375
- DataType::Float64 => {
376
- let arr = batch.column(field_index).as_primitive::<Float64Type>();
408
+ for col in &float_columns {
409
+ let (field_index, token) = match col {
410
+ FloatColumn::F64(field_index, arr) => (
411
+ *field_index,
377
412
  arr.is_valid(row_in_batch)
378
413
  .then(|| non_finite_token(arr.value(row_in_batch)))
379
- .flatten()
380
- }
381
- DataType::Float32 => {
382
- let arr = batch.column(field_index).as_primitive::<Float32Type>();
414
+ .flatten(),
415
+ ),
416
+ FloatColumn::F32(field_index, arr) => (
417
+ *field_index,
383
418
  arr.is_valid(row_in_batch)
384
419
  .then(|| non_finite_token(arr.value(row_in_batch) as f64))
385
- .flatten()
386
- }
387
- _ => None,
420
+ .flatten(),
421
+ ),
388
422
  };
389
423
  if let Some(token) = token {
390
424
  lines[global_row] = replace_nth_top_level_null(&lines[global_row], field_index, token);
@@ -173,6 +173,217 @@ async fn execute_statement_omits_compression_when_disabled() {
173
173
  );
174
174
  }
175
175
 
176
+ /// Regression test for the SEA "embedded first chunk" optimization: a real
177
+ /// workspace's SUCCEEDED statement response already carries chunk 0's
178
+ /// presigned URL directly in `result.external_links`, so the client must
179
+ /// skip that chunk's own `GET .../result/chunks/0` resolution call entirely
180
+ /// and go straight to downloading the blob. `.expect(0)` on that mock fails
181
+ /// the test (on `MockServer` drop) if it's ever hit -- proves the round trip
182
+ /// is actually skipped, not just that the pipeline still produces correct
183
+ /// rows some other way. Chunk 1 has no embedded link, so it must still go
184
+ /// through the normal resolution path -- proves both branches coexist
185
+ /// correctly in the same statement.
186
+ #[tokio::test]
187
+ async fn pre_resolved_chunk0_link_skips_the_extra_resolution_get() {
188
+ let server = MockServer::start().await;
189
+
190
+ Mock::given(method("GET"))
191
+ .and(path(format!("/api/2.0/sql/warehouses/{WAREHOUSE_ID}")))
192
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"state": "RUNNING"})))
193
+ .mount(&server)
194
+ .await;
195
+
196
+ let uri = server.uri();
197
+ Mock::given(method("POST"))
198
+ .and(path("/api/2.0/sql/statements"))
199
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
200
+ "statement_id": STATEMENT_ID,
201
+ "status": {"state": "SUCCEEDED"},
202
+ "manifest": {
203
+ "chunks": [{"chunk_index": 0, "row_count": 5}, {"chunk_index": 1, "row_count": 5}],
204
+ "schema": {"columns": [
205
+ {"name": "id", "type_name": "LONG"},
206
+ {"name": "label", "type_name": "STRING"},
207
+ ]},
208
+ },
209
+ "result": {
210
+ "external_links": [{"chunk_index": 0, "external_link": format!("{uri}/_data/chunk-0")}]
211
+ },
212
+ })))
213
+ .mount(&server)
214
+ .await;
215
+
216
+ // Never mounted for chunk 0 -- `pre_resolved_link` must mean the fetch
217
+ // worker never even attempts this request.
218
+ Mock::given(method("GET"))
219
+ .and(path(format!("/api/2.0/sql/statements/{STATEMENT_ID}/result/chunks/0")))
220
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"external_links": []})))
221
+ .expect(0)
222
+ .mount(&server)
223
+ .await;
224
+ Mock::given(method("GET"))
225
+ .and(path(format!("/api/2.0/sql/statements/{STATEMENT_ID}/result/chunks/1")))
226
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
227
+ "external_links": [{"external_link": format!("{uri}/_data/chunk-1")}]
228
+ })))
229
+ .expect(1)
230
+ .mount(&server)
231
+ .await;
232
+
233
+ Mock::given(method("GET"))
234
+ .and(path_regex(r"^/_data/chunk-0$"))
235
+ .respond_with(
236
+ ResponseTemplate::new(200).set_body_raw(build_chunk_bytes(0, 5), "application/vnd.apache.arrow.stream"),
237
+ )
238
+ .mount(&server)
239
+ .await;
240
+ Mock::given(method("GET"))
241
+ .and(path_regex(r"^/_data/chunk-1$"))
242
+ .respond_with(
243
+ ResponseTemplate::new(200).set_body_raw(build_chunk_bytes(5, 10), "application/vnd.apache.arrow.stream"),
244
+ )
245
+ .mount(&server)
246
+ .await;
247
+
248
+ let client = Arc::new(DbClient::new(&server.uri(), WAREHOUSE_ID, "fake-token"));
249
+ let summary = run_pipeline(client, "SELECT * FROM t", None, None, None).await.unwrap();
250
+
251
+ assert_eq!(summary.num_chunks, 2);
252
+ assert_eq!(summary.num_rows(), 10);
253
+ assert_ids_in_order(&summary.batches, 10);
254
+ }
255
+
256
+ /// Regression test for a bug caught in code review before it shipped:
257
+ /// `pre_resolved` was originally a plain `HashMap<i64, String>`, which keeps
258
+ /// only the *last* entry when `result.external_links` has more than one for
259
+ /// the same `chunk_index` (a real, supported shape -- `fetch_chunk_index`'s
260
+ /// own resolution path already returns `Vec<Bytes>` per chunk for exactly
261
+ /// this reason). Two links for chunk_index 0 here -- if the fix regresses
262
+ /// back to losing all but the last, this comes back with 5 rows instead of
263
+ /// 10.
264
+ #[tokio::test]
265
+ async fn pre_resolved_links_supports_multiple_links_for_the_same_chunk_index() {
266
+ let server = MockServer::start().await;
267
+
268
+ Mock::given(method("GET"))
269
+ .and(path(format!("/api/2.0/sql/warehouses/{WAREHOUSE_ID}")))
270
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"state": "RUNNING"})))
271
+ .mount(&server)
272
+ .await;
273
+
274
+ let uri = server.uri();
275
+ Mock::given(method("POST"))
276
+ .and(path("/api/2.0/sql/statements"))
277
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
278
+ "statement_id": STATEMENT_ID,
279
+ "status": {"state": "SUCCEEDED"},
280
+ "manifest": {
281
+ "chunks": [{"chunk_index": 0, "row_count": 10}],
282
+ "schema": {"columns": [
283
+ {"name": "id", "type_name": "LONG"},
284
+ {"name": "label", "type_name": "STRING"},
285
+ ]},
286
+ },
287
+ "result": {
288
+ "external_links": [
289
+ {"chunk_index": 0, "external_link": format!("{uri}/_data/chunk-0a")},
290
+ {"chunk_index": 0, "external_link": format!("{uri}/_data/chunk-0b")},
291
+ ]
292
+ },
293
+ })))
294
+ .mount(&server)
295
+ .await;
296
+ Mock::given(method("GET"))
297
+ .and(path(format!("/api/2.0/sql/statements/{STATEMENT_ID}/result/chunks/0")))
298
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"external_links": []})))
299
+ .expect(0)
300
+ .mount(&server)
301
+ .await;
302
+
303
+ Mock::given(method("GET"))
304
+ .and(path_regex(r"^/_data/chunk-0a$"))
305
+ .respond_with(
306
+ ResponseTemplate::new(200).set_body_raw(build_chunk_bytes(0, 5), "application/vnd.apache.arrow.stream"),
307
+ )
308
+ .mount(&server)
309
+ .await;
310
+ Mock::given(method("GET"))
311
+ .and(path_regex(r"^/_data/chunk-0b$"))
312
+ .respond_with(
313
+ ResponseTemplate::new(200).set_body_raw(build_chunk_bytes(5, 10), "application/vnd.apache.arrow.stream"),
314
+ )
315
+ .mount(&server)
316
+ .await;
317
+
318
+ let client = Arc::new(DbClient::new(&server.uri(), WAREHOUSE_ID, "fake-token"));
319
+ let summary = run_pipeline(client, "SELECT * FROM t", None, None, None).await.unwrap();
320
+
321
+ assert_eq!(
322
+ summary.num_rows(),
323
+ 10,
324
+ "both pre-resolved links for chunk_index 0 must be fetched, not just the last one"
325
+ );
326
+ }
327
+
328
+ /// Regression test for a bug caught in code review before it shipped: an
329
+ /// omitempty-style server serializer could drop a zero-valued `chunk_index`
330
+ /// field entirely rather than emit `0` -- exactly chunk 0, the chunk this
331
+ /// optimization targets most. Before `chunk_index` had `#[serde(default)]`,
332
+ /// this would either fail the whole `StatementResponseBody` parse (turning
333
+ /// an optional fast path into a hard failure for the entire statement) or
334
+ /// -- depending on the exact fix -- misattribute the link to the wrong
335
+ /// chunk. Omitting `chunk_index` here must still resolve to chunk 0.
336
+ #[tokio::test]
337
+ async fn pre_resolved_link_with_omitted_chunk_index_defaults_to_chunk_zero() {
338
+ let server = MockServer::start().await;
339
+
340
+ Mock::given(method("GET"))
341
+ .and(path(format!("/api/2.0/sql/warehouses/{WAREHOUSE_ID}")))
342
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"state": "RUNNING"})))
343
+ .mount(&server)
344
+ .await;
345
+
346
+ let uri = server.uri();
347
+ Mock::given(method("POST"))
348
+ .and(path("/api/2.0/sql/statements"))
349
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
350
+ "statement_id": STATEMENT_ID,
351
+ "status": {"state": "SUCCEEDED"},
352
+ "manifest": {
353
+ "chunks": [{"chunk_index": 0, "row_count": 5}],
354
+ "schema": {"columns": [
355
+ {"name": "id", "type_name": "LONG"},
356
+ {"name": "label", "type_name": "STRING"},
357
+ ]},
358
+ },
359
+ // "chunk_index" deliberately omitted, not set to 0.
360
+ "result": {
361
+ "external_links": [{"external_link": format!("{uri}/_data/chunk-0")}]
362
+ },
363
+ })))
364
+ .mount(&server)
365
+ .await;
366
+ Mock::given(method("GET"))
367
+ .and(path(format!("/api/2.0/sql/statements/{STATEMENT_ID}/result/chunks/0")))
368
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"external_links": []})))
369
+ .expect(0)
370
+ .mount(&server)
371
+ .await;
372
+ Mock::given(method("GET"))
373
+ .and(path_regex(r"^/_data/chunk-0$"))
374
+ .respond_with(
375
+ ResponseTemplate::new(200).set_body_raw(build_chunk_bytes(0, 5), "application/vnd.apache.arrow.stream"),
376
+ )
377
+ .mount(&server)
378
+ .await;
379
+
380
+ let client = Arc::new(DbClient::new(&server.uri(), WAREHOUSE_ID, "fake-token"));
381
+ let summary = run_pipeline(client, "SELECT * FROM t", None, None, None).await.unwrap();
382
+
383
+ assert_eq!(summary.num_rows(), 5);
384
+ assert_ids_in_order(&summary.batches, 5);
385
+ }
386
+
176
387
  #[tokio::test]
177
388
  async fn full_pipeline_happy_path() {
178
389
  let server = MockServer::start().await;
@@ -41,7 +41,7 @@ class Client:
41
41
  warehouse_id: str,
42
42
  token: str | None = None,
43
43
  token_provider: Callable[[], str | Awaitable[str]] | None = None,
44
- chunk_fetch_concurrency: int = 32,
44
+ chunk_fetch_concurrency: int = 64,
45
45
  http_timeout: float = 60.0,
46
46
  wait_timeout: str = "30s",
47
47
  warehouse_start_timeout: float = 300.0,
@@ -33,7 +33,7 @@ class DatabricksClient:
33
33
  token_provider: TokenProvider | None = None,
34
34
  http_timeout: float = 60.0,
35
35
  wait_timeout: str = "30s",
36
- chunk_fetch_concurrency: int = 32,
36
+ chunk_fetch_concurrency: int = 64,
37
37
  warehouse_start_timeout: float = 300.0,
38
38
  warehouse_confirmed_running_ttl_s: float = 30.0,
39
39
  compress_results: bool = True,
@@ -44,8 +44,10 @@ class DatabricksClient:
44
44
  self.http_timeout = http_timeout
45
45
  self.wait_timeout = wait_timeout
46
46
  # Each concurrent fetch slot holds a whole chunk's raw bytes in
47
- # memory. 32 -- tuned for the Rust core's real OS-thread parallelism,
48
- # which scales well past what asyncio+GIL concurrency used to buy.
47
+ # memory. 64 -- tuned for the Rust core's real OS-thread parallelism,
48
+ # which scales well past what asyncio+GIL concurrency used to buy;
49
+ # measured against a real 400-chunk/5.6M-row table (see client.rs's
50
+ # own comment for the numbers).
49
51
  self.chunk_fetch_concurrency = chunk_fetch_concurrency
50
52
  self.warehouse_start_timeout = warehouse_start_timeout
51
53
  # Requests LZ4-compressed cloud-fetch chunks (matches
File without changes
File without changes