Skip to main content

appcore_supervisor/
watchdog.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: watchdog.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/24 13:18:47 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/24 13:18:47 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Lock-independent reconciliation progress watchdog.
12
13use crate::{SupervisorError, SupervisorResult};
14use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
15
16/// Default watchdog check interval.
17pub const DEFAULT_WATCHDOG_CHECK_INTERVAL_MS: u64 = 1_000;
18/// Default maximum interval without completed reconciliation.
19pub const DEFAULT_WATCHDOG_STALL_TIMEOUT_MS: u64 = 15_000;
20
21/// Watchdog policy supplied by the installation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct WatchdogConfig {
24    /// Whether watchdog health affects Runtime health.
25    pub enabled: bool,
26    /// Independent watchdog evaluation interval.
27    pub check_interval_ms: u64,
28    /// Maximum interval without a completed reconciliation.
29    pub stall_timeout_ms: u64,
30}
31
32impl WatchdogConfig {
33    /// Validates safe watchdog bounds.
34    pub fn validate(self) -> SupervisorResult<Self> {
35        if self.check_interval_ms == 0 || self.stall_timeout_ms == 0 {
36            return Err(SupervisorError::InvalidConfiguration(
37                "watchdog intervals must be greater than zero".to_string(),
38            ));
39        }
40        if self.enabled && self.stall_timeout_ms <= self.check_interval_ms {
41            return Err(SupervisorError::InvalidConfiguration(
42                "watchdog stall timeout must exceed its check interval".to_string(),
43            ));
44        }
45        Ok(self)
46    }
47}
48
49impl Default for WatchdogConfig {
50    fn default() -> Self {
51        Self {
52            enabled: true,
53            check_interval_ms: DEFAULT_WATCHDOG_CHECK_INTERVAL_MS,
54            stall_timeout_ms: DEFAULT_WATCHDOG_STALL_TIMEOUT_MS,
55        }
56    }
57}
58
59/// Observable watchdog lifecycle state.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum WatchdogState {
62    /// No reconciliation cycle has completed yet.
63    Starting,
64    /// Reconciliation is completing inside the configured timeout.
65    Healthy,
66    /// A reconciliation cycle stopped making progress.
67    Stalled,
68    /// The watchdog itself encountered an unrecoverable failure.
69    Failed,
70    /// Runtime shutdown is in progress.
71    Stopping,
72}
73
74impl WatchdogState {
75    fn as_u8(self) -> u8 {
76        match self {
77            Self::Starting => 0,
78            Self::Healthy => 1,
79            Self::Stalled => 2,
80            Self::Failed => 3,
81            Self::Stopping => 4,
82        }
83    }
84
85    fn from_u8(value: u8) -> Self {
86        match value {
87            1 => Self::Healthy,
88            2 => Self::Stalled,
89            3 => Self::Failed,
90            4 => Self::Stopping,
91            _ => Self::Starting,
92        }
93    }
94}
95
96/// Atomic watchdog state shared by reconcile, health, and watchdog threads.
97pub struct SupervisorWatchdog {
98    config: WatchdogConfig,
99    created_at_ms: u64,
100    last_reconcile_at_ms: AtomicU64,
101    reconcile_sequence: AtomicU64,
102    last_progress_at_ms: AtomicU64,
103    state: AtomicU8,
104}
105
106impl SupervisorWatchdog {
107    /// Creates a watchdog with validated installation policy.
108    pub fn new(config: WatchdogConfig, created_at_ms: u64) -> SupervisorResult<Self> {
109        Ok(Self::from_validated(config.validate()?, created_at_ms))
110    }
111
112    pub(crate) fn with_default(created_at_ms: u64) -> Self {
113        Self::from_validated(WatchdogConfig::default(), created_at_ms)
114    }
115
116    fn from_validated(config: WatchdogConfig, created_at_ms: u64) -> Self {
117        Self {
118            config,
119            created_at_ms,
120            last_reconcile_at_ms: AtomicU64::new(0),
121            reconcile_sequence: AtomicU64::new(0),
122            last_progress_at_ms: AtomicU64::new(0),
123            state: AtomicU8::new(WatchdogState::Starting.as_u8()),
124        }
125    }
126
127    /// Records entry into one reconciliation cycle without taking a lock.
128    pub fn record_reconcile_started(&self, timestamp_ms: u64) {
129        self.last_reconcile_at_ms
130            .store(timestamp_ms, Ordering::Release);
131    }
132
133    /// Records successful completion and returns the new sequence.
134    pub fn record_reconcile_completed(&self, timestamp_ms: u64) -> u64 {
135        self.last_reconcile_at_ms
136            .store(timestamp_ms, Ordering::Release);
137        self.last_progress_at_ms
138            .store(timestamp_ms, Ordering::Release);
139        let sequence = self
140            .reconcile_sequence
141            .fetch_add(1, Ordering::AcqRel)
142            .saturating_add(1);
143        if !matches!(
144            self.state(),
145            WatchdogState::Stopping | WatchdogState::Failed
146        ) {
147            self.set_state(WatchdogState::Healthy);
148        }
149        sequence
150    }
151
152    /// Evaluates progress and returns a state transition when one occurred.
153    pub fn evaluate(&self, timestamp_ms: u64) -> Option<(WatchdogState, WatchdogState)> {
154        let previous = self.state();
155        if matches!(previous, WatchdogState::Stopping | WatchdogState::Failed) {
156            return None;
157        }
158        let next = if !self.config.enabled {
159            WatchdogState::Healthy
160        } else if self.stalled_for_ms(timestamp_ms) > self.config.stall_timeout_ms {
161            WatchdogState::Stalled
162        } else if self.reconcile_sequence.load(Ordering::Acquire) == 0 {
163            WatchdogState::Starting
164        } else {
165            WatchdogState::Healthy
166        };
167        if previous == next {
168            return None;
169        }
170        self.set_state(next);
171        Some((previous, next))
172    }
173
174    /// Marks watchdog shutdown without changing reconciliation counters.
175    pub fn mark_stopping(&self) {
176        self.set_state(WatchdogState::Stopping);
177    }
178
179    /// Marks an unrecoverable watchdog failure.
180    pub fn mark_failed(&self) {
181        self.set_state(WatchdogState::Failed);
182    }
183
184    /// Returns immutable watchdog policy.
185    pub fn config(&self) -> WatchdogConfig {
186        self.config
187    }
188
189    /// Returns the current atomic watchdog state.
190    pub fn state(&self) -> WatchdogState {
191        WatchdogState::from_u8(self.state.load(Ordering::Acquire))
192    }
193
194    /// Returns the most recent reconciliation start or completion time.
195    pub fn last_reconcile_at_ms(&self) -> u64 {
196        self.last_reconcile_at_ms.load(Ordering::Acquire)
197    }
198
199    /// Returns the number of completed reconciliation cycles.
200    pub fn reconcile_sequence(&self) -> u64 {
201        self.reconcile_sequence.load(Ordering::Acquire)
202    }
203
204    /// Returns the most recent completed reconciliation time.
205    pub fn last_progress_at_ms(&self) -> u64 {
206        self.last_progress_at_ms.load(Ordering::Acquire)
207    }
208
209    /// Returns elapsed time without a completed reconciliation cycle.
210    pub fn stalled_for_ms(&self, timestamp_ms: u64) -> u64 {
211        let progress = self.last_progress_at_ms();
212        timestamp_ms.saturating_sub(if progress == 0 {
213            self.created_at_ms
214        } else {
215            progress
216        })
217    }
218
219    fn set_state(&self, state: WatchdogState) {
220        self.state.store(state.as_u8(), Ordering::Release);
221    }
222}