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; 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) <noreply@anthropic.com>
7831c0c827134e4753a9631765ab76a3fb3b63ba · by Sergio · 2026-05-08T19:50:37Z (5 months ago) · parent d7b488616 · browse the tree at this commit
Semantic delta · 21
crates/modules/nouser/core/src/bin/nouser.rs
crates/modules/nouser/core/src/db.rs
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 <sha>`.
## 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<dyn std::error::E
let files = scanner::scan_directory(dir, &ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory(&files, min_files());
- let mut db = db::MonadDb::new();
+ let mut db = open_db()?;
db.ingest_files(files);
db.replace_monads(monads);
Ok((db, n_files))
}
+/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
+/// si no, store en memoria.
+fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
+ 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<Id, Manifest>` para
-//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un
-//! índice `file_id → Vec<monad_id>` (membership).
+//! - **Memoria** (default, cache): `BTreeMap<Id, T>` 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;
+
+#[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),
+}
-/// Store de Mónadas + archivos. Operaciones lock-free (mut & por
-/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`.
-#[derive(Debug, Default)]
+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<FileId, FileEntry>,
monads: BTreeMap<MonadId, MonadManifest>,
+ persistence: Option<sled::Db>,
+}
+
+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<Path>) -> Result<Self, MonadDbError> {
+ 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<FileEntry> {
+ 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<FileEntry>) {
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<MonadManifest> {
+ 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<MonadManifest>) {
+ // 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<ulid::Ulid, MonadDbError> {
+ 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");
+ }
}