feat(nakui-ui): snapshot/compaction automático del event log al startup
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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,88 @@ ratio/diff ver `git show <sha>`.
|
||||
|
||||
## 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<Option<msg>, 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
|
||||
|
||||
Reference in New Issue
Block a user