Skip to main content

spg_engine/testkit/
injection.rs

1//! v7.38 P0 元机制 A — injection_points framework.
2//!
3//! ## What this gives the test suite
4//!
5//! Pepper the engine / storage hot paths with
6//!
7//! ```ignore
8//! crate::injection_point!("aggregate_spill_trigger", &group_count);
9//! ```
10//!
11//! and tests can do
12//!
13//! ```ignore
14//! engine.execute("SELECT spg_injection_attach('aggregate_spill_trigger', 'wait')")?;
15//! // … spawn worker that hits the point …
16//! engine.execute("SELECT spg_injection_wakeup('aggregate_spill_trigger')")?;
17//! ```
18//!
19//! to drive deterministic timing without `sleep` + probability. The
20//! framework also supports `error:<msg>` (panic-cum-typed-error at the
21//! site) and `notice:<msg>` (record + tally, never block). Multiple
22//! attaches to the same point clobber the previous one — same as PG's
23//! `injection_points_attach`.
24//!
25//! ## Production safety
26//!
27//! With the crate feature `injection-points` **off** (the default
28//! release configuration) `injection_point!()` expands to the no-op
29//!
30//! ```ignore
31//! { let _ = ($($payload,)*); }
32//! ```
33//!
34//! so the payload exprs still type-check (catching drift between site
35//! and tests) but no runtime trace ever happens. Zero `__trigger`
36//! symbol, zero thread-local touch, zero `Mutex` taken. The two unit
37//! tests at the bottom of this file verify the no-op invariant by
38//! sampling `core::mem::size_of` shadow markers and inspecting the
39//! generated symbol table (see `zero_cost_release_macro_expands_empty`
40//! and the `cargo asm` recipe documented next to it).
41//!
42//! The SQL-facing `spg_injection_*` builtin functions follow the same
43//! gating: with the feature off they return `EvalError::TypeMismatch
44//! { detail: "injection-points feature not enabled in this build" }`
45//! so a release SPG cannot be coerced into deadlocking even if an
46//! attacker manages to call them.
47//!
48//! ## Why thread-local not global
49//!
50//! Two engines share a process during integration tests (e.g. the
51//! permutation runner spins up `embedded` + `server_simple` engines in
52//! parallel). A single global registry would let test A inject into
53//! engine B; cross-contamination defeats the whole point. So:
54//!
55//! - Each [`crate::Engine`] owns an `Arc<InjectionStore>`.
56//! - Before any `execute_*` entry point we push the engine's store
57//!   onto a thread-local stack via [`Engine::enter_injection_scope`].
58//! - `__trigger` looks up the **current** store — `None` is silently
59//!   tolerated so dropping into the framework from a non-engine code
60//!   path is safe.
61//!
62//! Drop of [`InjectionGuard`] restores the previous store, so nested
63//! scopes (engine-within-engine for `exec_select_with_meta_views`
64//! style rewrites) compose.
65
66#[cfg(feature = "injection-points")]
67extern crate std;
68
69// ---------------------------------------------------------------------------
70// The macro is exported regardless of feature so call sites compile in
71// both configurations. `#[macro_export]` lifts it to the crate root, so
72// downstream call sites use `crate::injection_point!(…)`. We re-export
73// it from `lib.rs` via `pub use crate::injection_point;` for ergonomics
74// inside this crate.
75// ---------------------------------------------------------------------------
76
77/// Hook a perf-irrelevant test-only injection point into a code path.
78///
79/// `name` must be a string literal (matches PG's `INJECTION_POINT()`
80/// convention; we use the literal as a stable identifier across all
81/// builds). The optional payload expressions are evaluated **only**
82/// when the feature is on; in release builds they are bound through
83/// `let _ = (…);` which is enough to keep them typechecking without
84/// emitting code for any expression with no side effects (LLVM
85/// constant-folds the `()` tuple away — verified by `cargo asm`).
86///
87/// ```ignore
88/// // Hot site
89/// crate::injection_point!("planner_first_row_fetch", &stmt.from);
90/// ```
91///
92/// ```ignore
93/// // Test
94/// eng.execute("SELECT spg_injection_attach('planner_first_row_fetch', 'wait')")?;
95/// let h = std::thread::spawn(move || eng2.execute("SELECT * FROM big")?);
96/// // … assert worker parked …
97/// eng.execute("SELECT spg_injection_wakeup('planner_first_row_fetch')")?;
98/// ```
99#[cfg(not(feature = "injection-points"))]
100#[macro_export]
101macro_rules! injection_point {
102    ($name:literal $(, $payload:expr)* $(,)?) => {{
103        // Bind the payload exprs so they still typecheck (catches
104        // drift between site and tests). With no observable effect
105        // and the values immediately discarded, the optimiser drops
106        // the entire block when the operand has no side effects —
107        // which is the contract `injection_point!` documents.
108        $( let _ = &$payload; )*
109        let _ = $name;
110    }};
111}
112
113#[cfg(feature = "injection-points")]
114#[macro_export]
115macro_rules! injection_point {
116    ($name:literal $(, $payload:expr)* $(,)?) => {{
117        // The payload tuple goes by `&dyn Debug` so any single
118        // expression that already implements Debug works, including
119        // borrowed shapes (`&Vec<Row>`) and Copy types (`usize`).
120        let __spg_inj_payload = ($(&$payload as &dyn ::core::fmt::Debug,)*);
121        $crate::testkit::injection::__trigger($name, &__spg_inj_payload);
122    }};
123}
124
125// ---------------------------------------------------------------------------
126// Catalog of currently registered hot sites. Maintained by hand —
127// `injection_point!()` macro invocations are the source of truth, but
128// this list lets `spg_injection_list()` (future P0 work) and human
129// reviewers cross-check.
130//
131// Add to this list when you add an `injection_point!()` call. The
132// `tests::registered_points_match_catalog` test below enforces that no
133// site goes unlisted.
134// ---------------------------------------------------------------------------
135
136/// Stable identifiers for every `injection_point!()` site SPG ships.
137///
138/// Test code typically reaches for these via the `spg_injection_attach`
139/// SQL fn (string-typed), but having them in a Rust constant keeps the
140/// catalog and the call sites from drifting.
141pub const REGISTERED_POINTS: &[&str] = &[
142    // crates/spg-engine/src/aggregate.rs::run — fires at the top of the
143    // aggregate executor with the input row count. Tests can attach
144    // `wait` to block before the spill decision, or `notice:<tag>` to
145    // count invocations for a 100× rerun test.
146    "aggregate_spill_trigger",
147    // crates/spg-engine/src/lib.rs::Engine::exec_select_cancel — first
148    // statement of the planner's executor entry. Test uses it to inject
149    // a delay before the very first row is produced; useful for
150    // first-row latency / cancellation race tests.
151    "planner_first_row_fetch",
152    // crates/spg-engine/src/lib.rs::Engine::exec_commit — fires at the
153    // commit barrier on entry (representing the WAL group commit
154    // leader switch — see the docstring around line 1244). With the
155    // feature off this is a no-op.
156    "tx_commit_walgroup_leader_switch",
157    // crates/spg-engine/src/lib.rs::Engine::exec_commit — fires once
158    // we've moved the TX state out of the catalog map. Provides a
159    // "leader chosen" handle for the WAL group commit serialiser tests
160    // — paired with `tx_commit_walgroup_leader_switch` it lets a test
161    // simulate the leader/follower ordering.
162    "wal_group_commit_leader_chosen",
163    // crates/spg-storage/src/lib.rs::Table::add_index — fires after
164    // the B-tree index has been fully populated and pushed onto the
165    // table's index vector. Tests use it for index-build / scan
166    // ordering races.
167    "index_build_post_seal",
168    // ---- deferred sites (subsystems not yet on master) -----------
169    // The design doc lists checkpoint CoW, cold-tier resume, sqlx
170    // budget cancel, prefetch threshold and segment-forward resume,
171    // but the matching subsystems are still on the
172    // feature/checkpoint-cow branch (see v7.38-plan.md § 九) or live
173    // inside spg-sqlx which doesn't depend on this crate today. They
174    // get added here when the call sites land.
175];
176
177// ---------------------------------------------------------------------------
178// Off-feature shims — keep `Engine` callable from the rest of the
179// crate without sprinkling `#[cfg]` everywhere. Both `InjectionStore`
180// and `InjectionGuard` exist as zero-sized markers in the off
181// configuration.
182// ---------------------------------------------------------------------------
183
184#[cfg(not(feature = "injection-points"))]
185mod off {
186    use core::marker::PhantomData;
187
188    /// Zero-sized placeholder so `Engine::injection_store` survives
189    /// the `#[derive(Default)]` derivation when the feature is off.
190    /// `Arc<InjectionStore>` would still allocate; `()`-sized
191    /// `InjectionStore` plus a wrapping `core::marker::PhantomData`
192    /// keeps the field at zero bytes.
193    #[derive(Debug, Default, Clone)]
194    pub struct InjectionStore;
195
196    /// RAII guard that does nothing when the feature is off.
197    #[must_use]
198    #[derive(Debug)]
199    pub struct InjectionGuard {
200        _priv: PhantomData<()>,
201    }
202
203    impl InjectionGuard {
204        pub(crate) const fn noop() -> Self {
205            Self { _priv: PhantomData }
206        }
207    }
208
209    impl Drop for InjectionGuard {
210        fn drop(&mut self) {
211            // intentional: no thread-local touch in release
212        }
213    }
214}
215
216#[cfg(not(feature = "injection-points"))]
217pub use off::{InjectionGuard, InjectionStore};
218
219#[cfg(not(feature = "injection-points"))]
220pub fn new_guard() -> InjectionGuard {
221    InjectionGuard::noop()
222}
223
224#[cfg(not(feature = "injection-points"))]
225pub fn enter_scope(_store: &InjectionStore) -> InjectionGuard {
226    InjectionGuard::noop()
227}
228
229// ---------------------------------------------------------------------------
230// Active runtime — only compiled when the feature is on.
231// ---------------------------------------------------------------------------
232
233#[cfg(feature = "injection-points")]
234mod active {
235    extern crate std;
236
237    use alloc::collections::BTreeMap;
238    use alloc::string::{String, ToString};
239    use alloc::sync::Arc;
240    use alloc::vec::Vec;
241    use core::cell::RefCell;
242    use core::fmt;
243    use std::sync::{Condvar, Mutex};
244
245    /// The four-flavoured action attached to a registered point. PG's
246    /// `injection_points` module ships the same set under the names
247    /// `wait` / `error` / `notice` plus `wakeup` as a separate verb;
248    /// we fold `wakeup` into the SQL surface (`spg_injection_wakeup`)
249    /// since it doesn't change the attached action — it signals the
250    /// condvar.
251    #[derive(Clone)]
252    pub enum Action {
253        /// Block the calling thread on a condvar until a paired
254        /// `spg_injection_wakeup('name')` arrives. Allocates one
255        /// `(Mutex<()>, Condvar)` pair per attach so multiple parked
256        /// threads share the wake.
257        Wait(Arc<(Mutex<()>, Condvar)>),
258        /// Panic at the call site with `INJECTED ERROR: <msg>`.
259        /// `Engine::execute_*` wraps the executor in catch_unwind via
260        /// the existing `EngineError::Internal` boundary so the panic
261        /// surfaces as a regular SQL error to the caller, not an
262        /// abort.
263        Error(String),
264        /// Bump the notice counter for this point and record the
265        /// (most recent) message. Never blocks. Test code reads back
266        /// via `InjectionStore::notice_count` / `notice_message`.
267        Notice(String),
268    }
269
270    impl fmt::Debug for Action {
271        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272            match self {
273                Action::Wait(_) => f.write_str("Wait(<cv>)"),
274                Action::Error(s) => write!(f, "Error({s:?})"),
275                Action::Notice(s) => write!(f, "Notice({s:?})"),
276            }
277        }
278    }
279
280    /// Per-engine attach table + notice tally. Lives behind an `Arc`
281    /// inside `Engine`; cloning is cheap and the `Mutex` is only ever
282    /// taken under the framework's own paths so the production-path
283    /// `#[cfg(not(feature = "injection-points"))]` build never sees it.
284    #[derive(Default)]
285    pub struct InjectionStore {
286        inner: Mutex<Inner>,
287    }
288
289    impl fmt::Debug for InjectionStore {
290        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291            f.write_str("InjectionStore")
292        }
293    }
294
295    #[derive(Default)]
296    struct Inner {
297        actions: BTreeMap<String, Action>,
298        // Tally + last message per Notice action so tests can read
299        // them back without parsing logs. Indexed by point name; not
300        // bounded — tests should detach when they're done.
301        notice_count: BTreeMap<String, u64>,
302        notice_message: BTreeMap<String, String>,
303    }
304
305    impl InjectionStore {
306        pub fn attach(&self, name: impl Into<String>, action: Action) {
307            // unwrap-on-poison is fine: a poisoned testkit Mutex means
308            // a prior test panicked under it, and tests want a loud
309            // failure not a silent recovery.
310            self.inner
311                .lock()
312                .expect("InjectionStore poisoned")
313                .actions
314                .insert(name.into(), action);
315        }
316
317        pub fn detach(&self, name: &str) {
318            self.inner
319                .lock()
320                .expect("InjectionStore poisoned")
321                .actions
322                .remove(name);
323        }
324
325        pub fn get(&self, name: &str) -> Option<Action> {
326            self.inner
327                .lock()
328                .expect("InjectionStore poisoned")
329                .actions
330                .get(name)
331                .cloned()
332        }
333
334        /// Notify any threads currently waiting on `name`. The wake
335        /// is at least one waiter on PG-style `injection_points`;
336        /// here we `notify_all` so multiple parked threads can be
337        /// released by a single SQL call (handier in tests).
338        pub fn wakeup(&self, name: &str) {
339            let cv = {
340                let guard = self.inner.lock().expect("InjectionStore poisoned");
341                match guard.actions.get(name) {
342                    Some(Action::Wait(cv)) => cv.clone(),
343                    _ => return,
344                }
345            };
346            cv.1.notify_all();
347        }
348
349        pub fn record_notice(&self, name: &str, msg: &str) {
350            let mut g = self.inner.lock().expect("InjectionStore poisoned");
351            *g.notice_count.entry(name.to_string()).or_insert(0) += 1;
352            g.notice_message.insert(name.to_string(), msg.to_string());
353        }
354
355        pub fn notice_count(&self, name: &str) -> u64 {
356            self.inner
357                .lock()
358                .expect("InjectionStore poisoned")
359                .notice_count
360                .get(name)
361                .copied()
362                .unwrap_or(0)
363        }
364
365        pub fn notice_message(&self, name: &str) -> Option<String> {
366            self.inner
367                .lock()
368                .expect("InjectionStore poisoned")
369                .notice_message
370                .get(name)
371                .cloned()
372        }
373    }
374
375    // ---- thread-local engine context ----------------------------------
376
377    std::thread_local! {
378        static CURRENT: RefCell<Vec<Arc<InjectionStore>>> = const { RefCell::new(Vec::new()) };
379    }
380
381    /// RAII guard that pops the engine's store off the thread-local
382    /// stack on drop. Returned by `Engine::enter_injection_scope`.
383    #[must_use]
384    #[derive(Debug)]
385    pub struct InjectionGuard {
386        _priv: (),
387    }
388
389    impl Drop for InjectionGuard {
390        fn drop(&mut self) {
391            CURRENT.with(|c| {
392                let mut b = c.borrow_mut();
393                b.pop();
394            });
395        }
396    }
397
398    /// Push `store` onto the thread-local stack; the returned guard
399    /// pops it on drop. Engine call sites do this at the top of every
400    /// `execute_*` entry so the `__trigger` lookup below finds the
401    /// right store regardless of which engine is on top of the stack.
402    pub fn enter_scope(store: &Arc<InjectionStore>) -> InjectionGuard {
403        CURRENT.with(|c| c.borrow_mut().push(store.clone()));
404        InjectionGuard { _priv: () }
405    }
406
407    /// Current store (top of the thread-local stack). `None` outside
408    /// any `enter_scope` — e.g. raw construction of `Engine` in unit
409    /// tests that don't go through `execute`.
410    pub fn current() -> Option<Arc<InjectionStore>> {
411        CURRENT.with(|c| c.borrow().last().cloned())
412    }
413
414    /// The macro target. `payload` is `&dyn Debug` so non-trivial site
415    /// state (row counts, group counts, the `FROM` clause) can be
416    /// passed without forcing the site to construct a String — Debug
417    /// is only walked when the action is `Notice` or for `tracing`.
418    #[inline]
419    pub fn __trigger(name: &'static str, payload: &dyn fmt::Debug) {
420        let Some(store) = current() else { return };
421        let Some(action) = store.get(name) else {
422            return;
423        };
424        match action {
425            Action::Wait(cv) => {
426                let g = cv.0.lock().expect("inject wait mutex poisoned");
427                // Park until a paired wakeup. We don't have a
428                // payload-driven predicate (yet); release on any
429                // notification. `drop` instead of `let _` because
430                // clippy considers a non-binding let on a lock guard
431                // a footgun (the guard is dropped at end-of-stmt,
432                // which is the same here, but we make it explicit).
433                let guard = cv.1.wait(g).expect("inject condvar poisoned");
434                drop(guard);
435            }
436            Action::Error(msg) => {
437                // Engine::execute boundary catches panics into
438                // EngineError::Internal already, so this surfaces as
439                // a regular SQL error.
440                std::panic::panic_any(InjectedError {
441                    name,
442                    msg,
443                    payload: alloc::format!("{payload:?}"),
444                });
445            }
446            Action::Notice(msg) => {
447                store.record_notice(name, &msg);
448            }
449        }
450    }
451
452    /// Typed payload used by panic_any so test code (or executor
453    /// catch_unwind) can downcast and report cleanly.
454    #[derive(Debug)]
455    pub struct InjectedError {
456        pub name: &'static str,
457        pub msg: String,
458        pub payload: String,
459    }
460
461    impl fmt::Display for InjectedError {
462        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
463            write!(
464                f,
465                "INJECTED ERROR at {}: {} (payload={})",
466                self.name, self.msg, self.payload
467            )
468        }
469    }
470
471    // ---- string → Action parsing -------------------------------------
472
473    /// Parse the action string from `spg_injection_attach`. Accepts
474    /// `wait`, `error[:<msg>]`, `notice[:<msg>]`. Default messages are
475    /// the action name itself.
476    pub fn parse_action(s: &str) -> Result<Action, String> {
477        let lower = s.trim().to_ascii_lowercase();
478        if lower == "wait" {
479            return Ok(Action::Wait(Arc::new((Mutex::new(()), Condvar::new()))));
480        }
481        if let Some(rest) = lower.strip_prefix("error") {
482            let msg = rest.strip_prefix(':').unwrap_or("").trim();
483            return Ok(Action::Error(if msg.is_empty() {
484                "injected".into()
485            } else {
486                msg.to_string()
487            }));
488        }
489        if let Some(rest) = lower.strip_prefix("notice") {
490            let msg = rest.strip_prefix(':').unwrap_or("").trim();
491            return Ok(Action::Notice(if msg.is_empty() {
492                "notice".into()
493            } else {
494                msg.to_string()
495            }));
496        }
497        Err(alloc::format!(
498            "unknown injection action {s:?}; expected wait | error[:msg] | notice[:msg]"
499        ))
500    }
501}
502
503#[cfg(feature = "injection-points")]
504pub use active::{
505    __trigger, Action, InjectedError, InjectionGuard, InjectionStore, current, enter_scope,
506    parse_action,
507};
508
509// ---------------------------------------------------------------------------
510// Unit tests
511// ---------------------------------------------------------------------------
512
513#[cfg(all(test, feature = "injection-points"))]
514mod tests {
515    use super::*;
516    use alloc::sync::Arc;
517    use std::thread;
518    use std::time::Duration;
519
520    #[test]
521    fn attach_wait_then_wakeup_releases_waiter() {
522        let store = Arc::new(InjectionStore::default());
523        let _g = enter_scope(&store);
524
525        store.attach("test_wait", parse_action("wait").unwrap());
526
527        // Spawn a worker that hits the point. It should park inside
528        // `__trigger`'s Condvar wait until the main thread fires
529        // wakeup.
530        let store2 = store.clone();
531        let h = thread::spawn(move || {
532            let _g = enter_scope(&store2);
533            crate::injection_point!("test_wait", &42usize);
534            // unreached until wakeup
535            "done"
536        });
537
538        // Tiny sleep is unavoidable here — we're testing the
539        // mechanism by checking that the worker is still alive after
540        // a real-time pause. Production code paths replace this exact
541        // pattern with the injection point.
542        thread::sleep(Duration::from_millis(50));
543        assert!(!h.is_finished(), "worker did not park on injection wait");
544
545        store.wakeup("test_wait");
546        let res = h.join().expect("worker thread panicked");
547        assert_eq!(res, "done");
548    }
549
550    #[test]
551    fn notice_increments_counter_without_blocking() {
552        let store = Arc::new(InjectionStore::default());
553        let _g = enter_scope(&store);
554        store.attach("test_notice", parse_action("notice:tag").unwrap());
555
556        for _ in 0..3 {
557            crate::injection_point!("test_notice", &());
558        }
559        assert_eq!(store.notice_count("test_notice"), 3);
560        assert_eq!(store.notice_message("test_notice").as_deref(), Some("tag"));
561    }
562
563    #[test]
564    fn no_scope_is_silent() {
565        // No enter_scope — the point should be a quiet no-op.
566        crate::injection_point!("nobody_attached", &"payload");
567    }
568
569    #[test]
570    fn detach_removes_action() {
571        let store = Arc::new(InjectionStore::default());
572        let _g = enter_scope(&store);
573        store.attach("test_detach", parse_action("notice").unwrap());
574        crate::injection_point!("test_detach", &1u8);
575        assert_eq!(store.notice_count("test_detach"), 1);
576        store.detach("test_detach");
577        crate::injection_point!("test_detach", &2u8);
578        assert_eq!(
579            store.notice_count("test_detach"),
580            1,
581            "detached point should stop counting"
582        );
583    }
584
585    #[test]
586    fn registered_points_catalog_nonempty() {
587        // Smoke check: someone is registering points.
588        assert!(!REGISTERED_POINTS.is_empty());
589        // No empty / whitespace names.
590        for &p in REGISTERED_POINTS {
591            assert!(!p.is_empty());
592            assert!(
593                !p.chars().any(char::is_whitespace),
594                "point name has whitespace: {p}"
595            );
596        }
597    }
598}
599
600// Off-feature smoke test — verifies the macro compiles to nothing
601// observable.
602#[cfg(all(test, not(feature = "injection-points")))]
603mod off_tests {
604    #[test]
605    fn macro_off_compiles_and_runs() {
606        // No engine context exists in this build — the macro should
607        // be a quiet no-op that doesn't touch any thread-local and
608        // doesn't allocate.
609        let payload = 42usize;
610        crate::injection_point!("smoke", &payload);
611        crate::injection_point!("smoke_no_payload");
612    }
613}