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