Skip to main content

powdb_query/
cancel.rs

1//! Cooperative query cancellation.
2//!
3//! An unindexed nested-loop join (or any other unbounded executor loop) can run
4//! for tens of seconds at full CPU on a crafted dataset. Nothing inside the
5//! executor used to check a deadline, so a per-query timeout could not actually
6//! stop such a query: it held the engine lock / transaction gate for its whole
7//! run, wedging even reads on other connections, and a client disconnect could
8//! not free the CPU either. This module adds a cheap cooperative-cancellation
9//! signal that the server installs per query and cancellable read/target-
10//! discovery loops poll, so an over-budget query can return a clean, typed
11//! error promptly and release its locks.
12//!
13//! Mutation application is an intentional boundary: target discovery may be
14//! cancelled and the token is checked once immediately before the first write,
15//! but an entered write phase runs to completion. The storage layer has no
16//! statement savepoint that could undo a logged prefix, so polling between row
17//! writes would trade responsiveness for a partial mutation reported as an
18//! error. Atomicity wins at that boundary.
19//!
20//! ## Why a thread-local token
21//!
22//! This mirrors the memory-budget accumulator (see [`crate::executor::mem_budget`]):
23//! each query runs to completion synchronously on a single `spawn_blocking`
24//! thread, so a thread-local slot holding the current query's cancellation
25//! token is both correct and contention-free. The token itself is an
26//! `Arc<ExecCancel>` so the async side of the server can hold a clone and flip
27//! it (on timeout or client disconnect) while the executor thread reads it.
28//!
29//! ## How loops poll it
30//!
31//! Reading the token on every row would add a thread-local lookup (and, for the
32//! deadline, an `Instant::now()`) to the hottest loop in the engine. Instead
33//! each loop holds a [`CancelCheck`] with a plain local counter and calls
34//! [`CancelCheck::tick`] once per iteration; only every 4096th tick performs the
35//! real check. Statement entry points perform one immediate check before work
36//! begins. The amortized loop cost is a register increment and a mask test.
37
38use std::cell::RefCell;
39use std::marker::PhantomData;
40use std::rc::Rc;
41use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
42use std::sync::Arc;
43#[cfg(test)]
44use std::time::Duration;
45use std::time::Instant;
46
47use crate::result::QueryError;
48
49const STATE_RUNNING: u8 = 0;
50const STATE_TIMEOUT: u8 = 1;
51const STATE_DISCONNECT: u8 = 2;
52
53/// Number of cancellation tokens installed across executor threads. The
54/// overwhelmingly common embedded/no-cancel path sees zero and skips the TLS
55/// lookup entirely. A non-zero value is only a hint that this thread might
56/// have a token; [`check`] still consults its thread-local slot for authority.
57static ACTIVE_INSTALLS: AtomicUsize = AtomicUsize::new(0);
58
59/// Whether any executor thread currently has a cancellation token installed.
60///
61/// This is only a fast-path hint: callers that observe `true` must still use
62/// [`check`] for the authoritative thread-local decision. Observing `false` is
63/// sufficient to skip per-iteration polling because no thread can currently
64/// have an installed token.
65#[inline]
66pub(crate) fn has_active_install() -> bool {
67    ACTIVE_INSTALLS.load(Ordering::Relaxed) != 0
68}
69
70/// One real cancellation check per this many [`CancelCheck::tick`] calls.
71/// A power of two so the gate is a mask test. 4096 rows of nested-loop work
72/// between checks bounds the post-deadline overrun to sub-millisecond while
73/// keeping the per-row cost to an increment.
74const CHECK_INTERVAL_MASK: u32 = 0xFFF;
75
76/// Why a query was cancelled. Determines the (wire-safe) error message.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum CancelReason {
79    /// The per-query deadline elapsed.
80    Timeout,
81    /// The client that issued the query disconnected.
82    Disconnect,
83}
84
85/// Shared cancellation signal for one executing query.
86///
87/// Construct with [`ExecCancel::with_deadline`] (server path) or
88/// [`ExecCancel::new`] (never auto-cancels; useful for explicit `cancel` only).
89/// The async side keeps a clone and calls [`ExecCancel::cancel`]; the executor
90/// thread reads it through the installed thread-local via [`CancelCheck`].
91#[derive(Debug)]
92pub struct ExecCancel {
93    state: AtomicU8,
94    deadline: Option<Instant>,
95    timeout_ms: u64,
96    #[cfg(test)]
97    checkpoints: AtomicUsize,
98    #[cfg(test)]
99    block_at_checkpoint: AtomicUsize,
100}
101
102impl ExecCancel {
103    /// A token with no deadline. Only an explicit [`ExecCancel::cancel`] trips it.
104    pub fn new() -> Self {
105        Self {
106            state: AtomicU8::new(STATE_RUNNING),
107            deadline: None,
108            timeout_ms: 0,
109            #[cfg(test)]
110            checkpoints: AtomicUsize::new(0),
111            #[cfg(test)]
112            block_at_checkpoint: AtomicUsize::new(0),
113        }
114    }
115
116    /// A token that trips itself once `deadline` passes. `timeout_ms` is the
117    /// configured timeout used to render the timeout error message.
118    pub fn with_deadline(deadline: Instant, timeout_ms: u64) -> Self {
119        Self {
120            state: AtomicU8::new(STATE_RUNNING),
121            deadline: Some(deadline),
122            timeout_ms,
123            #[cfg(test)]
124            checkpoints: AtomicUsize::new(0),
125            #[cfg(test)]
126            block_at_checkpoint: AtomicUsize::new(0),
127        }
128    }
129
130    /// Explicitly cancel for `reason`. Idempotent; the first reason to win
131    /// stands. Safe to call from any thread.
132    pub fn cancel(&self, reason: CancelReason) {
133        let want = match reason {
134            CancelReason::Timeout => STATE_TIMEOUT,
135            CancelReason::Disconnect => STATE_DISCONNECT,
136        };
137        // Only record a reason if none is set yet. A passed deadline is persisted
138        // by `reason()` when an executor checkpoint observes it.
139        let _ =
140            self.state
141                .compare_exchange(STATE_RUNNING, want, Ordering::Relaxed, Ordering::Relaxed);
142    }
143
144    /// Whether the query should stop: explicitly cancelled, or past its deadline.
145    pub fn is_cancelled(&self) -> bool {
146        self.reason().is_some()
147    }
148
149    /// The cancellation reason, or `None` if the query may continue. An
150    /// explicit cancel wins; otherwise a passed deadline reports `Timeout`.
151    pub fn reason(&self) -> Option<CancelReason> {
152        match self.state.load(Ordering::Relaxed) {
153            STATE_TIMEOUT => return Some(CancelReason::Timeout),
154            STATE_DISCONNECT => return Some(CancelReason::Disconnect),
155            _ => {}
156        }
157        if let Some(deadline) = self.deadline {
158            if Instant::now() >= deadline {
159                // Persist a deadline-driven timeout once it is observed. Without
160                // this transition, a later disconnect could overwrite an already
161                // reported timeout because the deadline used to be a computed
162                // result only. Whichever explicit signal/checkpoint wins the CAS
163                // remains the stable reason for the rest of the query.
164                let _ = self.state.compare_exchange(
165                    STATE_RUNNING,
166                    STATE_TIMEOUT,
167                    Ordering::Relaxed,
168                    Ordering::Relaxed,
169                );
170                return match self.state.load(Ordering::Relaxed) {
171                    STATE_DISCONNECT => Some(CancelReason::Disconnect),
172                    _ => Some(CancelReason::Timeout),
173                };
174            }
175        }
176        None
177    }
178
179    /// The typed error this token's current reason maps to, or `None` if the
180    /// query may continue.
181    fn error(&self) -> Option<QueryError> {
182        #[cfg(test)]
183        {
184            let count = self.checkpoints.fetch_add(1, Ordering::Relaxed) + 1;
185            // Deterministic rendezvous for cancellation tests: once the target
186            // checkpoint is reached, park this executor thread until the test's
187            // observer thread delivers the cancel. Without this, a fast scan can
188            // finish its remaining rows before the observer is scheduled, and
189            // the test flakes on contended CI runners. Bounded so a broken test
190            // cannot hang the suite; on expiry the query simply continues and
191            // the test's own assertion reports the failure.
192            let target = self.block_at_checkpoint.load(Ordering::Relaxed);
193            if target != 0 && count >= target && self.reason().is_none() {
194                let give_up = Instant::now() + Duration::from_secs(3);
195                while self.reason().is_none() && Instant::now() < give_up {
196                    std::thread::yield_now();
197                }
198            }
199        }
200        match self.reason()? {
201            CancelReason::Timeout => Some(QueryError::Timeout {
202                timeout_ms: self.timeout_ms,
203            }),
204            CancelReason::Disconnect => Some(QueryError::Cancelled),
205        }
206    }
207
208    /// Number of real executor cancellation checkpoints observed by this
209    /// token. Tests use this to signal cancellation only after a query has
210    /// entered the loop under test, avoiding sleep- or scheduler-based races.
211    #[cfg(test)]
212    pub(crate) fn checkpoint_count(&self) -> usize {
213        self.checkpoints.load(Ordering::Relaxed)
214    }
215
216    /// Arm the test-only checkpoint rendezvous: the executor thread parks at
217    /// the first real checkpoint numbered `target` or later until this token
218    /// is cancelled (bounded internally). Must be set before the query runs.
219    #[cfg(test)]
220    pub(crate) fn block_at_checkpoint(&self, target: usize) {
221        self.block_at_checkpoint.store(target, Ordering::Relaxed);
222    }
223}
224
225impl Default for ExecCancel {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231thread_local! {
232    /// The cancellation token for the query currently executing on this thread,
233    /// installed by [`install`] for the duration of one query.
234    static CURRENT: RefCell<Option<Arc<ExecCancel>>> = const { RefCell::new(None) };
235}
236
237/// Install `token` as the current thread's cancellation token, returning a
238/// guard that restores the previous token (if any) on drop. Nesting is
239/// save/restore so a recursively executed sub-statement (e.g. a view refresh)
240/// remains cancellable under the outer token.
241#[must_use = "the guard must be held for the duration of the query"]
242pub fn install(token: Arc<ExecCancel>) -> InstallGuard {
243    let prev = CURRENT.with(|c| c.borrow_mut().replace(token));
244    ACTIVE_INSTALLS.fetch_add(1, Ordering::Relaxed);
245    InstallGuard {
246        prev,
247        _thread_bound: PhantomData,
248    }
249}
250
251/// RAII guard from [`install`]; restores the previous token on drop.
252///
253/// The marker intentionally makes this guard `!Send`: installation and
254/// restoration both access thread-local state and therefore must occur on the
255/// same executor thread.
256pub struct InstallGuard {
257    prev: Option<Arc<ExecCancel>>,
258    _thread_bound: PhantomData<Rc<()>>,
259}
260
261impl Drop for InstallGuard {
262    fn drop(&mut self) {
263        CURRENT.with(|c| *c.borrow_mut() = self.prev.take());
264        ACTIVE_INSTALLS.fetch_sub(1, Ordering::Relaxed);
265    }
266}
267
268/// Perform the real cancellation check against the installed token. Returns the
269/// typed error if the query has been cancelled or has passed its deadline.
270/// Cheap no-op (one relaxed atomic load) when no token is installed anywhere.
271#[inline]
272pub fn check() -> Result<(), QueryError> {
273    if !has_active_install() {
274        return Ok(());
275    }
276    CURRENT.with(|c| match c.borrow().as_ref() {
277        Some(token) => match token.error() {
278            Some(err) => Err(err),
279            None => Ok(()),
280        },
281        None => Ok(()),
282    })
283}
284
285/// A per-loop cancellation poller with a local iteration counter. Call
286/// [`CancelCheck::tick`] once per loop iteration; it performs the real [`check`]
287/// once per 4096 iterations, so the amortized per-iteration cost is a register
288/// increment and a mask test. Public statement entry points perform the initial
289/// immediate check.
290#[derive(Debug)]
291pub struct CancelCheck {
292    n: u32,
293}
294
295impl CancelCheck {
296    #[inline]
297    pub fn new() -> Self {
298        Self { n: 0 }
299    }
300
301    /// Advance the counter; every 4096th call runs the real cancellation check
302    /// and propagates its typed error.
303    #[inline]
304    pub fn tick(&mut self) -> Result<(), QueryError> {
305        self.n = self.n.wrapping_add(1);
306        if self.n & CHECK_INTERVAL_MASK == 0 {
307            check()
308        } else {
309            Ok(())
310        }
311    }
312}
313
314impl Default for CancelCheck {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::time::Duration;
324
325    #[test]
326    fn no_token_installed_never_cancels() {
327        let mut cc = CancelCheck::new();
328        for _ in 0..100_000 {
329            cc.tick().expect("no token means never cancelled");
330        }
331        assert!(check().is_ok());
332    }
333
334    #[test]
335    fn explicit_cancel_trips_check() {
336        let token = Arc::new(ExecCancel::new());
337        let _guard = install(Arc::clone(&token));
338        assert!(check().is_ok());
339        token.cancel(CancelReason::Disconnect);
340        match check() {
341            Err(QueryError::Cancelled) => {}
342            other => panic!("expected Cancelled, got {other:?}"),
343        }
344    }
345
346    #[test]
347    fn passed_deadline_reports_timeout() {
348        let token = Arc::new(ExecCancel::with_deadline(
349            Instant::now() - Duration::from_millis(1),
350            2000,
351        ));
352        let _guard = install(Arc::clone(&token));
353        match check() {
354            Err(QueryError::Timeout { timeout_ms }) => assert_eq!(timeout_ms, 2000),
355            other => panic!("expected Timeout, got {other:?}"),
356        }
357        token.cancel(CancelReason::Disconnect);
358        assert_eq!(
359            token.reason(),
360            Some(CancelReason::Timeout),
361            "an observed timeout must remain the stable cancellation reason"
362        );
363    }
364
365    #[test]
366    fn explicit_timeout_beats_disconnect_ordering() {
367        let token = ExecCancel::new();
368        token.cancel(CancelReason::Timeout);
369        // A later disconnect cannot override the first recorded reason.
370        token.cancel(CancelReason::Disconnect);
371        assert_eq!(token.reason(), Some(CancelReason::Timeout));
372    }
373
374    #[test]
375    fn guard_restores_previous_token_on_drop() {
376        let outer = Arc::new(ExecCancel::new());
377        let guard_outer = install(Arc::clone(&outer));
378        {
379            let inner = Arc::new(ExecCancel::new());
380            let _guard_inner = install(Arc::clone(&inner));
381            inner.cancel(CancelReason::Disconnect);
382            assert!(check().is_err(), "inner token active");
383        }
384        // Inner guard dropped: outer token restored, still running.
385        assert!(check().is_ok(), "outer token restored and running");
386        outer.cancel(CancelReason::Timeout);
387        assert!(check().is_err());
388        drop(guard_outer);
389        assert!(check().is_ok(), "no token after outermost guard drops");
390    }
391
392    #[test]
393    fn tick_checks_at_interval_boundary() {
394        let token = Arc::new(ExecCancel::new());
395        let _guard = install(Arc::clone(&token));
396        token.cancel(CancelReason::Timeout);
397        let mut cc = CancelCheck::new();
398        // The first 4095 ticks do not perform the real check; the 4096th does.
399        for _ in 0..CHECK_INTERVAL_MASK {
400            cc.tick().expect("pre-boundary ticks skip the real check");
401        }
402        assert!(cc.tick().is_err(), "the 4096th tick runs the real check");
403    }
404}