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}