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}