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}