From 7831c0c827134e4753a9631765ab76a3fb3b63ba Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 8 May 2026 19:50:37 +0000 Subject: [PATCH] feat(nouser): persistencia sled write-through del MonadDb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MonadDb ahora soporta backend dual: - MonadDb::new() → memoria pura (default, back-compat). - MonadDb::open(path) → sled-backed con cache en memoria. Carga contenido existente al abrir; cada insert_* hace write-through (cache + sled). Diseño: - 2 trees sled: files y monads. - Wire format: serde_json (ergonomía + inspectability con sled-cli; los manifests son chicos, JSON gana sobre postcard aquí). - Reads SIEMPRE desde la cache — sled se consulta sólo al abrir. - replace_monads() purga el tree de sled antes de escribir. Bin nouser: nueva env var NOUSER_DB_PATH. Si está set, persiste; si no, in-memory: $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core scan: 102 archivos, 5 mónadas $ ls /tmp/monads.sled blobs conf Tests nuevos en db.rs: - persistence_roundtrip — escribe, cierra, reabre, datos están. - replace_monads_purges_persistent_tree — replace limpia tree. 24 tests en nouser-core (era 22, +2). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 31 ++++ Cargo.lock | 1 + crates/modules/nouser/core/Cargo.toml | 1 + crates/modules/nouser/core/src/bin/nouser.rs | 13 +- crates/modules/nouser/core/src/db.rs | 186 +++++++++++++++++-- 5 files changed, 220 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edc6c5c..2d42f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ ratio/diff ver `git show `. ## 2026-05-08 +### feat(nouser): persistencia sled write-through del MonadDb +`MonadDb` ahora soporta backend dual: + +- `MonadDb::new()` → memoria pura (default, back-compat). +- `MonadDb::open(path)` → sled-backed con cache en memoria. Carga + contenido existente al abrir; cada `insert_*` hace write-through + (cache + sled). + +Diseño: +- 2 trees sled: `files` y `monads`. +- Wire format: serde_json (ergonomía + inspectability con sled-cli; + los manifests son chicos, JSON gana sobre postcard aquí). +- Reads SIEMPRE desde la cache — sled se consulta sólo al abrir. +- `replace_monads()` purga el tree de sled antes de escribir. + +Bin nouser: nueva env var `NOUSER_DB_PATH`. Si está set, persiste +en esa ruta; si no, in-memory: + + $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core + scan: 102 archivos en crates/core, 5 mónadas + $ ls /tmp/monads.sled + blobs conf + $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core + # segunda corrida re-escribe la DB con el nuevo scan + +Tests nuevos en db.rs: +- `persistence_roundtrip` — escribe, cierra, reabre, datos están. +- `replace_monads_purges_persistent_tree` — replace limpia el tree. + +24 tests en nouser-core (era 22, +2). + ### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime Antes: cada `spawn(card)` creaba un thread + tokio runtime propio. Para módulos que publican muchas sesiones (nouser daemon con 50+ diff --git a/Cargo.lock b/Cargo.lock index de58a10..180c34c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6367,6 +6367,7 @@ dependencies = [ "nouser-nous", "serde", "serde_json", + "sled", "tempfile", "thiserror 2.0.18", "ulid", diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml index 6245607..eb89103 100644 --- a/crates/modules/nouser/core/Cargo.toml +++ b/crates/modules/nouser/core/Cargo.toml @@ -16,6 +16,7 @@ brahman-sidecar = { path = "../../../shared/brahman-sidecar" } blake3 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sled = { workspace = true } thiserror = { workspace = true } ulid = { workspace = true } walkdir = "2" diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs index 7e648a2..b27b9d8 100644 --- a/crates/modules/nouser/core/src/bin/nouser.rs +++ b/crates/modules/nouser/core/src/bin/nouser.rs @@ -68,6 +68,7 @@ fn print_usage(prog: &str) { eprintln!(); eprintln!("env:"); eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)"); + eprintln!(" NOUSER_DB_PATH si está set, abre sled en esa ruta (persistencia)"); eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)"); } @@ -89,12 +90,22 @@ fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box Result> { + if let Ok(path) = std::env::var("NOUSER_DB_PATH") { + Ok(db::MonadDb::open(&path)?) + } else { + Ok(db::MonadDb::new()) + } +} + fn cmd_scan(args: &[String]) -> Cmd { let dir = require_dir(args)?; let (db, n_files) = run_scan(&dir)?; diff --git a/crates/modules/nouser/core/src/db.rs b/crates/modules/nouser/core/src/db.rs index 1d76023..f1997aa 100644 --- a/crates/modules/nouser/core/src/db.rs +++ b/crates/modules/nouser/core/src/db.rs @@ -1,35 +1,108 @@ -//! DB en memoria de Mónadas y archivos. +//! DB de Mónadas y archivos. Backend dual: //! -//! Phase A: store volátil con índices `BTreeMap` para -//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un -//! índice `file_id → Vec` (membership). +//! - **Memoria** (default, cache): `BTreeMap` para reads O(log n). +//! - **Persistencia** (opcional): sled-backed write-through. Si se abre +//! con `MonadDb::open(path)`, cada `insert_*` escribe a sled además +//! de la cache. Reads siempre vienen de la cache. +//! +//! Wire format: JSON via serde_json. Los manifestos son chicos y +//! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre +//! postcard en debuggability. use std::collections::BTreeMap; +use std::path::Path; use nouser_card::{FileEntry, FileId, MonadId, MonadManifest}; +use thiserror::Error; -/// Store de Mónadas + archivos. Operaciones lock-free (mut & por -/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`. -#[derive(Debug, Default)] +#[derive(Debug, Error)] +pub enum MonadDbError { + #[error("sled: {0}")] + Sled(#[from] sled::Error), + #[error("JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("ULID inválido en clave: {0}")] + BadKey(String), +} + +const TREE_FILES: &str = "files"; +const TREE_MONADS: &str = "monads"; + +/// Store de Mónadas + archivos. Cache en memoria + persistencia +/// opcional sled. pub struct MonadDb { files: BTreeMap, monads: BTreeMap, + persistence: Option, +} + +impl Default for MonadDb { + fn default() -> Self { + Self::new() + } } impl MonadDb { + /// Store en memoria pura (sin persistencia). El estado se pierde al salir. pub fn new() -> Self { - Self::default() + Self { + files: BTreeMap::new(), + monads: BTreeMap::new(), + persistence: None, + } + } + + /// Abre (o crea) un store sled-backed en `path`. Carga el contenido + /// existente a la cache antes de devolver. + pub fn open(path: impl AsRef) -> Result { + let db = sled::open(path)?; + let mut files = BTreeMap::new(); + let mut monads = BTreeMap::new(); + + let files_tree = db.open_tree(TREE_FILES)?; + for kv in files_tree.iter() { + let (k, v) = kv?; + let id = decode_key(&k)?; + let entry: FileEntry = serde_json::from_slice(&v)?; + files.insert(id, entry); + } + let monads_tree = db.open_tree(TREE_MONADS)?; + for kv in monads_tree.iter() { + let (k, v) = kv?; + let id = decode_key(&k)?; + let monad: MonadManifest = serde_json::from_slice(&v)?; + monads.insert(id, monad); + } + + Ok(Self { + files, + monads, + persistence: Some(db), + }) + } + + /// `true` si tiene backend persistente. + pub fn is_persistent(&self) -> bool { + self.persistence.is_some() } // ---- Files ---- pub fn insert_file(&mut self, file: FileEntry) -> Option { + if let Some(db) = &self.persistence { + // Write-through: si falla el persist, lo logeamos pero la + // memoria queda actualizada. Filosofía: cache nunca miente + // sobre el último estado conocido en este proceso. + if let Err(e) = persist_file(db, &file) { + eprintln!("[MonadDb] persist file falló: {e}"); + } + } self.files.insert(file.id, file) } pub fn ingest_files(&mut self, files: Vec) { for f in files { - self.files.insert(f.id, f); + self.insert_file(f); } } @@ -48,13 +121,24 @@ impl MonadDb { // ---- Monads ---- pub fn insert_monad(&mut self, monad: MonadManifest) -> Option { + if let Some(db) = &self.persistence { + if let Err(e) = persist_monad(db, &monad) { + eprintln!("[MonadDb] persist monad falló: {e}"); + } + } self.monads.insert(monad.id, monad) } pub fn replace_monads(&mut self, monads: Vec) { + // Si hay persistencia, limpiar tree antes de insertar. + if let Some(db) = &self.persistence { + if let Ok(tree) = db.open_tree(TREE_MONADS) { + let _ = tree.clear(); + } + } self.monads.clear(); for m in monads { - self.monads.insert(m.id, m); + self.insert_monad(m); } } @@ -80,6 +164,27 @@ impl MonadDb { } } +fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> { + let tree = db.open_tree(TREE_FILES)?; + let key = f.id.to_string(); + let val = serde_json::to_vec(f)?; + tree.insert(key.as_bytes(), val)?; + Ok(()) +} + +fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> { + let tree = db.open_tree(TREE_MONADS)?; + let key = m.id.to_string(); + let val = serde_json::to_vec(m)?; + tree.insert(key.as_bytes(), val)?; + Ok(()) +} + +fn decode_key(k: &[u8]) -> Result { + let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?; + ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -106,6 +211,7 @@ mod tests { db.ingest_files(vec![f1, f2]); assert_eq!(db.file_count(), 2); assert!(db.file(id1).is_some()); + assert!(!db.is_persistent()); } #[test] @@ -125,7 +231,6 @@ mod tests { db.insert_monad(m); let resolved = db.resolve_members(mid); - // Sólo el archivo realmente presente en files. assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].id, id1); } @@ -146,4 +251,63 @@ mod tests { assert_eq!(db.monad_count(), 1); assert!(db.monads().next().unwrap().label == "b"); } + + #[test] + fn persistence_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let dbpath = tmp.path().join("monads.sled"); + + // Escribimos algunos datos + { + let mut db = MonadDb::open(&dbpath).expect("open"); + assert!(db.is_persistent()); + let f = mk_file("/persist/a.rs"); + let fid = f.id; + db.insert_file(f); + + let mut m = MonadManifest::new("persist-test"); + m.members.insert(fid); + m.dominant_lens = Lens::Code; + m.touch(); + db.insert_monad(m); + } + + // Reabrimos y verificamos que están + let db = MonadDb::open(&dbpath).expect("reopen"); + assert_eq!(db.file_count(), 1); + assert_eq!(db.monad_count(), 1); + let m = db.monads().next().unwrap(); + assert_eq!(m.label, "persist-test"); + assert_eq!(m.cardinality, 1); + } + + #[test] + fn replace_monads_purges_persistent_tree() { + let tmp = tempfile::tempdir().unwrap(); + let dbpath = tmp.path().join("replace.sled"); + + { + let mut db = MonadDb::open(&dbpath).unwrap(); + let mut m1 = MonadManifest::new("old"); + m1.members.insert(FileId::from(Ulid::new())); + m1.touch(); + db.insert_monad(m1); + } + + // Reabrir, replace, verificar + { + let mut db = MonadDb::open(&dbpath).unwrap(); + assert_eq!(db.monad_count(), 1); + let mut m2 = MonadManifest::new("new"); + m2.members.insert(FileId::from(Ulid::new())); + m2.touch(); + db.replace_monads(vec![m2]); + assert_eq!(db.monad_count(), 1); + } + + // Tercera apertura: sólo "new" sobrevive + let db = MonadDb::open(&dbpath).unwrap(); + assert_eq!(db.monad_count(), 1); + assert_eq!(db.monads().next().unwrap().label, "new"); + } }