arrowbricks 5.0.2__tar.gz → 5.0.3__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 (50) hide show
  1. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/PKG-INFO +1 -1
  2. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/pyproject.toml +1 -1
  3. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/Cargo.lock +1 -1
  4. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/Cargo.toml +1 -1
  5. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client/download.rs +11 -4
  6. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client/sea.rs +1 -8
  7. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client.rs +1 -0
  8. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/heartbeat.rs +4 -17
  9. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/json_convert.rs +26 -38
  10. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline/ndjson.rs +14 -26
  11. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline/reorder.rs +9 -25
  12. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline/sea.rs +6 -9
  13. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline/thrift_exec.rs +2 -10
  14. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/src/arrowbricks/cursor.py +15 -10
  15. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/LICENSE +0 -0
  16. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/README.md +0 -0
  17. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/.gitignore +0 -0
  18. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/README.md +0 -0
  19. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
  20. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
  21. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/proptest-regressions/json_convert.txt +0 -0
  22. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/rustfmt.toml +0 -0
  23. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/arrow_ffi.rs +0 -0
  24. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client/error.rs +0 -0
  25. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client/model.rs +0 -0
  26. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client/thrift_rpc.rs +0 -0
  27. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/client/volume.rs +0 -0
  28. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/lib.rs +0 -0
  29. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline/stats.rs +0 -0
  30. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline/test_support.rs +0 -0
  31. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/pipeline.rs +0 -0
  32. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/src/thrift.rs +0 -0
  33. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests/common/mod.rs +0 -0
  34. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +0 -0
  35. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests/wiremock_thrift.rs +0 -0
  36. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
  37. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/conftest.py +0 -0
  38. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +0 -0
  39. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
  40. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
  41. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
  42. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_thrift.py +0 -0
  43. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
  44. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
  45. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/rust/arrowbricks_core/tests_py/thrift_mock.py +0 -0
  46. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/src/arrowbricks/__init__.py +0 -0
  47. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/src/arrowbricks/_core.pyi +0 -0
  48. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/src/arrowbricks/_streaming.py +0 -0
  49. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/src/arrowbricks/client.py +0 -0
  50. {arrowbricks-5.0.2 → arrowbricks-5.0.3}/src/arrowbricks/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrowbricks
