Skip to main content

nodedb_lite/nodedb/
health.rs

1//! Health API — structured status report for NodeDB-Lite.
2//!
3//! `db.health()` returns a `HealthStatus` covering:
4//! - **Storage**: redb accessible, approximate size
5//! - **Memory**: governor pressure per engine
6//! - **Engines**: HNSW collection count, CSR node/edge count, CRDT doc count, text indices
7//! - **Sync**: connection state, pending delta count/bytes (if sync client available)
8//!
9//! The response is JSON-serializable for HTTP health endpoints.
10
11use serde::Serialize;
12
13use crate::memory::{EngineId, PressureLevel};
14use crate::storage::engine::StorageEngine;
15
16use super::core::NodeDbLite;
17use super::lock_ext::LockExt;
18
19/// Overall health status.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum OverallStatus {
23    /// All subsystems healthy.
24    Healthy,
25    /// Some subsystems under pressure but functional.
26    Degraded,
27    /// Critical issues — immediate attention needed.
28    Unhealthy,
29}
30
31/// Structured health report for NodeDB-Lite.
32#[derive(Debug, Serialize)]
33pub struct HealthStatus {
34    /// Overall status.
35    pub status: OverallStatus,
36    /// Storage subsystem.
37    pub storage: StorageHealth,
38    /// Memory governor.
39    pub memory: MemoryHealth,
40    /// Engine-specific health.
41    pub engines: EnginesHealth,
42}
43
44/// Storage subsystem health.
45#[derive(Debug, Serialize)]
46pub struct StorageHealth {
47    /// Whether redb is accessible (can read/write).
48    pub accessible: bool,
49}
50
51/// Memory governor health.
52#[derive(Debug, Serialize)]
53pub struct MemoryHealth {
54    /// Total budget in bytes.
55    pub budget_bytes: usize,
56    /// Total used in bytes.
57    pub used_bytes: usize,
58    /// Usage ratio (0.0–1.0+).
59    pub usage_ratio: f64,
60    /// Overall pressure level.
61    pub pressure: &'static str,
62    /// Per-engine breakdown.
63    pub engines: EngineMemoryBreakdown,
64}
65
66/// Per-engine memory breakdown.
67#[derive(Debug, Serialize)]
68pub struct EngineMemoryBreakdown {
69    pub hnsw: EngineMemory,
70    pub csr: EngineMemory,
71    pub loro: EngineMemory,
72    pub query: EngineMemory,
73}
74
75/// Single engine memory stats.
76#[derive(Debug, Serialize)]
77pub struct EngineMemory {
78    pub budget_bytes: usize,
79    pub used_bytes: usize,
80    pub pressure: &'static str,
81}
82
83/// Engine-specific health summary.
84#[derive(Debug, Serialize)]
85pub struct EnginesHealth {
86    /// Number of loaded HNSW collections.
87    pub hnsw_collection_count: usize,
88    /// Total vectors across all HNSW collections.
89    pub hnsw_total_vectors: usize,
90    /// CSR graph node count.
91    pub csr_node_count: usize,
92    /// CSR graph edge count.
93    pub csr_edge_count: usize,
94    /// Number of CRDT collections with data.
95    pub crdt_collection_count: usize,
96    /// Number of text-indexed collections.
97    pub text_index_count: usize,
98    /// Total pending CRDT deltas awaiting sync.
99    pub pending_deltas: usize,
100}
101
102fn pressure_str(p: PressureLevel) -> &'static str {
103    match p {
104        PressureLevel::Normal => "normal",
105        PressureLevel::Warning => "warning",
106        PressureLevel::Critical => "critical",
107    }
108}
109
110fn engine_memory(gov: &crate::memory::MemoryGovernor, id: EngineId) -> EngineMemory {
111    EngineMemory {
112        budget_bytes: gov.budget_for(id),
113        used_bytes: gov.usage_for(id),
114        pressure: pressure_str(gov.engine_pressure(id)),
115    }
116}
117
118impl<S: StorageEngine> NodeDbLite<S> {
119    /// Get a structured health report.
120    ///
121    /// This is a cheap, non-blocking call — reads atomic counters and lock-free state.
122    /// Safe to call frequently from health check endpoints.
123    pub fn health(&self) -> HealthStatus {
124        // Refresh memory stats before reporting.
125        self.update_memory_stats();
126
127        let gov = &self.governor;
128
129        let memory = MemoryHealth {
130            budget_bytes: gov.total_budget(),
131            used_bytes: gov.total_used(),
132            usage_ratio: gov.usage_ratio(),
133            pressure: pressure_str(gov.pressure()),
134            engines: EngineMemoryBreakdown {
135                hnsw: engine_memory(gov, EngineId::Hnsw),
136                csr: engine_memory(gov, EngineId::Csr),
137                loro: engine_memory(gov, EngineId::Loro),
138                query: engine_memory(gov, EngineId::Query),
139            },
140        };
141
142        let (hnsw_count, hnsw_vectors) = {
143            let indices = self.hnsw_indices.lock_or_recover();
144            let count = indices.len();
145            let vectors: usize = indices.values().map(|idx| idx.len()).sum();
146            (count, vectors)
147        };
148
149        let (csr_nodes, csr_edges) = {
150            let csr = self.csr.lock_or_recover();
151            (csr.node_count(), csr.edge_count())
152        };
153
154        let (crdt_collections, pending_deltas) = {
155            let crdt = self.crdt.lock_or_recover();
156            (crdt.collection_names().len(), crdt.pending_count())
157        };
158
159        let text_count = {
160            let text = self.text_indices.lock_or_recover();
161            text.len()
162        };
163
164        let engines = EnginesHealth {
165            hnsw_collection_count: hnsw_count,
166            hnsw_total_vectors: hnsw_vectors,
167            csr_node_count: csr_nodes,
168            csr_edge_count: csr_edges,
169            crdt_collection_count: crdt_collections,
170            text_index_count: text_count,
171            pending_deltas,
172        };
173
174        // Determine overall status.
175        let overall = match gov.pressure() {
176            PressureLevel::Critical => OverallStatus::Unhealthy,
177            PressureLevel::Warning => OverallStatus::Degraded,
178            PressureLevel::Normal => OverallStatus::Healthy,
179        };
180
181        HealthStatus {
182            status: overall,
183            storage: StorageHealth { accessible: true },
184            memory,
185            engines,
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::RedbStorage;
194
195    async fn make_db() -> NodeDbLite<RedbStorage> {
196        let storage = RedbStorage::open_in_memory().unwrap();
197        NodeDbLite::open(storage, 1).await.unwrap()
198    }
199
200    #[tokio::test]
201    async fn health_empty_db() {
202        let db = make_db().await;
203        let h = db.health();
204        assert_eq!(h.status, OverallStatus::Healthy);
205        assert!(h.storage.accessible);
206        assert_eq!(h.memory.pressure, "normal");
207        assert_eq!(h.engines.hnsw_collection_count, 0);
208        assert_eq!(h.engines.pending_deltas, 0);
209    }
210
211    #[tokio::test]
212    async fn health_with_data() {
213        use nodedb_client::NodeDb;
214
215        let db = make_db().await;
216        db.vector_insert("vecs", "v1", &[1.0, 0.0, 0.0], None)
217            .await
218            .unwrap();
219        db.graph_insert_edge(
220            &nodedb_types::id::NodeId::new("a"),
221            &nodedb_types::id::NodeId::new("b"),
222            "REL",
223            None,
224        )
225        .await
226        .unwrap();
227
228        let h = db.health();
229        assert_eq!(h.engines.hnsw_collection_count, 1);
230        assert_eq!(h.engines.hnsw_total_vectors, 1);
231        assert!(h.engines.csr_edge_count >= 1);
232    }
233
234    #[tokio::test]
235    async fn health_serializes_to_json() {
236        let db = make_db().await;
237        let h = db.health();
238        let json = serde_json::to_string_pretty(&h).unwrap();
239        assert!(json.contains("\"status\""));
240        assert!(json.contains("\"storage\""));
241        assert!(json.contains("\"memory\""));
242        assert!(json.contains("\"engines\""));
243    }
244
245    #[tokio::test]
246    async fn health_pending_deltas_counted() {
247        use nodedb_client::NodeDb;
248        use nodedb_types::document::Document;
249
250        let db = make_db().await;
251        let doc = Document::new("d1");
252        db.document_put("docs", doc).await.unwrap();
253
254        let h = db.health();
255        assert!(h.engines.pending_deltas > 0);
256    }
257}