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.
Files changed (49) hide show
  1. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/PKG-INFO +3 -3
  2. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/README.md +2 -2
  3. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/pyproject.toml +1 -1
  4. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.lock +1 -1
  5. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/Cargo.toml +1 -1
  6. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/model.rs +25 -1
  7. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/sea.rs +38 -4
  8. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/heartbeat.rs +12 -0
  9. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/lib.rs +60 -22
  10. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/ndjson.rs +4 -5
  11. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/sea.rs +8 -1
  12. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/stats.rs +24 -0
  13. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/thrift_exec.rs +44 -1
  14. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_pipeline.rs +94 -0
  15. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_thrift.rs +122 -0
  16. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/_streaming.py +36 -14
  17. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/cursor.py +2 -17
  18. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/LICENSE +0 -0
  19. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/.gitignore +0 -0
  20. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/README.md +0 -0
  21. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/duckdb_query.py +0 -0
  22. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/examples/fastapi_sse.py +0 -0
  23. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/proptest-regressions/json_convert.txt +0 -0
  24. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/rustfmt.toml +0 -0
  25. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/download.rs +0 -0
  26. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/error.rs +0 -0
  27. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/thrift_rpc.rs +0 -0
  28. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client/volume.rs +0 -0
  29. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/client.rs +0 -0
  30. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/json_convert.rs +0 -0
  31. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/reorder.rs +0 -0
  32. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline/test_support.rs +0 -0
  33. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/pipeline.rs +0 -0
  34. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/src/thrift.rs +0 -0
  35. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/common/mod.rs +0 -0
  36. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests/wiremock_volume_files.rs +0 -0
  37. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/conftest.py +0 -0
  38. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_ipc_stream.py +0 -0
  39. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_parameters.py +0 -0
  40. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_stream_ndjson_lines.py +0 -0
  41. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_streaming.py +0 -0
  42. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_thrift.py +0 -0
  43. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_token_provider.py +0 -0
  44. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/test_volume_files.py +0 -0
  45. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/rust/arrowbricks_core/tests_py/thrift_mock.py +0 -0
  46. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/__init__.py +0 -0
  47. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/_core.pyi +0 -0
  48. {arrowbricks-3.1.4 → arrowbricks-3.2.0}/src/arrowbricks/client.py +0 -0
  49. {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.1.4
3
+ Version: 3.2.0
4
4
  Requires-Dist: arro3-core>=0.8 ; extra == 'arro3'
5
5
  Provides-Extra: arro3
6
6
  License-File: LICENSE
@@ -160,9 +160,9 @@ conn = connect(host=..., warehouse_id=..., token=..., on_event=log_query)
160
160
 
161
161
  ## Cancellation
162
162
 
163
- When a chunk download times out (`total_timeout_s`) or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while it's in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
163
+ When `total_timeout_s` elapses or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while a query is in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
164
164
 
165
- This covers `Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, `client.stream_query_json(...)`, and the lower-level `._core.Client`'s own streamed APIs -- everything that wraps the chunk-download phase in the Rust-level heartbeat. Neither `total_timeout_s` nor a bare `task.cancel()` on `Cursor.execute()`/`execute_streamed()` itself (the initial submit/poll wait) triggers a cancel -- by the time a query's chunks are being fetched at all, the statement has typically already finished running server-side, so there's usually nothing left to cancel there in practice.
165
+ This covers both phases of a query: the submit/poll wait while the statement is still running (`Cursor.execute()`/`execute_streamed()`, and the start of `client.stream_query_json(...)`), and the chunk download afterwards (`Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, the rest of `stream_query_json`). `stream_query_json`'s `total_timeout_s` is one budget across both phases. On `protocol="sea"` there is one short blind spot: the submit request itself can wait server-side for up to `wait_timeout` (default 30s) before Databricks returns a statement id, and a query abandoned inside that window can't be cancelled.
166
166
 
167
167
  ## Errors
168
168
 
@@ -148,9 +148,9 @@ conn = connect(host=..., warehouse_id=..., token=..., on_event=log_query)
148
148
 
149
149
  ## Cancellation
150
150
 
