Skip to content

Commit a2d6c1e

Browse files
committed
Fix audit DB consuming 94% CPU when idle
ChronDB uses a GraalVM native-image shared library whose internal threads (GC, services) burn CPU even with zero operations. The connection was opened eagerly at startup and never closed. Introduced a DbPool with lazy acquire/release lifecycle and a dedicated GC thread that tears down the GraalVM isolate after 120s of inactivity. Next operation transparently reopens it. The audit module now fully owns its own connection lifecycle — no external reaper needed. Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
1 parent a6652be commit a2d6c1e

3 files changed

Lines changed: 91 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcp"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
edition = "2021"
55
description = "CLI that turns MCP servers into terminal commands"
66
authors = ["Thiago Avelino <avelinorun@gmail.com>"]

src/audit.rs

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,77 @@ impl AuditFilter {
176176
}
177177
}
178178

179+
/// How long the ChronDB connection can be idle before the GC closes it.
180+
const AUDIT_DB_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
181+
182+
/// How often the GC thread checks for idle connections.
183+
const AUDIT_GC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
184+
185+
/// Manages the ChronDB connection lifecycle: open on first use, close when idle.
186+
///
187+
/// ChronDB loads a GraalVM native-image shared library whose internal threads
188+
/// consume CPU even when no operations are in flight. This struct ensures the
189+
/// isolate only exists while the database is actively being used.
190+
///
191+
/// A background GC thread monitors `last_used` and drops the connection after
192+
/// [`AUDIT_DB_IDLE_TIMEOUT`] of inactivity, tearing down the GraalVM isolate.
193+
/// The next operation transparently reopens it (like Go's `defer` on each use
194+
/// cycle).
195+
pub(crate) struct DbPool {
196+
data_path: String,
197+
index_path: String,
198+
inner: std::sync::Mutex<DbPoolInner>,
199+
}
200+
201+
struct DbPoolInner {
202+
db: Option<Arc<ChronDB>>,
203+
last_used: std::time::Instant,
204+
}
205+
206+
impl DbPool {
207+
fn new(data_path: String, index_path: String) -> Self {
208+
Self {
209+
data_path,
210+
index_path,
211+
inner: std::sync::Mutex::new(DbPoolInner {
212+
db: None,
213+
last_used: std::time::Instant::now(),
214+
}),
215+
}
216+
}
217+
218+
/// Acquire a handle to ChronDB — opens if not connected.
219+
fn acquire(&self) -> Result<Arc<ChronDB>> {
220+
let mut inner = self.inner.lock().unwrap();
221+
inner.last_used = std::time::Instant::now();
222+
if let Some(ref db) = inner.db {
223+
return Ok(db.clone());
224+
}
225+
let db = ChronDB::open(&self.data_path, &self.index_path)
226+
.map_err(|e| anyhow::anyhow!("failed to open audit db: {e:?}"))?;
227+
let db = Arc::new(db);
228+
inner.db = Some(db.clone());
229+
eprintln!("[audit] database opened");
230+
Ok(db)
231+
}
232+
233+
/// GC tick: close if idle longer than `max_idle`.
234+
/// Returns true if the connection was closed.
235+
fn gc(&self, max_idle: std::time::Duration) -> bool {
236+
let mut inner = self.inner.lock().unwrap();
237+
if inner.db.is_some() && inner.last_used.elapsed() >= max_idle {
238+
inner.db = None; // Drop → SharedWorker::drop → graal_tear_down_isolate
239+
eprintln!("[audit] database closed (idle {:?})", max_idle);
240+
return true;
241+
}
242+
false
243+
}
244+
}
245+
179246
pub enum AuditLogger {
180247
Active {
181248
sender: tokio::sync::mpsc::UnboundedSender<AuditEntry>,
182-
db: Arc<ChronDB>,
249+
pool: Arc<DbPool>,
183250
},
184251
Disabled,
185252
}
@@ -199,12 +266,11 @@ impl AuditLogger {
199266
std::fs::create_dir_all(&index_path)
200267
.with_context(|| format!("failed to create audit index dir: {index_path}"))?;
201268

202-
let db = ChronDB::open(&data_path, &index_path)
203-
.map_err(|e| anyhow::anyhow!("failed to open audit db: {e:?}"))?;
204-
let db = Arc::new(db);
269+
let pool = Arc::new(DbPool::new(data_path, index_path));
205270

271+
// Writer thread: receives entries via channel, writes to ChronDB on demand.
206272
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AuditEntry>();
207-
let db_clone = db.clone();
273+
let writer_pool = pool.clone();
208274
tokio::task::spawn_blocking(move || {
209275
while let Some(entry) = rx.blocking_recv() {
210276
let key = format!(
@@ -213,12 +279,24 @@ impl AuditLogger {
213279
uuid::Uuid::new_v4()
214280
);
215281
if let Ok(doc) = serde_json::to_value(&entry) {
216-
let _ = db_clone.put(&key, &doc, None);
282+
if let Ok(db) = writer_pool.acquire() {
283+
let _ = db.put(&key, &doc, None);
284+
}
217285
}
218286
}
219287
});
220288

221-
Ok(AuditLogger::Active { sender: tx, db })
289+
// GC thread: monitors idle time, closes ChronDB when not in use.
290+
let gc_pool = pool.clone();
291+
std::thread::Builder::new()
292+
.name("audit-gc".to_string())
293+
.spawn(move || loop {
294+
std::thread::sleep(AUDIT_GC_INTERVAL);
295+
gc_pool.gc(AUDIT_DB_IDLE_TIMEOUT);
296+
})
297+
.ok();
298+
299+
Ok(AuditLogger::Active { sender: tx, pool })
222300
}
223301

224302
pub fn log(&self, entry: AuditEntry) {
@@ -230,7 +308,8 @@ impl AuditLogger {
230308
pub fn query_recent(&self, limit: usize) -> Result<Vec<AuditEntry>> {
231309
match self {
232310
AuditLogger::Disabled => Ok(vec![]),
233-
AuditLogger::Active { db, .. } => {
311+
AuditLogger::Active { pool, .. } => {
312+
let db = pool.acquire()?;
234313
let raw = db
235314
.list_by_prefix("audit:", None)
236315
.map_err(|e| anyhow::anyhow!("failed to query audit logs: {e:?}"))?;
@@ -242,7 +321,8 @@ impl AuditLogger {
242321
pub fn query_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>> {
243322
match self {
244323
AuditLogger::Disabled => Ok(vec![]),
245-
AuditLogger::Active { db, .. } => {
324+
AuditLogger::Active { pool, .. } => {
325+
let db = pool.acquire()?;
246326
let raw = db
247327
.list_by_prefix("audit:", None)
248328
.map_err(|e| anyhow::anyhow!("failed to query audit logs: {e:?}"))?;

0 commit comments

Comments
 (0)