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