mentedb_server/
maintenance.rs1use std::sync::Arc;
12use std::time::Duration;
13
14use mentedb::MenteDb;
15use mentedb::consolidation::archival::ArchivalDecision;
16use mentedb_extraction::{ExtractionConfig, ExtractionLlmJudge, HttpExtractionProvider};
17use tracing::{info, warn};
18
19pub(crate) const MIN_CLUSTER_SIZE: usize = 2;
21const CANONICALIZE_LIMIT: usize = 32;
24const CONFLICT_CHECK_LIMIT: usize = 32;
27pub(crate) const SIMILARITY_THRESHOLD: f32 = 0.85;
29const CACHE_MAX_AGE_US: u64 = 24 * 3600 * 1_000_000;
31pub(crate) const MAX_FORGETS_PER_SWEEP: usize = 500;
33
34#[derive(Debug, Default)]
36pub struct MaintenanceReport {
37 pub consolidated: usize,
38 pub decayed: usize,
39 pub forgotten: usize,
40}
41
42pub fn run_sweep(db: &MenteDb) -> MaintenanceReport {
45 let mut report = MaintenanceReport::default();
46
47 match db.find_consolidation_candidates(MIN_CLUSTER_SIZE, SIMILARITY_THRESHOLD) {
50 Ok(candidates) => {
51 for c in candidates {
52 if db.consolidate_cluster(&c.memories).is_ok() {
53 report.consolidated += 1;
54 }
55 }
56 }
57 Err(e) => warn!("maintenance: consolidation failed: {e}"),
58 }
59
60 match db.apply_decay_global() {
62 Ok(n) => report.decayed = n,
63 Err(e) => warn!("maintenance: decay failed: {e}"),
64 }
65
66 match db.evaluate_archival_global() {
68 Ok(decisions) => {
69 for (id, decision) in decisions {
70 if report.forgotten >= MAX_FORGETS_PER_SWEEP {
71 break;
72 }
73 if decision == ArchivalDecision::Delete && db.forget(id).is_ok() {
74 report.forgotten += 1;
75 }
76 }
77 }
78 Err(e) => warn!("maintenance: archival failed: {e}"),
79 }
80
81 db.evict_stale_speculative(CACHE_MAX_AGE_US);
83
84 if let Err(e) = db.flush() {
86 warn!("maintenance: flush failed: {e}");
87 }
88
89 report
90}
91
92pub async fn canonicalize_topics(db: &MenteDb, config: &Option<ExtractionConfig>) -> usize {
97 let Some(cfg) = config.clone() else {
98 return 0;
99 };
100 let provider = match HttpExtractionProvider::new(cfg.reader_config()) {
102 Ok(p) => p,
103 Err(e) => {
104 warn!("maintenance: topic canonicalization skipped, provider build failed: {e}");
105 return 0;
106 }
107 };
108 let judge = ExtractionLlmJudge::new(provider);
109 let n = db
110 .canonicalize_trajectory_topics(judge, CANONICALIZE_LIMIT)
111 .await;
112 if n > 0 {
113 info!(count = n, "maintenance: trajectory topics canonicalized");
114 }
115 n
116}
117
118pub async fn detect_conflicts(db: &MenteDb, config: &Option<ExtractionConfig>) -> usize {
124 let Some(cfg) = config.clone() else {
125 return 0;
126 };
127 let provider = match HttpExtractionProvider::new(cfg.reader_config()) {
129 Ok(p) => p,
130 Err(e) => {
131 warn!("maintenance: conflict detection skipped, provider build failed: {e}");
132 return 0;
133 }
134 };
135 let judge = ExtractionLlmJudge::new(provider);
136 let candidates = db.memory_ids();
137 let params = mentedb::llm_consolidation::ConflictDetectionParams::default();
138 match db
139 .detect_conflicts_with_llm(&candidates, judge, ¶ms, CONFLICT_CHECK_LIMIT)
140 .await
141 {
142 Ok(n) => {
143 if n > 0 {
144 info!(count = n, "maintenance: conflicts detected");
145 }
146 n
147 }
148 Err(e) => {
149 warn!("maintenance: conflict detection failed: {e}");
150 0
151 }
152 }
153}
154
155pub fn spawn_scheduler(
159 databases: Arc<crate::databases::Databases>,
160 interval_hours: u64,
161 extraction_config: Option<ExtractionConfig>,
162) {
163 if interval_hours == 0 {
164 info!("maintenance scheduler disabled (--maintenance-interval-hours 0)");
165 return;
166 }
167 info!("maintenance scheduler enabled: sweep every {interval_hours}h");
168 tokio::spawn(async move {
169 let interval = Duration::from_secs(interval_hours * 3600);
170 loop {
171 tokio::time::sleep(interval).await;
172 for (name, db) in databases.open_handles() {
175 let db_sweep = Arc::clone(&db);
176 match tokio::task::spawn_blocking(move || run_sweep(&db_sweep)).await {
177 Ok(r) => info!(
178 db = %name,
179 consolidated = r.consolidated,
180 decayed = r.decayed,
181 forgotten = r.forgotten,
182 "maintenance sweep complete"
183 ),
184 Err(e) => warn!(db = %name, "maintenance sweep task panicked: {e}"),
185 }
186 canonicalize_topics(&db, &extraction_config).await;
187 detect_conflicts(&db, &extraction_config).await;
188 }
189 }
190 });
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use mentedb::prelude::*;
197 use mentedb_embedding::hash_provider::HashEmbeddingProvider;
198
199 #[test]
200 fn sweep_runs_on_a_populated_db_without_error() {
201 let path = std::env::temp_dir().join(format!("mentedb_maint_test_{}", std::process::id()));
202 let _ = std::fs::remove_dir_all(&path);
203 let db = MenteDb::open_with_embedder(&path, Box::new(HashEmbeddingProvider::new(256)))
204 .expect("open db");
205
206 let agent = AgentId::new();
207 for i in 0..5 {
208 let content = format!("fact number {i} about the project");
209 let emb = db.embed_text(&content).unwrap().unwrap();
210 db.store(MemoryNode::new(agent, MemoryType::Semantic, content, emb))
211 .unwrap();
212 }
213
214 let report = run_sweep(&db);
218 assert!(report.forgotten <= MAX_FORGETS_PER_SWEEP);
219
220 drop(db);
221 let _ = std::fs::remove_dir_all(&path);
222 }
223}