Skip to content

Commit d5dacad

Browse files
authored
Merge pull request #206 from estie-inc/feat/query-cancel
feat: add explicit query cancellation API
2 parents c58b163 + 9f095db commit d5dacad

21 files changed

Lines changed: 2235 additions & 164 deletions

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,9 @@ name = "statement_bind"
169169
required-features = [
170170
"external-browser-sso",
171171
]
172+
173+
[[example]]
174+
name = "query_cancel"
175+
required-features = [
176+
"external-browser-sso",
177+
]

examples/query_cancel.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use std::{env, time::Duration};
2+
3+
use snowflake_connector_rs::{
4+
AuthConfig, Client, ClientConfig, ExternalBrowserConfig, QueryCancelStatus, QueryOptions,
5+
Session, SessionConfig,
6+
};
7+
8+
#[tokio::main]
9+
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
10+
let session = build_session().await?;
11+
12+
let query = session.query_handle_with_options(
13+
"CALL SYSTEM$WAIT(120)",
14+
QueryOptions::new().with_query_cancel_request_timeout(Duration::from_secs(10)),
15+
)?;
16+
17+
let canceller = query.canceller();
18+
let execution = tokio::spawn(query.execute());
19+
20+
tokio::time::sleep(Duration::from_secs(1)).await;
21+
22+
let status = canceller.cancel().await?;
23+
if status == QueryCancelStatus::NotSubmitted {
24+
println!("cancelled before the query was submitted");
25+
} else {
26+
println!("cancel status: {status:?}");
27+
}
28+
29+
let execution_result = execution.await?;
30+
match execution_result {
31+
Ok(_cursor) => println!("query completed before the cancel took effect"),
32+
Err(error) if error.is_cancelled() => println!("query was cancelled: {error}"),
33+
Err(error) => return Err(error.into()),
34+
}
35+
36+
Ok(())
37+
}
38+
39+
async fn build_session() -> std::result::Result<Session, Box<dyn std::error::Error>> {
40+
let username = env::var("SNOWFLAKE_USERNAME")?;
41+
let account = env::var("SNOWFLAKE_ACCOUNT")?;
42+
let role = env::var("SNOWFLAKE_ROLE").ok();
43+
let warehouse = env::var("SNOWFLAKE_WAREHOUSE").ok();
44+
let database = env::var("SNOWFLAKE_DATABASE").ok();
45+
let schema = env::var("SNOWFLAKE_SCHEMA").ok();
46+
47+
let mut session_config = SessionConfig::new();
48+
if let Some(value) = warehouse {
49+
session_config = session_config.with_warehouse(value);
50+
}
51+
if let Some(value) = database {
52+
session_config = session_config.with_database(value);
53+
}
54+
if let Some(value) = schema {
55+
session_config = session_config.with_schema(value);
56+
}
57+
if let Some(value) = role {
58+
session_config = session_config.with_role(value);
59+
}
60+
61+
let client = Client::new(
62+
ClientConfig::new(
63+
&username,
64+
&account,
65+
AuthConfig::external_browser(ExternalBrowserConfig::default()),
66+
)
67+
.with_session(session_config),
68+
)?;
69+
Ok(client.create_session().await?)
70+
}

src/config.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub struct SessionConfig {
3131
}
3232

3333
pub(crate) const DEFAULT_QUERY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
34+
pub(crate) const DEFAULT_QUERY_CANCEL_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
3435
const DEFAULT_COLLECT_PREFETCH_CONCURRENCY: NonZeroUsize =
3536
NonZeroUsize::new(8).expect("default concurrency is non-zero");
3637

@@ -41,13 +42,15 @@ const DEFAULT_COLLECT_PREFETCH_CONCURRENCY: NonZeroUsize =
4142
#[derive(Clone, Debug)]
4243
pub struct QueryConfig {
4344
query_response_timeout: Duration,
45+
query_cancel_request_timeout: Duration,
4446
collect_prefetch_concurrency: NonZeroUsize,
4547
}
4648

