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
|
## 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
|
||||||
|
|||||||
@@ -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, ¶ms, cx) {
|
match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user