Skip to main content

spg_engine/
cancel.rs

1//! Cooperative query cancellation, split out of `lib.rs` (lib.rs split
2//! 19). `CancelToken` is the lightweight handle threaded through every
3//! scanning loop: it wraps an optional `&AtomicBool` flag (the server's
4//! per-query watchdog) and an optional monotonic deadline (PG
5//! `statement_timeout`), and `check()` returns `EngineError::Cancelled`
6//! when either trips. `none()` is the zero-cost default for the
7//! uncancellable path. Public API — `spg-server` / `spg-embedded`
8//! construct tokens via `CancelToken::none().with_deadline(...)`.
9
10use crate::EngineError;
11
12/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
13/// cancellation (PG `statement_timeout`). Returns microseconds
14/// since some host-stable monotonic origin (typically the first
15/// call into `Instant::now()` on the server). The engine never
16/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
17pub type MonotonicNowFn = fn() -> u64;
18
19#[derive(Debug, Clone, Copy)]
20struct Deadline {
21    now_fn: MonotonicNowFn,
22    /// Absolute deadline in `now_fn()` units (microseconds).
23    deadline_us: u64,
24}
25
26#[derive(Debug, Clone, Copy)]
27pub struct CancelToken<'a> {
28    flag: Option<&'a core::sync::atomic::AtomicBool>,
29    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
30    // checkpoint also fires `EngineError::Cancelled` once
31    // `(now_fn)() >= deadline_us`. No new check sites, no thread
32    // spawn per query — the monotonic now-fn read is a vDSO
33    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
34    // the host actually wired a deadline (statement_timeout > 0).
35    deadline: Option<Deadline>,
36}
37
38impl<'a> CancelToken<'a> {
39    #[must_use]
40    pub const fn none() -> Self {
41        Self {
42            flag: None,
43            deadline: None,
44        }
45    }
46
47    #[must_use]
48    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
49        Self {
50            flag: Some(f),
51            deadline: None,
52        }
53    }
54
55    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
56    /// must return microseconds since a stable origin; the token
57    /// trips when `now_fn() >= deadline_us`. Compose with
58    /// `from_flag(...)` when both a watchdog flag and a per-statement
59    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
60    /// plus session `statement_timeout`); the tighter of the two
61    /// wins by virtue of either signaling first.
62    #[must_use]
63    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
64        self.deadline = Some(Deadline {
65            now_fn,
66            deadline_us,
67        });
68        self
69    }
70
71    #[must_use]
72    pub fn is_cancelled(self) -> bool {
73        if self
74            .flag
75            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
76        {
77            return true;
78        }
79        // Deadline check is the second branch so the "no timeout"
80        // hot path (`deadline: None`) elides the now-fn call —
81        // predicted-not-taken on the SLO INSERT loop.
82        if let Some(d) = self.deadline
83            && (d.now_fn)() >= d.deadline_us
84        {
85            return true;
86        }
87        false
88    }
89
90    /// Returns `Err(Cancelled)` if the token has been tripped.
91    /// Used at row-loop checkpoints to bail cooperatively without
92    /// scattering raw `is_cancelled` checks across the executor.
93    #[inline]
94    pub fn check(self) -> Result<(), EngineError> {
95        if self.is_cancelled() {
96            Err(EngineError::Cancelled)
97        } else {
98            Ok(())
99        }
100    }
101
102    /// v7.37.14 (B2.3 [PG+]) — time-budgeted cooperative cancel
103    /// check. PG's `CHECK_FOR_INTERRUPTS` is per-tuple-count: the
104    /// scanning loop calls it every N rows. That bounds latency to
105    /// "N tuple processing time", which on a wide-row scan can
106    /// stretch into seconds before a Ctrl-C is honoured.
107    ///
108    /// SPG goes past that with a time-budget variant: callers
109    /// thread a `last_check_us` cursor through the loop and the
110    /// helper guarantees the underlying full check (flag +
111    /// deadline) fires at most `budget_us` after the previous one,
112    /// regardless of tuple count. 100ms is the recommended budget;
113    /// it bounds cancel-surface latency to that wall-clock window
114    /// even on a single-tuple-takes-seconds path (large aggregate
115    /// over a wide column, deep recursive CTE, etc.).
116    ///
117    /// `last_check_us` MUST be initialised to `0` by the caller and
118    /// is updated in place when a real check runs (so the first
119    /// call always falls through to a real check). With no
120    /// deadline attached this method is a no-op — the budget only
121    /// kicks in when there's a deadline that could trip.
122    ///
123    /// Hot-path overhead: one monotonic clock read (~20 ns vDSO)
124    /// + one u64 subtraction. The full check fires only every
125    /// budget window, so per-tuple cost stays in the single-digit
126    /// nanoseconds.
127    ///
128    /// # Errors
129    /// Same as [`Self::check`] — `EngineError::Cancelled` if the
130    /// underlying flag or deadline has tripped.
131    #[inline]
132    pub fn check_with_budget(
133        self,
134        last_check_us: &mut u64,
135        budget_us: u64,
136    ) -> Result<(), EngineError> {
137        let Some(d) = self.deadline else {
138            // No deadline ⇒ no budget enforcement. The flag-only
139            // path is already cheap; just call the inline check.
140            return self.check();
141        };
142        let now = (d.now_fn)();
143        // `*last_check_us == 0` is the "uninitialised" sentinel —
144        // forces a real check on the first call so the caller
145        // doesn't need to seed it with the current clock value
146        // before the loop. After the first call last_check_us is
147        // always non-zero (any non-zero monotonic value).
148        if *last_check_us != 0 && now.saturating_sub(*last_check_us) < budget_us {
149            return Ok(());
150        }
151        *last_check_us = now.max(1); // never store 0 after init
152        self.check()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    extern crate std;
159    use super::*;
160    use core::sync::atomic::AtomicU64;
161
162    // Deterministic monotonic clock for unit tests. Shared static
163    // because `MonotonicNowFn` is `fn()` (no capture), so tests
164    // serialise around CLOCK_LOCK to avoid stomping each other's
165    // cursor when cargo-test runs them in parallel.
166    static TEST_CLOCK: AtomicU64 = AtomicU64::new(0);
167    static CLOCK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
168
169    fn reset_clock_to(value: u64) {
170        TEST_CLOCK.store(value, core::sync::atomic::Ordering::SeqCst);
171    }
172
173    fn advance_clock(by_us: u64) -> u64 {
174        TEST_CLOCK.fetch_add(by_us, core::sync::atomic::Ordering::SeqCst) + by_us
175    }
176
177    fn read_clock() -> u64 {
178        TEST_CLOCK.load(core::sync::atomic::Ordering::SeqCst)
179    }
180
181    /// v7.37.14 (B2.3 [PG+] TDD) — without time-budget, a hot loop
182    /// that doesn't call `check()` won't surface a deadline trip
183    /// until the loop ends. With `check_with_budget(100ms)` the
184    /// cancel fires at most one budget window after the deadline.
185    #[test]
186    fn v7_37_14_check_with_budget_fires_after_one_window() {
187        let _g = CLOCK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
188        reset_clock_to(0);
189        // Deadline at 500_000 µs (500ms).
190        let token = CancelToken::none().with_deadline(read_clock, 500_000);
191        let mut last = 0u64;
192
193        // Iterations 1-5: cheap fast-path (each advances 50ms;
194        // budget=100ms so every other iter does a real check).
195        // The deadline (500ms) hasn't tripped yet.
196        for i in 1..=5 {
197            advance_clock(50_000);
198            let result = token.check_with_budget(&mut last, 100_000);
199            assert!(
200                result.is_ok(),
201                "iter {i}: clock {}µs deadline 500_000µs — should not yet cancel",
202                read_clock()
203            );
204        }
205        assert_eq!(read_clock(), 250_000, "5 iters × 50ms = 250ms");
206
207        // Advance past the deadline.
208        advance_clock(300_000); // now 550ms, past 500ms deadline
209        let result = token.check_with_budget(&mut last, 100_000);
210        assert!(
211            matches!(result, Err(EngineError::Cancelled)),
212            "after deadline trip, budget check must surface Cancelled; got {result:?}"
213        );
214    }
215
216    /// With no deadline attached, `check_with_budget` is a no-op
217    /// (the cheap-path early-return is the flag-only check).
218    #[test]
219    fn v7_37_14_no_deadline_check_with_budget_is_cheap_flag_only() {
220        let flag = AtomicU64::new(0); // unused; we use a real AtomicBool
221        let _ = flag; // silence
222        let token = CancelToken::none();
223        let mut last = 0u64;
224        // 100 budgeted checks must not produce any error.
225        for _ in 0..100 {
226            assert!(token.check_with_budget(&mut last, 100_000).is_ok());
227        }
228        // last never updated because there's no deadline → no
229        // monotonic source to read.
230        assert_eq!(last, 0, "no-deadline path doesn't touch last_check_us");
231    }
232
233    /// Budget is honoured: within one window, the helper does NOT
234    /// re-check the deadline; once the window elapses, it does.
235    /// Verifies the "skip until budget elapsed" branch.
236    #[test]
237    fn v7_37_14_budget_skips_within_window() {
238        let _g = CLOCK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
239        reset_clock_to(0);
240        // Deadline at 100ms (already past clock=0 + small advance).
241        let token = CancelToken::none().with_deadline(read_clock, 100_000);
242        let mut last = 0u64;
243
244        // First call advances clock by 30µs (still well within budget=100ms).
245        // Initial last=0, so first call IS a real check → updates last.
246        advance_clock(30);
247        let _ = token.check_with_budget(&mut last, 100_000);
248        let after_first = last;
249        assert!(
250            after_first > 0,
251            "first call must perform real check + update"
252        );
253
254        // Second call within budget — last must NOT change.
255        advance_clock(30);
256        let _ = token.check_with_budget(&mut last, 100_000);
257        assert_eq!(
258            last, after_first,
259            "second call within budget window must skip real check"
260        );
261
262        // Advance past budget — next call DOES check + updates.
263        advance_clock(150_000); // now well past 100ms budget
264        let _ = token.check_with_budget(&mut last, 100_000);
265        assert!(
266            last > after_first,
267            "after budget elapses, real check fires + last updates"
268        );
269    }
270}