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