arrowbricks 3.1.4__tar.gz → 4.0.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.4 → arrowbricks-4.0.0}/PKG-INFO +12 -3
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/README.md +11 -2
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/pyproject.toml +3 -7
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/Cargo.lock +3 -136
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/Cargo.toml +9 -19
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/README.md +5 -6
- arrowbricks-4.0.0/rust/arrowbricks_core/src/arrow_ffi.rs +106 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client/model.rs +25 -1
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client/sea.rs +40 -3
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client.rs +23 -9
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/heartbeat.rs +12 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/lib.rs +68 -34
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline/ndjson.rs +23 -5
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline/sea.rs +9 -2
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline/stats.rs +24 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline/thrift_exec.rs +47 -3
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +140 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests/wiremock_thrift.rs +242 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +38 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_thrift.py +5 -4
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/src/arrowbricks/_core.pyi +16 -4
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/src/arrowbricks/_streaming.py +36 -14
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/src/arrowbricks/cursor.py +5 -17
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/LICENSE +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/.gitignore +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/proptest-regressions/json_convert.txt +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/rustfmt.toml +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client/download.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client/error.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client/thrift_rpc.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/client/volume.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/json_convert.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline/reorder.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline/test_support.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/pipeline.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/src/thrift.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests/common/mod.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/conftest.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/rust/arrowbricks_core/tests_py/thrift_mock.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/src/arrowbricks/__init__.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/src/arrowbricks/client.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-4.0.0}/src/arrowbricks/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arrowbricks
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.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
|
|
|
@@ -212,6 +212,15 @@ except ArrowbricksError:
|
|
|
212
212
|
|
|
213
213
|
## Arrow vs. row-tuple fetches
|
|
214
214
|
|
|
215
|
+
From 4.0, Arrow fetches and `ReplayableArrowChunk.to_table()` return
|
|
216
|
+
`arrowbricks._core.Table`. It exposes `num_rows`, `num_columns`,
|
|
217
|
+
`column_names`, `len(table)` and the Arrow capsule interfaces. For methods
|
|
218
|
+
such as `.column()`, `.schema` or `.to_batches()`, first convert with
|
|
219
|
+
`arro3.core.Table.from_arrow(table)`, `pyarrow.table(table)` or
|
|
220
|
+
`polars.from_arrow(table)`. DuckDB can consume it directly.
|
|
221
|
+
`write_ipc_stream` requires `__arrow_c_stream__`; wrap an array-only producer
|
|
222
|
+
in a table before passing it in.
|
|
223
|
+
|
|
215
224
|
Everything above works with zero dependencies installed *except* row-tuple fetches. `fetchall_arrow`/`fetchmany_arrow` return an Arrow table straight from the Rust core -- the faster path if your code can consume Arrow directly (DuckDB, pyarrow, polars, a Parquet writer, ...):
|
|
216
225
|
|
|
217
226
|
```python
|
|
@@ -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
|
|
|
@@ -200,6 +200,15 @@ except ArrowbricksError:
|
|
|
200
200
|
|
|
201
201
|
## Arrow vs. row-tuple fetches
|
|
202
202
|
|
|
203
|
+
From 4.0, Arrow fetches and `ReplayableArrowChunk.to_table()` return
|
|
204
|
+
`arrowbricks._core.Table`. It exposes `num_rows`, `num_columns`,
|
|
205
|
+
`column_names`, `len(table)` and the Arrow capsule interfaces. For methods
|
|
206
|
+
such as `.column()`, `.schema` or `.to_batches()`, first convert with
|
|
207
|
+
`arro3.core.Table.from_arrow(table)`, `pyarrow.table(table)` or
|
|
208
|
+
`polars.from_arrow(table)`. DuckDB can consume it directly.
|
|
209
|
+
`write_ipc_stream` requires `__arrow_c_stream__`; wrap an array-only producer
|
|
210
|
+
in a table before passing it in.
|
|
211
|
+
|
|
203
212
|
Everything above works with zero dependencies installed *except* row-tuple fetches. `fetchall_arrow`/`fetchmany_arrow` return an Arrow table straight from the Rust core -- the faster path if your code can consume Arrow directly (DuckDB, pyarrow, polars, a Parquet writer, ...):
|
|
204
213
|
|
|
205
214
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "arrowbricks"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.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"
|
|
@@ -15,12 +15,8 @@ requires-python = ">=3.11"
|
|
|
15
15
|
dependencies = []
|
|
16
16
|
|
|
17
17
|
[project.optional-dependencies]
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# interop (not a value pyo3-arrow can materialize itself), so
|
|
21
|
-
# fetchone/fetchmany/fetchall need this installed. `pip install
|
|
22
|
-
# arrowbricks[arro3]` -- or handle rows as Arrow via fetchall_arrow/
|
|
23
|
-
# fetchmany_arrow instead, which don't need it at all.
|
|
18
|
+
# Row materialization converts our capsule table through arro3-core.
|
|
19
|
+
# Arrow fetches and streaming do not need this optional dependency.
|
|
24
20
|
arro3 = ["arro3-core>=0.8"]
|
|
25
21
|
|
|
26
22
|
[project.urls]
|
|
@@ -81,7 +81,6 @@ dependencies = [
|
|
|
81
81
|
"atoi",
|
|
82
82
|
"base64 0.23.1",
|
|
83
83
|
"chrono",
|
|
84
|
-
"comfy-table",
|
|
85
84
|
"half",
|
|
86
85
|
"lexical-core",
|
|
87
86
|
"num-traits",
|
|
@@ -178,7 +177,7 @@ dependencies = [
|
|
|
178
177
|
|
|
179
178
|
[[package]]
|
|
180
179
|
name = "arrowbricks_core"
|
|
181
|
-
version = "
|
|
180
|
+
version = "4.0.0"
|
|
182
181
|
dependencies = [
|
|
183
182
|
"arrow-array",
|
|
184
183
|
"arrow-buffer",
|
|
@@ -193,7 +192,6 @@ dependencies = [
|
|
|
193
192
|
"memchr",
|
|
194
193
|
"proptest",
|
|
195
194
|
"pyo3",
|
|
196
|
-
"pyo3-arrow",
|
|
197
195
|
"pyo3-async-runtimes",
|
|
198
196
|
"pythonize",
|
|
199
197
|
"reqwest",
|
|
@@ -303,9 +301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
|
303
301
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
|
304
302
|
dependencies = [
|
|
305
303
|
"iana-time-zone",
|
|
306
|
-
"js-sys",
|
|
307
304
|
"num-traits",
|
|
308
|
-
"wasm-bindgen",
|
|
309
305
|
"windows-link",
|
|
310
306
|
]
|
|
311
307
|
|
|
@@ -329,16 +325,6 @@ dependencies = [
|
|
|
329
325
|
"memchr",
|
|
330
326
|
]
|
|
331
327
|
|
|
332
|
-
[[package]]
|
|
333
|
-
name = "comfy-table"
|
|
334
|
-
version = "7.2.2"
|
|
335
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
336
|
-
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
|
337
|
-
dependencies = [
|
|
338
|
-
"unicode-segmentation",
|
|
339
|
-
"unicode-width",
|
|
340
|
-
]
|
|
341
|
-
|
|
342
328
|
[[package]]
|
|
343
329
|
name = "const-random"
|
|
344
330
|
version = "0.1.18"
|
|
@@ -889,7 +875,7 @@ dependencies = [
|
|
|
889
875
|
"jni-sys",
|
|
890
876
|
"log",
|
|
891
877
|
"simd_cesu8",
|
|
892
|
-
"thiserror
|
|
878
|
+
"thiserror",
|
|
893
879
|
"walkdir",
|
|
894
880
|
"windows-link",
|
|
895
881
|
]
|
|
@@ -1039,16 +1025,6 @@ dependencies = [
|
|
|
1039
1025
|
"twox-hash",
|
|
1040
1026
|
]
|
|
1041
1027
|
|
|
1042
|
-
[[package]]
|
|
1043
|
-
name = "matrixmultiply"
|
|
1044
|
-
version = "0.3.11"
|
|
1045
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1046
|
-
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
|
1047
|
-
dependencies = [
|
|
1048
|
-
"autocfg",
|
|
1049
|
-
"rawpointer",
|
|
1050
|
-
]
|
|
1051
|
-
|
|
1052
1028
|
[[package]]
|
|
1053
1029
|
name = "memchr"
|
|
1054
1030
|
version = "2.8.3"
|
|
@@ -1066,21 +1042,6 @@ dependencies = [
|
|
|
1066
1042
|
"windows-sys 0.61.2",
|
|
1067
1043
|
]
|
|
1068
1044
|
|
|
1069
|
-
[[package]]
|
|
1070
|
-
name = "ndarray"
|
|
1071
|
-
version = "0.17.2"
|
|
1072
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1073
|
-
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
|
1074
|
-
dependencies = [
|
|
1075
|
-
"matrixmultiply",
|
|
1076
|
-
"num-complex",
|
|
1077
|
-
"num-integer",
|
|
1078
|
-
"num-traits",
|
|
1079
|
-
"portable-atomic",
|
|
1080
|
-
"portable-atomic-util",
|
|
1081
|
-
"rawpointer",
|
|
1082
|
-
]
|
|
1083
|
-
|
|
1084
1045
|
[[package]]
|
|
1085
1046
|
name = "num-bigint"
|
|
1086
1047
|
version = "0.5.1"
|
|
@@ -1129,23 +1090,6 @@ dependencies = [
|
|
|
1129
1090
|
"libc",
|
|
1130
1091
|
]
|
|
1131
1092
|
|
|
1132
|
-
[[package]]
|
|
1133
|
-
name = "numpy"
|
|
1134
|
-
version = "0.29.0"
|
|
1135
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1136
|
-
checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4"
|
|
1137
|
-
dependencies = [
|
|
1138
|
-
"half",
|
|
1139
|
-
"libc",
|
|
1140
|
-
"ndarray",
|
|
1141
|
-
"num-complex",
|
|
1142
|
-
"num-integer",
|
|
1143
|
-
"num-traits",
|
|
1144
|
-
"pyo3",
|
|
1145
|
-
"pyo3-build-config",
|
|
1146
|
-
"rustc-hash",
|
|
1147
|
-
]
|
|
1148
|
-
|
|
1149
1093
|
[[package]]
|
|
1150
1094
|
name = "once_cell"
|
|
1151
1095
|
version = "1.21.4"
|
|
@@ -1194,15 +1138,6 @@ version = "1.14.0"
|
|
|
1194
1138
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1195
1139
|
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
|
1196
1140
|
|
|
1197
|
-
[[package]]
|
|
1198
|
-
name = "portable-atomic-util"
|
|
1199
|
-
version = "0.2.7"
|
|
1200
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1201
|
-
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
|
1202
|
-
dependencies = [
|
|
1203
|
-
"portable-atomic",
|
|
1204
|
-
]
|
|
1205
|
-
|
|
1206
1141
|
[[package]]
|
|
1207
1142
|
name = "potential_utf"
|
|
1208
1143
|
version = "0.1.5"
|
|
@@ -1255,9 +1190,6 @@ version = "0.29.2"
|
|
|
1255
1190
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1256
1191
|
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
|
|
1257
1192
|
dependencies = [
|
|
1258
|
-
"chrono",
|
|
1259
|
-
"chrono-tz",
|
|
1260
|
-
"indexmap",
|
|
1261
1193
|
"libc",
|
|
1262
1194
|
"once_cell",
|
|
1263
1195
|
"portable-atomic",
|
|
@@ -1266,27 +1198,6 @@ dependencies = [
|
|
|
1266
1198
|
"pyo3-macros",
|
|
1267
1199
|
]
|
|
1268
1200
|
|
|
1269
|
-
[[package]]
|
|
1270
|
-
name = "pyo3-arrow"
|
|
1271
|
-
version = "0.19.0"
|
|
1272
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1273
|
-
checksum = "3d5ddf226a2dbf7607570d0657c2bf6fbe299208368b2914f3dd7e7ba0b57688"
|
|
1274
|
-
dependencies = [
|
|
1275
|
-
"arrow-array",
|
|
1276
|
-
"arrow-buffer",
|
|
1277
|
-
"arrow-cast",
|
|
1278
|
-
"arrow-data",
|
|
1279
|
-
"arrow-schema",
|
|
1280
|
-
"arrow-select",
|
|
1281
|
-
"chrono",
|
|
1282
|
-
"chrono-tz",
|
|
1283
|
-
"half",
|
|
1284
|
-
"indexmap",
|
|
1285
|
-
"numpy",
|
|
1286
|
-
"pyo3",
|
|
1287
|
-
"thiserror 1.0.69",
|
|
1288
|
-
]
|
|
1289
|
-
|
|
1290
1201
|
[[package]]
|
|
1291
1202
|
name = "pyo3-async-runtimes"
|
|
1292
1203
|
version = "0.29.0"
|
|
@@ -1413,12 +1324,6 @@ dependencies = [
|
|
|
1413
1324
|
"rand_core",
|
|
1414
1325
|
]
|
|
1415
1326
|
|
|
1416
|
-
[[package]]
|
|
1417
|
-
name = "rawpointer"
|
|
1418
|
-
version = "0.2.1"
|
|
1419
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1420
|
-
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
|
1421
|
-
|
|
1422
1327
|
[[package]]
|
|
1423
1328
|
name = "regex"
|
|
1424
1329
|
version = "1.13.1"
|
|
@@ -1499,12 +1404,6 @@ dependencies = [
|
|
|
1499
1404
|
"windows-sys 0.52.0",
|
|
1500
1405
|
]
|
|
1501
1406
|
|
|
1502
|
-
[[package]]
|
|
1503
|
-
name = "rustc-hash"
|
|
1504
|
-
version = "2.1.3"
|
|
1505
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1506
|
-
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
1507
|
-
|
|
1508
1407
|
[[package]]
|
|
1509
1408
|
name = "rustc_version"
|
|
1510
1409
|
version = "0.4.1"
|
|
@@ -1850,33 +1749,13 @@ dependencies = [
|
|
|
1850
1749
|
"windows-sys 0.61.2",
|
|
1851
1750
|
]
|
|
1852
1751
|
|
|
1853
|
-
[[package]]
|
|
1854
|
-
name = "thiserror"
|
|
1855
|
-
version = "1.0.69"
|
|
1856
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1857
|
-
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
1858
|
-
dependencies = [
|
|
1859
|
-
"thiserror-impl 1.0.69",
|
|
1860
|
-
]
|
|
1861
|
-
|
|
1862
1752
|
[[package]]
|
|
1863
1753
|
name = "thiserror"
|
|
1864
1754
|
version = "2.0.20"
|
|
1865
1755
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1866
1756
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
|
1867
1757
|
dependencies = [
|
|
1868
|
-
"thiserror-impl
|
|
1869
|
-
]
|
|
1870
|
-
|
|
1871
|
-
[[package]]
|
|
1872
|
-
name = "thiserror-impl"
|
|
1873
|
-
version = "1.0.69"
|
|
1874
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1875
|
-
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
1876
|
-
dependencies = [
|
|
1877
|
-
"proc-macro2",
|
|
1878
|
-
"quote",
|
|
1879
|
-
"syn 2.0.119",
|
|
1758
|
+
"thiserror-impl",
|
|
1880
1759
|
]
|
|
1881
1760
|
|
|
1882
1761
|
[[package]]
|
|
@@ -2047,18 +1926,6 @@ version = "1.0.24"
|
|
|
2047
1926
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2048
1927
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
2049
1928
|
|
|
2050
|
-
[[package]]
|
|
2051
|
-
name = "unicode-segmentation"
|
|
2052
|
-
version = "1.13.3"
|
|
2053
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2054
|
-
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
|
2055
|
-
|
|
2056
|
-
[[package]]
|
|
2057
|
-
name = "unicode-width"
|
|
2058
|
-
version = "0.2.2"
|
|
2059
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2060
|
-
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
|
2061
|
-
|
|
2062
1929
|
[[package]]
|
|
2063
1930
|
name = "untrusted"
|
|
2064
1931
|
version = "0.9.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "arrowbricks_core"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
|
|
@@ -11,7 +11,10 @@ crate-type = ["cdylib", "rlib"]
|
|
|
11
11
|
[dependencies]
|
|
12
12
|
# Depend on the components we use. The umbrella `arrow` crate also builds
|
|
13
13
|
# arithmetic, string, and row kernels even with default-features disabled.
|
|
14
|
-
|
|
14
|
+
# `ffi` backs src/arrow_ffi.rs, this crate's own Arrow PyCapsule bridge.
|
|
15
|
+
# `chrono-tz` lets arrow-json format Databricks' named-zone timestamps
|
|
16
|
+
# ("Etc/UTC"); pyo3-arrow used to enable it implicitly.
|
|
17
|
+
arrow-array = { version = "59.1.0", features = ["ffi", "chrono-tz"] }
|
|
15
18
|
arrow-buffer = "59.1.0"
|
|
16
19
|
arrow-ipc = "59.1.0"
|
|
17
20
|
arrow-schema = "59.1.0"
|
|
@@ -20,15 +23,14 @@ bytes = "1.12.1"
|
|
|
20
23
|
# Already in the dependency graph; vectorized newline search in NDJSON output.
|
|
21
24
|
memchr = "2"
|
|
22
25
|
# Already an unconditional transitive dependency of arrow-cast (pulled in by
|
|
23
|
-
#
|
|
26
|
+
# arrow-json) at this exact version -- declared directly here too so
|
|
24
27
|
# json_convert.rs can decode BINARY columns without hand-rolling base64. No
|
|
25
28
|
# new code ships that wasn't already linked in (feature unification, same
|
|
26
29
|
# reasoning as chrono/rustls below).
|
|
27
30
|
base64 = "0.23"
|
|
28
|
-
# Already an unconditional transitive dependency of
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# arithmetic. No new code ships that wasn't already linked in.
|
|
31
|
+
# Already an unconditional transitive dependency of arrow-array -- declared
|
|
32
|
+
# directly here too so json_convert.rs can parse DATE/TIMESTAMP/TIMESTAMP_NTZ
|
|
33
|
+
# strings without hand-rolling calendar arithmetic. No new code ships that wasn't already linked in.
|
|
32
34
|
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
|
33
35
|
# Decompresses cloud-fetch chunk bytes when the server honors our
|
|
34
36
|
# `result_compression: LZ4_FRAME` request (see client.rs's execute_statement)
|
|
@@ -36,12 +38,6 @@ chrono = { version = "0.4", default-features = false, features = ["std"] }
|
|
|
36
38
|
# Pure Rust, no C toolchain dependency -- unlike reqwest's TLS stack below.
|
|
37
39
|
lz4_flex = { version = "0.14", default-features = false, features = ["frame", "std"] }
|
|
38
40
|
pyo3 = { version = "0.29.1", features = ["abi3-py311"] }
|
|
39
|
-
# pyo3-arrow's only optional feature is buffer_protocol (on by default),
|
|
40
|
-
# which we don't use -- numpy/chrono/chrono-tz/arrow-cast(prettyprint) below
|
|
41
|
-
# it are unconditional dependencies of the crate itself, not features, so
|
|
42
|
-
# they ship regardless; not worth dropping pyo3-arrow over that (reimplementing
|
|
43
|
-
# the Arrow C Data Interface ourselves is a much bigger risk than the MBs saved).
|
|
44
|
-
pyo3-arrow = { version = "0.19.0", default-features = false }
|
|
45
41
|
pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] }
|
|
46
42
|
pythonize = "0.29.0"
|
|
47
43
|
# reqwest's own `rustls` feature is hardcoded to `__rustls-aws-lc-rs`, no way
|
|
@@ -135,9 +131,3 @@ opt-level = "s"
|
|
|
135
131
|
strip = true
|
|
136
132
|
lto = true
|
|
137
133
|
codegen-units = 1
|
|
138
|
-
|
|
139
|
-
[profile.release.package.pyo3-arrow]
|
|
140
|
-
# This bridge is dominated by interoperability helpers. Optimizing it for
|
|
141
|
-
# size saves ~32 KiB on macOS arm64; the IPC decoder keeps opt-level="s".
|
|
142
|
-
# Replay and live-query benchmarks are recorded in benchmarks/2026-09-06.md.
|
|
143
|
-
opt-level = "z"
|
|
@@ -63,12 +63,11 @@ asyncio.run(main())
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
`table` implements the [Arrow C Data Interface](https://arrow.apache.org/docs/format/CDataInterface.html)
|
|
66
|
-
(`__arrow_c_stream__`/`
|
|
67
|
-
protocol (DuckDB, pyarrow, arro3) can import it with zero copies, no
|
|
68
|
-
dependency needed.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
unlike the table object itself.
|
|
66
|
+
(`__arrow_c_stream__`/`__arrow_c_schema__`) -- any consumer that speaks that
|
|
67
|
+
protocol (DuckDB, pyarrow, polars, arro3) can import it with zero copies, no
|
|
68
|
+
extra dependency needed. The table itself only exposes `num_rows`,
|
|
69
|
+
`num_columns`, `column_names` and `len()`; to read values, import it into an
|
|
70
|
+
Arrow library first (e.g. `arro3.core.Table.from_arrow(table)`).
|
|
72
71
|
|
|
73
72
|
## API
|
|
74
73
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
//! Arrow PyCapsule interface, both directions, on arrow-rs's own
|
|
2
|
+
//! `ffi_stream` -- replaces `pyo3-arrow`, whose `Table.__arrow_c_stream__`
|
|
3
|
+
//! honors `requested_schema` via `arrow_cast::cast` and so kept the whole cast
|
|
4
|
+
//! kernel (~1.8 MiB, a third of `.text`) linked in for a feature no caller of
|
|
5
|
+
//! this crate used.
|
|
6
|
+
|
|
7
|
+
use std::ffi::CStr;
|
|
8
|
+
|
|
9
|
+
use arrow_array::ffi::FFI_ArrowSchema;
|
|
10
|
+
use arrow_array::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream};
|
|
11
|
+
use arrow_array::{RecordBatch, RecordBatchIterator};
|
|
12
|
+
use arrow_schema::SchemaRef;
|
|
13
|
+
use pyo3::exceptions::PyRuntimeError;
|
|
14
|
+
use pyo3::prelude::*;
|
|
15
|
+
use pyo3::types::{PyCapsule, PyCapsuleMethods};
|
|
16
|
+
|
|
17
|
+
const STREAM_CAPSULE: &CStr = c"arrow_array_stream";
|
|
18
|
+
const SCHEMA_CAPSULE: &CStr = c"arrow_schema";
|
|
19
|
+
|
|
20
|
+
/// An in-memory Arrow table. Consume it through `__arrow_c_stream__`
|
|
21
|
+
/// (arro3, pyarrow, polars, DuckDB, ...); the attributes are just enough to
|
|
22
|
+
/// inspect a result without importing another Arrow library.
|
|
23
|
+
#[pyclass(name = "Table", module = "arrowbricks._core", frozen)]
|
|
24
|
+
pub struct PyTable {
|
|
25
|
+
batches: Vec<RecordBatch>,
|
|
26
|
+
schema: SchemaRef,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl PyTable {
|
|
30
|
+
pub fn try_new(batches: Vec<RecordBatch>, schema: SchemaRef) -> PyResult<Self> {
|
|
31
|
+
// A stream advertises one schema for every batch. Exporting different
|
|
32
|
+
// buffer layouts under it can make consumers misinterpret memory.
|
|
33
|
+
// Like the previous bridge, ignore metadata and nullability differences.
|
|
34
|
+
if batches.iter().any(|batch| {
|
|
35
|
+
let fields = batch.schema_ref().fields();
|
|
36
|
+
fields.len() != schema.fields().len()
|
|
37
|
+
|| fields.iter().zip(schema.fields()).any(|(actual, expected)| {
|
|
38
|
+
actual.name() != expected.name() || !actual.data_type().equals_datatype(expected.data_type())
|
|
39
|
+
})
|
|
40
|
+
}) {
|
|
41
|
+
return Err(PyRuntimeError::new_err("All batches must have same schema"));
|
|
42
|
+
}
|
|
43
|
+
Ok(Self { batches, schema })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[pymethods]
|
|
48
|
+
impl PyTable {
|
|
49
|
+
#[getter]
|
|
50
|
+
fn num_rows(&self) -> usize {
|
|
51
|
+
self.batches.iter().map(RecordBatch::num_rows).sum()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[getter]
|
|
55
|
+
fn num_columns(&self) -> usize {
|
|
56
|
+
self.schema.fields().len()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[getter]
|
|
60
|
+
fn column_names(&self) -> Vec<String> {
|
|
61
|
+
self.schema.fields().iter().map(|f| f.name().clone()).collect()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn __len__(&self) -> usize {
|
|
65
|
+
self.num_rows()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn __repr__(&self) -> String {
|
|
69
|
+
format!(
|
|
70
|
+
"arrowbricks.Table(num_rows={}, num_columns={})",
|
|
71
|
+
self.num_rows(),
|
|
72
|
+
self.num_columns()
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// `requested_schema` is ignored, as the PyCapsule interface allows: the
|
|
77
|
+
/// consumer checks the schema it actually gets.
|
|
78
|
+
#[pyo3(signature = (requested_schema=None))]
|
|
79
|
+
fn __arrow_c_stream__<'py>(
|
|
80
|
+
&self,
|
|
81
|
+
py: Python<'py>,
|
|
82
|
+
requested_schema: Option<Bound<'py, PyAny>>,
|
|
83
|
+
) -> PyResult<Bound<'py, PyCapsule>> {
|
|
84
|
+
let _ = requested_schema;
|
|
85
|
+
let reader = RecordBatchIterator::new(self.batches.clone().into_iter().map(Ok), self.schema.clone());
|
|
86
|
+
PyCapsule::new_with_value(py, FFI_ArrowArrayStream::new(Box::new(reader)), STREAM_CAPSULE)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn __arrow_c_schema__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyCapsule>> {
|
|
90
|
+
let schema =
|
|
91
|
+
FFI_ArrowSchema::try_from(self.schema.as_ref()).map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
|
|
92
|
+
PyCapsule::new_with_value(py, schema, SCHEMA_CAPSULE)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Imports any object implementing `__arrow_c_stream__`.
|
|
97
|
+
pub fn import_stream(obj: &Bound<'_, PyAny>) -> PyResult<ArrowArrayStreamReader> {
|
|
98
|
+
let capsule = obj.call_method0("__arrow_c_stream__")?.cast_into::<PyCapsule>()?;
|
|
99
|
+
let ptr = capsule
|
|
100
|
+
.pointer_checked(Some(STREAM_CAPSULE))?
|
|
101
|
+
.cast::<FFI_ArrowArrayStream>();
|
|
102
|
+
// SAFETY: the capsule name check guarantees an `ArrowArrayStream`;
|
|
103
|
+
// `from_raw` moves it out and marks the capsule's copy released, so the
|
|
104
|
+
// capsule destructor does not release it a second time.
|
|
105
|
+
unsafe { ArrowArrayStreamReader::from_raw(ptr.as_ptr()) }.map_err(|e| PyRuntimeError::new_err(e.to_string()))
|
|
106
|
+
}
|
|
@@ -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,17 @@ 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
|
|
378
|
-
self.
|
|
384
|
+
if result.is_ok() {
|
|
385
|
+
self.note_warehouse_running();
|
|
379
386
|
}
|
|
387
|
+
checkin.finish(result.is_ok());
|
|
380
388
|
result
|
|
381
389
|
}
|
|
382
390
|
|
|
@@ -390,6 +398,9 @@ impl DbClient {
|
|
|
390
398
|
.authed_json(reqwest::Method::POST, &url, Some(&body), Some(stats))
|
|
391
399
|
.await?;
|
|
392
400
|
|
|
401
|
+
stats.set_in_flight(CancelHandle::Sea {
|
|
402
|
+
statement_id: data.statement_id.clone(),
|
|
403
|
+
});
|
|
393
404
|
while !matches!(
|
|
394
405
|
data.status.state.as_str(),
|
|
395
406
|
"SUCCEEDED" | "FAILED" | "CANCELED" | "CLOSED"
|
|
@@ -400,6 +411,7 @@ impl DbClient {
|
|
|
400
411
|
.authed_json(reqwest::Method::GET, &poll_url, None, Some(stats))
|
|
401
412
|
.await?;
|
|
402
413
|
}
|
|
414
|
+
stats.clear_in_flight();
|
|
403
415
|
|
|
404
416
|
match data.status.state.as_str() {
|
|
405
417
|
"FAILED" => {
|
|
@@ -599,6 +611,31 @@ impl DbClient {
|
|
|
599
611
|
/// panic case would let that worker's unfetched work vanish with no error at
|
|
600
612
|
/// all: the channel closing normally looks to the consumer exactly like a
|
|
601
613
|
/// complete, successful result instead of a truncated one.
|
|
614
|
+
/// Returns `submit_and_poll`'s session to the pool even when its future is
|
|
615
|
+
/// dropped mid-poll (a timeout or cancellation) -- without it the pool's
|
|
616
|
+
/// reservation for that key leaks, and after `MAX_SESSIONS_PER_KEY` such
|
|
617
|
+
/// drops every later query for the key runs session-less.
|
|
618
|
+
struct SessionCheckin<'a> {
|
|
619
|
+
client: &'a DbClient,
|
|
620
|
+
catalog: Option<&'a str>,
|
|
621
|
+
schema: Option<&'a str>,
|
|
622
|
+
session_id: Option<String>,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
impl SessionCheckin<'_> {
|
|
626
|
+
fn finish(&mut self, keep: bool) {
|
|
627
|
+
if let Some(id) = self.session_id.take() {
|
|
628
|
+
self.client.checkin_session(self.catalog, self.schema, id, keep);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
impl Drop for SessionCheckin<'_> {
|
|
634
|
+
fn drop(&mut self) {
|
|
635
|
+
self.finish(false);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
602
639
|
async fn join_first_error(handles: Vec<tokio::task::JoinHandle<Result<(), ApiError>>>) -> Option<ApiError> {
|
|
603
640
|
let mut first_err = None;
|
|
604
641
|
for h in handles {
|