diff --git a/scripts/store-gc.sh b/scripts/store-gc.sh new file mode 100755 index 00000000..a9e88a92 --- /dev/null +++ b/scripts/store-gc.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# store-gc.sh — recolector de basura del store content-addressed. `hammer gc` no existe; esto es. +# +# ── EL PROBLEMA QUE RESUELVE ──────────────────────────────────────────────────────────────────── +# El store acumula un artefacto por cada SELLADO, no uno por receta. Cuando una dep cambia, el +# ArtifactHash de todo lo que cuelga de ella cambia, y el sellado nuevo se suma al viejo en vez de +# reemplazarlo. Medido el 2026-08-05: 1996 artefactos para 1130 recetas — 12 copias de +# `libqalculate`, 9 de `qt6-qtdeclarative`, 8 de `gtk4`. 51G de los 74G eran versiones anteriores. +# +# ── EL CRITERIO, Y POR QUÉ TIENE DOS ESCALONES ────────────────────────────────────────────────── +# El conjunto VIVO es `hammer hash` sobre TODAS las recetas de TODAS las colas: es la respuesta a +# «¿cuál es el hash vigente de esta receta HOY?», que el store solo no puede dar. Lo que no está en +# ese conjunto es RANCIO — pero rancio se parte en dos cosas muy distintas: +# +# SUPERADO — su nombre de receta TIENE un artefacto vigente presente en el store. Es una versión +# anterior de algo que ya está sellado al día. Borrarlo no pierde nada: si hiciera +# falta, se reconstruye desde la receta, que está en git. +# +# HUÉRFANO — ningún artefacto con ese nombre es el hash vigente. O sea que el hash de HOY no está +# sellado y éste es el ÚNICO ejemplar que existe. Borrarlo sí pierde: es la diferencia +# entre «reconstruible en 2 minutos» y «hay que rehacer la torre». En la medición del +# 2026-08-05 eran 255 artefactos (17G) y casi todos KDE (`kio`×8, `kparts`×8, +# `kcmutils`×8) — o sea que las recetas KDE se movieron después del último sellado y el +# escritorio hidratado vive de artefactos que ya no son vigentes. +# +# Por eso el default borra SÓLO los superados. `--huerfanos` existe para cuando lo que se quiera sea +# exactamente eso, y hay que quererlo a propósito. +# +# ⚠ EL ESPACIO NO SIEMPRE SE LIBERA. Los rootfs hidratados (`work/*-rootfs`) son HARDLINKS al store. +# Borrar el directorio del store no rompe el rootfs —los datos siguen vivos por el otro enlace— pero +# tampoco libera el bloque hasta que el rootfs también se vaya. Es una propiedad, no un fallo. +# +# ⚠ NO BARRE `store-rust/` NI `store-kern/`: son stores aparte (frentes selfhost y kernel) con sus +# propias recetas. Sus `.hammer` son manifiestos de artefacto, no recetas de entrada. +# +# Uso: scripts/store-gc.sh [--aplicar] [--huerfanos] +# (sin --aplicar es un DRY-RUN: mide y no borra) +# Env: STORE (def ./store) HAMMER (def ./target/release/hammer) JOBS (def 8) +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" +STORE="${STORE:-./store}"; HAMMER="${HAMMER:-./target/release/hammer}"; JOBS="${JOBS:-8}" +APLICAR=0; HUERFANOS=0 +for a in "$@"; do + case "$a" in + --aplicar) APLICAR=1 ;; + --huerfanos) HUERFANOS=1 ;; + *) echo "opción desconocida: $a" >&2; exit 2 ;; + esac +done + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT + +# ── 1. el conjunto VIVO ───────────────────────────────────────────────────────────────────────── +# Todas las colas, más las recetas puestas en escena en `tandas/`. Las de tandas suelen no resolver +# sus deps (la resolución es hermano→padre y viven fuera de `recipes/`) y su hash falla; se incluyen +# igual porque las que SÍ resuelven son vivas de pleno derecho y omitirlas las volvería rancias. +{ ls recipes/*.toml recipes/incoming-*/*.toml 2>/dev/null + find tandas -name '*.toml' 2>/dev/null | xargs grep -l '^\[source\]' 2>/dev/null +} | sort -u > "$TMP/recetas.txt" +echo "==> recetas barridas: $(wc -l < "$TMP/recetas.txt")" + +xargs -P "$JOBS" -I{} sh -c "$HAMMER --store $STORE hash \"{}\" 2>/dev/null | tail -1" < "$TMP/recetas.txt" \ + | grep '^b3:' | sed 's/^b3://' | sort -u > "$TMP/vivos.txt" +echo " hashes vigentes calculables: $(wc -l < "$TMP/vivos.txt")" + +# Cuántas NO dieron hash. No es fatal (ver arriba) pero es el número que dice cuánta confianza tiene +# la clasificación: si sube mucho, algo se rompió en la resolución de deps y NO hay que borrar. +FALLOS=$(( $(wc -l < "$TMP/recetas.txt") - $( + xargs -P "$JOBS" -I{} sh -c "$HAMMER --store $STORE hash \"{}\" >/dev/null 2>&1 && echo ok" < "$TMP/recetas.txt" | wc -l) )) +echo " recetas cuyo hash FALLA: $FALLOS" + +# ── 2. clasificar el store ────────────────────────────────────────────────────────────────────── +ls "$STORE" | grep -E '^[0-9a-f]{64}-' > "$TMP/dirs.txt" +python3 - "$TMP" <<'PY' +import sys, os +t = sys.argv[1] +vivos = set(open(f"{t}/vivos.txt").read().split()) +dirs = [l.strip() for l in open(f"{t}/dirs.txt") if l.strip()] +part = lambda d: (d.split('-', 1)[0], d.split('-', 1)[1]) +nombres_vivos = {n for h, n in map(part, dirs) if h in vivos} +sup, hue, viv = [], [], [] +for d in dirs: + h, n = part(d) + (viv if h in vivos else (sup if n in nombres_vivos else hue)).append(d) +for f, xs in (("vigentes", viv), ("superados", sup), ("huerfanos", hue)): + open(f"{t}/{f}.txt", "w").write("".join(x + "\n" for x in xs)) +print(f" artefactos: {len(dirs)} · vigentes {len(viv)} · superados {len(sup)} · huérfanos {len(hue)}") +PY + +espacio() { # du sobre una lista de dirs, tolerante a lista vacía + [ -s "$1" ] || { echo "0"; return; } + sed "s|^|$STORE/|" "$1" | tr '\n' '\0' | du -sch --files0-from=- 2>/dev/null | tail -1 | cut -f1 +} +echo "==> espacio: superados $(espacio "$TMP/superados.txt") · huérfanos $(espacio "$TMP/huerfanos.txt")" + +# ── 3. borrar ─────────────────────────────────────────────────────────────────────────────────── +OBJETIVO="$TMP/superados.txt" +if [ "$HUERFANOS" = 1 ]; then + cat "$TMP/superados.txt" "$TMP/huerfanos.txt" > "$TMP/objetivo.txt"; OBJETIVO="$TMP/objetivo.txt" + echo "!! --huerfanos: se borran TAMBIÉN los únicos ejemplares de su receta" +fi + +if [ "$APLICAR" != 1 ]; then + echo "==> DRY-RUN: no se borró nada. Repetí con --aplicar." + exit 0 +fi + +MANIFIESTO="work/store-gc-$(date +%Y-%m-%dT%H%M%S).txt" +mkdir -p work; cp "$OBJETIVO" "$MANIFIESTO" +echo "==> manifiesto de lo borrado: $MANIFIESTO" +ANTES=$(df -h /home 2>/dev/null | tail -1 | awk '{print $4}') +sed "s|^|$STORE/|" "$OBJETIVO" | xargs rm -rf +echo "==> $(wc -l < "$OBJETIVO") artefactos borrados · libres: $ANTES → $(df -h /home 2>/dev/null | tail -1 | awk '{print $4}')"