3
- Version: 5.0.2
3
+ Version: 5.0.3
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 = "5.0.2"
3
+ version = "5.0.3"
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"
@@ -177,7 +177,7 @@ dependencies = [
177
177
 
178
178
  [[package]]
179
179
  name = "arrowbricks_core"
180
- version = "5.0.2"
180
+ version = "5.0.3"
181
181
  dependencies = [
182
182
  "arrow-array",
183
183
  "arrow-buffer",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "arrowbricks_core"
3
- version = "5.0.2"
3
+ version = "5.0.3"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -39,14 +39,21 @@ use super::model::QueryStatsAccumulator;
39
39
  /// calls) -- so looping `read_to_end` on one decoder until its underlying
40
40
  /// reader is exhausted reads every frame without reconstructing a decoder.
41
41
  pub(crate) fn decompress_lz4_frame(compressed: &Bytes) -> Result<Bytes, ApiError> {
42
- use std::io::Read;
43
42
  // LZ4 on Arrow-IPC data often compresses several-fold. Starting at the
44
43
  // compressed size can therefore pay for repeated buffer growth. Neither
45
44
  // size is a bound on the other: incompressible input can expand slightly.
46
45
  // `* 4` remains a heuristic; Vec grows normally when it underestimates.
47
46
  // See benchmark_lz4_capacity_hypotheses for the allocation tradeoffs.
48
47
  let mut out = Vec::with_capacity(compressed.len() * 4);
49
- let mut decoder = lz4_flex::frame::FrameDecoder::new(&compressed[..]);
48
+ lz4_frame_decode_into(compressed, &mut out)?;
49
+ Ok(Bytes::from(out))
50
+ }
51
+
52
+ /// Appends every concatenated LZ4 frame in `src` to `out` -- the loop
53
+ /// `decompress_lz4_frame` and the Thrift inline-blob path both need.
54
+ pub(crate) fn lz4_frame_decode_into(src: &[u8], out: &mut Vec<u8>) -> Result<(), ApiError> {
55
+ use std::io::Read;
56
+ let mut decoder = lz4_flex::frame::FrameDecoder::new(src);
50
57
  // Terminate on the *reader* being exhausted, not on "output stopped
51
58
  // growing" -- found in code review that a frame which happens to decode
52
59
  // to zero bytes (a real, valid LZ4 Frame shape: header + immediate
@@ -61,10 +68,10 @@ pub(crate) fn decompress_lz4_frame(compressed: &Bytes) -> Result<Bytes, ApiError
61
68
  // between two real ones, see `decompress_lz4_frame_survives_a_zero_content_frame_in_the_middle`.
62
69
  while !decoder.get_ref().is_empty() {
63
70
  decoder
64
- .read_to_end(&mut out)
71
+ .read_to_end(out)
65
72
  .map_err(|e| ApiError::permanent(format!("LZ4 frame decompress failed: {e}")))?;
66
73
  }
67
- Ok(Bytes::from(out))
74
+ Ok(())
68
75
  }
69
76
 
70
77
  /// Reads the body into one buffer sized from `Content-Length`. Frames are
@@ -654,20 +654,13 @@ async fn join_first_error(handles: Vec<tokio::task::JoinHandle<Result<(), ApiErr
654
654
  #[cfg(test)]
655
655
  mod tests {
656
656
  use super::*;
657
- use crate::client::ApiErrorKind;
658
657
 
659
658
  fn ok_task() -> tokio::task::JoinHandle<Result<(), ApiError>> {
660
659
  tokio::spawn(async { Ok(()) })
661
660
  }
662
661
 
663
662
  fn err_task(msg: &'static str) -> tokio::task::JoinHandle<Result<(), ApiError>> {
664
- tokio::spawn(async move {
665
- Err(ApiError {
666
- message: msg.to_string(),
667
- transient: false,
668
- kind: ApiErrorKind::Other,
669
- })
670
- })
663
+ tokio::spawn(async move { Err(ApiError::permanent(msg)) })
671
664
  }
672
665
 
673
666
  fn panicking_task() -> tokio::task::JoinHandle<Result<(), ApiError>> {
@@ -44,6 +44,7 @@ mod sea;
44
44
  mod thrift_rpc;
45
45
  mod volume;
46
46
 
47
+ pub(crate) use download::lz4_frame_decode_into;
47
48
  pub(crate) use error::join_error;
48
49
  pub use error::{ApiError, ApiErrorKind};
49
50
  pub use model::{
@@ -12,7 +12,7 @@ use std::time::{Duration, Instant};
12
12
 
13
13
  use tokio::task::JoinHandle;
14
14
 
15
- use crate::client::{ApiError, ApiErrorKind, join_error};
15
+ use crate::client::{ApiError, join_error};
16
16
 
17
17
  /// Matches `_streaming.py`'s `_HEARTBEAT_INTERVAL_S` -- well under typical
18
18
  /// PaaS idle-connection ceilings for a caller forwarding these as SSE
@@ -85,11 +85,7 @@ async fn check_deadline_or_wait<T>(
85
85
  let _ = handle.await;
86
86
  *handle_slot = None;
87
87
  let secs = total_timeout_s.unwrap_or(0.0);
88
- Err(ApiError {
89
- message: format!("Query exceeded {secs}s timeout"),
90
- transient: false,
91
- kind: ApiErrorKind::Other,
92
- })
88
+ Err(ApiError::permanent(format!("Query exceeded {secs}s timeout")))
93
89
  }
94
90
 
95
91
  /// Shared `Drop` logic for `HeartbeatWait`/`HeartbeatStream`: best-effort, if
@@ -475,17 +471,8 @@ mod tests {
475
471
 
476
472
  #[tokio::test]
477
473
  async fn wrapped_future_error_propagates() {
478
- let mut wait: HeartbeatWait<()> = HeartbeatWait::with_interval(
479
- async {
480
- Err(ApiError {
481
- message: "boom".into(),
482
- transient: false,
483
- kind: ApiErrorKind::Other,
484
- })
485
- },
486
- None,
487
- TEST_INTERVAL,
488
- );
474
+ let mut wait: HeartbeatWait<()> =
475
+ HeartbeatWait::with_interval(async { Err(ApiError::permanent("boom")) }, None, TEST_INTERVAL);
489
476
  let err = wait.tick().await.unwrap_err();
490
477
  assert_eq!(err.message, "boom");
491
478
  }
@@ -28,14 +28,12 @@ use arrow_schema::{DataType, Field, Fields, Schema, TimeUnit};
28
28
  use chrono::{DateTime, NaiveDate, NaiveDateTime};
29
29
  use serde_json::Value as JsonValue;
30
30
 
31
- use crate::client::{ApiError, ApiErrorKind, ColumnDescription};
31
+ use crate::client::{ApiError, ColumnDescription};
32
32
 
33
33
  fn conv_err(column: &str, value: &str, type_name: &str, detail: impl std::fmt::Display) -> ApiError {
34
- ApiError {
35
- message: format!("column `{column}` (type {type_name}): could not parse {value:?}: {detail}"),
36
- transient: false,
37
- kind: ApiErrorKind::Other,
38
- }
34
+ ApiError::permanent(format!(
35
+ "column `{column}` (type {type_name}): could not parse {value:?}: {detail}"
36
+ ))
39
37
  }
40
38
 
41
39
  pub fn json_array_to_record_batch(
@@ -46,11 +44,10 @@ pub fn json_array_to_record_batch(
46
44
  let mut arrays: Vec<ArrayRef> = Vec::with_capacity(columns.len());
47
45
 
48
46
  for (col_idx, col) in columns.iter().enumerate() {
49
- let type_name = col.type_name.as_deref().ok_or_else(|| ApiError {
50
- message: format!("column `{}` has no type_name in the manifest", col.name),
51
- transient: false,
52
- kind: ApiErrorKind::Other,
53
- })?;
47
+ let type_name = col
48
+ .type_name
49
+ .as_deref()
50
+ .ok_or_else(|| ApiError::permanent(format!("column `{}` has no type_name in the manifest", col.name)))?;
54
51
  let values = rows.iter().map(|row| row.get(col_idx).cloned().flatten());
55
52
  let (data_type, array) = build_column(
56
53
  &col.name,
@@ -65,11 +62,8 @@ pub fn json_array_to_record_batch(
65
62
  }
66
63
 
67
64
  let schema = Arc::new(Schema::new(fields));
68
- RecordBatch::try_new(schema, arrays).map_err(|e| ApiError {
69
- message: format!("failed to assemble RecordBatch from JSON_ARRAY data: {e}"),
70
- transient: false,
71
- kind: ApiErrorKind::Other,
72
- })
65
+ RecordBatch::try_new(schema, arrays)
66
+ .map_err(|e| ApiError::permanent(format!("failed to assemble RecordBatch from JSON_ARRAY data: {e}")))
73
67
  }
74
68
 
75
69
  #[allow(clippy::too_many_lines)]
@@ -153,11 +147,9 @@ fn build_column(
153
147
  }
154
148
  "DECIMAL" => {
155
149
  let (Some(precision), Some(scale)) = (precision, scale) else {
156
- return Err(ApiError {
157
- message: format!("column `{name}`: DECIMAL type missing type_precision/type_scale in manifest"),
158
- transient: false,
159
- kind: ApiErrorKind::Other,
160
- });
150
+ return Err(ApiError::permanent(format!(
151
+ "column `{name}`: DECIMAL type missing type_precision/type_scale in manifest"
152
+ )));
161
153
  };
162
154
  let mut out = Vec::new();
163
155
  for v in values {
@@ -168,10 +160,10 @@ fn build_column(
168
160
  }
169
161
  let array = Decimal128Array::from(out)
170
162
  .with_precision_and_scale(precision, scale)
171
- .map_err(|e| ApiError {
172
- message: format!("column `{name}`: invalid DECIMAL(precision={precision}, scale={scale}): {e}"),
173
- transient: false,
174
- kind: ApiErrorKind::Other,
163
+ .map_err(|e| {
164
+ ApiError::permanent(format!(
165
+ "column `{name}`: invalid DECIMAL(precision={precision}, scale={scale}): {e}"
166
+ ))
175
167
  })?;
176
168
  Ok((DataType::Decimal128(precision, scale), Arc::new(array)))
177
169
  }
@@ -259,15 +251,13 @@ fn build_column(
259
251
  Ok((DataType::Binary, Arc::new(BinaryArray::from(refs))))
260
252
  }
261
253
  "STRUCT" => {
262
- let type_text = type_text.ok_or_else(|| ApiError {
263
- message: format!("column `{name}`: STRUCT type missing type_text in manifest"),
264
- transient: false,
265
- kind: ApiErrorKind::Other,
254
+ let type_text = type_text.ok_or_else(|| {
255
+ ApiError::permanent(format!("column `{name}`: STRUCT type missing type_text in manifest"))
266
256
  })?;
267
- let field_defs = parse_struct_fields(type_text).map_err(|e| ApiError {
268
- message: format!("column `{name}`: could not parse STRUCT type_text {type_text:?}: {e}"),
269
- transient: false,
270
- kind: ApiErrorKind::Other,
257
+ let field_defs = parse_struct_fields(type_text).map_err(|e| {
258
+ ApiError::permanent(format!(
259
+ "column `{name}`: could not parse STRUCT type_text {type_text:?}: {e}"
260
+ ))
271
261
  })?;
272
262
 
273
263
  // One parsed JSON object per row (`None` = the whole struct is
@@ -338,11 +328,9 @@ fn build_column(
338
328
  .map_err(|e| ApiError::permanent(format!("column `{name}`: invalid STRUCT array: {e}")))?;
339
329
  Ok((DataType::Struct(fields), Arc::new(struct_array)))
340
330
  }
341
- other => Err(ApiError {
342
- message: format!("column `{name}`: type {other} isn't supported by the INLINE/JSON_ARRAY fast path"),
343
- transient: false,
344
- kind: ApiErrorKind::Other,
345
- }),
331
+ other => Err(ApiError::permanent(format!(
332
+ "column `{name}`: type {other} isn't supported by the INLINE/JSON_ARRAY fast path"
333
+ ))),
346
334
  }
347
335
  }
348
336
 
@@ -16,7 +16,7 @@ use arrow_array::types::{Float32Type, Float64Type};
16
16
  use arrow_schema::DataType;
17
17
  use serde_json::Value;
18
18
 
19
- use crate::client::{ApiError, ApiErrorKind, CancelHandle, DbClient, Protocol, QueryStatsAccumulator, join_error};
19
+ use crate::client::{ApiError, CancelHandle, DbClient, Protocol, QueryStatsAccumulator, join_error};
20
20
 
21
21
  use super::reorder::{ReorderBuffer, decode_chunk_item};
22
22
  use super::sea::submit_sea_and_report;
@@ -83,16 +83,12 @@ fn encode_ndjson_lines(batches: &[RecordBatch], non_finite_as_string: bool) -> R
83
83
  let builder = arrow_json::WriterBuilder::new().with_explicit_nulls(true);
84
84
  let mut writer = builder.build::<_, arrow_json::writer::LineDelimited>(&mut output);
85
85
  let refs: Vec<&RecordBatch> = batches.iter().collect();
86
- writer.write_batches(&refs).map_err(|e| ApiError {
87
- message: format!("NDJSON encode error: {e}"),
88
- transient: false,
89
- kind: ApiErrorKind::Other,
90
- })?;
91
- writer.finish().map_err(|e| ApiError {
92
- message: format!("NDJSON encode error: {e}"),
93
- transient: false,
94
- kind: ApiErrorKind::Other,
95
- })?;
86
+ writer
87
+ .write_batches(&refs)
88
+ .map_err(|e| ApiError::permanent(format!("NDJSON encode error: {e}")))?;
89
+ writer
90
+ .finish()
91
+ .map_err(|e| ApiError::permanent(format!("NDJSON encode error: {e}")))?;
96
92
  }
97
93
  if !output.pending.is_empty() {
98
94
  return Err(ApiError::permanent("NDJSON encode produced an unterminated row"));
@@ -407,23 +403,15 @@ mod tests {
407
403
  let builder = arrow_json::WriterBuilder::new().with_explicit_nulls(true);
408
404
  let mut writer = builder.build::<_, arrow_json::writer::LineDelimited>(&mut buf);
409
405
  let refs: Vec<&RecordBatch> = batches.iter().collect();
410
- writer.write_batches(&refs).map_err(|e| ApiError {
411
- message: format!("NDJSON encode error: {e}"),
412
- transient: false,
413
- kind: ApiErrorKind::Other,
414
- })?;
415
- writer.finish().map_err(|e| ApiError {
416
- message: format!("NDJSON encode error: {e}"),
417
- transient: false,
418
- kind: ApiErrorKind::Other,
419
- })?;
406
+ writer
407
+ .write_batches(&refs)
408
+ .map_err(|e| ApiError::permanent(format!("NDJSON encode error: {e}")))?;
409
+ writer
410
+ .finish()
411
+ .map_err(|e| ApiError::permanent(format!("NDJSON encode error: {e}")))?;
420
412
  }
421
413
  let mut lines: Vec<String> = String::from_utf8(buf)
422
- .map_err(|e| ApiError {
423
- message: format!("NDJSON encode produced invalid UTF-8: {e}"),
424
- transient: false,
425
- kind: ApiErrorKind::Other,
426
- })?
414
+ .map_err(|e| ApiError::permanent(format!("NDJSON encode produced invalid UTF-8: {e}")))?
427
415
  .lines()
428
416
  .map(|line| line.to_string())
429
417
  .collect();
@@ -15,7 +15,7 @@ use arrow_schema::SchemaRef;
15
15
  use bytes::Bytes;
16
16
  use tokio::sync::mpsc;
17
17
 
18
- use crate::client::{ApiError, ApiErrorKind, ChunkItem};
18
+ use crate::client::{ApiError, ChunkItem};
19
19
 
20
20
  /// Same dict-of-lists-keyed-by-index shape as `_ResultSet._pending`: a
21
21
  /// `chunk_index` can carry more than one blob (multiple `external_links` per
@@ -123,11 +123,9 @@ fn decode_chunk(blob: &Bytes) -> Result<Vec<RecordBatch>, ApiError> {
123
123
  /// schema-only streams that contain no record batches.
124
124
  pub(crate) fn decode_ipc_stream(blob: &Bytes) -> Result<(Vec<RecordBatch>, SchemaRef), ApiError> {
125
125
  if blob.is_empty() {
126
- return Err(ApiError {
127
- message: "empty Arrow IPC chunk: expected at least a schema message".to_string(),
128
- transient: false,
129
- kind: ApiErrorKind::Other,
130
- });
126
+ return Err(ApiError::permanent(
127
+ "empty Arrow IPC chunk: expected at least a schema message",
128
+ ));
131
129
  }
132
130
  let mut buffer = ArrowBuffer::from(blob.clone());
133
131
  let mut decoder = StreamDecoder::new();
@@ -137,19 +135,13 @@ pub(crate) fn decode_ipc_stream(blob: &Bytes) -> Result<(Vec<RecordBatch>, Schem
137
135
  Ok(Some(batch)) => batches.push(batch),
138
136
  Ok(None) => {}
139
137
  Err(e) => {
140
- return Err(ApiError {
141
- message: format!("Arrow IPC decode error: {e}"),
142
- transient: false,
143
- kind: ApiErrorKind::Other,
144
- });
138
+ return Err(ApiError::permanent(format!("Arrow IPC decode error: {e}")));
145
139
  }
146
140
  }
147
141
  }
148
- decoder.finish().map_err(|e| ApiError {
149
- message: format!("bad Arrow IPC stream: {e}"),
150
- transient: false,
151
- kind: ApiErrorKind::Other,
152
- })?;
142
+ decoder
143
+ .finish()
144
+ .map_err(|e| ApiError::permanent(format!("bad Arrow IPC stream: {e}")))?;
153
145
  let schema = decoder
154
146
  .schema()
155
147
  .ok_or_else(|| ApiError::permanent("Arrow IPC stream has no schema"))?;
@@ -299,15 +291,7 @@ mod tests {
299
291
 
300
292
  #[tokio::test]
301
293
  async fn error_surfaces_after_already_yielded_items() {
302
- let sent = vec![
303
- Ok(item(0)),
304
- Ok(item(1)),
305
- Err(ApiError {
306
- message: "boom".into(),
307
- transient: false,
308
- kind: ApiErrorKind::Other,
309
- }),
310
- ];
294
+ let sent = vec![Ok(item(0)), Ok(item(1)), Err(ApiError::permanent("boom"))];
311
295
  let (tx, rx) = mpsc::channel(sent.len());
312
296
  for r in sent {
313
297
  tx.send(r).await.unwrap();
@@ -17,8 +17,8 @@ use serde_json::Value;
17
17
  use tokio::sync::mpsc;
18
18
 
19
19
  use crate::client::{
20
- ApiError, ApiErrorKind, CancelHandle, ColumnDescription, DbClient, InlineOrExternal, QueryStatsAccumulator,
21
- QueryStatsData, StatementSubmitResult, join_error,
20
+ ApiError, CancelHandle, ColumnDescription, DbClient, InlineOrExternal, QueryStatsAccumulator, QueryStatsData,
21
+ StatementSubmitResult, join_error,
22
22
  };
23
23
 
24
24
  use super::reorder::{ReorderBuffer, decode_chunk_item};
@@ -100,13 +100,10 @@ impl ResultStream {
100
100
  /// single capped batch isn't enough).
101
101
  async fn fetch_at_least(&mut self, want_rows: usize) -> Result<(), ApiError> {
102
102
  if self.poisoned {
103
- return Err(ApiError {
104
- message: "this result was left incomplete by a previous cancelled, timed-out, or failed fetch -- \
105
- re-run the query instead of continuing to use this cursor/result"
106
- .to_string(),
107
- transient: false,
108
- kind: ApiErrorKind::Other,
109
- });
103
+ return Err(ApiError::permanent(
104
+ "this result was left incomplete by a previous cancelled, timed-out, or failed fetch -- \
105
+ re-run the query instead of continuing to use this cursor/result",
106
+ ));
110
107
  }
111
108
  let mut guard = PoisonOnDrop::new(&mut self.poisoned, &mut self.reporter, self.stats.as_ref());
112
109
  while self.pending_rows < want_rows && !self.exhausted {
@@ -21,6 +21,7 @@ use tokio::sync::mpsc;
21
21
 
22
22
  use crate::client::{
23
23
  ApiError, CancelHandle, ChunkItem, ColumnDescription, DbClient, QueryStatsAccumulator, join_error,
24
+ lz4_frame_decode_into,
24
25
  };
25
26
  use crate::thrift;
26
27
 
@@ -854,16 +855,7 @@ fn build_inline_blob(
854
855
  for b in batches {
855
856
  row_count += b.row_count;
856
857
  if lz4_compressed {
857
- // Decompress directly into the output buffer using FrameDecoder,
858
- // avoiding a separate intermediate allocation and copy. The
859
- // FrameDecoder's read_to_end appends to the existing buffer.
860
- use std::io::Read;
861
- let mut decoder = lz4_flex::frame::FrameDecoder::new(&b.batch[..]);
862
- while !decoder.get_ref().is_empty() {
863
- decoder
864
- .read_to_end(&mut out)
865
- .map_err(|e| ApiError::permanent(format!("LZ4 frame decompress failed: {e}")))?;
866
- }
858
+ lz4_frame_decode_into(&b.batch, &mut out)?;
867
859
  } else {
868
860
  out.extend_from_slice(&b.batch);
869
861
  }
@@ -48,6 +48,11 @@ Description = tuple[str, str | None, None, None, None, None, None]
48
48
  _ROW_BATCH = 1000
49
49
 
50
50
 
51
+ def _join_rows(buffered: list[Row], rows: list[Row]) -> list[Row]:
52
+ # `buffered + rows` copies every row; usually there's nothing buffered.
53
+ return buffered + rows if buffered else rows
54
+
55
+
51
56
  def _table_to_rows(table: Any) -> list[Row]:
52
57
  if table.num_rows == 0:
53
58
  return []
@@ -223,8 +228,7 @@ class Cursor:
223
228
  if self._row_pos >= len(self._row_buffer):
224
229
  result = self._require_result()
225
230
  table = await result.fetchmany_arrow(_ROW_BATCH)
226
- if self._schema is None:
227
- self._schema = await result.schema()
231
+ await self._ensure_schema(result)
228
232
  self._row_buffer = _table_to_rows(table)
229
233
  self._row_pos = 0
230
234
  if self._row_pos >= len(self._row_buffer):
@@ -237,26 +241,28 @@ class Cursor:
237
241
  buffered = self._take_buffered(size)
238
242
  if len(buffered) == size:
239
243
  return buffered
240
- return buffered + _table_to_rows(await self.fetchmany_arrow(size - len(buffered)))
244
+ return _join_rows(buffered, _table_to_rows(await self.fetchmany_arrow(size - len(buffered))))
241
245
 
242
246
  async def fetchall(self) -> list[Row]:
243
247
  buffered = self._take_buffered(len(self._row_buffer))
244
- return buffered + _table_to_rows(await self.fetchall_arrow())
248
+ return _join_rows(buffered, _table_to_rows(await self.fetchall_arrow()))
249
+
250
+ async def _ensure_schema(self, result: _core.ResultSet) -> None:
251
+ if self._schema is None:
252
+ self._schema = await result.schema()
245
253
 
246
254
  async def fetchmany_arrow(self, size: int) -> core.Table:
247
255
  self._require_empty_row_buffer("fetchmany_arrow")
248
256
  result = self._require_result()
249
257
  table = await result.fetchmany_arrow(size)
250
- if self._schema is None:
251
- self._schema = await result.schema()
258
+ await self._ensure_schema(result)
252
259
  return table
253
260
 
254
261
  async def fetchall_arrow(self) -> core.Table:
255
262
  self._require_empty_row_buffer("fetchall_arrow")
256
263
  result = self._require_result()
257
264
  table = await result.fetchall_arrow()
258
- if self._schema is None:
259
- self._schema = await result.schema()
265
+ await self._ensure_schema(result)
260
266
  return table
261
267
 
262
268
  def fetchall_streamed(self, *, total_timeout_s: float | None = None) -> AsyncIterator[Any]:
@@ -317,8 +323,7 @@ class Cursor:
317
323
  if item is _core.HEARTBEAT:
318
324
  yield HEARTBEAT
319
325
  else:
320
- if self._schema is None:
321
- self._schema = await result.schema()
326
+ await self._ensure_schema(result)
322
327
  yield item
323
328
 
324
329
  return _gen()
File without changes
File without changes