4749
impl Default for QueryConfig {
4850
fn default() -> Self {
4951
Self {
5052
query_response_timeout: DEFAULT_QUERY_RESPONSE_TIMEOUT,
53+
query_cancel_request_timeout: DEFAULT_QUERY_CANCEL_REQUEST_TIMEOUT,
5154
collect_prefetch_concurrency: DEFAULT_COLLECT_PREFETCH_CONCURRENCY,
5255
}
5356
}
@@ -229,6 +232,15 @@ impl QueryConfig {
229232
self
230233
}
231234

235+
/// Sets the client-side deadline for an explicit query cancellation request. Defaults to `30s`.
236+
///
237+
/// The deadline covers abort request transport, response body reading, and bounded transport retries. It is
238+
/// independent of [`Self::with_query_response_timeout`].
239+
pub fn with_query_cancel_request_timeout(mut self, timeout: Duration) -> Self {
240+
self.query_cancel_request_timeout = timeout;
241+
self
242+
}
243+
232244
/// Sets the default number of partitions fetched concurrently during collection. Defaults to `8`.
233245
pub fn with_collect_prefetch_concurrency(mut self, concurrency: NonZeroUsize) -> Self {
234246
self.collect_prefetch_concurrency = concurrency;
@@ -324,6 +336,7 @@ impl From<SessionConfig> for InitialSessionConfig {
324336
#[derive(Debug)]
325337
pub(crate) struct QueryExecutionPolicy {
326338
query_response_timeout: Duration,
339+
query_cancel_request_timeout: Duration,
327340
collect_prefetch_concurrency: NonZeroUsize,
328341
}
329342

@@ -334,6 +347,9 @@ impl QueryExecutionPolicy {
334347
query_response_timeout: options
335348
.query_response_timeout
336349
.unwrap_or(self.query_response_timeout),
350+
query_cancel_request_timeout: options
351+
.query_cancel_request_timeout
352+
.unwrap_or(self.query_cancel_request_timeout),
337353
collect_prefetch_concurrency: options
338354
.collect_prefetch_concurrency
339355
.unwrap_or(self.collect_prefetch_concurrency),
@@ -345,13 +361,15 @@ impl QueryExecutionPolicy {
345361
#[derive(Clone, Copy, Debug)]
346362
pub(crate) struct QueryExecutionSettings {
347363
pub(crate) query_response_timeout: Duration,
364+
pub(crate) query_cancel_request_timeout: Duration,
348365
pub(crate) collect_prefetch_concurrency: NonZeroUsize,
349366
}
350367

351368
impl From<QueryConfig> for QueryExecutionPolicy {
352369
fn from(config: QueryConfig) -> Self {
353370
Self {
354371
query_response_timeout: config.query_response_timeout,
372+
query_cancel_request_timeout: config.query_cancel_request_timeout,
355373
collect_prefetch_concurrency: config.collect_prefetch_concurrency,
356374
}
357375
}
@@ -498,6 +516,10 @@ mod tests {
498516
let policy: QueryExecutionPolicy = QueryConfig::default().into();
499517
let settings = policy.resolve_options(QueryOptions::default());
500518
assert_eq!(settings.query_response_timeout, Duration::from_secs(300));
519+
assert_eq!(
520+
settings.query_cancel_request_timeout,
521+
Duration::from_secs(30)
522+
);
501523
}
502524

503525
#[test]
@@ -542,6 +564,27 @@ mod tests {
542564
);
543565
}
544566

567+
#[test]
568+
fn query_options_with_query_cancel_request_timeout_overrides_only_cancel_timeout() {
569+
let policy: QueryExecutionPolicy = QueryConfig::default()
570+
.with_query_response_timeout(Duration::from_secs(120))
571+
.with_collect_prefetch_concurrency(NonZeroUsize::new(4).unwrap())
572+
.into();
573+
let settings = policy.resolve_options(
574+
QueryOptions::default().with_query_cancel_request_timeout(Duration::from_secs(7)),
575+
);
576+
577+
assert_eq!(settings.query_response_timeout, Duration::from_secs(120));
578+
assert_eq!(
579+
settings.query_cancel_request_timeout,
580+
Duration::from_secs(7)
581+
);
582+
assert_eq!(
583+
settings.collect_prefetch_concurrency,
584+
NonZeroUsize::new(4).unwrap()
585+
);
586+
}
587+
545588
#[test]
546589
fn query_options_with_collect_prefetch_concurrency_overrides_only_concurrency() {
547590
let policy: QueryExecutionPolicy = QueryConfig::default()

src/error.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ pub use schema::{
2323
};
2424

2525
pub(crate) use parse::RowsetParseError;
26-
pub(crate) use query_scoped::{QueryScopedError, QueryScopedRepr, QueryScopedResult};
26+
pub(crate) use query_scoped::{
27+
QueryScopedError, QueryScopedRepr, QueryScopedResult, with_optional_query_id,
28+
};
2729
pub(crate) use repr::{
28-
AuthError, ConfigError, InternalError, NetworkError, ProtocolError, ServerError,
29-
SessionExpiredError, TimeoutError,
30+
AuthError, CancelledError, ConfigError, InternalError, NetworkError, ProtocolError,
31+
ServerError, SessionExpiredError, TimeoutError,
3032
};
3133

3234
const VALUE_PREVIEW_MAX_CHARS: usize = 128;
@@ -44,6 +46,7 @@ const JSON_BODY_PREVIEW_MAX_BYTES: usize = 1024;
4446
///
4547
/// - Snowflake-provided fields: [`snowflake_code`](Error::snowflake_code),
4648
/// [`snowflake_message`](Error::snowflake_message), [`query_id`](Error::query_id)
49+
/// - Cancellation failures: [`is_cancelled`](Error::is_cancelled)
4750
/// - Decode failures: [`as_schema_error`](Error::as_schema_error) for schema validation failures,
4851
/// [`as_cell_decode_error`](Error::as_cell_decode_error) for cell conversion failures,
4952
/// [`as_custom_plan_error`](Error::as_custom_plan_error) for plan-time failures raised by a hand-written decoder,
@@ -119,6 +122,8 @@ pub enum ErrorKind {
119122
Network,
120123
/// Snowflake rejected the request and returned a server-side error message.
121124
Server,
125+
/// A user-requested cancellation prevented submission or terminated the query.
126+
Cancelled,
122127
/// The current session token is no longer valid.
123128
SessionExpired,
124129
/// The connector timed out while waiting for a response.
@@ -157,6 +162,7 @@ impl Error {
157162
Repr::Auth(_) => ErrorKind::Auth,
158163
Repr::Network { .. } => ErrorKind::Network,
159164
Repr::Server(_) => ErrorKind::Server,
165+
Repr::Cancelled(_) => ErrorKind::Cancelled,
160166
Repr::SessionExpired(_) => ErrorKind::SessionExpired,
161167
Repr::Timeout { .. } => ErrorKind::Timeout,
162168
Repr::Protocol { .. } => ErrorKind::Protocol,
@@ -176,6 +182,7 @@ impl Error {
176182
Repr::Auth(AuthError::LoginRejected { message }) => message.as_deref(),
177183
Repr::SessionExpired(SessionExpiredError { message, .. }) => message.as_deref(),
178184
Repr::Server(ServerError { message, .. }) => message.as_deref(),
185+
Repr::Cancelled(CancelledError { message, .. }) => message.as_deref(),
179186
_ => None,
180187
}
181188
}
@@ -185,6 +192,7 @@ impl Error {
185192
match &*self.repr {
186193
Repr::SessionExpired(SessionExpiredError { code, .. }) => code.as_deref(),
187194
Repr::Server(ServerError { code, .. }) => code.as_deref(),
195+
Repr::Cancelled(CancelledError { code, .. }) => code.as_deref(),
188196
_ => None,
189197
}
190198
}
@@ -204,6 +212,7 @@ impl Error {
204212
| Repr::Protocol { query_id, .. }
205213
| Repr::Internal { query_id, .. } => query_id.as_deref(),
206214
Repr::Server(ServerError { query_id, .. }) => query_id.as_deref(),
215+
Repr::Cancelled(CancelledError { query_id, .. }) => query_id.as_deref(),
207216
Repr::SessionExpired(SessionExpiredError { query_id, .. }) => query_id.as_deref(),
208217
_ => None,
209218
}
@@ -221,6 +230,11 @@ impl Error {
221230
matches!(&*self.repr, Repr::Server(_))
222231
}
223232

233+
/// Reports whether this error is a cancellation, i.e. [`ErrorKind::Cancelled`].
234+
pub fn is_cancelled(&self) -> bool {
235+
matches!(&*self.repr, Repr::Cancelled(_))
236+
}
237+
224238
pub fn as_cell_decode_error(&self) -> Option<&CellDecodeError> {
225239
match &*self.repr {
226240
Repr::CellDecode(error) => Some(error),
@@ -328,6 +342,12 @@ impl From<ServerError> for Error {
328342
}
329343
}
330344

345+
impl From<CancelledError> for Error {
346+
fn from(error: CancelledError) -> Self {
347+
Self::new(Repr::Cancelled(error))
348+
}
349+
}
350+
331351
impl From<SessionExpiredError> for Error {
332352
fn from(error: SessionExpiredError) -> Self {
333353
Self::new(Repr::SessionExpired(error))
@@ -500,6 +520,20 @@ impl ServerError {
500520
}
501521
}
502522

523+
impl CancelledError {
524+
pub(crate) fn new(
525+
code: Option<String>,
526+
message: Option<String>,
527+
query_id: Option<Arc<str>>,
528+
) -> Self {
529+
Self {
530+
code: code.map(String::into_boxed_str),
531+
message: message.map(String::into_boxed_str),
532+
query_id,
533+
}
534+
}
535+
}
536+
503537
impl SessionExpiredError {
504538
pub(crate) fn new(
505539
code: Option<String>,
@@ -527,6 +561,10 @@ impl TimeoutError {
527561
Self::Query
528562
}
529563

564+
pub(crate) fn query_cancel() -> Self {
565+
Self::QueryCancel
566+
}
567+
530568
#[cfg(feature = "external-browser-sso")]
531569
pub(crate) fn browser_callback() -> Self {
532570
Self::BrowserCallback
@@ -745,6 +783,37 @@ mod tests {
745783
assert_eq!(server_err.to_string(), "Snowflake server error 390100");
746784
}
747785

786+
#[test]
787+
fn cancelled_error_exposes_structured_details() {
788+
let err: Error = CancelledError::new(
789+
Some("000604".to_string()),
790+
Some("SQL execution canceled".to_string()),
791+
Some(Arc::from("query-id")),
792+
)
793+
.into();
794+
795+
assert_eq!(err.kind(), ErrorKind::Cancelled);
796+
assert!(err.is_cancelled());
797+
assert_eq!(err.snowflake_code(), Some("000604"));
798+
assert_eq!(err.snowflake_message(), Some("SQL execution canceled"));
799+
assert_eq!(err.query_id(), Some("query-id"));
800+
assert_eq!(
801+
err.to_string(),
802+
"query cancelled: SQL execution canceled (query id: query-id)"
803+
);
804+
}
805+
806+
#[test]
807+
fn cancelled_error_without_server_fields_has_stable_display() {
808+
let err: Error = CancelledError::new(None, None, None).into();
809+
assert_eq!(err.kind(), ErrorKind::Cancelled);
810+
assert!(err.is_cancelled());
811+
assert_eq!(err.to_string(), "query cancelled");
812+
assert_eq!(err.snowflake_code(), None);
813+
assert_eq!(err.snowflake_message(), None);
814+
assert_eq!(err.query_id(), None);
815+
}
816+
748817
#[test]
749818
fn session_expired_preserves_snowflake_fields() {
750819
let err: Error = SessionExpiredError::new(

src/error/display.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use std::{
66
use super::{
77
Error,
88
repr::{
9-
AuthError, ConfigError, InternalError, NetworkError, ProtocolError, Repr, ServerError,
10-
TimeoutError,
9+
AuthError, CancelledError, ConfigError, InternalError, NetworkError, ProtocolError, Repr,
10+
ServerError, TimeoutError,
1111
},
1212
};
1313

@@ -79,6 +79,18 @@ impl Display for Error {
7979
}
8080
Ok(())
8181
}
82+
Repr::Cancelled(CancelledError {
83+
message, query_id, ..
84+
}) => {
85+
f.write_str("query cancelled")?;
86+
if let Some(message) = message {
87+
write!(f, ": {message}")?;
88+
}
89+
if let Some(query_id) = query_id {
90+
write!(f, " (query id: {query_id})")?;
91+
}
92+
Ok(())
93+
}
8294
Repr::SessionExpired(_) => f.write_str("session expired"),
8395
Repr::Timeout {
8496
error: TimeoutError::Request(_),
@@ -88,6 +100,10 @@ impl Display for Error {
88100
error: TimeoutError::Query,
89101
..
90102
} => f.write_str("timed out waiting for query response"),
103+
Repr::Timeout {
104+
error: TimeoutError::QueryCancel,
105+
..
106+
} => f.write_str("timed out waiting for query cancellation response"),
91107
#[cfg(feature = "external-browser-sso")]
92108
Repr::Timeout {
93109
error: TimeoutError::BrowserCallback,

0 commit comments

Comments
 (0)