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}