Skip to main content

grafeo_common/testing/
statement_failure.rs

1//! Statement / commit failure injection for testing rollback paths.
2//!
3//! When the `testing-statement-injection` feature is enabled,
4//! [`maybe_fail_statement`] counts down a **thread-local** counter and returns
5//! [`InjectedFailure`] when it reaches zero. [`maybe_fail_commit`] returns
6//! [`InjectedFailure`] once if a commit trigger is armed.
7//!
8//! Unlike [`crash`](super::crash) (which panics), injection returns an error
9//! so the session's normal error path runs. This matches how real runtime
10//! errors (constraint violations, parse errors) behave: the transaction stays
11//! active and the caller is expected to issue `ROLLBACK` explicitly.
12//!
13//! Thread-local storage ensures concurrent tests never interfere with each
14//! other; only the thread that calls [`enable_statement_failure_after`] /
15//! [`enable_commit_failure_once`] is affected.
16//!
17//! When the feature is **disabled**, all functions compile to no-ops with
18//! zero runtime overhead.
19//!
20//! # Example
21//!
22//! ```ignore
23//! use grafeo_common::testing::statement_failure::with_statement_failure_after;
24//!
25//! with_statement_failure_after(3, || {
26//!     // 1st and 2nd calls to maybe_fail_statement succeed.
27//!     // 3rd returns Err(InjectedFailure).
28//! });
29//! ```
30
31use std::fmt;
32
33/// Error returned by [`maybe_fail_statement`] / [`maybe_fail_commit`] when
34/// the injection fires. Tests match on this via the session's error chain.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct InjectedFailure;
37
38impl fmt::Display for InjectedFailure {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str("injected test failure")
41    }
42}
43
44impl std::error::Error for InjectedFailure {}
45
46#[cfg(feature = "testing-statement-injection")]
47mod inner {
48    use super::InjectedFailure;
49    use std::cell::Cell;
50
51    thread_local! {
52        static STATEMENT_COUNTER: Cell<u64> = const { Cell::new(u64::MAX) };
53        static STATEMENT_ENABLED: Cell<bool> = const { Cell::new(false) };
54        static COMMIT_TRIGGER: Cell<bool> = const { Cell::new(false) };
55    }
56
57    /// Conditionally return [`InjectedFailure`] when the statement counter
58    /// reaches zero. Insert this at the entry of each statement-execution
59    /// boundary. Zero-overhead when injection is disabled.
60    ///
61    /// Uses thread-local state so concurrent tests don't interfere.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`InjectedFailure`] when statement injection is enabled and
66    /// the counter reaches zero.
67    #[inline]
68    pub fn maybe_fail_statement() -> Result<(), InjectedFailure> {
69        STATEMENT_ENABLED.with(|enabled| {
70            if !enabled.get() {
71                return Ok(());
72            }
73            STATEMENT_COUNTER.with(|counter| {
74                let prev = counter.get();
75                counter.set(prev.wrapping_sub(1));
76                if prev == 1 {
77                    Err(InjectedFailure)
78                } else {
79                    Ok(())
80                }
81            })
82        })
83    }
84
85    /// Conditionally return [`InjectedFailure`] once if a commit failure is
86    /// armed. The trigger is one-shot: it consumes itself on first call.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`InjectedFailure`] once per call to [`enable_commit_failure_once`].
91    #[inline]
92    pub fn maybe_fail_commit() -> Result<(), InjectedFailure> {
93        COMMIT_TRIGGER.with(|trigger| {
94            if trigger.get() {
95                trigger.set(false);
96                Err(InjectedFailure)
97            } else {
98                Ok(())
99            }
100        })
101    }
102
103    /// Arm statement injection to fire on the `count`-th call to
104    /// [`maybe_fail_statement`].
105    ///
106    /// Only affects the calling thread.
107    pub fn enable_statement_failure_after(count: u64) {
108        STATEMENT_COUNTER.with(|c| c.set(count));
109        STATEMENT_ENABLED.with(|e| e.set(true));
110    }
111
112    /// Arm a one-shot commit failure for the next call to [`maybe_fail_commit`].
113    ///
114    /// Only affects the calling thread.
115    pub fn enable_commit_failure_once() {
116        COMMIT_TRIGGER.with(|t| t.set(true));
117    }
118
119    /// Disable both statement and commit injection (reset to no-op behavior).
120    ///
121    /// Only affects the calling thread.
122    pub fn disable_injection() {
123        STATEMENT_ENABLED.with(|e| e.set(false));
124        STATEMENT_COUNTER.with(|c| c.set(u64::MAX));
125        COMMIT_TRIGGER.with(|t| t.set(false));
126    }
127}
128
129#[cfg(not(feature = "testing-statement-injection"))]
130mod inner {
131    use super::InjectedFailure;
132
133    /// No-op when injection is disabled.
134    ///
135    /// # Errors
136    ///
137    /// Always returns `Ok(())` when the `testing-statement-injection` feature
138    /// is off; the result type is kept so call sites compile uniformly.
139    #[inline]
140    pub fn maybe_fail_statement() -> Result<(), InjectedFailure> {
141        Ok(())
142    }
143
144    /// No-op when injection is disabled.
145    ///
146    /// # Errors
147    ///
148    /// Always returns `Ok(())` when the `testing-statement-injection` feature
149    /// is off; the result type is kept so call sites compile uniformly.
150    #[inline]
151    pub fn maybe_fail_commit() -> Result<(), InjectedFailure> {
152        Ok(())
153    }
154
155    /// No-op when injection is disabled.
156    pub fn enable_statement_failure_after(_count: u64) {}
157
158    /// No-op when injection is disabled.
159    pub fn enable_commit_failure_once() {}
160
161    /// No-op when injection is disabled.
162    pub fn disable_injection() {}
163}
164
165pub use inner::*;
166
167/// Run `f` with statement injection armed to fire on the `fail_after`-th call
168/// to [`maybe_fail_statement`]. Injection is automatically disabled after the
169/// closure returns.
170pub fn with_statement_failure_after<F, T>(fail_after: u64, f: F) -> T
171where
172    F: FnOnce() -> T,
173{
174    enable_statement_failure_after(fail_after);
175    let result = f();
176    disable_injection();
177    result
178}
179
180/// Run `f` with a one-shot commit failure armed. Injection is automatically
181/// disabled after the closure returns.
182pub fn with_commit_failure<F, T>(f: F) -> T
183where
184    F: FnOnce() -> T,
185{
186    enable_commit_failure_once();
187    let result = f();
188    disable_injection();
189    result
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    #[cfg(feature = "testing-statement-injection")]
198    fn statement_fires_at_exact_count() {
199        let result = with_statement_failure_after(3, || {
200            let a = maybe_fail_statement();
201            let b = maybe_fail_statement();
202            let c = maybe_fail_statement();
203            (a, b, c)
204        });
205        assert_eq!(result.0, Ok(()));
206        assert_eq!(result.1, Ok(()));
207        assert_eq!(result.2, Err(InjectedFailure));
208    }
209
210    #[test]
211    #[cfg(feature = "testing-statement-injection")]
212    fn commit_fires_once() {
213        let result = with_commit_failure(|| {
214            let a = maybe_fail_commit();
215            let b = maybe_fail_commit();
216            (a, b)
217        });
218        assert_eq!(result.0, Err(InjectedFailure));
219        assert_eq!(result.1, Ok(()), "commit trigger is one-shot");
220    }
221
222    #[test]
223    fn completes_when_count_exceeds_calls() {
224        let result = with_statement_failure_after(100, || {
225            let a = maybe_fail_statement();
226            let b = maybe_fail_statement();
227            (a, b)
228        });
229        assert_eq!(result.0, Ok(()));
230        assert_eq!(result.1, Ok(()));
231    }
232
233    #[test]
234    fn disabled_by_default() {
235        assert_eq!(maybe_fail_statement(), Ok(()));
236        assert_eq!(maybe_fail_commit(), Ok(()));
237    }
238
239    #[test]
240    fn disable_resets_state() {
241        enable_statement_failure_after(2);
242        enable_commit_failure_once();
243        disable_injection();
244        assert_eq!(maybe_fail_statement(), Ok(()));
245        assert_eq!(maybe_fail_statement(), Ok(()));
246        assert_eq!(maybe_fail_commit(), Ok(()));
247    }
248}