Skip to main content

systemprompt_api/services/health/
monitor.rs

1//! Background process monitor for managed services.
2//!
3//! [`ProcessMonitor`] spawns a polling loop that checks each running service's
4//! PID against the live process table, marks vanished processes as crashed, and
5//! reports aggregate state via [`HealthSummary`] and [`ModuleHealth`].
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use anyhow::Result;
11use std::time::Duration;
12use systemprompt_database::ServiceRepository;
13use systemprompt_scheduler::ProcessCleanup;
14use tokio::task::JoinHandle;
15use tracing::{info, warn};
16
17#[derive(Debug)]
18pub struct ProcessMonitor {
19    repository: ServiceRepository,
20    monitor_handle: Option<JoinHandle<()>>,
21    check_interval: Duration,
22}
23
24impl ProcessMonitor {
25    pub const fn new(repository: ServiceRepository) -> Self {
26        Self::with_interval(repository, Duration::from_secs(30))
27    }
28
29    pub const fn with_interval(repository: ServiceRepository, interval: Duration) -> Self {
30        Self {
31            repository,
32            monitor_handle: None,
33            check_interval: interval,
34        }
35    }
36
37    pub fn start(&mut self) {
38        if self.monitor_handle.is_some() {
39            warn!("Process monitor already started");
40            return;
41        }
42
43        info!("Starting centralized process monitoring");
44
45        let repository = self.repository.clone();
46        let interval = self.check_interval;
47
48        let handle = tokio::spawn(async move { Self::monitor_loop(repository, interval).await });
49
50        self.monitor_handle = Some(handle);
51        info!("Centralized process monitoring started");
52    }
53
54    pub fn stop(&mut self) {
55        if let Some(handle) = self.monitor_handle.take() {
56            info!("Stopping process monitoring");
57            handle.abort();
58            info!("Process monitoring stopped");
59        }
60    }
61
62    pub const fn is_running(&self) -> bool {
63        self.monitor_handle.is_some()
64    }
65
66    async fn monitor_loop(repository: ServiceRepository, check_interval: Duration) {
67        info!(
68            interval_secs = check_interval.as_secs(),
69            "Process monitor loop started"
70        );
71
72        let mut interval = tokio::time::interval(check_interval);
73
74        loop {
75            interval.tick().await;
76
77            if let Err(e) = Self::perform_monitoring_cycle(&repository).await {
78                warn!(error = %e, "Monitoring cycle failed");
79            }
80        }
81    }
82
83    async fn perform_monitoring_cycle(repository: &ServiceRepository) -> Result<()> {
84        let services = repository.list_running_services_with_pid().await?;
85
86        if services.is_empty() {
87            return Ok(());
88        }
89
90        let mut healthy_count = 0;
91        let mut crashed_count = 0;
92
93        for service in services {
94            if let Some(pid) = service.pid {
95                let pid = pid as u32;
96
97                if Self::process_exists(pid) {
98                    healthy_count += 1;
99                } else {
100                    repository.mark_service_crashed(&service.name).await?;
101
102                    crashed_count += 1;
103                    warn!(
104                        module = %service.module_name,
105                        service = %service.name,
106                        pid = pid,
107                        "Detected crashed service"
108                    );
109                }
110            }
111        }
112
113        if crashed_count == 0 {
114            info!(healthy = healthy_count, "All services healthy");
115        } else {
116            warn!(
117                healthy = healthy_count,
118                crashed = crashed_count,
119                "Service health check completed with failures"
120            );
121        }
122
123        Ok(())
124    }
125
126    fn process_exists(pid: u32) -> bool {
127        ProcessCleanup::process_exists(pid)
128    }
129
130    pub async fn health_check_all(&self) -> Result<HealthSummary> {
131        info!("Running health check on all services");
132
133        let services = self.repository.list_running_services_with_pid().await?;
134
135        let mut summary = HealthSummary::default();
136
137        for service in services {
138            if let Some(pid) = service.pid {
139                let pid = pid as u32;
140                let healthy = Self::process_exists(pid);
141
142                info!(
143                    module = %service.module_name,
144                    service = %service.name,
145                    pid = pid,
146                    healthy = healthy,
147                    "Service health status"
148                );
149
150                *summary
151                    .modules
152                    .entry(service.module_name)
153                    .or_insert_with(ModuleHealth::default) += if healthy {
154                    ModuleHealth {
155                        healthy: 1,
156                        crashed: 0,
157                    }
158                } else {
159                    ModuleHealth {
160                        healthy: 0,
161                        crashed: 1,
162                    }
163                };
164            }
165        }
166
167        let total_healthy = summary.modules.values().map(|m| m.healthy).sum::<u32>();
168        let total_crashed = summary.modules.values().map(|m| m.crashed).sum::<u32>();
169
170        if total_crashed == 0 {
171            info!(healthy = total_healthy, "All services are healthy");
172        } else {
173            warn!(
174                healthy = total_healthy,
175                total = total_healthy + total_crashed,
176                "Some services are unhealthy"
177            );
178        }
179
180        Ok(summary)
181    }
182}
183
184impl Drop for ProcessMonitor {
185    fn drop(&mut self) {
186        if let Some(handle) = self.monitor_handle.take() {
187            handle.abort();
188        }
189    }
190}
191
192#[derive(Debug, Default)]
193pub struct HealthSummary {
194    pub modules: std::collections::HashMap<String, ModuleHealth>,
195}
196
197#[derive(Debug, Default, Copy, Clone)]
198pub struct ModuleHealth {
199    pub healthy: u32,
200    pub crashed: u32,
201}
202
203impl std::ops::AddAssign for ModuleHealth {
204    fn add_assign(&mut self, other: Self) {
205        self.healthy += other.healthy;
206        self.crashed += other.crashed;
207    }
208}
209
210impl HealthSummary {
211    pub fn total_healthy(&self) -> u32 {
212        self.modules.values().map(|m| m.healthy).sum()
213    }
214
215    pub fn total_crashed(&self) -> u32 {
216        self.modules.values().map(|m| m.crashed).sum()
217    }
218
219    pub fn is_all_healthy(&self) -> bool {
220        self.total_crashed() == 0
221    }
222}