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:
@@ -6,6 +6,70 @@ ratio/diff ver `git show <sha>`.
|
||||
|
||||
## 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
|
||||
Cierra otro pendiente de UX. El banner de confirmación de delete
|
||||
ya tenía botones [Cancelar] / [Confirmar], pero la acción más
|
||||
|
||||
@@ -100,6 +100,18 @@ struct MetaUi {
|
||||
/// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
|
||||
/// Navegación a otra view también cancela.
|
||||
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.).
|
||||
toast: Option<SharedString>,
|
||||
/// Si la carga de módulos falló al inicio.
|
||||
@@ -301,6 +313,9 @@ impl MetaUi {
|
||||
form_inputs: BTreeMap::new(),
|
||||
editing: None,
|
||||
pending_delete: None,
|
||||
snap_path,
|
||||
snapshot_threshold,
|
||||
writes_since_compact: 0,
|
||||
toast: initial_toast,
|
||||
load_error,
|
||||
}
|
||||
@@ -416,6 +431,56 @@ impl MetaUi {
|
||||
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
|
||||
/// list). Mutaciones contra el store ocurren acá.
|
||||
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 —
|
||||
// el record ya está sincronizado (incluso
|
||||
// un NoChange cierra el modo edit).
|
||||
@@ -477,9 +549,9 @@ impl MetaUi {
|
||||
} => {
|
||||
match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
|
||||
Ok(op_count) => {
|
||||
self.toast = Some(SharedString::from(format!(
|
||||
"morphism '{name}' OK ({op_count} op(s) aplicadas)"
|
||||
)));
|
||||
let base = format!("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 {
|
||||
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:
|
||||
/// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el
|
||||
/// snapshot junto al log para que un usuario pueda mover la pareja
|
||||
@@ -1153,10 +1235,14 @@ impl MetaUi {
|
||||
this.pending_delete = None;
|
||||
match this.commit_delete(&entity_for_confirm, id_owned) {
|
||||
Ok(()) => {
|
||||
this.toast = Some(SharedString::from(format!(
|
||||
let base = format!(
|
||||
"borrado {entity_for_confirm} {}",
|
||||
short_uuid(&id_owned)
|
||||
)));
|
||||
);
|
||||
this.toast = Some(append_compact_msg(
|
||||
base,
|
||||
this.tick_runtime_compact(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
this.toast = Some(SharedString::from(format!(
|
||||
@@ -1856,6 +1942,86 @@ mod tests {
|
||||
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]
|
||||
fn snapshot_path_for_replaces_extension() {
|
||||
use std::path::Path;
|
||||
|
||||
Reference in New Issue
Block a user