arrowbricks 3.1.3__tar.gz → 3.2.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 (49) hide show
  1. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/PKG-INFO +14 -4
  2. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/README.md +13 -3
  3. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/pyproject.toml +1 -1
  4. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.lock +2 -1
  5. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.toml +3 -1
  6. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/README.md +2 -2
  7. arrowbricks-3.2.0/rust/arrowbricks_core/proptest-regressions/json_convert.txt +7 -0
  8. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/download.rs +66 -10
  9. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/model.rs +25 -1
  10. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/sea.rs +38 -4
  11. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/heartbeat.rs +12 -0
  12. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/json_convert.rs +11 -1
  13. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/lib.rs +64 -23
  14. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/ndjson.rs +159 -16
  15. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/sea.rs +8 -1
  16. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/stats.rs +24 -0
  17. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/thrift_exec.rs +51 -3
  18. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +94 -0
  19. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_thrift.rs +177 -0
  20. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +37 -0
  21. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/_streaming.py +36 -14
  22. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/cursor.py +2 -17
  23. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/LICENSE +0 -0
  24. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/.gitignore +0 -0
  25. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
  26. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
  27. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/rustfmt.toml +0 -0
  28. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/error.rs +0 -0
  29. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/thrift_rpc.rs +0 -0
  30. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/volume.rs +0 -0
  31. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client.rs +0 -0
  32. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/reorder.rs +0 -0
  33. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/test_support.rs +0 -0
  34. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline.rs +0 -0
  35. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/thrift.rs +0 -0
  36. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/common/mod.rs +0 -0
  37. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
  38. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/conftest.py +0 -0
  39. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
  40. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
  41. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
  42. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_thrift.py +0 -0
  43. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
  44. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
  45. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/thrift_mock.py +0 -0
  46. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/__init__.py +0 -0
  47. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/_core.pyi +0 -0
  48. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/client.py +0 -0
  49. {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrowbricks
3
- Version: 3.1.3
3
+ Version: 3.2.0
4
4
  Requires-Dist: arro3-core>=0.8 ; extra == 'arro3'
5
5
  Provides-Extra: arro3
6
6
  License-File: LICENSE
@@ -160,9 +160,9 @@ conn = connect(host=..., warehouse_id=..., token=..., on_event=log_query)
160
160
 
161
161
  ## Cancellation
162
162
 
163
- When a chunk download times out (`total_timeout_s`) or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while it's in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
163
+ When `total_timeout_s` elapses or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while a query is in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
164
164
 
165
- This covers `Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, `client.stream_query_json(...)`, and the lower-level `._core.Client`'s own streamed APIs -- everything that wraps the chunk-download phase in the Rust-level heartbeat. Neither `total_timeout_s` nor a bare `task.cancel()` on `Cursor.execute()`/`execute_streamed()` itself (the initial submit/poll wait) triggers a cancel -- by the time a query's chunks are being fetched at all, the statement has typically already finished running server-side, so there's usually nothing left to cancel there in practice.
165
+ This covers both phases of a query: the submit/poll wait while the statement is still running (`Cursor.execute()`/`execute_streamed()`, and the start of `client.stream_query_json(...)`), and the chunk download afterwards (`Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, the rest of `stream_query_json`). `stream_query_json`'s `total_timeout_s` is one budget across both phases. On `protocol="sea"` there is one short blind spot: the submit request itself can wait server-side for up to `wait_timeout` (default 30s) before Databricks returns a statement id, and a query abandoned inside that window can't be cancelled.
166
166
 
167
167
  ## Errors
168
168
 
@@ -300,11 +300,19 @@ python examples/benchmark_vs_connector.py
300
300
  For comparing arrowbricks versions with identical APIs, use
301
301
  [`examples/benchmark_versions.py`](examples/benchmark_versions.py). It keeps
302
302
  one connection per version, discards warm-ups, alternates execution order,
303
- passes concurrency explicitly, and checks row counts. See
303
+ passes concurrency explicitly, and checks row counts. Add `--verify-ipc`
304
+ (requires `arro3-core`) to compare serialized Arrow results in memory after
305
+ timing. Results must have stable values, schema metadata, row order, and batch boundaries;
306
+ verification contributes to peak process memory, and checksums are not printed. See
304
307
  [`benchmarks/2026-09-06.md`](benchmarks/2026-09-06.md) for replay/cache measurements
305
308
  and [`benchmarks/2026-09-06-downloads.md`](benchmarks/2026-09-06-downloads.md)
306
309
  for the subsequent cloud-fetch scheduling measurements and limits.
307
310
 
