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.
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/PKG-INFO +14 -4
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/README.md +13 -3
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/pyproject.toml +1 -1
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.lock +2 -1
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.toml +3 -1
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/README.md +2 -2
- arrowbricks-3.2.0/rust/arrowbricks_core/proptest-regressions/json_convert.txt +7 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/download.rs +66 -10
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/model.rs +25 -1
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/sea.rs +38 -4
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/heartbeat.rs +12 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/json_convert.rs +11 -1
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/lib.rs +64 -23
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/ndjson.rs +159 -16
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/sea.rs +8 -1
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/stats.rs +24 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/thrift_exec.rs +51 -3
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +94 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_thrift.rs +177 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +37 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/_streaming.py +36 -14
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/cursor.py +2 -17
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/LICENSE +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/.gitignore +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/rustfmt.toml +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/error.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/thrift_rpc.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/volume.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/reorder.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/test_support.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/thrift.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/common/mod.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/conftest.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_thrift.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/thrift_mock.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/__init__.py +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/_core.pyi +0 -0
- {arrowbricks-3.1.3 → arrowbricks-3.2.0}/src/arrowbricks/client.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 --
|
|
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
|
|
40
|
-
///
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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,
|
|
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
|
-
|
|
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::
|
|
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) =
|
|
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
|
-
///
|
|
912
|
-
/// submit/poll/spawn-workers step (`Pending` -> `
|
|
913
|
-
///
|
|
914
|
-
/// `
|
|
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
|
|
971
|
-
let
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
)
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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,
|