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
+1
View File
@@ -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"
+12 -1
View File
@@ -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)?;
+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
//! 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;
/// 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<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");
}
}