311
+ Follow-up experiments cover [spare request slots](benchmarks/2026-09-06-spare-slots.md),
312
+ [concurrent replay and NDJSON encoding](benchmarks/2026-09-06-lowlevel.md),
313
+ and the rejected [LZ4 capacity](benchmarks/2026-09-06-lz4-capacity.md) and
314
+ [direct-fill buffer](benchmarks/2026-09-06-direct-fill.md) changes.
315
+
308
316
  Cached IPC replay can be measured without a warehouse:
309
317
 
310
318
  ```bash
@@ -315,6 +323,8 @@ Replays now share immutable input bytes across decoded tables, reducing
315
323
  repeated copies and memory use. Arrow may still copy misaligned fixed-width
316
324
  buffers or decompress IPC-compressed bodies. Keeping a small slice of a
317
325
  decoded array can retain the full source allocation until that slice is released.
326
+ Decoding releases the Python interpreter lock, so independent replay calls
327
+ can run concurrently on separate Python threads.
318
328
 
319
329
  `BENCHMARK_SQL` overrides the query, `BENCHMARK_RUNS` (default 3) controls how many timed runs to average.
320
330
 
@@ -148,9 +148,9 @@ conn = connect(host=..., warehouse_id=..., token=..., on_event=log_query)
148
148
 
149
149
  ## Cancellation
150
150
 
151
- When a chunk download times out (`total_timeout_s`) or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while it's in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
151
+ When `total_timeout_s` elapses or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while a query is in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
152
152
 
153
- This covers `Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, `client.stream_query_json(...)`, and the lower-level `._core.Client`'s own streamed APIs -- everything that wraps the chunk-download phase in the Rust-level heartbeat. Neither `total_timeout_s` nor a bare `task.cancel()` on `Cursor.execute()`/`execute_streamed()` itself (the initial submit/poll wait) triggers a cancel -- by the time a query's chunks are being fetched at all, the statement has typically already finished running server-side, so there's usually nothing left to cancel there in practice.
153
+ This covers both phases of a query: the submit/poll wait while the statement is still running (`Cursor.execute()`/`execute_streamed()`, and the start of `client.stream_query_json(...)`), and the chunk download afterwards (`Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, the rest of `stream_query_json`). `stream_query_json`'s `total_timeout_s` is one budget across both phases. On `protocol="sea"` there is one short blind spot: the submit request itself can wait server-side for up to `wait_timeout` (default 30s) before Databricks returns a statement id, and a query abandoned inside that window can't be cancelled.
154
154
 
155
155
  ## Errors
156
156
 
@@ -288,11 +288,19 @@ python examples/benchmark_vs_connector.py
288
288
  For comparing arrowbricks versions with identical APIs, use
289
289
  [`examples/benchmark_versions.py`](examples/benchmark_versions.py). It keeps
290
290
  one connection per version, discards warm-ups, alternates execution order,
291
- passes concurrency explicitly, and checks row counts. See
291
+ passes concurrency explicitly, and checks row counts. Add `--verify-ipc`
292
+ (requires `arro3-core`) to compare serialized Arrow results in memory after
293
+ timing. Results must have stable values, schema metadata, row order, and batch boundaries;
294
+ verification contributes to peak process memory, and checksums are not printed. See
292
295
  [`benchmarks/2026-09-06.md`](benchmarks/2026-09-06.md) for replay/cache measurements
293
296
  and [`benchmarks/2026-09-06-downloads.md`](benchmarks/2026-09-06-downloads.md)
294
297
  for the subsequent cloud-fetch scheduling measurements and limits.
295
298
 
299
+ Follow-up experiments cover [spare request slots](benchmarks/2026-09-06-spare-slots.md),
300
+ [concurrent replay and NDJSON encoding](benchmarks/2026-09-06-lowlevel.md),
301
+ and the rejected [LZ4 capacity](benchmarks/2026-09-06-lz4-capacity.md) and
302
+ [direct-fill buffer](benchmarks/2026-09-06-direct-fill.md) changes.
303
+
296
304
  Cached IPC replay can be measured without a warehouse:
297
305
 
298
306
  ```bash
@@ -303,6 +311,8 @@ Replays now share immutable input bytes across decoded tables, reducing
303
311
  repeated copies and memory use. Arrow may still copy misaligned fixed-width
304
312
  buffers or decompress IPC-compressed bodies. Keeping a small slice of a
305
313
  decoded array can retain the full source allocation until that slice is released.
314
+ Decoding releases the Python interpreter lock, so independent replay calls
315
+ can run concurrently on separate Python threads.
306
316
 
307
317
  `BENCHMARK_SQL` overrides the query, `BENCHMARK_RUNS` (default 3) controls how many timed runs to average.
308
318
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arrowbricks"
3
- version = "3.1.3"
3
+ version = "3.2.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"
@@ -178,7 +178,7 @@ dependencies = [
178
178
 
179
179
  [[package]]
180
180
  name = "arrowbricks_core"
181
- version = "3.1.3"
181
+ version = "3.2.0"
182
182
  dependencies = [
183
183
  "arrow-array",
184
184
  "arrow-buffer",
@@ -190,6 +190,7 @@ dependencies = [
190
190
  "chrono",
191
191
  "hyper-rustls",
192
192
  "lz4_flex",
193
+ "memchr",
193
194
  "proptest",
194
195
  "pyo3",
195
196
  "pyo3-arrow",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "arrowbricks_core"
3
- version = "3.1.3"
3
+ version = "3.2.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -17,6 +17,8 @@ arrow-ipc = "59.1.0"
17
17
  arrow-schema = "59.1.0"
18
18
  arrow-json = "59.1.0"
19
19
  bytes = "1.12.1"
20
+ # Already in the dependency graph; vectorized newline search in NDJSON output.
21
+ memchr = "2"
20
22
  # Already an unconditional transitive dependency of arrow-cast (pulled in by
21
23
  # pyo3-arrow) at this exact version -- declared directly here too so
22
24
  # json_convert.rs can decode BINARY columns without hand-rolling base64. No
@@ -73,7 +73,7 @@ unlike the table object itself.
73
73
  ## API
74
74
 
75
75
  - `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, protocol="thrift", retry_attempts=6, retry_max_wait_s=20.0)` -- exactly one of `token`/`token_provider`. `retry_attempts`/`retry_max_wait_s` tune the retry policy (total attempts, exponential-backoff ceiling in seconds) behind every retryable request this client makes; `retry_attempts` must be at least 1. `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. `protocol="thrift"` (the default, as of this crate's own real-workspace benchmarking -- see `AGENTS.md`'s design-invariant entry) speaks the same HiveServer2-compatible Thrift-over-HTTPS protocol `databricks-sql-connector` uses by default (`thrift.rs`, a hand-rolled `TBinaryProtocol` reader/writer -- no new Cargo dependency) -- measurably faster for small queries (its `ExecuteStatement` RPC can return a small result inline via `getDirectResults`, in the same call that submits the statement) at the cost of `prefer_inline` becoming a silent no-op (Thrift has no INLINE-disposition equivalent, and doesn't need one); never slower than SEA on any query shape tested. `protocol="sea"` instead talks to the REST Statement Execution API -- still fully supported, opt in explicitly if you have a reason to prefer it. On par with SEA for a large, multi-chunk result too -- `run_thrift_fetch_loop` (`pipeline.rs`) fans its chunk downloads out across a `chunk_fetch_concurrency`-sized worker pool spanning the *whole* result (pipelined with the sequential `FetchResults` discovery calls, not serialized behind them), the same concurrency shape as SEA's own `fetch_chunks_with_backpressure` (see `AGENTS.md`'s own entry on this).
76
- - `Client.execute(statement, *, catalog=None, schema=None, parameters=None, prefer_inline=False) -> ResultSet` -- submits and starts background chunk fetching without pulling anything yet. `parameters` is Databricks' own named-parameter format (`[{"name":..., "value":..., "type":...}]`), passed straight through. `prefer_inline=True` submits with `disposition=INLINE, format=JSON_ARRAY` instead, for a caller who expects a small (well under Databricks' 25 MiB inline cap) result and wants to skip the chunk-fetch round trip -- on a result too big for INLINE, or containing a column type `json_convert.rs` doesn't map (STRUCT/ARRAY-of-STRUCT/MAP/VARIANT), it transparently falls back to a second, normal `execute()` and the query runs twice. See `AGENTS.md`'s "Design invariants" section (in the root package) for the full reasoning and real-workspace verification behind this.
76
+ - `Client.execute(statement, *, catalog=None, schema=None, parameters=None, prefer_inline=False) -> ResultSet` -- submits and starts background chunk fetching without pulling anything yet. `parameters` is Databricks' own named-parameter format (`[{"name":..., "value":..., "type":...}]`), passed straight through. `prefer_inline=True` submits with `disposition=INLINE, format=JSON_ARRAY` instead, for a caller who expects a small (well under Databricks' 25 MiB inline cap) result and wants to skip the chunk-fetch round trip -- the recognized INLINE byte-limit failure falls back to a second, normal `execute()`. If an already-succeeded result cannot be converted (including unsupported empty STRUCT arrays), it raises `ArrowbricksError` without resubmitting the statement. Use the byte-limit retry only with SQL that is safe to execute again. See `AGENTS.md`'s "Design invariants" section (in the root package) for the full reasoning and real-workspace verification behind this.
77
77
  - `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.
78
78
  - `ResultSet.fetchall_arrow() -> Table` -- drains everything remaining.
79
79
  - `ResultSet.fetchall_arrow_streamed(*, total_timeout_s=None)` -- same as `fetchall_arrow()`, but an async iterator yielding the `HEARTBEAT` singleton while pulling chunks instead of blocking silently (bridge e.g. an SSE connection through the download), then a `Table` exactly once. Raises if `total_timeout_s` elapses first.
@@ -82,7 +82,7 @@ unlike the table object itself.
82
82
  - `Client.stream_ndjson_lines(statement, *, catalog=None, schema=None, parameters=None, total_timeout_s=None)` -- chunk-at-a-time: an async iterator yielding the `HEARTBEAT` singleton while waiting on the statement or any individual chunk, then a `list[str]` of NDJSON lines (one per row, explicit nulls, ISO-8601 timestamps) per chunk in logical order. Decode and JSON encoding both happen in Rust -- backs `stream_query_json` end to end.
83
83
  - `Client.upload_volume_file(volume_path, data: bytes)` / `Client.delete_volume_file(volume_path)` -- Unity Catalog volume files via the Files API. Delete treats a 404 as success (idempotent). Both raise a plain `RuntimeError` (message only) on failure.
84
84
  - `write_ipc_stream(stream, buf)` -- free function; writes any object implementing `__arrow_c_stream__` (a `Table` from this crate, arro3, pyarrow, ...) as uncompressed Arrow-IPC stream bytes to a Python file-like object. No dependency needed regardless of the input's origin.
85
- - `read_ipc_stream(data: bytes) -> Table` -- free function; the exact inverse of `write_ipc_stream`, parsing raw Arrow-IPC stream bytes back into a `Table`. No dependency needed regardless of where the bytes came from -- backs `arrowbricks.ReplayableArrowChunk`, which needs to re-parse the same cached bytes on every `__arrow_c_stream__` call.
85
+ - `read_ipc_stream(data: bytes) -> Table` -- free function; the exact inverse of `write_ipc_stream`, parsing raw Arrow-IPC stream bytes back into a `Table`. No dependency needed regardless of where the bytes came from -- backs `arrowbricks.ReplayableArrowChunk`, which needs to re-parse the same cached bytes on every `__arrow_c_stream__` call. Decoding releases the Python interpreter lock so independent replay calls can run concurrently; decoded arrays retain ownership of the immutable input bytes.
86
86
  - `HEARTBEAT` -- module-level singleton; compare with `is`, e.g. `if item is _core.HEARTBEAT: ...`.
87
87
 
88
88
  ## With DuckDB
@@ -0,0 +1,7 @@
1
+ # Seeds for failure cases proptest has generated in the past. It is
2
+ # automatically read and these particular cases re-run before any
3
+ # novel cases are generated.
4
+ #
5
+ # It is recommended to check this file in to source control so that
6
+ # everyone who runs the test benefits from these saved cases.
7
+ cc c23ce2dbe22693eff6644d643de77c6c113dc918c876434391fb93a05438d706 # shrinks to type_name = "STRUCT", precision = None, scale = None, type_text = Some(""), values = []
@@ -36,18 +36,15 @@ use super::model::QueryStatsAccumulator;
36
36
  /// after each `EndMark` and picks up the next concatenated frame on a
37
37
  /// subsequent `read_to_end` call against the *same* instance (verified: the
38
38
  /// decoder's position in the underlying byte slice carries over across
39
- /// calls) -- so looping `read_to_end` on one decoder until it stops growing
40
- /// `out` reads every frame without reconstructing a decoder per frame.
39
+ /// calls) -- so looping `read_to_end` on one decoder until its underlying
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
42
  use std::io::Read;
43
- // `compressed.len()` is a real lower bound, but LZ4 on Arrow-IPC data
44
- // (long dictionary/offset-buffer runs, mostly-repeated bytes) typically
45
- // compresses several-fold -- estimating just the lower bound means the
46
- // real decompressed size almost always blows past initial capacity,
47
- // paying for repeated doubling-and-copy growth on every chunk. `* 4` is
48
- // a heuristic, not a guarantee (`Vec` still grows normally if it's wrong
49
- // either way) -- just a better starting point than the guaranteed-too-
50
- // small lower bound.
43
+ // LZ4 on Arrow-IPC data often compresses several-fold. Starting at the
44
+ // compressed size can therefore pay for repeated buffer growth. Neither
45
+ // size is a bound on the other: incompressible input can expand slightly.
46
+ // `* 4` remains a heuristic; Vec grows normally when it underestimates.
47
+ // See benchmark_lz4_capacity_hypotheses for the allocation tradeoffs.
51
48
  let mut out = Vec::with_capacity(compressed.len() * 4);
52
49
  let mut decoder = lz4_flex::frame::FrameDecoder::new(&compressed[..]);
53
50
  // Terminate on the *reader* being exhausted, not on "output stopped
@@ -301,6 +298,65 @@ impl DbClient {
301
298
  mod tests {
302
299
  use super::*;
303
300
 
301
+ #[test]
302
+ #[ignore = "manual release-mode decompression allocation experiment"]
303
+ fn benchmark_lz4_capacity_hypotheses() {
304
+ use std::hint::black_box;
305
+ use std::io::{Read, Write};
306
+ use std::time::Instant;
307
+
308
+ // Deterministic random bytes mixed with repeated bytes cover different
309
+ // compression ratios without depending on warehouse data.
310
+ for random_fraction in [0, 25, 50, 100] {
311
+ let mut state = 0x12345678_u64;
312
+ let input: Vec<u8> = (0..8 * 1024 * 1024)
313
+ .map(|i| {
314
+ state ^= state << 13;
315
+ state ^= state >> 7;
316
+ state ^= state << 17;
317
+ if i % 100 < random_fraction { state as u8 } else { 0 }
318
+ })
319
+ .collect();
320
+ let mut compressed = Vec::new();
321
+ for part in input.chunks(512 * 1024) {
322
+ let mut encoder = lz4_flex::frame::FrameEncoder::new(Vec::new());
323
+ encoder.write_all(part).unwrap();
324
+ compressed.extend(encoder.finish().unwrap());
325
+ }
326
+ let variants = [
327
+ ("compressed_x1", compressed.len()),
328
+ ("compressed_x2", compressed.len() * 2),
329
+ ("compressed_x4", compressed.len() * 4),
330
+ ("exact", input.len()),
331
+ ("short_hint", input.len() - 64),
332
+ ];
333
+ for round in 0..10 {
334
+ for index in 0..variants.len() {
335
+ let (variant, capacity) = variants[(index + round) % variants.len()];
336
+ let start = Instant::now();
337
+ let mut output = Vec::with_capacity(capacity);
338
+ let mut decoder = lz4_flex::frame::FrameDecoder::new(black_box(compressed.as_slice()));
339
+ while !decoder.get_ref().is_empty() {
340
+ decoder.read_to_end(&mut output).unwrap();
341
+ }
342
+ let seconds = start.elapsed().as_secs_f64();
343
+ assert_eq!(output, input);
344
+ if round > 1 {
345
+ println!(
346
+ "{}",
347
+ serde_json::json!({
348
+ "random_percent": random_fraction, "variant": variant,
349
+ "round": round, "seconds": seconds,
350
+ "compressed_bytes": compressed.len(), "decoded_bytes": output.len(),
351
+ "retained_capacity": output.capacity()
352
+ })
353
+ );
354
+ }
355
+ }
356
+ }
357
+ }
358
+ }
359
+
304
360
  #[tokio::test]
305
361
  async fn split_download_starts_tail_requests_before_the_probe_body_finishes() {
306
362
  use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -6,6 +6,7 @@
6
6
  //! statement gets you before any chunk is fetched (`ChunkMeta`,
7
7
  //! `StatementSubmitResult`, `InlineOrExternal`, `ChunkItem`).
8
8
 
9
+ use std::sync::Mutex;
9
10
  use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering};
10
11
 
11
12
  use bytes::Bytes;
@@ -55,7 +56,7 @@ pub struct ColumnDescription {
55
56
  /// `Drop for HeartbeatWait`/`Drop for HeartbeatStream`) can fire
56
57
  /// `DbClient::cancel_statement` without either of those generic structs
57
58
  /// needing to know anything protocol-specific themselves.
58
- #[derive(Clone)]
59
+ #[derive(Clone, Debug)]
59
60
  pub enum CancelHandle {
60
61
  Sea { statement_id: String },
61
62
  Thrift { operation: thrift::OperationHandle },
@@ -113,6 +114,17 @@ pub struct QueryStatsAccumulator {
113
114
  /// anything itself.
114
115
  warehouse_wait_bits: AtomicU64,
115
116
  outcome: AtomicU8,
117
+ /// The statement/operation handle while it's submitted but not yet
118
+ /// terminal -- set by the submit/poll loops (`client/sea.rs`'s
119
+ /// `submit_and_poll_inner`, `pipeline/thrift_exec.rs`'s
120
+ /// `submit_and_await_thrift_statement`) as soon as Databricks hands one
121
+ /// back, cleared once the statement reaches a terminal state. Whatever
122
+ /// is still here when `pipeline/stats.rs`'s `CancelInFlightOnDrop`
123
+ /// drops (the submit future was abandoned -- a `total_timeout_s`, a
124
+ /// Python-side `task.cancel()`/`asyncio.wait_for` -- or a poll failed
125
+ /// mid-wait) gets a best-effort server-side cancel, so a query nobody
126
+ /// is waiting for anymore stops running on the warehouse.
127
+ in_flight: Mutex<Option<CancelHandle>>,
116
128
  }
117
129
 
118
130
  const OUTCOME_UNSET: u8 = 0;
@@ -170,6 +182,18 @@ impl QueryStatsAccumulator {
170
182
  pub fn warehouse_wait_s(&self) -> f64 {
171
183
  f64::from_bits(self.warehouse_wait_bits.load(Ordering::Relaxed))
172
184
  }
185
+
186
+ pub fn set_in_flight(&self, handle: CancelHandle) {
187
+ *self.in_flight.lock().unwrap() = Some(handle);
188
+ }
189
+
190
+ pub fn clear_in_flight(&self) {
191
+ *self.in_flight.lock().unwrap() = None;
192
+ }
193
+
194
+ pub fn take_in_flight(&self) -> Option<CancelHandle> {
195
+ self.in_flight.lock().unwrap().take()
196
+ }
173
197
  }
174
198
 
175
199
  /// One query's worth of timing/counters, handed to `EventSink::on_event`
@@ -21,7 +21,8 @@ use super::DbClient;
21
21
  use super::POLL_INTERVAL;
22
22
  use super::error::{ApiError, join_error};
23
23
  use super::model::{
24
- ChunkItem, ChunkMeta, ColumnDescription, InlineOrExternal, QueryStatsAccumulator, StatementSubmitResult,
24
+ CancelHandle, ChunkItem, ChunkMeta, ColumnDescription, InlineOrExternal, QueryStatsAccumulator,
25
+ StatementSubmitResult,
25
26
  };
26
27
 
27
28
  /// Typed response shapes -- replaces navigating a dynamic `serde_json::Value`
@@ -373,10 +374,14 @@ impl DbClient {
373
374
  }
374
375
  }
375
376
 
377
+ let mut checkin = SessionCheckin {
378
+ client: self,
379
+ catalog,
380
+ schema,
381
+ session_id,
382
+ };
376
383
  let result = self.submit_and_poll_inner(body, stats).await;
377
- if let Some(id) = session_id {
378
- self.checkin_session(catalog, schema, id, result.is_ok());
379
- }
384
+ checkin.finish(result.is_ok());
380
385
  result
381
386
  }
382
387
 
@@ -390,6 +395,9 @@ impl DbClient {
390
395
  .authed_json(reqwest::Method::POST, &url, Some(&body), Some(stats))
391
396
  .await?;
392
397
 
398
+ stats.set_in_flight(CancelHandle::Sea {
399
+ statement_id: data.statement_id.clone(),
400
+ });
393
401
  while !matches!(
394
402
  data.status.state.as_str(),
395
403
  "SUCCEEDED" | "FAILED" | "CANCELED" | "CLOSED"
@@ -400,6 +408,7 @@ impl DbClient {
400
408
  .authed_json(reqwest::Method::GET, &poll_url, None, Some(stats))
401
409
  .await?;
402
410
  }
411
+ stats.clear_in_flight();
403
412
 
404
413
  match data.status.state.as_str() {
405
414
  "FAILED" => {
@@ -599,6 +608,31 @@ impl DbClient {
599
608
  /// panic case would let that worker's unfetched work vanish with no error at
600
609
  /// all: the channel closing normally looks to the consumer exactly like a
601
610
  /// complete, successful result instead of a truncated one.
611
+ /// Returns `submit_and_poll`'s session to the pool even when its future is
612
+ /// dropped mid-poll (a timeout or cancellation) -- without it the pool's
613
+ /// reservation for that key leaks, and after `MAX_SESSIONS_PER_KEY` such
614
+ /// drops every later query for the key runs session-less.
615
+ struct SessionCheckin<'a> {
616
+ client: &'a DbClient,
617
+ catalog: Option<&'a str>,
618
+ schema: Option<&'a str>,
619
+ session_id: Option<String>,
620
+ }
621
+
622
+ impl SessionCheckin<'_> {
623
+ fn finish(&mut self, keep: bool) {
624
+ if let Some(id) = self.session_id.take() {
625
+ self.client.checkin_session(self.catalog, self.schema, id, keep);
626
+ }
627
+ }
628
+ }
629
+
630
+ impl Drop for SessionCheckin<'_> {
631
+ fn drop(&mut self) {
632
+ self.finish(false);
633
+ }
634
+ }
635
+
602
636
  async fn join_first_error(handles: Vec<tokio::task::JoinHandle<Result<(), ApiError>>>) -> Option<ApiError> {
603
637
  let mut first_err = None;
604
638
  for h in handles {
@@ -175,6 +175,10 @@ impl<T: Send + 'static> HeartbeatWait<T> {
175
175
  self
176
176
  }
177
177
 
178
+ pub fn deadline(&self) -> Option<Instant> {
179
+ self.deadline
180
+ }
181
+
178
182
  /// One step: `Ok(Some(Tick::Heartbeat))` if still waiting,
179
183
  /// `Ok(Some(Tick::Ready(value)))` exactly once when the wrapped future
180
184
  /// completes, `Ok(None)` if already exhausted (caller should raise
@@ -254,6 +258,14 @@ impl<T: Send + 'static> HeartbeatStream<T> {
254
258
  }
255
259
  }
256
260
 
261
+ /// Shares an existing deadline (e.g. `HeartbeatWait::deadline` of the
262
+ /// submit/poll wait that preceded this stream) instead of starting a
263
+ /// fresh `total_timeout_s` budget at construction.
264
+ pub fn with_deadline(mut self, deadline: Option<Instant>) -> Self {
265
+ self.deadline = deadline;
266
+ self
267
+ }
268
+
257
269
  /// See `HeartbeatWait::with_cancel`'s own doc comment -- identical
258
270
  /// contract.
259
271
  pub fn with_cancel(mut self, on_cancel: impl FnOnce(bool) + Send + 'static) -> Self {
@@ -334,7 +334,8 @@ fn build_column(
334
334
 
335
335
  let validity: Vec<bool> = rows_parsed.iter().map(Option::is_some).collect();
336
336
  let fields = Fields::from(child_fields);
337
- let struct_array = StructArray::new(fields.clone(), child_arrays, Some(NullBuffer::from(validity)));
337
+ let struct_array = StructArray::try_new(fields.clone(), child_arrays, Some(NullBuffer::from(validity)))
338
+ .map_err(|e| ApiError::permanent(format!("column `{name}`: invalid STRUCT array: {e}")))?;
338
339
  Ok((DataType::Struct(fields), Arc::new(struct_array)))
339
340
  }
340
341
  other => Err(ApiError {
@@ -702,6 +703,15 @@ mod tests {
702
703
  assert!(s.is_null(1), "row 1's whole struct must be null");
703
704
  }
704
705
 
706
+ #[test]
707
+ fn empty_struct_returns_a_conversion_error_instead_of_panicking() {
708
+ let columns = vec![struct_col("s", "STRUCT<>")];
709
+ for rows in [vec![], vec![vec![Some("{}".to_string())]], vec![vec![None]]] {
710
+ let err = json_array_to_record_batch(&rows, &columns).unwrap_err();
711
+ assert!(err.message.contains("invalid STRUCT array"));
712
+ }
713
+ }
714
+
705
715
  #[test]
706
716
  fn struct_with_a_nested_unsupported_composite_field_errors_instead_of_guessing() {
707
717
  let columns = vec![struct_col("s", "STRUCT<a: INT, nested: STRUCT<x: INT>>")];
@@ -62,11 +62,14 @@ fn write_ipc_stream(py: Python<'_>, stream: Bound<'_, PyAny>, buf: Bound<'_, PyA
62
62
  #[pyfunction]
63
63
  #[pyo3(signature = (data))]
64
64
  fn read_ipc_stream(data: Bound<'_, PyBytes>) -> PyResult<PyTable> {
65
+ let py = data.py();
65
66
  // The immutable Python bytes own the memory for as long as any decoded
66
67
  // array needs it, including arrays exported through the C Data Interface.
67
68
  // PyBackedBytes provides that ownership without copying or custom unsafe code.
68
69
  let blob = bytes::Bytes::from_owner(pyo3::pybacked::PyBackedBytes::from(data));
69
- let (batches, schema) = pipeline::decode_ipc_stream(&blob).map_err(|e| PyRuntimeError::new_err(e.message))?;
70
+ let (batches, schema) = py
71
+ .detach(|| pipeline::decode_ipc_stream(&blob))
72
+ .map_err(|e| PyRuntimeError::new_err(e.message))?;
70
73
  PyTable::try_new(batches, schema).map_err(|e| PyRuntimeError::new_err(e.to_string()))
71
74
  }
72
75
 
@@ -908,13 +911,10 @@ impl PyFetchallArrowStreamedIter {
908
911
  }
909
912
  }
910
913
 
911
- /// Not-yet-started vs. running state for `PyNdjsonStreamIter`. The
912
- /// submit/poll/spawn-workers step (`Pending` -> `Running`) happens on the
913
- /// iterator's first `__anext__` call, un-heartbeated -- matching
914
- /// `stream_query_json`'s pre-cutover behavior (its submit/poll wait was never
915
- /// heartbeat-wrapped, only its chunk loop was). The `total_timeout_s` budget
916
- /// starts counting from `Running`, not from construction, for the same
917
- /// reason.
914
+ /// State for `PyNdjsonStreamIter`. The iterator's first `__anext__` starts
915
+ /// the submit/poll/spawn-workers step (`Pending` -> `Submitting`), which is
916
+ /// heartbeated and timed like the chunk pulls in `Running`; one
917
+ /// `total_timeout_s` budget, counted from that first call, covers both.
918
918
  enum PyNdjsonStreamState {
919
919
  Pending {
920
920
  client: Arc<DbClient>,
@@ -925,6 +925,15 @@ enum PyNdjsonStreamState {
925
925
  total_timeout_s: Option<f64>,
926
926
  non_finite_as_string: bool,
927
927
  },
928
+ /// Submit/poll in flight under the same `total_timeout_s` budget (and
929
+ /// heartbeats) as the chunk pulls that follow -- a timeout here drops
930
+ /// the submit future, which cancels the statement server-side (see
931
+ /// `pipeline/stats.rs`'s `CancelInFlightOnDrop`).
932
+ Submitting {
933
+ client: Arc<DbClient>,
934
+ wait: HeartbeatWait<NdjsonStream>,
935
+ total_timeout_s: Option<f64>,
936
+ },
928
937
  Running {
929
938
  stream: Arc<AsyncMutex<NdjsonStream>>,
930
939
  heartbeat: HeartbeatStream<Vec<String>>,
@@ -967,22 +976,54 @@ impl PyNdjsonStreamIter {
967
976
  else {
968
977
  unreachable!()
969
978
  };
970
- let client_for_cancel = client.clone();
971
- let stream = pipeline::execute_ndjson_stream(
979
+ let submit_client = client.clone();
980
+ let wait = HeartbeatWait::new(
981
+ async move {
982
+ pipeline::execute_ndjson_stream(
983
+ submit_client,
984
+ &statement,
985
+ catalog.as_deref(),
986
+ schema.as_deref(),
987
+ parameters,
988
+ non_finite_as_string,
989
+ )
990
+ .await
991
+ },
992
+ total_timeout_s,
993
+ );
994
+ *guard = PyNdjsonStreamState::Submitting {
972
995
  client,
973
- &statement,
974
- catalog.as_deref(),
975
- schema.as_deref(),
976
- parameters,
977
- non_finite_as_string,
978
- )
979
- .await
980
- .map_err(api_error_to_pyerr)?;
981
- let heartbeat = HeartbeatStream::new(total_timeout_s).with_cancel(pipeline::cancel_hook(
982
- client_for_cancel,
983
- stream.cancel_handle.clone(),
984
- stream.stats.clone(),
985
- ));
996
+ wait,
997
+ total_timeout_s,
998
+ };
999
+ }
1000
+ PyNdjsonStreamState::Submitting { wait, .. } => {
1001
+ let stream = match wait.tick().await {
1002
+ Ok(Some(Tick::Heartbeat)) => {
1003
+ return Python::attach(|py| heartbeat_singleton(py).map(|h| h.into_any()));
1004
+ }
1005
+ Ok(Some(Tick::Ready(stream))) => stream,
1006
+ Ok(None) => unreachable!("Submitting is replaced as soon as its wait yields Ready"),
1007
+ Err(e) => {
1008
+ *guard = PyNdjsonStreamState::Done;
1009
+ return Err(api_error_to_pyerr(e));
1010
+ }
1011
+ };
1012
+ let PyNdjsonStreamState::Submitting {
1013
+ client,
1014
+ wait,
1015
+ total_timeout_s,
1016
+ } = std::mem::replace(&mut *guard, PyNdjsonStreamState::Done)
1017
+ else {
1018
+ unreachable!()
1019
+ };
1020
+ let heartbeat = HeartbeatStream::new(total_timeout_s)
1021
+ .with_deadline(wait.deadline())
1022
+ .with_cancel(pipeline::cancel_hook(
1023
+ client,
1024
+ stream.cancel_handle.clone(),
1025
+ stream.stats.clone(),
1026
+ ));
986
1027
  *guard = PyNdjsonStreamState::Running {
987
1028
  stream: Arc::new(AsyncMutex::new(stream)),
988
1029
  heartbeat,