feat(nakui-ui): snapshot/compaction durante runtime cada N writes

El compact ya no es sólo en boot: en runtime, después de cada write
efectivo (commit_seed Created/Updated, commit_morphism, commit_delete),
incrementamos un contador en memoria y disparamos maybe_compact_log
cuando alcanza el threshold (mismo NAKUI_SNAPSHOT_THRESHOLD del
startup). El log no crece sin tope en sesiones largas.

- Nuevos fields en MetaUi: snap_path, snapshot_threshold,
  writes_since_compact (los dos primeros cacheados en new()).
- Nuevo método tick_runtime_compact: increment + check + maybe
  compact + reset. Si compact falla, counter NO se resetea (próximo
  write reintenta). Threshold 0 desactiva.
- Helper append_compact_msg concatena el msg de compact al toast
  del op original con "; " separator.
- Wireado en los 3 callsites de write. NoChange (edit sin cambios)
  no cuenta — preserva "1 write = 1 log entry = 1 tick".

2 tests nuevos: format del helper, ciclo de 7 writes con
threshold=3 verifica 2 compacts + counter residual + log final con
1 anchor + 1 write.

37 tests verdes (+2). Workspace build verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 22:02:00 +00:00
parent 9951307fd9
commit 613f4f299e
2 changed files with 236 additions and 6 deletions
+64
View File
@@ -6,6 +6,70 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09 ## 2026-05-09
### feat(nakui-ui): snapshot/compaction durante runtime cada N writes
Cierra el último pending del round de persistencia. Antes el compact
sólo corría al startup — para una sesión larga con muchas escrituras,
el log crecía sin tope hasta el próximo restart, y el siguiente boot
pagaba el costo lineal del replay.
Cambios:
- **Nuevos fields en `MetaUi`**:
- `snap_path: PathBuf` — cacheado del init para que el tick no
tenga que recomputarlo.
- `snapshot_threshold: usize` — leído del env en `new()` y
cacheado. `0` desactiva runtime compact (mismo env y
semantic que el threshold de startup).
- `writes_since_compact: u64` — contador que incrementa por cada
write efectivo y se resetea cuando el threshold dispara
`maybe_compact_log`.
- **Nuevo método `tick_runtime_compact()`**:
- Early return si `threshold == 0`.
- Increment + check vs threshold.
- Si cruza: lock log + store, llama `maybe_compact_log`.
- **Si compactó OK**: counter = 0, devuelve msg.
- **Si `maybe_compact_log` returned None** (counter dijo "go"
pero entries < 2): counter = 0 (no re-entrar cada write).
- **Si error**: counter NO se resetea (próximo write reintenta),
devuelve el error.
- **Nuevo helper `append_compact_msg(base, opt)`**: concatena el
msg del compact al toast del op original con `";"` separator.
- **Wireup en 3 callsites de write efectivo**:
- `apply_action::SeedEntity`: tick si outcome != NoChange.
- `apply_action::Morphism`: tick siempre que Ok.
- Click handler `[Confirmar]` del delete modal: tick si commit_delete Ok.
- **NoChange no cuenta**: un edit que no cambia nada no escribe al
log, así que tampoco debería avanzar el counter — preserva la
semantic "1 write = 1 log entry = 1 tick".
2 tests nuevos:
- `append_compact_msg_handles_both_branches` — base solo vs base
+ compact, formato del separator.
- `runtime_compact_cycle_resets_counter_after_threshold` — E2E
estilo simulación: 7 writes con threshold=3 → 2 compacts (en
write 3 y 6), counter residual = 1, log final con 2 entries
(1 anchor + 1 write residual). Reproduce el algoritmo del tick
sin GPUI cx; si la lógica del método cambia, se rompe como signal.
37 tests verdes (+2). Workspace build verde.
Trade-offs:
- **Counter en memoria, no persistido**: si la app crashea entre
compacts, al próximo boot el counter parte de 0. El startup
compact (basado en entry_count del log file) compensa esto:
si quedó mucho post-último-compact, se compacta al boot.
- **Lock orden**: tick toma log lock primero, store lock después.
Misma orden que `commit_seed` y `commit_morphism`, no debería
haber deadlock.
- **Costo del tick**: 1 increment + 1 compare por write. Cuando
cruza threshold, 1 read del log (entries) + 1 snapshot write +
1 compact. Para threshold=50 es ~1 fsync cada 50 writes —
amortiza bien.
Pendientes restantes:
- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
- **Validación cross-field** (UUID del EntityRef existe en la
entity referida).
### feat(nakui-ui): atajo Esc para cancelar el modal de delete ### feat(nakui-ui): atajo Esc para cancelar el modal de delete
Cierra otro pendiente de UX. El banner de confirmación de delete Cierra otro pendiente de UX. El banner de confirmación de delete
ya tenía botones [Cancelar] / [Confirmar], pero la acción más ya tenía botones [Cancelar] / [Confirmar], pero la acción más
+172 -6
View File
@@ -100,6 +100,18 @@ struct MetaUi {
/// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia). /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
/// Navegación a otra view también cancela. /// Navegación a otra view también cancela.
pending_delete: Option<(String, Uuid)>, pending_delete: Option<(String, Uuid)>,
/// Path del snapshot sibling del log; cacheado en `new()` para
/// que `tick_runtime_compact` no tenga que recomputarlo.
snap_path: PathBuf,
/// Threshold de auto-compaction (número de writes antes de
/// snapshot+compact). Cacheado del env `NAKUI_SNAPSHOT_THRESHOLD`
/// al startup; 0 = runtime compact desactivado.
snapshot_threshold: usize,
/// Contador de writes desde el último compact (o desde el boot
/// si nunca compactamos). Incrementa por cada commit_seed
/// efectivo / commit_morphism / commit_delete; cuando alcanza
/// `snapshot_threshold` dispara el auto-compact y se resetea.
writes_since_compact: u64,
/// Mensaje toast al pie (success de submit, error de carga, etc.). /// Mensaje toast al pie (success de submit, error de carga, etc.).
toast: Option<SharedString>, toast: Option<SharedString>,
/// Si la carga de módulos falló al inicio. /// Si la carga de módulos falló al inicio.
@@ -301,6 +313,9 @@ impl MetaUi {
form_inputs: BTreeMap::new(), form_inputs: BTreeMap::new(),
editing: None, editing: None,
pending_delete: None, pending_delete: None,
snap_path,
snapshot_threshold,
writes_since_compact: 0,
toast: initial_toast, toast: initial_toast,
load_error, load_error,
} }
@@ -416,6 +431,56 @@ impl MetaUi {
Ok(()) Ok(())
} }
/// Incrementa el contador de writes y, si cruzó el threshold,
/// ejecuta `maybe_compact_log` (snapshot + compact). Devuelve un
/// mensaje de status si compactó (para concatenar al toast del
/// op original) o si la compactación falló; `None` si todavía no
/// alcanzó el threshold.
///
/// Llamar SIEMPRE después de cada write efectivo (commit_seed
/// Created/Updated, commit_morphism Ok, commit_delete Ok).
/// `NoChange` NO debería llamar — no hay write nuevo que contar.
///
/// Threshold == 0 desactiva el runtime compact (early return).
fn tick_runtime_compact(&mut self) -> Option<String> {
if self.snapshot_threshold == 0 {
return None;
}
self.writes_since_compact += 1;
if self.writes_since_compact < self.snapshot_threshold as u64 {
return None;
}
let log_arc = self.event_log.as_ref()?.clone();
let mut log = match log_arc.lock() {
Ok(l) => l,
Err(_) => return Some("auto-compact skip: log mutex envenenado".into()),
};
let store = match self.store.lock() {
Ok(s) => s,
Err(_) => return Some("auto-compact skip: store mutex envenenado".into()),
};
match maybe_compact_log(&mut log, &self.snap_path, &store, self.snapshot_threshold) {
Ok(Some(msg)) => {
self.writes_since_compact = 0;
Some(msg)
}
Ok(None) => {
// Counter cruzó pero entry_count no — log tiene < 2
// entries en disco (ej: snapshot ya cubrió todo y sólo
// quedan los nuevos). Reseteamos para no re-entrar
// en cada write subsiguiente.
self.writes_since_compact = 0;
None
}
Err(e) => {
// Compactación falló — NO reseteamos el counter, así
// el próximo write reintenta. El usuario ve el error
// en el toast.
Some(format!("auto-compact: {e}"))
}
}
}
/// Aplica una acción (click en menú, botón de form, action de /// Aplica una acción (click en menú, botón de form, action de
/// list). Mutaciones contra el store ocurren acá. /// list). Mutaciones contra el store ocurren acá.
fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) { fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
@@ -450,7 +515,14 @@ impl MetaUi {
) )
} }
}; };
self.toast = Some(SharedString::from(toast_msg)); // NoChange no escribió al log → no avanza el
// counter de runtime compact. Created/Updated
// sí lo hacen.
let was_write = !matches!(outcome, CommitOutcome::NoChange(_));
self.toast = Some(append_compact_msg(
toast_msg,
was_write.then(|| self.tick_runtime_compact()).flatten(),
));
// Limpia editing tras un commit exitoso — // Limpia editing tras un commit exitoso —
// el record ya está sincronizado (incluso // el record ya está sincronizado (incluso
// un NoChange cierra el modo edit). // un NoChange cierra el modo edit).
@@ -477,9 +549,9 @@ impl MetaUi {
} => { } => {
match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) { match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
Ok(op_count) => { Ok(op_count) => {
self.toast = Some(SharedString::from(format!( let base = format!("morphism '{name}' OK ({op_count} op(s) aplicadas)");
"morphism '{name}' OK ({op_count} op(s) aplicadas)" self.toast =
))); Some(append_compact_msg(base, self.tick_runtime_compact()));
if let Some(v) = next_view { if let Some(v) = next_view {
self.select_view(mod_idx, v, cx); self.select_view(mod_idx, v, cx);
} }
@@ -777,6 +849,16 @@ impl CommitOutcome {
} }
} }
/// Concatena un mensaje de compact opcional al toast del op original.
/// Devuelve el toast resultante listo para ir a `SharedString`.
/// Sin `compact_msg` devuelve `base` tal cual.
fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
match compact_msg {
Some(m) => SharedString::from(format!("{base}; {m}")),
None => SharedString::from(base),
}
}
/// Devuelve el path del snapshot sibling para un log dado: /// Devuelve el path del snapshot sibling para un log dado:
/// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el /// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el
/// snapshot junto al log para que un usuario pueda mover la pareja /// snapshot junto al log para que un usuario pueda mover la pareja
@@ -1153,10 +1235,14 @@ impl MetaUi {
this.pending_delete = None; this.pending_delete = None;
match this.commit_delete(&entity_for_confirm, id_owned) { match this.commit_delete(&entity_for_confirm, id_owned) {
Ok(()) => { Ok(()) => {
this.toast = Some(SharedString::from(format!( let base = format!(
"borrado {entity_for_confirm} {}", "borrado {entity_for_confirm} {}",
short_uuid(&id_owned) short_uuid(&id_owned)
))); );
this.toast = Some(append_compact_msg(
base,
this.tick_runtime_compact(),
));
} }
Err(e) => { Err(e) => {
this.toast = Some(SharedString::from(format!( this.toast = Some(SharedString::from(format!(
@@ -1856,6 +1942,86 @@ mod tests {
assert!(!delta.contains_key("internal_marker")); assert!(!delta.contains_key("internal_marker"));
} }
#[test]
fn append_compact_msg_handles_both_branches() {
// Sin compact: base solo, sin separador.
let s = append_compact_msg("creado X".into(), None);
assert_eq!(s.as_ref(), "creado X");
// Con compact: concatena con "; ".
let s = append_compact_msg(
"creado X".into(),
Some("auto-compact: snapshot @ seq 49".into()),
);
assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
}
/// Simula el ciclo de write+tick que ocurriría en runtime: con
/// threshold=N, los primeros N-1 writes no compactan, el N-ésimo
/// dispara compact y resetea el counter, los siguientes vuelven
/// a acumular.
///
/// Como `tick_runtime_compact` es un método de `MetaUi` (necesita
/// el cx GPUI para construir el state completo), reproducimos el
/// algorithm por separado: counter manual + invocación directa de
/// `maybe_compact_log`. Si la lógica del tick cambia, este test
/// se va a romper como signal de que hay que actualizarlo.
#[test]
fn runtime_compact_cycle_resets_counter_after_threshold() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
drop(tmp);
let snap_path = snapshot_path_for(&path);
let threshold: usize = 3;
let mut store = MemoryStore::new();
let mut log = EventLog::open(&path).unwrap();
let mut counter: u64 = 0;
let mut total_compactions = 0u32;
// 7 writes con threshold=3 → 2 compacts (en write 3 y en
// write 6), counter restante = 1 al final.
for i in 0..7u64 {
let id = Uuid::new_v4();
log.append(LogEntry::Seed {
seq: i,
entity: "row".into(),
id,
data: json!({"i": i}),
schema_hash: None,
})
.unwrap();
store.seed("row", id, json!({"i": i}));
// Tick.
counter += 1;
if counter >= threshold as u64 {
let res =
maybe_compact_log(&mut log, &snap_path, &store, threshold).unwrap();
if res.is_some() {
total_compactions += 1;
}
counter = 0;
}
}
assert_eq!(
total_compactions, 2,
"con 7 writes y threshold 3 deberíamos disparar 2 compacts"
);
assert_eq!(counter, 1, "1 write residual sin compactar");
// El log final debería tener: el anchor del último compact +
// el write residual = 2 entries.
let entries_after = EventLog::open(&path).unwrap().entries().unwrap().len();
assert_eq!(
entries_after, 2,
"1 anchor del último compact + 1 write residual"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&snap_path);
}
#[test] #[test]
fn snapshot_path_for_replaces_extension() { fn snapshot_path_for_replaces_extension() {
use std::path::Path; use std::path::Path;