Skip to main content

ogre_watchdog/
watchdog.rs

1use dashmap::DashMap;
2use once_cell::sync::Lazy;
3use std::fmt::{Display, Formatter};
4use std::{
5    fmt::Debug,
6    sync::{
7        atomic::{AtomicBool, Ordering::Relaxed},
8        Arc,
9    },
10    thread,
11    time::{Duration, Instant},
12};
13
14static START: Lazy<Instant> = Lazy::new(Instant::now);
15
16#[inline(always)]
17fn now_ms() -> u64 {
18    START.elapsed().as_millis() as u64
19}
20
21/// OS thread handle (only meaningful with demotion feature).
22#[derive(Clone, Copy, Debug)]
23pub enum OsThreadHandle {
24    #[cfg(target_os = "linux")]
25    LinuxTid(i32),
26    #[cfg(target_os = "windows")]
27    WindowsTid(u32),
28    None,
29}
30
31#[cfg(feature = "demotion")]
32impl OsThreadHandle {
33    #[inline(always)]
34    pub fn current() -> Self {
35        #[cfg(target_os = "linux")]
36        unsafe {
37            OsThreadHandle::LinuxTid(libc::syscall(libc::SYS_gettid) as i32)
38        }
39        #[cfg(target_os = "windows")]
40        {
41            use windows_sys::Win32::System::Threading::GetCurrentThreadId;
42            OsThreadHandle::WindowsTid(unsafe { GetCurrentThreadId() })
43        }
44    }
45}
46
47/// Config for the watchdog.
48#[derive(Clone, Debug)]
49pub struct Config {
50    /// Maximum number of unhealthy (stalled) executors tolerated before meltdown.
51    /// Meltdown triggers when stalled_count > max_unhealthy_executors AND there is at least 1 active work.
52    pub max_unhealthy_executors: usize,
53    /// Hard maximum runtime per work unit. If a work runs longer, it's considered stalled.
54    pub stall_threshold: Duration,
55    /// How often the watchdog thread checks health.
56    pub check_interval: Duration,
57    /// Enable thread priority demotion of stalled executors (only present with the demotion feature).
58    #[cfg(feature = "demotion")]
59    pub demote_stalled_threads: bool,
60}
61
62impl Default for Config {
63    fn default() -> Self {
64        Self {
65            max_unhealthy_executors: 0,
66            stall_threshold: Duration::from_secs(299),
67            check_interval: Duration::from_secs(30),
68            #[cfg(feature = "demotion")]
69            demote_stalled_threads: true,
70        }
71    }
72}
73
74/// Hooks trait: supply your own to get compile-time inlining & zero branching.
75/// IMPORTANT: All hooks must avoid taking app locks, blocking I/O, or heavy allocation.
76pub trait Hooks<ExecutorIdType, EventType>: Send + Sync + 'static {
77    /// Called when executor/work is admitted.
78    fn on_started(&self, _key: &ExecutorIdType, _token: &EventType) {}
79    /// Called exactly once when a work first transitions to stalled.
80    fn on_stalled(&self, _key: &ExecutorIdType, _token: &EventType) {}
81    /// Called when a previously stalled work finishes before meltdown.
82    /// Use this to undo any DLQ pre-marking and (optionally) promote thread priority back.
83    fn on_recovered(&self, _key: &ExecutorIdType, _token: &EventType) {}
84    /// Final best-effort callback before termination/restart.
85    /// You decide how to terminate (std::process::exit, abort, panic, etc).
86    fn on_meltdown(&self, _stalled: &[(ExecutorIdType, EventType)]) {}
87}
88
89/// Default no-op hooks.
90pub struct NoopHooks;
91impl<ExecutorIdType, EventType> Hooks<ExecutorIdType, EventType> for NoopHooks {}
92
93#[cfg(feature = "demotion")]
94#[derive(Clone, Copy, Debug)]
95struct DemotionInfo {
96    handle: OsThreadHandle,
97    #[cfg(target_os = "linux")]
98    orig_nice: i32,
99    #[cfg(target_os = "windows")]
100    orig_prio: i32,
101}
102
103struct Entry<EventType: Clone> {
104    started_ms: u64,
105    is_stalled: AtomicBool,
106    event_payload: EventType,
107    #[cfg(feature = "demotion")]
108    demotion: Option<DemotionInfo>,
109}
110
111pub struct Watchdog<ExecutorIdType, EventType, HooksType = NoopHooks>
112where
113    ExecutorIdType: Eq + std::hash::Hash + Clone + Debug + Send + Sync + 'static,
114    EventType: Clone + Send + Sync + 'static,
115    HooksType: Hooks<ExecutorIdType, EventType>,
116{
117    config: Config,
118    accepting: AtomicBool,
119    entries: DashMap<ExecutorIdType, Entry<EventType>>,
120    hooks: HooksType,
121    #[cfg(feature = "systemd")]
122    systemd_ping: bool,
123}
124
125impl<ExecutorIdType, EventType> Watchdog<ExecutorIdType, EventType, NoopHooks>
126where
127    ExecutorIdType: Eq + std::hash::Hash + Clone + Debug + Send + Sync + 'static,
128    EventType: Clone + Send + Sync + 'static,
129{
130    pub fn new(cfg: Config) -> Self {
131        Self {
132            config: cfg,
133            accepting: AtomicBool::new(true),
134            entries: DashMap::new(),
135            hooks: NoopHooks,
136            #[cfg(feature = "systemd")]
137            systemd_ping: false,
138        }
139    }
140}
141
142impl<ExecutorIdType, EventType, HooksType> Watchdog<ExecutorIdType, EventType, HooksType>
143where
144    ExecutorIdType: Eq + std::hash::Hash + Clone + Debug + Send + Sync + 'static,
145    EventType: Clone + Send + Sync + 'static,
146    HooksType: Hooks<ExecutorIdType, EventType>,
147{
148    /// Swap in custom hooks (compile-time monomorphized)
149    pub fn with_hooks<NewHooksType: Hooks<ExecutorIdType, EventType>>(
150        self,
151        hooks: NewHooksType,
152    ) -> Watchdog<ExecutorIdType, EventType, NewHooksType> {
153        Watchdog {
154            config: self.config,
155            accepting: self.accepting,
156            entries: self.entries,
157            hooks,
158            #[cfg(feature = "systemd")]
159            systemd_ping: self.systemd_ping,
160        }
161    }
162    /// Enable systemd Watchdog pings (Linux) in the watchdog loop (optional).
163    #[cfg(feature = "systemd")]
164    pub fn with_systemd_ping(mut self, enabled: bool) -> Self {
165        self.systemd_ping = enabled;
166        self
167    }
168
169    /// Set the admission gate
170    pub fn set_accepting(&self, v: bool) {
171        self.accepting.store(v, Relaxed);
172    }
173
174    /// Called when a unit of work is admitted. Returns Err if draining (ACCEPTING=false)
175    pub fn on_work_start(
176        &self,
177        key: &ExecutorIdType,
178        event_payload: &EventType,
179    ) -> Result<(), MeltdownInProgress> {
180        if !self.accepting.load(Relaxed) {
181            return Err(MeltdownInProgress);
182        }
183
184        #[cfg(feature = "demotion")]
185        let demotion = if self.config.demote_stalled_threads {
186            // Capture OS handle & current priority so we can restore on recovery.
187            let handle = OsThreadHandle::current();
188            #[cfg(target_os = "linux")]
189            unsafe {
190                let orig = libc::getpriority(libc::PRIO_PROCESS, 0) as i32;
191                Some(DemotionInfo {
192                    handle,
193                    orig_nice: orig,
194                })
195            }
196            #[cfg(target_os = "windows")]
197            {
198                use windows_sys::Win32::System::Threading::{GetCurrentThread, GetThreadPriority};
199                unsafe {
200                    let h = GetCurrentThread();
201                    let p = GetThreadPriority(h);
202                    Some(DemotionInfo {
203                        handle,
204                        orig_prio: p,
205                    })
206                }
207            }
208        } else {
209            None
210        };
211
212        self.entries.insert(
213            key.clone(),
214            Entry {
215                started_ms: now_ms(),
216                is_stalled: AtomicBool::new(false),
217                event_payload: event_payload.clone(),
218                #[cfg(feature = "demotion")]
219                demotion,
220            },
221        );
222        self.hooks.on_started(key, event_payload);
223        Ok(())
224    }
225
226    /// Finish and clear the entry; missing keys are ignored (idempotent)
227    pub fn on_work_finish(&self, key: &ExecutorIdType) {
228        if let Some((executor_id, entry)) = self.entries.remove(key) {
229            if entry.is_stalled.load(Relaxed) {
230                // If it had been stalled, signal recovery and restore priority.
231                self.hooks.on_recovered(&executor_id, &entry.event_payload);
232                #[cfg(feature = "demotion")]
233                if let Some(info) = entry.demotion {
234                    let _ = promote_thread(info);
235                }
236            }
237        }
238    }
239
240    /// Spawn the watchdog thread
241    pub fn start(self) -> Arc<Self> {
242        let this = Arc::new(self);
243        let this_clone = this.clone();
244        thread::spawn(move || {
245            let stall_ms = this.config.stall_threshold.as_millis() as u64;
246
247            loop {
248                #[cfg(feature = "systemd")]
249                if this.systemd_ping {
250                    let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
251                }
252
253                thread::sleep(this.config.check_interval);
254
255                let now = now_ms();
256
257                let mut stalled_count = 0;
258
259                // Snapshot health. Mark stalled exactly once.
260                for kv in this.entries.iter() {
261                    let started = kv.value().started_ms;
262                    if now.saturating_sub(started) > stall_ms {
263                        if !kv.value().is_stalled.swap(true, Relaxed) {
264                            #[cfg(feature = "demotion")]
265                            if this.config.demote_stalled_threads {
266                                if let Some(info) = kv.value().demotion {
267                                    let _ = demote_thread(info);
268                                }
269                            }
270                            this.hooks.on_stalled(kv.key(), &kv.value().event_payload);
271                        }
272                        stalled_count += 1;
273                    }
274                }
275
276                // should the watchdog's meltdown kick-in?
277                if stalled_count > this.config.max_unhealthy_executors {
278                    break;
279                }
280            }
281
282            // stop admission, give a bounded grace equal to `stall_threshold`,
283            // then invoke user-provided meltdown hook (which, typically, forcibly terminate the process).
284            this.accepting.store(false, Relaxed);
285            thread::sleep(this.config.stall_threshold);
286
287            if !this.entries.is_empty() {
288                let pairs: Vec<(ExecutorIdType, EventType)> = this
289                    .entries
290                    .iter()
291                    .map(|kv| (kv.key().clone(), kv.value().event_payload.clone()))
292                    .collect();
293                this.hooks.on_meltdown(&pairs);
294            }
295        });
296        this_clone
297    }
298}
299
300/// Returned when admission is closed (draining).
301#[derive(Debug)]
302pub struct Draining;
303
304#[cfg(feature = "demotion")]
305fn demote_thread(info: DemotionInfo) -> std::io::Result<()> {
306    match info.handle {
307        #[cfg(target_os = "linux")]
308        OsThreadHandle::LinuxTid(tid) => unsafe {
309            let r = libc::setpriority(libc::PRIO_PROCESS, tid as u32, 19);
310            if r == 0 {
311                Ok(())
312            } else {
313                Err(std::io::Error::last_os_error())
314            }
315        },
316        #[cfg(target_os = "windows")]
317        OsThreadHandle::WindowsTid(tid) => {
318            use windows_sys::Win32::Foundation::CloseHandle;
319            use windows_sys::Win32::System::Threading::{
320                OpenThread, SetThreadPriority, THREAD_PRIORITY_IDLE, THREAD_QUERY_INFORMATION,
321                THREAD_SET_INFORMATION,
322            };
323            unsafe {
324                let h = OpenThread(THREAD_SET_INFORMATION | THREAD_QUERY_INFORMATION, 0, tid);
325                if h == std::ptr::null_mut() {
326                    return Err(std::io::Error::last_os_error());
327                }
328                let ok = SetThreadPriority(h, THREAD_PRIORITY_IDLE);
329                let last = std::io::Error::last_os_error();
330                CloseHandle(h);
331                if ok != 0 {
332                    Ok(())
333                } else {
334                    Err(last)
335                }
336            }
337        }
338        _ => Ok(()),
339    }
340}
341
342#[cfg(feature = "demotion")]
343fn promote_thread(info: DemotionInfo) -> std::io::Result<()> {
344    match info.handle {
345        #[cfg(target_os = "linux")]
346        OsThreadHandle::LinuxTid(tid) => unsafe {
347            let r = libc::setpriority(libc::PRIO_PROCESS, tid as u32, info.orig_nice);
348            if r == 0 {
349                Ok(())
350            } else {
351                Err(std::io::Error::last_os_error())
352            }
353        },
354        #[cfg(target_os = "windows")]
355        OsThreadHandle::WindowsTid(tid) => {
356            use windows_sys::Win32::Foundation::CloseHandle;
357            use windows_sys::Win32::System::Threading::{
358                OpenThread, SetThreadPriority, THREAD_QUERY_INFORMATION, THREAD_SET_INFORMATION,
359            };
360            unsafe {
361                let h = OpenThread(THREAD_SET_INFORMATION | THREAD_QUERY_INFORMATION, 0, tid);
362                if h == std::ptr::null_mut() {
363                    return Err(std::io::Error::last_os_error());
364                }
365                let ok = SetThreadPriority(h, info.orig_prio);
366                let last = std::io::Error::last_os_error();
367                CloseHandle(h);
368                if ok != 0 {
369                    Ok(())
370                } else {
371                    Err(last)
372                }
373            }
374        }
375        _ => Ok(()),
376    }
377}
378
379#[derive(Debug)]
380pub struct MeltdownInProgress;
381impl Display for MeltdownInProgress {
382    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
383        <Self as Debug>::fmt(self, f)
384    }
385}
386impl std::error::Error for MeltdownInProgress {}