From 7e2be96a57e1ad8d3123fbf38d3c706faa0a9dcd Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 21:37:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(nakui-ui):=20snapshot/compaction=20autom?= =?UTF-8?q?=C3=A1tico=20del=20event=20log=20al=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wirea el snapshot machinery existente en nakui-core (Snapshot, replay_with_snapshot_into, EventLog::compact_through) al runtime de la UI. Reduce el costo de boot de O(events) a O(events_post_snapshot). - Path: sibling del log, extensión `.snap.json`. - Threshold via `NAKUI_SNAPSHOT_THRESHOLD` env (default 50; 0 = off). - Helper `maybe_compact_log` captura snapshot + compacta dejando la última entry como anchor del cursor (sin anchor, EventLog::open vería log vacío y perdería next_seq). - WAL order: write snap antes de compactar; un crash entre los dos da el mismo outcome al próximo boot (replay skipea entries cubiertas por snap). - Fail-soft: snap load/compact errors no son fatales, sólo banner. 5 tests nuevos: derivación del path, dos no-ops bajo threshold, E2E 60 entries → snapshot+compact → reopen+replay → 60 records intactos. 31 tests verdes en nakui-ui. Workspace build verde. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 82 +++++++++ crates/apps/nakui-ui/src/main.rs | 298 ++++++++++++++++++++++++++++++- 2 files changed, 371 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca4098..7a0613b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,88 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nakui-ui): snapshot/compaction automático del event log al startup +Cierra el último gran pendiente del round: el replay full cada +startup escala lineal en el log. Con 60+ entries el costo de boot +se nota; con 10k entries es prohibitivo. Wireamos el snapshot +machinery que ya estaba en `nakui-core` (`Snapshot`, +`replay_with_snapshot_into`, `EventLog::compact_through`) al +runtime de la UI. + +Cambios: +- **Path del snapshot**: sibling del log, extensión `.snap.json`. + `nakui-ui-state.jsonl` ↔ `nakui-ui-state.snap.json`. +- **Nuevo helper `snapshot_path_for(log_path)`** — derivación + pura, testeable. +- **Nuevo helper `maybe_compact_log(log, snap_path, store, threshold)`**: + - Si `entry_count >= threshold` y `>= 2`, captura + `Snapshot::from_memory_store(store, next_seq - 1)`, lo escribe + atómicamente, y compacta el log dejando la última entry como + anchor. + - Anchor invariant: `EventLog::open` deriva `next_seq` del primer + entry del archivo. Si compactáramos *todo* el log file, al + reabrir el cursor volvería a 0 y el próximo append crashearía + con `NonMonotonic`. Por eso compactamos sólo hasta + `next_seq - 2` — la entry del `snap.seq` queda como anchor del + cursor; `replay_with_snapshot_into` la skipea porque snap ya + cubre hasta ese seq inclusive. + - Threshold via env `NAKUI_SNAPSHOT_THRESHOLD`, default 50. + `0` desactiva por completo. + - Devuelve `Result, String>`: `Ok(Some)` si compactó, + `Ok(None)` si no había payoff, `Err` si snap o compact fallaron. +- **`MetaUi::new` reescrito**: + - Carga snapshot al inicio (Some/None según exista). + - `replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store)` + en lugar de `replay_into`. + - Después del replay corre `maybe_compact_log` con el threshold. + - Toast inicial menciona snapshot loaded si aplica + ("snapshot @ seq K") y la compactación si ocurrió. + - Errores de snapshot load **no son fatales**: cae a full replay + con un msg en el banner. + - Errores de auto-compact **no son fatales**: el log + snap + quedan como estaban, msg al banner. + +5 tests nuevos: +- `snapshot_path_for_replaces_extension` — `.jsonl` → `.snap.json`, + edge case sin extensión. +- `maybe_compact_log_below_threshold_noops` — 5 entries vs threshold + 50: no toca nada, no escribe snap. +- `maybe_compact_log_threshold_zero_noops` — threshold 0 = disabled. +- `maybe_compact_log_then_reopen_preserves_records` — E2E: + - Escribe 60 seeds (log + store en sync). + - Compacta (60 >= 50): snap escrito, log queda con 1 anchor entry, + msg reporta "59 entries dropped (1 anchor kept)". + - Reopen: `next_seq=60` se preserva via anchor, `entries.len()=1`. + - Replay con snap loadado en store fresco: los 60 records están. + - Segunda corrida del compact con threshold=1: no-op (idempotente). + +31 tests verdes (+5). Workspace build verde tras la nueva firma. + +Trade-offs y notas: +- **Fail-soft**: cualquier error de snap/compact no rompe el boot; + la UI sigue funcionando con full replay y el toast lo reporta. + Sólo `EventLog::open` failing es no-recoverable (pierde + persistencia). +- **Crash-safety**: WAL order preservado — escribimos snap (atómico + via tempfile + fsync + rename) ANTES de compactar el log + (atómico igual). Si crasheamos entre los dos, próximo boot ve + snap@K + log con todas las entries 0..N — replay skippea las que + snap cubre, outcome idéntico. +- **Sólo en startup**: no hay snapshot durante runtime. Para sesiones + largas con muchas escrituras, el log puede crecer arbitrariamente + hasta el próximo restart. Pendiente futuro: snapshot N writes + desde el último compact. +- **Anchor entry sobrevive sin uso útil**: el costo es 1 línea JSON + por compact. No es preocupación a menos que el threshold sea + muy chico (cada compact deja 1 línea de basura). + +Pendientes restantes: +- **EntityRef validation post-submit** — validar UUID parseable al + submit en lugar de al execute del morphism. +- **Atajo Esc para Cancelar** del modal de delete. +- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío. +- **Snapshot durante runtime** (cada N writes, no sólo al startup). + ### feat(nakui-ui): edit delta-only — sólo campos modificados al log/store Antes de este cambio, editar un record emitía un `FieldOp::Set` por **cada field del form**, incluso los no tocados. Eso bloata el log diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 8cbc8ac..f1c6ec8 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -31,7 +31,9 @@ use gpui::{ Render, SharedString, Window, WindowBounds, WindowOptions, }; use nakui_core::delta::{FieldOp, FieldPath}; -use nakui_core::event_log::{execute_and_log_with_recovery, replay_into, EventLog, LogEntry}; +use nakui_core::event_log::{ + execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot, +}; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; use nakui_ui_schema::{ @@ -123,24 +125,61 @@ impl MetaUi { ), }; - // Persistencia: abrir/crear el event log y hacer replay al - // store. Path por env `NAKUI_EVENT_LOG`, default - // `./nakui-ui-state.jsonl`. Si abrir o replay falla, el - // runtime sigue en modo in-memory (sin persistencia) y el - // load_error se acumula al banner. + // Persistencia: abrir/crear el event log + opcionalmente un + // snapshot sibling para acortar el replay. Path del log por + // env `NAKUI_EVENT_LOG`, default `./nakui-ui-state.jsonl`. El + // snapshot vive como sibling con extensión `.snap.json`. + // + // Si abrir o replay falla, el runtime sigue en modo in-memory + // (sin persistencia) y el load_error se acumula al banner. + // + // Threshold de auto-compaction via env + // `NAKUI_SNAPSHOT_THRESHOLD` (default 50): después del replay, + // si el log file tiene >= N entries, capturamos un snapshot + // del store actual y compactamos el log. La próxima boot ya + // arranca de snapshot + log corto. let log_path = std::env::var("NAKUI_EVENT_LOG") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl")); + let snap_path = snapshot_path_for(&log_path); + let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(50); let mut store = MemoryStore::new(); let mut initial_toast: Option = None; + + // Cargar snapshot (si existe y no falla). Un snapshot + // corrupto no es fatal: caemos a full replay del log. + let snapshot: Option = match Snapshot::load(&snap_path) { + Ok(s) => s, + Err(e) => { + let msg = format!( + "snapshot {}: {e} — full replay", + snap_path.display() + ); + match &load_error { + Some(prev) => { + load_error = Some(SharedString::from(format!("{prev}; {msg}"))); + } + None => load_error = Some(SharedString::from(msg)), + } + None + } + }; + let event_log = match EventLog::open(&log_path) { - Ok(log) => { - match replay_into(&log, &mut store) { + Ok(mut log) => { + match replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store) { Ok(()) => { let n = log.next_seq(); + let from_snap = snapshot + .as_ref() + .map(|s| format!(" (snapshot @ seq {})", s.seq)) + .unwrap_or_default(); if n > 0 { initial_toast = Some(SharedString::from(format!( - "log {} cargado: {n} evento(s) replayed", + "log {} cargado: next_seq={n}{from_snap}", log_path.display() ))); } else { @@ -149,6 +188,37 @@ impl MetaUi { log_path.display() ))); } + + // Auto-compact si pasamos el threshold. No + // fatal — un fallo deja log+snap como están. + match maybe_compact_log( + &mut log, + &snap_path, + &store, + snapshot_threshold, + ) { + Ok(Some(msg)) => { + let prev = initial_toast + .map(|t| t.to_string()) + .unwrap_or_default(); + initial_toast = Some(SharedString::from(format!( + "{prev}; {msg}" + ))); + } + Ok(None) => {} + Err(e) => { + let msg = format!("auto-compact: {e}"); + match &load_error { + Some(prev) => { + load_error = Some(SharedString::from(format!( + "{prev}; {msg}" + ))); + } + None => load_error = Some(SharedString::from(msg)), + } + } + } + Some(Arc::new(Mutex::new(log))) } Err(e) => { @@ -707,6 +777,69 @@ impl CommitOutcome { } } +/// Devuelve el path del snapshot sibling para un log dado: +/// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el +/// snapshot junto al log para que un usuario pueda mover la pareja +/// sin desincronizarlos. +fn snapshot_path_for(log_path: &std::path::Path) -> PathBuf { + log_path.with_extension("snap.json") +} + +/// Si el log file tiene >= `threshold` entries, captura un snapshot +/// del store actual y compacta el log. Idempotente abajo del threshold. +/// +/// Cursor invariant: `EventLog::open` re-deriva `next_seq` del primer +/// entry del archivo. Si compactáramos *todo*, al reabrir el cursor +/// volvería a 0 — el próximo append crashearía con NonMonotonic. Por +/// eso siempre dejamos la última entry como anchor: compactamos sólo +/// hasta `next_seq - 2`. La survivor entry del snap.seq queda en +/// disco pero `replay_with_snapshot_into` la skipea (snap ya cubre +/// hasta snap.seq inclusive), así el costo es 1 línea de log y el +/// resultado del replay es idéntico. +/// +/// Orden de operaciones (importa por crash safety): +/// 1. Capturar snapshot en memoria. +/// 2. `Snapshot::write` (atómico via tempfile + fsync + rename). +/// 3. `EventLog::compact_through` (atómico igual). +/// Si (3) falla tras (2) éxito, el próximo boot ve snap@K + log con +/// entries 0..N — `replay_with_snapshot_into` skipea las cubiertas +/// por snap, outcome idéntico. +/// +/// Devuelve `Ok(Some(msg))` si compactó, `Ok(None)` si no había +/// nada que hacer, `Err(s)` si snapshot/compact falló. +fn maybe_compact_log( + log: &mut EventLog, + snap_path: &std::path::Path, + store: &MemoryStore, + threshold: usize, +) -> Result, String> { + if threshold == 0 { + return Ok(None); + } + let entry_count = log + .entries() + .map_err(|e| format!("read entries: {e}"))? + .len(); + if entry_count < threshold || entry_count < 2 { + // < 2 entries: la regla "dejar 1 como anchor" no permite + // dropear nada útil (sólo el anchor sobreviviría). + // entry_count= 50). + let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap(); + assert!(res.is_some(), "60 >= 50 debe compactar"); + let msg = res.unwrap(); + assert!(msg.contains("seq 59"), "msg debe incluir el seq: {msg}"); + assert!( + msg.contains("59 entries dropped"), + "msg debe reportar 59 dropped (60 - 1 anchor): {msg}" + ); + + // 3. Verificar: snapshot existe, log queda con 1 entry + // (anchor del cursor), next_seq se preserva (60). + assert!(snap_path.exists(), "snapshot debería existir"); + let log_after = EventLog::open(&path).unwrap(); + assert_eq!( + log_after.entries().unwrap().len(), + 1, + "log debería tener 1 anchor entry tras compact" + ); + assert_eq!( + log_after.next_seq(), + 60, + "next_seq se preserva via anchor entry" + ); + + // 4. Re-open + replay desde snapshot → todos los records. + // El anchor entry cae bajo snap.seq así que se skipea. + let snap = Snapshot::load(&snap_path).unwrap().expect("snap loadeable"); + assert_eq!(snap.seq, 59); + let mut fresh_store = MemoryStore::new(); + replay_with_snapshot_into(&log_after, Some(&snap), &mut fresh_store).unwrap(); + for (i, id) in ids.iter().enumerate() { + assert_eq!( + fresh_store.load("row", *id), + Some(json!({"i": i as u64})), + "record {i} debería estar tras snapshot+replay" + ); + } + + // 5. Idempotencia: segunda corrida del compact con threshold=1 + // no hace nada — queda 1 anchor entry, no hay nada útil + // que dropear. + let mut log_reopened = EventLog::open(&path).unwrap(); + let res2 = + maybe_compact_log(&mut log_reopened, &snap_path, &fresh_store, 1).unwrap(); + assert!( + res2.is_none(), + "post-compact: 1 anchor entry, segundo compact debe ser no-op" + ); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&snap_path); + } + #[test] fn resolve_param_strict_number_parses_i64() { let s = spec("qty", FieldKind::Number, true);