Skip to main content

uqa_core/
cancel.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Query cancellation support.
8//!
9//! A [`CancellationToken`] is a cheap-to-clone, thread-safe one-shot
10//! flag stored on `Engine` and propagated into every
11//! `PhysicalOperator` / `Operator` hot loop. Operators call
12//! [`CancellationToken::check`] at chunk boundaries; if the flag has
13//! been set from another thread, `check` returns
14//! [`QueryCancelled`] which surfaces to the SQL layer as
15//! `PostgreSQL` `SQLSTATE 57014` (`query_canceled`).
16//!
17//! ```rust
18//! use uqa_core::cancel::{CancellationToken, QueryCancelled};
19//!
20//! let tok = CancellationToken::new();
21//! let probe = tok.clone();
22//! tok.cancel();
23//! assert!(probe.is_cancelled());
24//! assert!(matches!(probe.check(), Err(QueryCancelled)));
25//! ```
26//!
27//! The token is a `Clone`-by-`Arc` handle: every clone speaks to the
28//! same underlying flag, so issuing `engine.cancel()` from one thread
29//! is immediately visible to any operator that received a clone of
30//! the token before the cancellation.
31
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::Arc;
34
35use thiserror::Error;
36
37/// Raised when a query is cancelled by user request. Matches
38/// `PostgreSQL` `SQLSTATE 57014` (`query_canceled`); its `Display` payload
39/// stays stable for log processing.
40#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
41#[error("canceling statement due to user request")]
42pub struct QueryCancelled;
43
44/// `PostgreSQL` SQLSTATE for [`QueryCancelled`].
45pub const SQLSTATE_QUERY_CANCELED: &str = "57014";
46
47/// Thread-safe cancellation token for query execution.
48///
49/// Uses an [`AtomicBool`] behind an [`Arc`] so cloning is `O(1)` and
50/// every clone observes the same cancellation flag. Once
51/// [`CancellationToken::cancel`] has been called, every subsequent
52/// [`CancellationToken::check`] returns [`QueryCancelled`] until
53/// [`CancellationToken::reset`] is called.
54#[derive(Debug, Clone, Default)]
55pub struct CancellationToken {
56    flag: Arc<AtomicBool>,
57}
58
59impl CancellationToken {
60    pub fn new() -> Self {
61        Self {
62            flag: Arc::new(AtomicBool::new(false)),
63        }
64    }
65
66    /// Signal cancellation. Subsequent [`Self::check`] / [`Self::is_cancelled`]
67    /// observe the flag as set across all clones of this token.
68    pub fn cancel(&self) {
69        self.flag.store(true, Ordering::Release);
70    }
71
72    /// Clear the cancellation signal for the next query. Operators
73    /// holding a clone of this token through their lifetime see the
74    /// reset on the next `check`.
75    pub fn reset(&self) {
76        self.flag.store(false, Ordering::Release);
77    }
78
79    pub fn is_cancelled(&self) -> bool {
80        self.flag.load(Ordering::Acquire)
81    }
82
83    /// Whether both handles observe the same signal, independently of its current cancelled state.
84    pub fn shares_signal(&self, other: &Self) -> bool {
85        Arc::ptr_eq(&self.flag, &other.flag)
86    }
87
88    /// Return [`QueryCancelled`] if cancellation was signalled.
89    /// `Ok(())` otherwise.
90    ///
91    /// Designed for the inner loop of every operator: a single
92    /// relaxed-ordered atomic load on the happy path.
93    pub fn check(&self) -> Result<(), QueryCancelled> {
94        if self.is_cancelled() {
95            Err(QueryCancelled)
96        } else {
97            Ok(())
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::thread;
106
107    #[test]
108    fn fresh_token_is_not_cancelled() {
109        let tok = CancellationToken::new();
110        assert!(!tok.is_cancelled());
111        assert!(tok.check().is_ok());
112    }
113
114    #[test]
115    fn cancel_propagates_through_clone() {
116        let tok = CancellationToken::new();
117        let observer = tok.clone();
118        tok.cancel();
119        assert!(observer.is_cancelled());
120        assert_eq!(observer.check(), Err(QueryCancelled));
121    }
122
123    #[test]
124    fn signal_identity_distinguishes_independent_tokens_with_equal_states() {
125        let first = CancellationToken::new();
126        let retained = first.clone();
127        let independent = CancellationToken::new();
128        assert!(first.shares_signal(&retained));
129        assert!(!first.shares_signal(&independent));
130        first.cancel();
131        independent.cancel();
132        assert!(first.shares_signal(&retained));
133        assert!(!first.shares_signal(&independent));
134        first.reset();
135        assert!(first.shares_signal(&retained));
136    }
137
138    #[test]
139    fn reset_clears_signal() {
140        let tok = CancellationToken::new();
141        tok.cancel();
142        tok.reset();
143        assert!(!tok.is_cancelled());
144        assert!(tok.check().is_ok());
145    }
146
147    #[test]
148    fn cancel_visible_across_threads() {
149        let tok = CancellationToken::new();
150        let worker = tok.clone();
151        let handle = thread::spawn(move || {
152            // Spin until the parent cancels (test-only; real
153            // operators check at chunk boundaries instead).
154            while !worker.is_cancelled() {
155                std::hint::spin_loop();
156            }
157            worker.check()
158        });
159        tok.cancel();
160        let res = handle.join().unwrap();
161        assert_eq!(res, Err(QueryCancelled));
162    }
163}