Skip to main content

origin_sync/
health.rs

1use crate::{SyncPolicy, SyncTarget};
2use origin_domain::{Health, SyncState};
3use serde::Serialize;
4use time::OffsetDateTime;
5
6/// One registered target and how it is doing — the shape the frontend renders.
7///
8/// Defined here rather than in the host layer: it is a contract type, and contract
9/// types belong with the domain they describe, not with the transport that carries
10/// them.
11#[derive(Debug, Clone, Serialize)]
12#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
13pub struct SyncStatus {
14    pub target: SyncTarget,
15    pub state: SyncState,
16    pub health: Health,
17    /// When the engine intends to run it next, RFC 3339.
18    pub due_at: Option<String>,
19}
20
21/// Failures before a target is considered critical rather than merely unhappy.
22const CRITICAL_STREAK: u32 = 3;
23
24/// How many intervals a target may go without a successful run before it counts as
25/// stale, even while nothing is visibly failing.
26const STALE_INTERVALS: i32 = 3;
27
28/// Translate sync bookkeeping into the shared health model.
29///
30/// Deliberately not part of `SyncState`: what counts as healthy depends on the
31/// cadence, and only the policy knows that.
32pub fn health_of(state: &SyncState, policy: &SyncPolicy, now: OffsetDateTime) -> Health {
33    if state.failure_streak >= CRITICAL_STREAK {
34        return Health::Critical;
35    }
36
37    match state.last_success {
38        // Never succeeded: unknown while untried, a warning once it has failed.
39        None if state.failure_streak == 0 => Health::Unknown,
40        None => Health::Warning,
41
42        Some(last_success) => {
43            let stale = now - last_success > policy.interval * STALE_INTERVALS;
44
45            if state.failure_streak > 0 || stale {
46                Health::Warning
47            } else {
48                Health::Healthy
49            }
50        }
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use origin_domain::{ErrorKind, SyncOutcome};
58    use time::Duration;
59    use time::macros::datetime;
60
61    const NOW: OffsetDateTime = datetime!(2026-08-23 10:00 UTC);
62
63    fn policy() -> SyncPolicy {
64        SyncPolicy::every(Duration::minutes(5))
65    }
66
67    fn failed(state: &mut SyncState, times: u32) {
68        for _ in 0..times {
69            state.record(
70                NOW,
71                SyncOutcome::Failed {
72                    kind: ErrorKind::Network,
73                    message: "timeout".into(),
74                },
75            );
76        }
77    }
78
79    #[test]
80    fn a_target_that_never_ran_is_unknown_not_broken() {
81        assert_eq!(
82            health_of(&SyncState::default(), &policy(), NOW),
83            Health::Unknown
84        );
85    }
86
87    #[test]
88    fn a_fresh_success_is_healthy() {
89        let mut state = SyncState::default();
90        state.record(NOW, SyncOutcome::Updated);
91
92        assert_eq!(health_of(&state, &policy(), NOW), Health::Healthy);
93    }
94
95    #[test]
96    fn a_single_failure_is_a_warning_not_a_crisis() {
97        let mut state = SyncState::default();
98        state.record(NOW, SyncOutcome::Updated);
99        failed(&mut state, 1);
100
101        assert_eq!(health_of(&state, &policy(), NOW), Health::Warning);
102    }
103
104    #[test]
105    fn repeated_failures_become_critical() {
106        let mut state = SyncState::default();
107        failed(&mut state, CRITICAL_STREAK);
108
109        assert_eq!(health_of(&state, &policy(), NOW), Health::Critical);
110    }
111
112    #[test]
113    fn silence_is_reported_even_when_nothing_visibly_failed() {
114        let mut state = SyncState::default();
115        state.record(NOW, SyncOutcome::Updated);
116
117        let much_later = NOW + Duration::minutes(5) * STALE_INTERVALS + Duration::seconds(1);
118
119        assert_eq!(
120            health_of(&state, &policy(), much_later),
121            Health::Warning,
122            "a target that quietly stopped running is not healthy"
123        );
124    }
125
126    #[test]
127    fn a_not_modified_response_keeps_a_target_healthy() {
128        let mut state = SyncState::default();
129        state.record(NOW, SyncOutcome::NotModified);
130
131        assert_eq!(health_of(&state, &policy(), NOW), Health::Healthy);
132    }
133}