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:
@@ -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+
|
||||
|
||||
Generated
+1
@@ -6367,6 +6367,7 @@ dependencies = [
|
||||
"nouser-nous",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sled",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ulid",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user