hammer-upgrade: GC de generaciones huérfanas — 'hammer upgrade prune' (E4 hardening)
Tras un rollback las generaciones más nuevas quedan inalcanzables (no hay redo). prune(state, keep) borra esas huérfanas (la cadena viva = current+ancestros vía parent siempre se conserva); --keep N recorta además la profundidad de rollback. live_chain() expuesto. 2 tests nuevos + paso prune en upgrade-e2e-test.sh (3->1 gens, árbol vivo intacto). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -277,6 +277,13 @@ enum UpgradeCmd {
|
||||
Rollback,
|
||||
/// Muestra la generación viva y el historial.
|
||||
Status,
|
||||
/// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
|
||||
Prune {
|
||||
/// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de
|
||||
/// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas.
|
||||
#[arg(long)]
|
||||
keep: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -871,6 +878,11 @@ fn main() -> anyhow::Result<()> {
|
||||
println!(" {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len());
|
||||
}
|
||||
}
|
||||
UpgradeCmd::Prune { keep } => {
|
||||
let r = hammer_upgrade::prune(&state_root, keep)?;
|
||||
println!("✓ prune: {} borradas, {} conservadas", r.removed.len(), r.kept.len());
|
||||
for id in &r.removed { println!(" - {id}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,57 @@ fn next_id(state_root: &Path) -> Result<u64> {
|
||||
Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1))
|
||||
}
|
||||
|
||||
/// La **cadena viva**: la generación actual seguida de sus ancestros vía `parent`, de la más nueva a
|
||||
/// la más vieja. Son las únicas generaciones que el `rollback` puede necesitar (no hay "redo": tras un
|
||||
/// rollback las generaciones más nuevas quedan **huérfanas**). Vacía si no hay `current`.
|
||||
pub fn live_chain(state_root: &Path) -> Result<Vec<u64>> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = current(state_root)?;
|
||||
let mut guard = 0usize; // anti-ciclo defensivo (un manifiesto manipulado no debe colgar el proceso)
|
||||
while let Some(id) = cur {
|
||||
if chain.contains(&id) || guard > 100_000 {
|
||||
break;
|
||||
}
|
||||
chain.push(id);
|
||||
guard += 1;
|
||||
cur = get(state_root, id)?.and_then(|m| m.parent);
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Reporte de un [`prune`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PruneReport {
|
||||
pub removed: Vec<u64>,
|
||||
pub kept: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Recupera espacio borrando generaciones (manifiesto + `backup/`) que el rollback ya no puede
|
||||
/// alcanzar. **Siempre** se conservan las de la [cadena viva](live_chain); el resto (huérfanas tras un
|
||||
/// rollback) se borran. Si `keep` es `Some(n)`, además se **recorta la cadena** a sus `n` generaciones
|
||||
/// más nuevas — esto limita la profundidad de rollback disponible (como `delete-generations` en NixOS),
|
||||
/// así que `None` (conservar toda la cadena) es lo seguro por defecto. Idempotente.
|
||||
pub fn prune(state_root: &Path, keep: Option<usize>) -> Result<PruneReport> {
|
||||
let chain = live_chain(state_root)?;
|
||||
let keep_set: std::collections::BTreeSet<u64> = match keep {
|
||||
Some(n) => chain.iter().take(n).copied().collect(),
|
||||
None => chain.iter().copied().collect(),
|
||||
};
|
||||
let mut report = PruneReport::default();
|
||||
for m in list(state_root)? {
|
||||
if keep_set.contains(&m.id) {
|
||||
report.kept.push(m.id);
|
||||
continue;
|
||||
}
|
||||
let gen_base = generations_dir(state_root).join(m.id.to_string());
|
||||
std::fs::remove_dir_all(&gen_base)?;
|
||||
report.removed.push(m.id);
|
||||
}
|
||||
report.removed.sort_unstable();
|
||||
report.kept.sort_unstable();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Enumeración de árboles.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
@@ -715,4 +766,50 @@ mod tests {
|
||||
assert_eq!(evs.len(), 2);
|
||||
assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_removes_orphans_but_keeps_live_chain() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = tmp.path().join("store");
|
||||
let root = tmp.path().join("root");
|
||||
let state = tmp.path().join("state");
|
||||
let v1 = seal_tree(&store, &h64("a1"), "v1", |w| write(&w.join("f"), b"1"));
|
||||
let v2 = seal_tree(&store, &h64("a2"), "v2", |w| write(&w.join("f"), b"2"));
|
||||
let v3 = seal_tree(&store, &h64("a3"), "v3", |w| write(&w.join("f"), b"3"));
|
||||
|
||||
apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1
|
||||
apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2 (parent 1)
|
||||
apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (parent 2)
|
||||
// rollback dos veces ⇒ current=1; las gens 2 y 3 quedan HUÉRFANAS (rollback no las alcanza).
|
||||
rollback(&root, &state, None).unwrap();
|
||||
rollback(&root, &state, None).unwrap();
|
||||
assert_eq!(current(&state).unwrap(), Some(1));
|
||||
assert_eq!(live_chain(&state).unwrap(), vec![1]);
|
||||
|
||||
let r = prune(&state, None).unwrap();
|
||||
assert_eq!(r.removed, vec![2, 3], "huérfanas borradas");
|
||||
assert_eq!(r.kept, vec![1], "cadena viva conservada");
|
||||
assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![1]);
|
||||
// idempotente.
|
||||
assert!(prune(&state, None).unwrap().removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_with_keep_trims_chain_depth() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = tmp.path().join("store");
|
||||
let root = tmp.path().join("root");
|
||||
let state = tmp.path().join("state");
|
||||
for (hx, nm, by) in [("b1", "v1", b"1"), ("b2", "v2", b"2"), ("b3", "v3", b"3")] {
|
||||
let d = seal_tree(&store, &h64(hx), nm, |w| write(&w.join("f"), by));
|
||||
apply(&store, &d, None, &root, &state, None).unwrap();
|
||||
}
|
||||
// current=3, cadena=[3,2,1]. keep=Some(2) ⇒ conserva 3 y 2, borra 1.
|
||||
assert_eq!(live_chain(&state).unwrap(), vec![3, 2, 1]);
|
||||
let r = prune(&state, Some(2)).unwrap();
|
||||
assert_eq!(r.kept, vec![2, 3]);
|
||||
assert_eq!(r.removed, vec![1]);
|
||||
// gen 1 borrada del disco; 2 y 3 siguen.
|
||||
assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![2, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user