Skip to main content

rpytest_daemon/
flakiness.rs

1//! Flakiness detection and tracking with explicit stability state machine.
2
3use crate::error::Result;
4use crate::models::{FlakinessRecord, StabilityState, TestOutcome};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8use tracing::debug;
9
10impl StabilityState {
11    /// Apply an outcome and return the new state.
12    ///
13    /// The `prev_outcome` is the most recent previous outcome (if any).
14    pub fn transition(self, outcome: &TestOutcome, prev_outcome: Option<&TestOutcome>) -> Self {
15        match self {
16            StabilityState::Unknown => match outcome {
17                TestOutcome::Passed => StabilityState::Stable {
18                    consecutive_passes: 1,
19                },
20                TestOutcome::Failed | TestOutcome::Error => StabilityState::Unstable {
21                    consecutive_failures: 1,
22                },
23                // Skipped, xfail, xpass don't contribute to stability tracking
24                _ => StabilityState::Unknown,
25            },
26            StabilityState::Stable { consecutive_passes } => match outcome {
27                TestOutcome::Passed => StabilityState::Stable {
28                    consecutive_passes: consecutive_passes + 1,
29                },
30                TestOutcome::Failed | TestOutcome::Error => {
31                    // First failure after stable streak: enter flaky
32                    StabilityState::Flaky { streak_count: 1 }
33                }
34                _ => StabilityState::Unknown,
35            },
36            StabilityState::Unstable {
37                consecutive_failures,
38            } => match outcome {
39                TestOutcome::Passed => {
40                    // First pass after unstable streak: enter flaky
41                    StabilityState::Flaky { streak_count: 1 }
42                }
43                TestOutcome::Failed | TestOutcome::Error => StabilityState::Unstable {
44                    consecutive_failures: consecutive_failures + 1,
45                },
46                _ => StabilityState::Unknown,
47            },
48            StabilityState::Flaky { streak_count } => {
49                // Determine if this outcome is a flip from the previous
50                let is_flip = prev_outcome.map_or(false, |prev| {
51                    let prev_is_fail = matches!(prev, TestOutcome::Failed | TestOutcome::Error);
52                    let curr_is_fail = matches!(outcome, TestOutcome::Failed | TestOutcome::Error);
53                    prev_is_fail != curr_is_fail
54                });
55
56                if is_flip {
57                    let new_streak = streak_count + 1;
58                    if new_streak >= 3 {
59                        // Promote to confirmed flaky after 3+ flips
60                        StabilityState::ConfirmedFlaky
61                    } else {
62                        StabilityState::Flaky {
63                            streak_count: new_streak,
64                        }
65                    }
66                } else {
67                    // No flip: transition to stable or unstable based on current outcome
68                    match outcome {
69                        TestOutcome::Passed => StabilityState::Stable {
70                            consecutive_passes: 1,
71                        },
72                        TestOutcome::Failed | TestOutcome::Error => StabilityState::Unstable {
73                            consecutive_failures: 1,
74                        },
75                        _ => StabilityState::Unknown,
76                    }
77                }
78            }
79            StabilityState::ConfirmedFlaky => {
80                // Once confirmed flaky, stay confirmed unless we see a long stable streak
81                match outcome {
82                    TestOutcome::Passed => {
83                        // Check if previous was also passed (no flip)
84                        let prev_was_pass =
85                            prev_outcome.map_or(false, |prev| matches!(prev, TestOutcome::Passed));
86                        if prev_was_pass {
87                            // Start counting stable streak
88                            StabilityState::Stable {
89                                consecutive_passes: 2, // this pass + previous pass
90                            }
91                        } else {
92                            StabilityState::ConfirmedFlaky
93                        }
94                    }
95                    TestOutcome::Failed | TestOutcome::Error => {
96                        let prev_was_fail = prev_outcome.map_or(false, |prev| {
97                            matches!(prev, TestOutcome::Failed | TestOutcome::Error)
98                        });
99                        if prev_was_fail {
100                            StabilityState::Unstable {
101                                consecutive_failures: 2,
102                            }
103                        } else {
104                            StabilityState::ConfirmedFlaky
105                        }
106                    }
107                    _ => StabilityState::ConfirmedFlaky,
108                }
109            }
110        }
111    }
112}
113
114/// Tracks test flakiness and manages auto-reruns.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct FlakinessTracker {
117    /// Flakiness records by node_id
118    records: HashMap<String, FlakinessRecord>,
119    /// Maximum number of outcomes to keep per test
120    max_outcomes: usize,
121    /// Path to persist data
122    storage_path: Option<PathBuf>,
123    /// Dirty flag for buffered writes
124    #[serde(skip)]
125    dirty: bool,
126}
127
128impl FlakinessTracker {
129    /// Create a new tracker.
130    pub fn new(storage_path: Option<PathBuf>) -> Self {
131        let mut tracker = FlakinessTracker {
132            records: HashMap::new(),
133            max_outcomes: 20,
134            storage_path,
135            dirty: false,
136        };
137
138        // Load from disk if path provided
139        if let Some(ref path) = tracker.storage_path {
140            if path.exists() {
141                let _ = tracker.load();
142            }
143        }
144
145        tracker
146    }
147
148    /// Record a test outcome and update statistics.
149    ///
150    /// Updates the stability state machine for the test.
151    pub fn record_outcome(&mut self, node_id: &str, outcome: TestOutcome, message: Option<&str>) {
152        let record = self
153            .records
154            .entry(node_id.to_string())
155            .or_insert_with(|| FlakinessRecord {
156                node_id: node_id.to_string(),
157                outcomes: Vec::new(),
158                consecutive_failures: 0,
159                consecutive_passes: 0,
160                flaky_streak: 0,
161                total_runs: 0,
162                last_failure_message: None,
163                stability: StabilityState::Unknown,
164            });
165
166        let prev_outcome = record.outcomes.last().cloned();
167
168        record.outcomes.push(outcome.clone().into());
169
170        // Keep only last N outcomes
171        if record.outcomes.len() > self.max_outcomes {
172            record.outcomes = record.outcomes[record.outcomes.len() - self.max_outcomes..].to_vec();
173        }
174
175        record.total_runs += 1;
176
177        // Update stability state machine
178        let prev_for_transition = prev_outcome.as_ref().and_then(|s| match s.as_str() {
179            "passed" => Some(TestOutcome::Passed),
180            "failed" => Some(TestOutcome::Failed),
181            "error" => Some(TestOutcome::Error),
182            "skipped" => Some(TestOutcome::Skipped),
183            "xfail" => Some(TestOutcome::Xfail),
184            "xpass" => Some(TestOutcome::Xpass),
185            _ => None,
186        });
187
188        let old_stability = std::mem::replace(
189            &mut record.stability,
190            StabilityState::Unknown, // temporary placeholder
191        );
192        record.stability = old_stability.transition(&outcome, prev_for_transition.as_ref());
193
194        // Update legacy counters for backward compatibility with serialization
195        match outcome {
196            TestOutcome::Failed | TestOutcome::Error => {
197                record.consecutive_failures += 1;
198                record.consecutive_passes = 0;
199                record.last_failure_message = message.map(|s| s.to_string());
200            }
201            TestOutcome::Passed => {
202                record.consecutive_passes += 1;
203                record.consecutive_failures = 0;
204            }
205            _ => {
206                // skipped, xfail, xpass - reset both
207                record.consecutive_failures = 0;
208                record.consecutive_passes = 0;
209            }
210        }
211
212        // Track flaky streaks (outcome flips) for backward compatibility
213        if let Some(ref prev) = prev_outcome {
214            let prev_str = prev.as_str();
215            let prev_is_fail = prev_str == "failed" || prev_str == "error";
216            let curr_is_fail = matches!(outcome, TestOutcome::Failed | TestOutcome::Error);
217            if prev_is_fail != curr_is_fail {
218                record.flaky_streak += 1;
219            }
220        }
221
222        // Mark as dirty - caller should call flush_if_dirty() periodically
223        self.dirty = true;
224    }
225
226    /// Get flakiness record for a test.
227    pub fn get_record(&self, node_id: &str) -> Option<&FlakinessRecord> {
228        self.records.get(node_id)
229    }
230
231    /// Check if a test is considered flaky.
232    pub fn is_flaky(&self, node_id: &str) -> bool {
233        self.records
234            .get(node_id)
235            .map_or(false, |r| r.stability.is_flaky())
236    }
237
238    /// Get the stability state for a test.
239    pub fn stability_state(&self, node_id: &str) -> StabilityState {
240        self.records
241            .get(node_id)
242            .map_or(StabilityState::Unknown, |r| r.stability.clone())
243    }
244
245    /// Internal check for flakiness based on record (legacy heuristic).
246    fn check_is_flaky(record: &FlakinessRecord) -> bool {
247        if record.outcomes.len() < 3 {
248            return false;
249        }
250        let has_pass = record.outcomes.iter().any(|o| o == "passed");
251        let has_fail = record
252            .outcomes
253            .iter()
254            .any(|o| o == "failed" || o == "error");
255        has_pass && has_fail && record.flaky_streak >= 2
256    }
257
258    /// Get failure rate for a test (0.0 - 1.0).
259    pub fn get_failure_rate(&self, node_id: &str) -> f64 {
260        if let Some(record) = self.records.get(node_id) {
261            if record.outcomes.is_empty() {
262                return 0.0;
263            }
264            let failures: usize = record
265                .outcomes
266                .iter()
267                .filter(|o| o.as_str() == "failed" || o.as_str() == "error")
268                .count();
269            failures as f64 / record.outcomes.len() as f64
270        } else {
271            0.0
272        }
273    }
274
275    /// Get all flaky tests.
276    pub fn get_flaky_tests(&self) -> Vec<&FlakinessRecord> {
277        self.records
278            .values()
279            .filter(|r| r.stability.is_flaky())
280            .collect()
281    }
282
283    /// Get unstable tests (some failures but not flaky).
284    pub fn get_unstable_tests(&self) -> Vec<&FlakinessRecord> {
285        self.records
286            .values()
287            .filter(|r| {
288                let has_fail = r
289                    .outcomes
290                    .iter()
291                    .any(|o| o.as_str() == "failed" || o.as_str() == "error");
292                has_fail && !r.stability.is_flaky()
293            })
294            .collect()
295    }
296
297    /// Get count of stable tests.
298    pub fn stable_count(&self) -> usize {
299        self.records
300            .values()
301            .filter(|r| {
302                !r.stability.is_flaky()
303                    && !r
304                        .outcomes
305                        .iter()
306                        .any(|o| o.as_str() == "failed" || o.as_str() == "error")
307            })
308            .count()
309    }
310
311    /// Get total tracked tests.
312    pub fn total_tracked(&self) -> usize {
313        self.records.len()
314    }
315
316    /// Save to disk.
317    pub fn save(&self) -> Result<()> {
318        if let Some(ref path) = self.storage_path {
319            if let Some(parent) = path.parent() {
320                std::fs::create_dir_all(parent)?;
321            }
322            let data: Vec<_> = self.records.values().collect();
323            let json = serde_json::to_string_pretty(&data)?;
324            std::fs::write(path, json)?;
325            debug!("Saved {} flakiness records", self.records.len());
326        }
327        Ok(())
328    }
329
330    /// Flush to disk only if there are pending changes.
331    /// This is more efficient than calling save() after every record_outcome().
332    pub fn flush_if_dirty(&mut self) -> Result<()> {
333        if self.dirty {
334            self.save()?;
335            self.dirty = false;
336        }
337        Ok(())
338    }
339
340    /// Check if there are unsaved changes.
341    pub fn is_dirty(&self) -> bool {
342        self.dirty
343    }
344
345    /// Load from disk.
346    pub fn load(&mut self) -> Result<()> {
347        if let Some(ref path) = self.storage_path {
348            if !path.exists() {
349                return Ok(());
350            }
351            let json = std::fs::read_to_string(path)?;
352            let data: Vec<FlakinessRecord> = serde_json::from_str(&json)?;
353            self.records.clear();
354            for record in data {
355                self.records.insert(record.node_id.clone(), record);
356            }
357            debug!("Loaded {} flakiness records", self.records.len());
358        }
359        Ok(())
360    }
361
362    /// Clear all records.
363    pub fn clear(&mut self) {
364        self.records.clear();
365        if let Some(ref path) = self.storage_path {
366            let _ = std::fs::remove_file(path);
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use tempfile::TempDir;
375
376    #[test]
377    fn test_stability_state_transitions() {
378        // Unknown -> Stable
379        let state = StabilityState::Unknown.transition(&TestOutcome::Passed, None);
380        assert!(matches!(
381            state,
382            StabilityState::Stable {
383                consecutive_passes: 1
384            }
385        ));
386
387        // Unknown -> Unstable
388        let state = StabilityState::Unknown.transition(&TestOutcome::Failed, None);
389        assert!(matches!(
390            state,
391            StabilityState::Unstable {
392                consecutive_failures: 1
393            }
394        ));
395
396        // Stable -> Flaky (first failure)
397        let state = StabilityState::Stable {
398            consecutive_passes: 3,
399        }
400        .transition(&TestOutcome::Failed, Some(&TestOutcome::Passed));
401        assert!(matches!(state, StabilityState::Flaky { streak_count: 1 }));
402
403        // Unstable -> Flaky (first pass)
404        let state = StabilityState::Unstable {
405            consecutive_failures: 2,
406        }
407        .transition(&TestOutcome::Passed, Some(&TestOutcome::Failed));
408        assert!(matches!(state, StabilityState::Flaky { streak_count: 1 }));
409
410        // Flaky with flip -> Flaky (streak increases)
411        let state = StabilityState::Flaky { streak_count: 1 }
412            .transition(&TestOutcome::Failed, Some(&TestOutcome::Passed));
413        assert!(matches!(state, StabilityState::Flaky { streak_count: 2 }));
414
415        // Flaky with enough flips -> ConfirmedFlaky
416        let state = StabilityState::Flaky { streak_count: 2 }
417            .transition(&TestOutcome::Passed, Some(&TestOutcome::Failed));
418        assert!(matches!(state, StabilityState::ConfirmedFlaky));
419
420        // Flaky without flip -> Stable
421        let state = StabilityState::Flaky { streak_count: 1 }
422            .transition(&TestOutcome::Passed, Some(&TestOutcome::Passed));
423        assert!(matches!(
424            state,
425            StabilityState::Stable {
426                consecutive_passes: 1
427            }
428        ));
429
430        // Flaky without flip -> Unstable
431        let state = StabilityState::Flaky { streak_count: 1 }
432            .transition(&TestOutcome::Failed, Some(&TestOutcome::Failed));
433        assert!(matches!(
434            state,
435            StabilityState::Unstable {
436                consecutive_failures: 1
437            }
438        ));
439
440        // Skipped resets to Unknown
441        let state = StabilityState::Stable {
442            consecutive_passes: 5,
443        }
444        .transition(&TestOutcome::Skipped, Some(&TestOutcome::Passed));
445        assert!(matches!(state, StabilityState::Unknown));
446    }
447
448    #[test]
449    fn test_record_outcome() {
450        let dir = TempDir::new().unwrap();
451        let storage_path = dir.path().join("flakiness.json");
452        let mut tracker = FlakinessTracker::new(Some(storage_path));
453
454        tracker.record_outcome("test_a", TestOutcome::Passed, None);
455        tracker.record_outcome("test_a", TestOutcome::Failed, Some("error"));
456
457        assert_eq!(tracker.total_tracked(), 1);
458        assert!(!tracker.is_flaky("test_a")); // Need more outcomes for flaky state
459
460        let state = tracker.stability_state("test_a");
461        assert!(matches!(state, StabilityState::Flaky { streak_count: 1 }));
462    }
463
464    #[test]
465    fn test_flaky_detection() {
466        let dir = TempDir::new().unwrap();
467        let storage_path = dir.path().join("flakiness.json");
468        let mut tracker = FlakinessTracker::new(Some(storage_path));
469
470        // Record alternating outcomes
471        tracker.record_outcome("test_a", TestOutcome::Passed, None);
472        tracker.record_outcome("test_a", TestOutcome::Failed, None);
473        tracker.record_outcome("test_a", TestOutcome::Passed, None);
474        tracker.record_outcome("test_a", TestOutcome::Failed, None);
475        tracker.record_outcome("test_a", TestOutcome::Passed, None);
476
477        assert!(tracker.is_flaky("test_a"));
478        assert_eq!(tracker.get_failure_rate("test_a"), 0.4);
479
480        let state = tracker.stability_state("test_a");
481        assert!(matches!(state, StabilityState::ConfirmedFlaky));
482    }
483
484    #[test]
485    fn test_get_flaky_tests() {
486        let dir = TempDir::new().unwrap();
487        let storage_path = dir.path().join("flakiness.json");
488        let mut tracker = FlakinessTracker::new(Some(storage_path));
489
490        tracker.record_outcome("test_flaky", TestOutcome::Passed, None);
491        tracker.record_outcome("test_flaky", TestOutcome::Failed, None);
492        tracker.record_outcome("test_flaky", TestOutcome::Passed, None);
493
494        tracker.record_outcome("test_stable", TestOutcome::Passed, None);
495        tracker.record_outcome("test_stable", TestOutcome::Passed, None);
496
497        let flaky = tracker.get_flaky_tests();
498        assert_eq!(flaky.len(), 1);
499        assert_eq!(flaky[0].node_id, "test_flaky");
500        assert!(flaky[0].stability.is_flaky());
501    }
502
503    #[test]
504    fn test_confirmed_flaky_recovery() {
505        let dir = TempDir::new().unwrap();
506        let storage_path = dir.path().join("flakiness.json");
507        let mut tracker = FlakinessTracker::new(Some(storage_path));
508
509        // Make test confirmed flaky
510        tracker.record_outcome("test_recover", TestOutcome::Passed, None);
511        tracker.record_outcome("test_recover", TestOutcome::Failed, None);
512        tracker.record_outcome("test_recover", TestOutcome::Passed, None);
513        tracker.record_outcome("test_recover", TestOutcome::Failed, None);
514        tracker.record_outcome("test_recover", TestOutcome::Passed, None);
515
516        assert!(tracker.is_flaky("test_recover"));
517        let state = tracker.stability_state("test_recover");
518        assert!(matches!(state, StabilityState::ConfirmedFlaky));
519
520        // Now pass consistently - should eventually become stable
521        tracker.record_outcome("test_recover", TestOutcome::Passed, None);
522        tracker.record_outcome("test_recover", TestOutcome::Passed, None);
523        tracker.record_outcome("test_recover", TestOutcome::Passed, None);
524
525        let state = tracker.stability_state("test_recover");
526        assert!(matches!(
527            state,
528            StabilityState::Stable {
529                consecutive_passes: 4
530            }
531        ));
532        assert!(!tracker.is_flaky("test_recover"));
533    }
534
535    #[test]
536    fn test_stable_count() {
537        let dir = TempDir::new().unwrap();
538        let storage_path = dir.path().join("flakiness.json");
539        let mut tracker = FlakinessTracker::new(Some(storage_path));
540
541        tracker.record_outcome("stable1", TestOutcome::Passed, None);
542        tracker.record_outcome("stable1", TestOutcome::Passed, None);
543
544        tracker.record_outcome("flaky1", TestOutcome::Passed, None);
545        tracker.record_outcome("flaky1", TestOutcome::Failed, None);
546        tracker.record_outcome("flaky1", TestOutcome::Passed, None);
547
548        tracker.record_outcome("unstable1", TestOutcome::Failed, None);
549        tracker.record_outcome("unstable1", TestOutcome::Failed, None);
550
551        assert_eq!(tracker.stable_count(), 1);
552        assert_eq!(tracker.get_flaky_tests().len(), 1);
553        assert_eq!(tracker.get_unstable_tests().len(), 1);
554    }
555}