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>
This commit is contained in:
Sergio
2026-05-08 19:50:37 +00:00
parent d7b4886164
commit 7831c0c827
5 changed files with 220 additions and 12 deletions
+31
View File
@@ -6,6 +6,37 @@ ratio/diff ver `git show <sha>`.
## 2026-05-08 ## 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 ### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime
Antes: cada `spawn(card)` creaba un thread + tokio runtime propio. Antes: cada `spawn(card)` creaba un thread + tokio runtime propio.
Para módulos que publican muchas sesiones (nouser daemon con 50+ Para módulos que publican muchas sesiones (nouser daemon con 50+
Generated
+1
View File
@@ -6367,6 +6367,7 @@ dependencies = [
"nouser-nous", "nouser-nous",
"serde", "serde",
"serde_json", "serde_json",
"sled",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ulid", "ulid",
+1
View File
@@ -16,6 +16,7 @@ brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
blake3 = { workspace = true } blake3 = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sled = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ulid = { workspace = true } ulid = { workspace = true }
walkdir = "2" walkdir = "2"
+12 -1
View File
@@ -68,6 +68,7 @@ fn print_usage(prog: &str) {
eprintln!(); eprintln!();
eprintln!("env:"); eprintln!("env:");
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)"); 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)"); 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 files = scanner::scan_directory(dir, &ScanConfig::default())?;
let n_files = files.len(); let n_files = files.len();
let monads = cluster::by_directory(&files, min_files()); let monads = cluster::by_directory(&files, min_files());
let mut db = db::MonadDb::new(); let mut db = open_db()?;
db.ingest_files(files); db.ingest_files(files);
db.replace_monads(monads); db.replace_monads(monads);
Ok((db, n_files)) 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 { fn cmd_scan(args: &[String]) -> Cmd {
let dir = require_dir(args)?; let dir = require_dir(args)?;
let (db, n_files) = run_scan(&dir)?; let (db, n_files) = run_scan(&dir)?;
+175 -11
View File
@@ -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 //! - **Memoria** (default, cache): `BTreeMap<Id, T>` para reads O(log n).
//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un //! - **Persistencia** (opcional): sled-backed write-through. Si se abre
//! índice `file_id → Vec<monad_id>` (membership). //! 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::collections::BTreeMap;
use std::path::Path;
use nouser_card::{FileEntry, FileId, MonadId, MonadManifest}; use nouser_card::{FileEntry, FileId, MonadId, MonadManifest};
use thiserror::Error;
/// Store de Mónadas + archivos. Operaciones lock-free (mut & por #[derive(Debug, Error)]
/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`. pub enum MonadDbError {
#[derive(Debug, Default)] #[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 { pub struct MonadDb {
files: BTreeMap<FileId, FileEntry>, files: BTreeMap<FileId, FileEntry>,
monads: BTreeMap<MonadId, MonadManifest>, monads: BTreeMap<MonadId, MonadManifest>,
persistence: Option<sled::Db>,
}
impl Default for MonadDb {
fn default() -> Self {
Self::new()
}
} }
impl MonadDb { impl MonadDb {
/// Store en memoria pura (sin persistencia). El estado se pierde al salir.
pub fn new() -> Self { 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 ---- // ---- Files ----
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> { 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) self.files.insert(file.id, file)
} }
pub fn ingest_files(&mut self, files: Vec<FileEntry>) { pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
for f in files { for f in files {
self.files.insert(f.id, f); self.insert_file(f);
} }
} }
@@ -48,13 +121,24 @@ impl MonadDb {
// ---- Monads ---- // ---- Monads ----
pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> { 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) self.monads.insert(monad.id, monad)
} }
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) { 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(); self.monads.clear();
for m in monads { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -106,6 +211,7 @@ mod tests {
db.ingest_files(vec![f1, f2]); db.ingest_files(vec![f1, f2]);
assert_eq!(db.file_count(), 2); assert_eq!(db.file_count(), 2);
assert!(db.file(id1).is_some()); assert!(db.file(id1).is_some());
assert!(!db.is_persistent());
} }
#[test] #[test]
@@ -125,7 +231,6 @@ mod tests {
db.insert_monad(m); db.insert_monad(m);
let resolved = db.resolve_members(mid); let resolved = db.resolve_members(mid);
// Sólo el archivo realmente presente en files.
assert_eq!(resolved.len(), 1); assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].id, id1); assert_eq!(resolved[0].id, id1);
} }
@@ -146,4 +251,63 @@ mod tests {
assert_eq!(db.monad_count(), 1); assert_eq!(db.monad_count(), 1);
assert!(db.monads().next().unwrap().label == "b"); 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");
}
} }