From 73e675d298b93b210f6b3f5e06ba69ba70ffa8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adem=C3=ADlson=20Tonato?= Date: Tue, 17 Feb 2026 18:33:16 -0300 Subject: [PATCH] feat(memory): optional SQLite connection open timeout - Add memory.sqlite_open_timeout_secs config (None = wait indefinitely). - When set, open the DB in a thread with recv_timeout; cap at 300s. - Default remains None for backward compatibility. - Document in README; add tests for timeout path and default. --- README.md | 3 ++ src/config/schema.rs | 8 ++++ src/memory/mod.rs | 1 + src/memory/sqlite.rs | 87 ++++++++++++++++++++++++++++++++++++++++++- src/onboard/wizard.rs | 1 + 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6b1fcc7..040488f 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,9 @@ keyword_weight = 0.3 # backend = "none" uses an explicit no-op memory backend (no persistence) +# Optional for backend = "sqlite": max seconds to wait when opening the DB (e.g. file locked). Omit or leave unset for no timeout. +# sqlite_open_timeout_secs = 30 + # Optional for backend = "lucid" # ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid # default: lucid # ZEROCLAW_LUCID_BUDGET=200 # default: 200 diff --git a/src/config/schema.rs b/src/config/schema.rs index bf25866..28d8248 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -781,6 +781,12 @@ pub struct MemoryConfig { /// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing #[serde(default = "default_true")] pub auto_hydrate: bool, + + // ── SQLite backend options ───────────────────────────────── + /// For sqlite backend: max seconds to wait when opening the DB (e.g. file locked). + /// None = wait indefinitely (default). Recommended max: 300. + #[serde(default)] + pub sqlite_open_timeout_secs: Option, } fn default_embedding_provider() -> String { @@ -845,6 +851,7 @@ impl Default for MemoryConfig { snapshot_enabled: false, snapshot_on_hygiene: false, auto_hydrate: true, + sqlite_open_timeout_secs: None, } } } @@ -2279,6 +2286,7 @@ default_temperature = 0.7 assert_eq!(m.archive_after_days, 7); assert_eq!(m.purge_after_days, 30); assert_eq!(m.conversation_retention_days, 30); + assert!(m.sqlite_open_timeout_secs.is_none()); } #[test] diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 45b7451..6798ee4 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -115,6 +115,7 @@ pub fn create_memory( config.vector_weight as f32, config.keyword_weight as f32, config.embedding_cache_size, + config.sqlite_open_timeout_secs, )?; Ok(mem) } diff --git a/src/memory/sqlite.rs b/src/memory/sqlite.rs index b0addeb..31cde20 100644 --- a/src/memory/sqlite.rs +++ b/src/memory/sqlite.rs @@ -1,14 +1,21 @@ use super::embeddings::EmbeddingProvider; use super::traits::{Memory, MemoryCategory, MemoryEntry}; use super::vector; +use anyhow::Context; use async_trait::async_trait; use chrono::Local; use parking_lot::Mutex; use rusqlite::{params, Connection}; use std::path::{Path, PathBuf}; +use std::sync::mpsc; use std::sync::Arc; +use std::thread; +use std::time::Duration; use uuid::Uuid; +/// Maximum allowed open timeout (seconds) to avoid unreasonable waits. +const SQLITE_OPEN_TIMEOUT_CAP_SECS: u64 = 300; + /// SQLite-backed persistent memory — the brain /// /// Full-stack search engine: @@ -34,15 +41,22 @@ impl SqliteMemory { 0.7, 0.3, 10_000, + None, ) } + /// Build SQLite memory with optional open timeout. + /// + /// If `open_timeout_secs` is `Some(n)`, opening the database is limited to `n` seconds + /// (capped at 300). Useful when the DB file may be locked or on slow storage. + /// `None` = wait indefinitely (default). pub fn with_embedder( workspace_dir: &Path, embedder: Arc, vector_weight: f32, keyword_weight: f32, cache_max: usize, + open_timeout_secs: Option, ) -> anyhow::Result { let db_path = workspace_dir.join("memory").join("brain.db"); @@ -50,7 +64,7 @@ impl SqliteMemory { std::fs::create_dir_all(parent)?; } - let conn = Connection::open(&db_path)?; + let conn = Self::open_connection(&db_path, open_timeout_secs)?; // ── Production-grade PRAGMA tuning ────────────────────── // WAL mode: concurrent reads during writes, crash-safe @@ -78,6 +92,37 @@ impl SqliteMemory { }) } + /// Open SQLite connection, optionally with a timeout (for locked/slow storage). + fn open_connection( + db_path: &Path, + open_timeout_secs: Option, + ) -> anyhow::Result { + let path_buf = db_path.to_path_buf(); + + let conn = if let Some(secs) = open_timeout_secs { + let capped = secs.min(SQLITE_OPEN_TIMEOUT_CAP_SECS); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let result = Connection::open(&path_buf); + let _ = tx.send(result); + }); + match rx.recv_timeout(Duration::from_secs(capped)) { + Ok(Ok(c)) => c, + Ok(Err(e)) => return Err(e).context("SQLite failed to open database"), + Err(mpsc::RecvTimeoutError::Timeout) => { + anyhow::bail!("SQLite connection open timed out after {} seconds", capped); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("SQLite open thread exited unexpectedly"); + } + } + } else { + Connection::open(&path_buf).context("SQLite failed to open database")? + }; + + Ok(conn) + } + /// Initialize all tables: memories, FTS5, `embedding_cache` fn init_schema(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch( @@ -1054,13 +1099,51 @@ mod tests { assert_eq!(new, 1); } + // ── Open timeout tests ──────────────────────────────────────── + + #[test] + fn open_with_timeout_succeeds_when_fast() { + let tmp = TempDir::new().unwrap(); + let embedder = Arc::new(super::super::embeddings::NoopEmbedding); + let mem = SqliteMemory::with_embedder(tmp.path(), embedder, 0.7, 0.3, 1000, Some(5)); + assert!( + mem.is_ok(), + "open with 5s timeout should succeed on fast path" + ); + assert_eq!(mem.unwrap().name(), "sqlite"); + } + + #[tokio::test] + async fn open_with_timeout_store_recall_unchanged() { + let tmp = TempDir::new().unwrap(); + let mem = SqliteMemory::with_embedder( + tmp.path(), + Arc::new(super::super::embeddings::NoopEmbedding), + 0.7, + 0.3, + 1000, + Some(2), + ) + .unwrap(); + mem.store( + "timeout_key", + "value with timeout", + MemoryCategory::Core, + None, + ) + .await + .unwrap(); + let entry = mem.get("timeout_key").await.unwrap().unwrap(); + assert_eq!(entry.content, "value with timeout"); + } + // ── With-embedder constructor test ─────────────────────────── #[test] fn with_embedder_noop() { let tmp = TempDir::new().unwrap(); let embedder = Arc::new(super::super::embeddings::NoopEmbedding); - let mem = SqliteMemory::with_embedder(tmp.path(), embedder, 0.7, 0.3, 1000); + let mem = SqliteMemory::with_embedder(tmp.path(), embedder, 0.7, 0.3, 1000, None); assert!(mem.is_ok()); assert_eq!(mem.unwrap().name(), "sqlite"); } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 760d1fb..6da691f 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -288,6 +288,7 @@ fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig { snapshot_enabled: false, snapshot_on_hygiene: false, auto_hydrate: true, + sqlite_open_timeout_secs: None, } }