151
- When a chunk download times out (`total_timeout_s`) or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while it's in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
151
+ When `total_timeout_s` elapses or your code cancels the surrounding coroutine (`task.cancel()`/`asyncio.wait_for`) while a query is in flight, arrowbricks fires a best-effort server-side cancel in the background (Thrift's `CancelOperation`, or SEA's `POST .../cancel`) so Databricks stops running the query instead of finishing it for nobody. This is fire-and-forget: the `QueryTimeout`/cancellation still reaches you immediately, and the cancel call's own result (success or failure) is never surfaced or awaited.
152
152
 
153
- This covers `Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, `client.stream_query_json(...)`, and the lower-level `._core.Client`'s own streamed APIs -- everything that wraps the chunk-download phase in the Rust-level heartbeat. Neither `total_timeout_s` nor a bare `task.cancel()` on `Cursor.execute()`/`execute_streamed()` itself (the initial submit/poll wait) triggers a cancel -- by the time a query's chunks are being fetched at all, the statement has typically already finished running server-side, so there's usually nothing left to cancel there in practice.
153
+ This covers both phases of a query: the submit/poll wait while the statement is still running (`Cursor.execute()`/`execute_streamed()`, and the start of `client.stream_query_json(...)`), and the chunk download afterwards (`Cursor.fetchall_streamed()`/`fetchall_arrow_streamed()`, the rest of `stream_query_json`). `stream_query_json`'s `total_timeout_s` is one budget across both phases. On `protocol="sea"` there is one short blind spot: the submit request itself can wait server-side for up to `wait_timeout` (default 30s) before Databricks returns a statement id, and a query abandoned inside that window can't be cancelled.
154
154
 
155
155
  ## Errors
156
156
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arrowbricks"
3
- version = "3.1.4"
3
+ version = "3.2.0"
4
4
  description = "Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands you the result as Arrow -- a DB-API-ish Cursor (fetchone/fetchmany/fetchall/fetchall_arrow) or NDJSON streaming. Rust/PyO3 core throughout -- zero required runtime dependencies."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -178,7 +178,7 @@ dependencies = [
178
178
 
179
179
  [[package]]
180
180
  name = "arrowbricks_core"
181
- version = "3.1.4"
181
+ version = "3.2.0"
182
182
  dependencies = [
183
183
  "arrow-array",
184
184
  "arrow-buffer",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "arrowbricks_core"
3
- version = "3.1.4"
3
+ version = "3.2.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -6,6 +6,7 @@
6
6
  //! statement gets you before any chunk is fetched (`ChunkMeta`,
7
7
  //! `StatementSubmitResult`, `InlineOrExternal`, `ChunkItem`).
8
8
 
9
+ use std::sync::Mutex;
9
10
  use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering};
10
11
 
11
12
  use bytes::Bytes;
@@ -55,7 +56,7 @@ pub struct ColumnDescription {
55
56
  /// `Drop for HeartbeatWait`/`Drop for HeartbeatStream`) can fire
56
57
  /// `DbClient::cancel_statement` without either of those generic structs
57
58
  /// needing to know anything protocol-specific themselves.
58
- #[derive(Clone)]
59
+ #[derive(Clone, Debug)]
59
60
  pub enum CancelHandle {
60
61
  Sea { statement_id: String },
61
62
  Thrift { operation: thrift::OperationHandle },
@@ -113,6 +114,17 @@ pub struct QueryStatsAccumulator {
113
114
  /// anything itself.
114
115
  warehouse_wait_bits: AtomicU64,
115
116
  outcome: AtomicU8,
117
+ /// The statement/operation handle while it's submitted but not yet
118
+ /// terminal -- set by the submit/poll loops (`client/sea.rs`'s
119
+ /// `submit_and_poll_inner`, `pipeline/thrift_exec.rs`'s
120
+ /// `submit_and_await_thrift_statement`) as soon as Databricks hands one
121
+ /// back, cleared once the statement reaches a terminal state. Whatever
122
+ /// is still here when `pipeline/stats.rs`'s `CancelInFlightOnDrop`
123
+ /// drops (the submit future was abandoned -- a `total_timeout_s`, a
124
+ /// Python-side `task.cancel()`/`asyncio.wait_for` -- or a poll failed
125
+ /// mid-wait) gets a best-effort server-side cancel, so a query nobody
126
+ /// is waiting for anymore stops running on the warehouse.
127
+ in_flight: Mutex<Option<CancelHandle>>,
116
128
  }
117
129
 
118
130
  const OUTCOME_UNSET: u8 = 0;
@@ -170,6 +182,18 @@ impl QueryStatsAccumulator {
170
182
  pub fn warehouse_wait_s(&self) -> f64 {
171
183
  f64::from_bits(self.warehouse_wait_bits.load(Ordering::Relaxed))
172
184
  }
185
+
186
+ pub fn set_in_flight(&self, handle: CancelHandle) {
187
+ *self.in_flight.lock().unwrap() = Some(handle);
188
+ }
189
+
190
+ pub fn clear_in_flight(&self) {
191
+ *self.in_flight.lock().unwrap() = None;
192
+ }
193
+
194
+ pub fn take_in_flight(&self) -> Option<CancelHandle> {
195
+ self.in_flight.lock().unwrap().take()
196
+ }
173
197
  }
174
198
 
175
199
  /// One query's worth of timing/counters, handed to `EventSink::on_event`
@@ -21,7 +21,8 @@ use super::DbClient;
21
21
  use super::POLL_INTERVAL;
22
22
  use super::error::{ApiError, join_error};
23
23
  use super::model::{
24
- ChunkItem, ChunkMeta, ColumnDescription, InlineOrExternal, QueryStatsAccumulator, StatementSubmitResult,
24
+ CancelHandle, ChunkItem, ChunkMeta, ColumnDescription, InlineOrExternal, QueryStatsAccumulator,
25
+ StatementSubmitResult,
25
26
  };
26
27
 
27
28
  /// Typed response shapes -- replaces navigating a dynamic `serde_json::Value`
@@ -373,10 +374,14 @@ impl DbClient {
373
374
  }
374
375
  }
375
376
 
377
+ let mut checkin = SessionCheckin {
378
+ client: self,
379
+ catalog,
380
+ schema,
381
+ session_id,
382
+ };
376
383
  let result = self.submit_and_poll_inner(body, stats).await;
377
- if let Some(id) = session_id {
378
- self.checkin_session(catalog, schema, id, result.is_ok());
379
- }
384
+ checkin.finish(result.is_ok());
380
385
  result
381
386
  }
382
387
 
@@ -390,6 +395,9 @@ impl DbClient {
390
395
  .authed_json(reqwest::Method::POST, &url, Some(&body), Some(stats))
391
396
  .await?;
392
397
 
398
+ stats.set_in_flight(CancelHandle::Sea {
399
+ statement_id: data.statement_id.clone(),
400
+ });
393
401
  while !matches!(
394
402
  data.status.state.as_str(),
395
403
  "SUCCEEDED" | "FAILED" | "CANCELED" | "CLOSED"
@@ -400,6 +408,7 @@ impl DbClient {
400
408
  .authed_json(reqwest::Method::GET, &poll_url, None, Some(stats))
401
409
  .await?;
402
410
  }
411
+ stats.clear_in_flight();
403
412
 
404
413
  match data.status.state.as_str() {
405
414
  "FAILED" => {
@@ -599,6 +608,31 @@ impl DbClient {
599
608
  /// panic case would let that worker's unfetched work vanish with no error at
600
609
  /// all: the channel closing normally looks to the consumer exactly like a
601
610
  /// complete, successful result instead of a truncated one.
611
+ /// Returns `submit_and_poll`'s session to the pool even when its future is
612
+ /// dropped mid-poll (a timeout or cancellation) -- without it the pool's
613
+ /// reservation for that key leaks, and after `MAX_SESSIONS_PER_KEY` such
614
+ /// drops every later query for the key runs session-less.
615
+ struct SessionCheckin<'a> {
616
+ client: &'a DbClient,
617
+ catalog: Option<&'a str>,
618
+ schema: Option<&'a str>,
619
+ session_id: Option<String>,
620
+ }
621
+
622
+ impl SessionCheckin<'_> {
623
+ fn finish(&mut self, keep: bool) {
624
+ if let Some(id) = self.session_id.take() {
625
+ self.client.checkin_session(self.catalog, self.schema, id, keep);
626
+ }
627
+ }
628
+ }
629
+
630
+ impl Drop for SessionCheckin<'_> {
631
+ fn drop(&mut self) {
632
+ self.finish(false);
633
+ }
634
+ }
635
+
602
636
  async fn join_first_error(handles: Vec<tokio::task::JoinHandle<Result<(), ApiError>>>) -> Option<ApiError> {
603
637
  let mut first_err = None;
604
638
  for h in handles {
@@ -175,6 +175,10 @@ impl<T: Send + 'static> HeartbeatWait<T> {
175
175
  self
176
176
  }
177
177
 
178
+ pub fn deadline(&self) -> Option<Instant> {
179
+ self.deadline
180
+ }
181
+
178
182
  /// One step: `Ok(Some(Tick::Heartbeat))` if still waiting,
179
183
  /// `Ok(Some(Tick::Ready(value)))` exactly once when the wrapped future
180
184
  /// completes, `Ok(None)` if already exhausted (caller should raise
@@ -254,6 +258,14 @@ impl<T: Send + 'static> HeartbeatStream<T> {
254
258
  }
255
259
  }
256
260
 
261
+ /// Shares an existing deadline (e.g. `HeartbeatWait::deadline` of the
262
+ /// submit/poll wait that preceded this stream) instead of starting a
263
+ /// fresh `total_timeout_s` budget at construction.
264
+ pub fn with_deadline(mut self, deadline: Option<Instant>) -> Self {
265
+ self.deadline = deadline;
266
+ self
267
+ }
268
+
257
269
  /// See `HeartbeatWait::with_cancel`'s own doc comment -- identical
258
270
  /// contract.
259
271
  pub fn with_cancel(mut self, on_cancel: impl FnOnce(bool) + Send + 'static) -> Self {
@@ -911,13 +911,10 @@ impl PyFetchallArrowStreamedIter {
911
911
  }
912
912
  }
913
913
 
914
- /// Not-yet-started vs. running state for `PyNdjsonStreamIter`. The
915
- /// submit/poll/spawn-workers step (`Pending` -> `Running`) happens on the
916
- /// iterator's first `__anext__` call, un-heartbeated -- matching
917
- /// `stream_query_json`'s pre-cutover behavior (its submit/poll wait was never
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 client_for_cancel = client.clone();
974
- let stream = pipeline::execute_ndjson_stream(
979
+ let submit_client = client.clone();
980
+ let wait = HeartbeatWait::new(
981
+ async move {
982
+ pipeline::execute_ndjson_stream(
983
+ submit_client,
984
+ &statement,
985
+ catalog.as_deref(),
986
+ schema.as_deref(),
987
+ parameters,
988
+ non_finite_as_string,
989
+ )
990
+ .await
991
+ },
992
+ total_timeout_s,
993
+ );
994
+ *guard = PyNdjsonStreamState::Submitting {
975
995
  client,
976
- &statement,
977
- catalog.as_deref(),
978
- schema.as_deref(),
979
- parameters,
980
- non_finite_as_string,
981
- )
982
- .await
983
- .map_err(api_error_to_pyerr)?;
984
- let heartbeat = HeartbeatStream::new(total_timeout_s).with_cancel(pipeline::cancel_hook(
985
- client_for_cancel,
986
- stream.cancel_handle.clone(),
987
- stream.stats.clone(),
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. Matches Python's `fetch_arrow_chunks_with_manifest`: this
304
- /// await itself is never heartbeat-wrapped (only the per-chunk pulls that
305
- /// follow are) -- `stream_query_json` only wraps its chunk iterator, not
306
- /// this initial submit/poll wait, so this crate preserves that same gap
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
- async for item in core_client.stream_ndjson_lines(
196
- sql,
197
- catalog=catalog,
198
- schema=schema,
199
- parameters=params,
200
- total_timeout_s=total_timeout_s,
201
- non_finite_as_string=(non_finite_floats == "string"),
202
- ):
203
- if item is _core.HEARTBEAT:
204
- yield HEARTBEAT
205
- continue
206
- for line in cast("list[str]", item):
207
- yield line
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, QueryTimeout, await_with_heartbeat, windowed_sql
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
- try:
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