arrowbricks 3.1.4__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.4 → arrowbricks-3.2.0}/PKG-INFO +3 -3
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/README.md +2 -2
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/pyproject.toml +1 -1
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.lock +1 -1
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.toml +1 -1
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/model.rs +25 -1
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/sea.rs +38 -4
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/heartbeat.rs +12 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/lib.rs +60 -22
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/ndjson.rs +4 -5
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/sea.rs +8 -1
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/stats.rs +24 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/thrift_exec.rs +44 -1
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +94 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_thrift.rs +122 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/_streaming.py +36 -14
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/cursor.py +2 -17
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/LICENSE +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/.gitignore +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/README.md +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/proptest-regressions/json_convert.txt +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/rustfmt.toml +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/download.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/error.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/thrift_rpc.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/volume.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/json_convert.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/reorder.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/test_support.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/thrift.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/common/mod.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/conftest.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_thrift.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/thrift_mock.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/__init__.py +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/_core.pyi +0 -0
- {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/client.py +0 -0
- {arrowbricks-3.1.4 → 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
|
|
|
@@ -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
|
|
|
@@ -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"
|
|
@@ -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 {
|
|
@@ -911,13 +911,10 @@ impl PyFetchallArrowStreamedIter {
|
|
|
911
911
|
}
|
|
912
912
|
}
|
|
913
913
|
|
|
914
|
-
///
|
|
915
|
-
/// submit/poll/spawn-workers step (`Pending` -> `
|
|
916
|
-
///
|
|
917
|
-
/// `
|
|
918
|
-
/// heartbeat-wrapped, only its chunk loop was). The `total_timeout_s` budget
|
|
919
|
-
/// starts counting from `Running`, not from construction, for the same
|
|
920
|
-
/// 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.
|
|
921
918
|
enum PyNdjsonStreamState {
|
|
922
919
|
Pending {
|
|
923
920
|
client: Arc<DbClient>,
|
|
@@ -928,6 +925,15 @@ enum PyNdjsonStreamState {
|
|
|
928
925
|
total_timeout_s: Option<f64>,
|
|
929
926
|
non_finite_as_string: bool,
|
|
930
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
|
+
},
|
|
931
937
|
Running {
|
|
932
938
|
stream: Arc<AsyncMutex<NdjsonStream>>,
|
|
933
939
|
heartbeat: HeartbeatStream<Vec<String>>,
|
|
@@ -970,22 +976,54 @@ impl PyNdjsonStreamIter {
|
|
|
970
976
|
else {
|
|
971
977
|
unreachable!()
|
|
972
978
|
};
|
|
973
|
-
let
|
|
974
|
-
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 {
|
|
975
995
|
client,
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
)
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
+
));
|
|
989
1027
|
*guard = PyNdjsonStreamState::Running {
|
|
990
1028
|
stream: Arc::new(AsyncMutex::new(stream)),
|
|
991
1029
|
heartbeat,
|
|
@@ -300,11 +300,10 @@ impl NdjsonStream {
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
/// Submit -> poll -> start background chunk fetching for the chunk-at-a-time
|
|
303
|
-
/// stream above.
|
|
304
|
-
///
|
|
305
|
-
///
|
|
306
|
-
///
|
|
307
|
-
/// rather than "fixing" it during the port.
|
|
303
|
+
/// stream above. `lib.rs`'s `PyNdjsonStreamIter` runs this under a
|
|
304
|
+
/// `HeartbeatWait` sharing the stream's `total_timeout_s` budget, so the
|
|
305
|
+
/// submit/poll wait gets heartbeats and a timeout too; abandoning it
|
|
306
|
+
/// cancels the statement server-side (`CancelInFlightOnDrop`).
|
|
308
307
|
///
|
|
309
308
|
/// **Branches on `client.protocol`, same as `PyDbClient::execute`'s own
|
|
310
309
|
/// dispatch (`lib.rs`) -- found missing entirely during a later review
|
|
@@ -22,7 +22,7 @@ use crate::client::{
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
use super::reorder::{ReorderBuffer, decode_chunk_item};
|
|
25
|
-
use super::stats::{PoisonOnDrop, StatsReporter, report_submit_error};
|
|
25
|
+
use super::stats::{CancelInFlightOnDrop, PoisonOnDrop, StatsReporter, report_submit_error};
|
|
26
26
|
|
|
27
27
|
pub struct ExecuteResult {
|
|
28
28
|
pub statement_id: String,
|
|
@@ -217,6 +217,7 @@ pub(crate) async fn submit_sea_and_report(
|
|
|
217
217
|
stats: &QueryStatsAccumulator,
|
|
218
218
|
) -> Result<(StatementSubmitResult, f64), ApiError> {
|
|
219
219
|
let submit_t0 = Instant::now();
|
|
220
|
+
let _cancel_guard = CancelInFlightOnDrop { client, stats };
|
|
220
221
|
match client
|
|
221
222
|
.execute_arrow_statement(statement, catalog, schema, parameters, stats)
|
|
222
223
|
.await
|
|
@@ -326,6 +327,10 @@ pub async fn execute_lazy_prefer_inline(
|
|
|
326
327
|
// submission rather than starting fresh -- see that fallback's own doc
|
|
327
328
|
// comment.
|
|
328
329
|
let submit_t0 = Instant::now();
|
|
330
|
+
let cancel_guard = CancelInFlightOnDrop {
|
|
331
|
+
client: &client,
|
|
332
|
+
stats: &stats,
|
|
333
|
+
};
|
|
329
334
|
let outcome = match client
|
|
330
335
|
.execute_arrow_statement_prefer_inline(statement, catalog, schema, parameters.clone(), &stats)
|
|
331
336
|
.await
|
|
@@ -336,6 +341,8 @@ pub async fn execute_lazy_prefer_inline(
|
|
|
336
341
|
return Err(e);
|
|
337
342
|
}
|
|
338
343
|
};
|
|
344
|
+
// The statement is terminal by now; released so `client` can move below.
|
|
345
|
+
drop(cancel_guard);
|
|
339
346
|
let submit_to_ready_s = submit_t0.elapsed().as_secs_f64();
|
|
340
347
|
|
|
341
348
|
let (statement_id, rows, columns) = match outcome {
|
|
@@ -45,6 +45,30 @@ pub fn cancel_hook(
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/// Held across a statement's submit/poll wait: if it drops while the
|
|
49
|
+
/// statement is still in flight (see `QueryStatsAccumulator::in_flight`),
|
|
50
|
+
/// fires the same fire-and-forget server-side cancel `cancel_hook` does.
|
|
51
|
+
/// `heartbeat.rs`'s hooks only exist once a `ResultStream`/`NdjsonStream`
|
|
52
|
+
/// has been built, i.e. after the statement is already terminal -- without
|
|
53
|
+
/// this, a timeout or cancellation during the wait itself (the phase a
|
|
54
|
+
/// long-running query actually spends its time in) released the caller but
|
|
55
|
+
/// left the statement running on the warehouse for nobody.
|
|
56
|
+
pub(crate) struct CancelInFlightOnDrop<'a> {
|
|
57
|
+
pub(crate) client: &'a Arc<DbClient>,
|
|
58
|
+
pub(crate) stats: &'a QueryStatsAccumulator,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl Drop for CancelInFlightOnDrop<'_> {
|
|
62
|
+
fn drop(&mut self) {
|
|
63
|
+
if let Some(handle) = self.stats.take_in_flight() {
|
|
64
|
+
let client = self.client.clone();
|
|
65
|
+
pyo3_async_runtimes::tokio::get_runtime().spawn(async move {
|
|
66
|
+
client.cancel_statement(&handle).await;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
/// Per-`ResultStream`/`NdjsonStream` bookkeeping needed to build and
|
|
49
73
|
/// dispatch exactly one `QueryStatsData` per query, at completion -- see
|
|
50
74
|
/// `client::QueryStatsAccumulator`/`EventSink` for the counters/dispatch
|
|
@@ -26,7 +26,7 @@ use crate::thrift;
|
|
|
26
26
|
|
|
27
27
|
use super::reorder::ReorderBuffer;
|
|
28
28
|
use super::sea::ResultStream;
|
|
29
|
-
use super::stats::{StatsReporter, report_submit_error};
|
|
29
|
+
use super::stats::{CancelInFlightOnDrop, StatsReporter, report_submit_error};
|
|
30
30
|
|
|
31
31
|
/// Encodes 16 raw bytes (a THandleIdentifier's guid) as lowercase hex --
|
|
32
32
|
/// used only to give a Thrift result stream a human-readable `statement_id`
|
|
@@ -55,6 +55,34 @@ struct ThriftStatementReady {
|
|
|
55
55
|
already_closed: bool,
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/// Releases and closes `submit_thrift_and_start_fetch`'s session if its
|
|
59
|
+
/// future is dropped during submit/poll (a timeout or cancellation), which
|
|
60
|
+
/// otherwise skipped both: the pool reservation leaked and the session
|
|
61
|
+
/// stayed open server-side until its TTL.
|
|
62
|
+
struct ThriftSessionOnDrop<'a> {
|
|
63
|
+
client: &'a Arc<DbClient>,
|
|
64
|
+
catalog: Option<&'a str>,
|
|
65
|
+
schema: Option<&'a str>,
|
|
66
|
+
session: Option<thrift::SessionHandle>,
|
|
67
|
+
from_pool: bool,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl Drop for ThriftSessionOnDrop<'_> {
|
|
71
|
+
fn drop(&mut self) {
|
|
72
|
+
let Some(session) = self.session.take() else {
|
|
73
|
+
return;
|
|
74
|
+
};
|
|
75
|
+
if self.from_pool {
|
|
76
|
+
self.client
|
|
77
|
+
.thrift_checkin_session(self.catalog, self.schema, session.clone(), false);
|
|
78
|
+
}
|
|
79
|
+
let client = self.client.clone();
|
|
80
|
+
pyo3_async_runtimes::tokio::get_runtime().spawn(async move {
|
|
81
|
+
client.thrift_close_session_raw(&session).await;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
async fn submit_and_await_thrift_statement(
|
|
59
87
|
client: &Arc<DbClient>,
|
|
60
88
|
session: &thrift::SessionHandle,
|
|
@@ -108,12 +136,18 @@ async fn submit_and_await_thrift_statement(
|
|
|
108
136
|
}
|
|
109
137
|
|
|
110
138
|
if !already_finished {
|
|
139
|
+
let _cancel_guard = CancelInFlightOnDrop { client, stats };
|
|
140
|
+
stats.set_in_flight(CancelHandle::Thrift {
|
|
141
|
+
operation: operation.clone(),
|
|
142
|
+
});
|
|
111
143
|
loop {
|
|
112
144
|
let status = client.thrift_get_operation_status_raw(&operation, stats).await?;
|
|
113
145
|
if let Some(e) = status.terminal_error() {
|
|
146
|
+
stats.clear_in_flight();
|
|
114
147
|
return Err(ApiError::statement_failed(format!("Thrift statement failed: {e}")));
|
|
115
148
|
}
|
|
116
149
|
if status.is_finished() {
|
|
150
|
+
stats.clear_in_flight();
|
|
117
151
|
break;
|
|
118
152
|
}
|
|
119
153
|
tokio::time::sleep(crate::client::THRIFT_POLL_INTERVAL).await;
|
|
@@ -306,7 +340,16 @@ pub(crate) async fn submit_thrift_and_start_fetch(
|
|
|
306
340
|
},
|
|
307
341
|
};
|
|
308
342
|
|
|
343
|
+
let mut session_guard = ThriftSessionOnDrop {
|
|
344
|
+
client: &client,
|
|
345
|
+
catalog,
|
|
346
|
+
schema,
|
|
347
|
+
session: Some(session.clone()),
|
|
348
|
+
from_pool,
|
|
349
|
+
};
|
|
309
350
|
let ready = submit_and_await_thrift_statement(&client, &session, statement, parameters.as_ref(), &stats).await;
|
|
351
|
+
session_guard.session = None;
|
|
352
|
+
drop(session_guard);
|
|
310
353
|
let submit_to_ready_s = submit_t0.elapsed().as_secs_f64();
|
|
311
354
|
|
|
312
355
|
// Exactly one of these two arms ever touches `session` -- a pooled
|
|
@@ -1615,3 +1615,97 @@ async fn sea_dropping_the_heartbeat_wait_mid_fetch_fires_cancel_statement() {
|
|
|
1615
1615
|
"a bare drop (not through tick()'s own timeout branch) must record outcome=cancelled, not timeout"
|
|
1616
1616
|
);
|
|
1617
1617
|
}
|
|
1618
|
+
|
|
1619
|
+
async fn mount_running_warehouse_and_statement(server: &MockServer, state: &str) {
|
|
1620
|
+
Mock::given(method("GET"))
|
|
1621
|
+
.and(path(format!("/api/2.0/sql/warehouses/{WAREHOUSE_ID}")))
|
|
1622
|
+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"state": "RUNNING"})))
|
|
1623
|
+
.mount(server)
|
|
1624
|
+
.await;
|
|
1625
|
+
Mock::given(method("POST"))
|
|
1626
|
+
.and(path("/api/2.0/sql/statements"))
|
|
1627
|
+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
1628
|
+
"statement_id": STATEMENT_ID,
|
|
1629
|
+
"status": {"state": state, "error": {"error_code": "BAD_SQL", "message": "nope"}},
|
|
1630
|
+
})))
|
|
1631
|
+
.mount(server)
|
|
1632
|
+
.await;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/// Abandoning the submit/poll wait itself (a `total_timeout_s` or a Python
|
|
1636
|
+
/// `task.cancel()` drops this future mid-poll) must cancel the statement
|
|
1637
|
+
/// server-side -- `heartbeat.rs`'s hooks don't exist yet at this point.
|
|
1638
|
+
#[tokio::test]
|
|
1639
|
+
async fn sea_abandoning_the_submit_poll_wait_fires_cancel_statement() {
|
|
1640
|
+
let server = MockServer::start().await;
|
|
1641
|
+
mount_running_warehouse_and_statement(&server, "PENDING").await;
|
|
1642
|
+
let cancel_calls = mount_cancel_statement_ok(&server).await;
|
|
1643
|
+
|
|
1644
|
+
let client = Arc::new(DbClient::new(&server.uri(), WAREHOUSE_ID, "fake-token").with_protocol(Protocol::Sea));
|
|
1645
|
+
let result = tokio::time::timeout(
|
|
1646
|
+
std::time::Duration::from_millis(300),
|
|
1647
|
+
execute_lazy(client, "SELECT * FROM t", None, None, None),
|
|
1648
|
+
)
|
|
1649
|
+
.await;
|
|
1650
|
+
assert!(
|
|
1651
|
+
result.is_err(),
|
|
1652
|
+
"the statement never leaves PENDING, so the wait must time out"
|
|
1653
|
+
);
|
|
1654
|
+
|
|
1655
|
+
wait_for_calls(&cancel_calls, 1).await;
|
|
1656
|
+
assert_eq!(cancel_calls.load(Ordering::SeqCst), 1);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
#[tokio::test]
|
|
1660
|
+
async fn sea_terminal_statement_does_not_fire_cancel_statement() {
|
|
1661
|
+
let server = MockServer::start().await;
|
|
1662
|
+
mount_running_warehouse_and_statement(&server, "FAILED").await;
|
|
1663
|
+
let cancel_calls = mount_cancel_statement_ok(&server).await;
|
|
1664
|
+
|
|
1665
|
+
let client = Arc::new(DbClient::new(&server.uri(), WAREHOUSE_ID, "fake-token").with_protocol(Protocol::Sea));
|
|
1666
|
+
let result = execute_lazy(client, "SELECT * FROM t", None, None, None).await;
|
|
1667
|
+
assert!(result.is_err());
|
|
1668
|
+
|
|
1669
|
+
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
1670
|
+
assert_eq!(
|
|
1671
|
+
cancel_calls.load(Ordering::SeqCst),
|
|
1672
|
+
0,
|
|
1673
|
+
"an already-terminal statement has nothing left to cancel"
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/// A submit future dropped mid-poll must still release its pooled session's
|
|
1678
|
+
/// reservation -- leaking one per abandonment would, after
|
|
1679
|
+
/// `MAX_SESSIONS_PER_KEY` of them, leave every later query session-less.
|
|
1680
|
+
#[tokio::test]
|
|
1681
|
+
async fn abandoning_the_submit_poll_wait_releases_the_session_reservation() {
|
|
1682
|
+
let server = MockServer::start().await;
|
|
1683
|
+
mount_running_warehouse_and_statement(&server, "PENDING").await;
|
|
1684
|
+
mount_cancel_statement_ok(&server).await;
|
|
1685
|
+
let session_calls = Arc::new(AtomicUsize::new(0));
|
|
1686
|
+
let session_calls_for_mock = session_calls.clone();
|
|
1687
|
+
Mock::given(method("POST"))
|
|
1688
|
+
.and(path("/api/2.0/sql/sessions"))
|
|
1689
|
+
.respond_with(move |_req: &wiremock::Request| {
|
|
1690
|
+
let n = session_calls_for_mock.fetch_add(1, Ordering::SeqCst);
|
|
1691
|
+
ResponseTemplate::new(200).set_body_json(json!({"session_id": format!("sess-{n}")}))
|
|
1692
|
+
})
|
|
1693
|
+
.mount(&server)
|
|
1694
|
+
.await;
|
|
1695
|
+
|
|
1696
|
+
let client = Arc::new(DbClient::new(&server.uri(), WAREHOUSE_ID, "fake-token").with_protocol(Protocol::Sea));
|
|
1697
|
+
for _ in 0..=MAX_SESSIONS_PER_KEY {
|
|
1698
|
+
let result = tokio::time::timeout(
|
|
1699
|
+
std::time::Duration::from_millis(100),
|
|
1700
|
+
execute_lazy(client.clone(), "SELECT * FROM t", Some("cat1"), None, None),
|
|
1701
|
+
)
|
|
1702
|
+
.await;
|
|
1703
|
+
assert!(result.is_err());
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
assert_eq!(
|
|
1707
|
+
session_calls.load(Ordering::SeqCst),
|
|
1708
|
+
MAX_SESSIONS_PER_KEY + 1,
|
|
1709
|
+
"every abandoned attempt must release its reservation, so each one can create a session"
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
@@ -1826,3 +1826,125 @@ async fn thrift_dropping_the_heartbeat_wait_mid_fetch_fires_cancel_operation() {
|
|
|
1826
1826
|
"a bare drop (not through tick()'s own timeout branch) must record outcome=cancelled, not timeout"
|
|
1827
1827
|
);
|
|
1828
1828
|
}
|
|
1829
|
+
|
|
1830
|
+
/// Thrift counterpart of `wiremock_pipeline.rs`'s
|
|
1831
|
+
/// `sea_abandoning_the_submit_poll_wait_fires_cancel_statement`.
|
|
1832
|
+
#[tokio::test]
|
|
1833
|
+
async fn thrift_abandoning_the_submit_poll_wait_fires_cancel_operation() {
|
|
1834
|
+
let server = MockServer::start().await;
|
|
1835
|
+
mount_open_session_always(&server, b"sess").await;
|
|
1836
|
+
Mock::given(method("POST"))
|
|
1837
|
+
.and(path(thrift_path()))
|
|
1838
|
+
.and(IsThriftRpc("ExecuteStatement"))
|
|
1839
|
+
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
1840
|
+
build_execute_statement_resp(b"op-poll", b"opsecret-poll", None),
|
|
1841
|
+
"application/x-thrift",
|
|
1842
|
+
))
|
|
1843
|
+
.mount(&server)
|
|
1844
|
+
.await;
|
|
1845
|
+
Mock::given(method("POST"))
|
|
1846
|
+
.and(path(thrift_path()))
|
|
1847
|
+
.and(IsThriftRpc("GetOperationStatus"))
|
|
1848
|
+
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
1849
|
+
build_get_operation_status_resp(operation_state::RUNNING, None),
|
|
1850
|
+
"application/x-thrift",
|
|
1851
|
+
))
|
|
1852
|
+
.mount(&server)
|
|
1853
|
+
.await;
|
|
1854
|
+
let cancel_calls = mount_cancel_operation_ok(&server).await;
|
|
1855
|
+
|
|
1856
|
+
let result = tokio::time::timeout(
|
|
1857
|
+
std::time::Duration::from_millis(500),
|
|
1858
|
+
execute_lazy_thrift(thrift_client(&server), "SELECT * FROM t", None, None, None),
|
|
1859
|
+
)
|
|
1860
|
+
.await;
|
|
1861
|
+
assert!(
|
|
1862
|
+
result.is_err(),
|
|
1863
|
+
"the operation never finishes, so the wait must time out"
|
|
1864
|
+
);
|
|
1865
|
+
|
|
1866
|
+
wait_for_calls(&cancel_calls, 1).await;
|
|
1867
|
+
assert_eq!(cancel_calls.load(Ordering::SeqCst), 1);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
#[tokio::test]
|
|
1871
|
+
async fn thrift_polled_terminal_error_does_not_fire_cancel_operation() {
|
|
1872
|
+
let server = MockServer::start().await;
|
|
1873
|
+
mount_open_session_always(&server, b"sess").await;
|
|
1874
|
+
Mock::given(method("POST"))
|
|
1875
|
+
.and(path(thrift_path()))
|
|
1876
|
+
.and(IsThriftRpc("ExecuteStatement"))
|
|
1877
|
+
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
1878
|
+
build_execute_statement_resp(b"op-err", b"opsecret-err", None),
|
|
1879
|
+
"application/x-thrift",
|
|
1880
|
+
))
|
|
1881
|
+
.mount(&server)
|
|
1882
|
+
.await;
|
|
1883
|
+
Mock::given(method("POST"))
|
|
1884
|
+
.and(path(thrift_path()))
|
|
1885
|
+
.and(IsThriftRpc("GetOperationStatus"))
|
|
1886
|
+
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
1887
|
+
build_get_operation_status_resp(operation_state::ERROR, Some("boom")),
|
|
1888
|
+
"application/x-thrift",
|
|
1889
|
+
))
|
|
1890
|
+
.mount(&server)
|
|
1891
|
+
.await;
|
|
1892
|
+
mount_close_operation_ok(&server).await;
|
|
1893
|
+
let cancel_calls = mount_cancel_operation_ok(&server).await;
|
|
1894
|
+
|
|
1895
|
+
let result = execute_lazy_thrift(thrift_client(&server), "SELECT * FROM t", None, None, None).await;
|
|
1896
|
+
assert!(result.is_err());
|
|
1897
|
+
|
|
1898
|
+
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
1899
|
+
assert_eq!(
|
|
1900
|
+
cancel_calls.load(Ordering::SeqCst),
|
|
1901
|
+
0,
|
|
1902
|
+
"an already-terminal operation has nothing left to cancel"
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
#[tokio::test]
|
|
1907
|
+
async fn thrift_abandoning_the_submit_poll_wait_closes_its_session() {
|
|
1908
|
+
let server = MockServer::start().await;
|
|
1909
|
+
mount_open_session_always(&server, b"sess").await;
|
|
1910
|
+
Mock::given(method("POST"))
|
|
1911
|
+
.and(path(thrift_path()))
|
|
1912
|
+
.and(IsThriftRpc("ExecuteStatement"))
|
|
1913
|
+
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
1914
|
+
build_execute_statement_resp(b"op-sess", b"opsecret-sess", None),
|
|
1915
|
+
"application/x-thrift",
|
|
1916
|
+
))
|
|
1917
|
+
.mount(&server)
|
|
1918
|
+
.await;
|
|
1919
|
+
Mock::given(method("POST"))
|
|
1920
|
+
.and(path(thrift_path()))
|
|
1921
|
+
.and(IsThriftRpc("GetOperationStatus"))
|
|
1922
|
+
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
1923
|
+
build_get_operation_status_resp(operation_state::RUNNING, None),
|
|
1924
|
+
"application/x-thrift",
|
|
1925
|
+
))
|
|
1926
|
+
.mount(&server)
|
|
1927
|
+
.await;
|
|
1928
|
+
mount_cancel_operation_ok(&server).await;
|
|
1929
|
+
let close_calls = Arc::new(AtomicUsize::new(0));
|
|
1930
|
+
let close_calls_for_mock = close_calls.clone();
|
|
1931
|
+
Mock::given(method("POST"))
|
|
1932
|
+
.and(path(thrift_path()))
|
|
1933
|
+
.and(IsThriftRpc("CloseSession"))
|
|
1934
|
+
.respond_with(move |_req: &Request| {
|
|
1935
|
+
close_calls_for_mock.fetch_add(1, Ordering::SeqCst);
|
|
1936
|
+
ResponseTemplate::new(200).set_body_raw(build_close_session_resp(), "application/x-thrift")
|
|
1937
|
+
})
|
|
1938
|
+
.mount(&server)
|
|
1939
|
+
.await;
|
|
1940
|
+
|
|
1941
|
+
let result = tokio::time::timeout(
|
|
1942
|
+
std::time::Duration::from_millis(500),
|
|
1943
|
+
execute_lazy_thrift(thrift_client(&server), "SELECT * FROM t", None, None, None),
|
|
1944
|
+
)
|
|
1945
|
+
.await;
|
|
1946
|
+
assert!(result.is_err());
|
|
1947
|
+
|
|
1948
|
+
wait_for_calls(&close_calls, 1).await;
|
|
1949
|
+
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
|
1950
|
+
}
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import contextlib
|
|
16
|
-
from collections.abc import AsyncIterator, Awaitable
|
|
16
|
+
from collections.abc import AsyncIterator, Awaitable, Iterator
|
|
17
17
|
from typing import Any, BinaryIO, TypeVar, cast
|
|
18
18
|
|
|
19
19
|
from . import _core
|
|
@@ -54,6 +54,22 @@ class QueryTimeout(_core.ArrowbricksError):
|
|
|
54
54
|
this change keeps working unchanged either way."""
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
@contextlib.contextmanager
|
|
58
|
+
def rust_timeout_as_query_timeout() -> Iterator[None]:
|
|
59
|
+
"""Translates the Rust-level heartbeat's `total_timeout_s` error -- a
|
|
60
|
+
plain `ArrowbricksError` with heartbeat.rs's literal `"Query exceeded
|
|
61
|
+
{secs}s timeout"` message -- into `QueryTimeout`, so callers see one
|
|
62
|
+
exception type whichever heartbeat implementation ran. Matched by a
|
|
63
|
+
prefix this crate controls end to end, so an unrelated error is never
|
|
64
|
+
misclassified."""
|
|
65
|
+
try:
|
|
66
|
+
yield
|
|
67
|
+
except _core.ArrowbricksError as exc:
|
|
68
|
+
if str(exc).startswith("Query exceeded"):
|
|
69
|
+
raise QueryTimeout(str(exc)) from exc
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
57
73
|
class _Heartbeat:
|
|
58
74
|
__slots__ = ()
|
|
59
75
|
|
|
@@ -185,6 +201,11 @@ async def stream_query_json(
|
|
|
185
201
|
top-level float columns are covered; one nested inside a STRUCT/ARRAY/MAP
|
|
186
202
|
still comes back as `null` either way.
|
|
187
203
|
|
|
204
|
+
`total_timeout_s` bounds the whole statement -- the submit/poll wait
|
|
205
|
+
(heartbeats included) and every chunk download after it -- and raises
|
|
206
|
+
`QueryTimeout` once it elapses, firing a best-effort server-side cancel
|
|
207
|
+
so the statement doesn't keep running on the warehouse.
|
|
208
|
+
|
|
188
209
|
Note this yields a whole chunk's rows at once -- Databricks' own chunk
|
|
189
210
|
sizing already bounds how much that is."""
|
|
190
211
|
if non_finite_floats not in ("null", "string"):
|
|
@@ -192,16 +213,17 @@ async def stream_query_json(
|
|
|
192
213
|
sql = windowed_sql(sql, row_limit=row_limit, offset=offset)
|
|
193
214
|
core_client = client._core_client # noqa: SLF001 -- same package, see client.py
|
|
194
215
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
with rust_timeout_as_query_timeout():
|
|
217
|
+
async for item in core_client.stream_ndjson_lines(
|
|
218
|
+
sql,
|
|
219
|
+
catalog=catalog,
|
|
220
|
+
schema=schema,
|
|
221
|
+
parameters=params,
|
|
222
|
+
total_timeout_s=total_timeout_s,
|
|
223
|
+
non_finite_as_string=(non_finite_floats == "string"),
|
|
224
|
+
):
|
|
225
|
+
if item is _core.HEARTBEAT:
|
|
226
|
+
yield HEARTBEAT
|
|
227
|
+
continue
|
|
228
|
+
for line in cast("list[str]", item):
|
|
229
|
+
yield line
|
|
@@ -20,7 +20,7 @@ from collections.abc import AsyncIterator
|
|
|
20
20
|
from typing import TYPE_CHECKING, Any
|
|
21
21
|
|
|
22
22
|
from . import _core
|
|
23
|
-
from ._streaming import HEARTBEAT,
|
|
23
|
+
from ._streaming import HEARTBEAT, await_with_heartbeat, rust_timeout_as_query_timeout, windowed_sql
|
|
24
24
|
from .client import DatabricksClient
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
@@ -309,7 +309,7 @@ class Cursor:
|
|
|
309
309
|
# `execute_streamed`'s own `_gen()` pattern.
|
|
310
310
|
self._require_empty_row_buffer("fetchall_arrow_streamed")
|
|
311
311
|
result = self._require_result()
|
|
312
|
-
|
|
312
|
+
with rust_timeout_as_query_timeout():
|
|
313
313
|
async for item in result.fetchall_arrow_streamed(total_timeout_s=total_timeout_s):
|
|
314
314
|
if item is _core.HEARTBEAT:
|
|
315
315
|
yield HEARTBEAT
|
|
@@ -317,21 +317,6 @@ class Cursor:
|
|
|
317
317
|
if self._schema is None:
|
|
318
318
|
self._schema = await result.schema()
|
|
319
319
|
yield item
|
|
320
|
-
except RuntimeError as exc:
|
|
321
|
-
# `heartbeat::HeartbeatWait`'s own `total_timeout_s` error
|
|
322
|
-
# (`format!("Query exceeded {secs}s timeout")`, heartbeat.rs)
|
|
323
|
-
# surfaces here as a plain `RuntimeError`, not this package's
|
|
324
|
-
# own `QueryTimeout` -- translated so callers relying on
|
|
325
|
-
# catching `QueryTimeout` (same contract `execute_streamed`/
|
|
326
|
-
# `fetchall_streamed` already promise) see the same exception
|
|
327
|
-
# type regardless of which heartbeat implementation is
|
|
328
|
-
# actually running underneath. Matched by a stable literal
|
|
329
|
-
# prefix this crate controls end to end (not string-matching
|
|
330
|
-
# someone else's error), so a real, unrelated `RuntimeError`
|
|
331
|
-
# from a genuine chunk-fetch failure is never misclassified.
|
|
332
|
-
if str(exc).startswith("Query exceeded"):
|
|
333
|
-
raise QueryTimeout(str(exc)) from exc
|
|
334
|
-
raise
|
|
335
320
|
|
|
336
321
|
return _gen()
|
|
337
322
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/proptest-regressions/json_convert.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arrowbricks-3.1.4 → arrowbricks-3.2.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
|
|
File without changes
|