store en el volumen: «está sellado» dejó de ser «está en este disco»

El store de trabajo se mudó al volumen de la granja y el laptop quedó con
219 artefactos: los de fuente privada (que la granja no puede rehacer) más
lo que todavía no está respaldado. 979 artefactos verificados en el box se
borraron de acá, y con ellos el caché .dmerge de 92 G que sostenía sus
bloques: 128 G → 8,2 G, 115 G libres.

Eso rompía tres cosas que leían el disco local como si fuera la verdad:

- build-state.py habría reportado `never` sobre ~950 sellados y el latido
  lo habría COMMITEADO. Un grafo recién escrito miente con más autoridad
  que uno viejo. Ahora resuelve la presencia contra la unión del store y
  los manifiestos, y expone `sealed_remoto` para que «el store se mudó»
  no se lea nunca como «el corpus creció».
- farm-sync.sh armaba el manifiesto con `ls ./store`, así que le habría
  dicho al worker «el hub tiene 220» y el worker habría rehecho ~950. Es
  el bucle de churn que el propio fichero documenta, al revés. Ahora es la
  unión, con un guardián que aborta si el manifiesto encoge.
- cosecha-cron.sh bajaba el store entero cada 30 min: habría deshecho la
  mudanza sola, como la poda de 24 G que se deshacía en 2026-08-07. Ahora
  baja sólo la lista de nombres.

Los cuatro grafos regenerados dan idéntico a antes del recorte
(766/11/2), que es la prueba de que no se perdió nada.

Queda abierto: los artefactos nuevos viven SÓLO en el volumen hasta que
alguien corra una pasada de respaldo. El volumen tiene borrado protegido,
pero es una copia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:22:36 -04:00
co-authored by Claude Opus 5
parent a8b6a09276
commit 9388beba49
4 changed files with 120 additions and 11 deletions
+48 -3
View File
@@ -139,13 +139,54 @@ def vigente_hash(path):
except Exception:
return None
# ── El store dejó de vivir en el laptop (2026-08-09) ───────────────────────────────────────────
# El store de trabajo se mudó al VOLUMEN de la granja y el disco local se quedó sólo con lo que la
# granja NO puede rehacer (las recetas de fuente privada) más lo que aún no está respaldado. Si el
# grafo siguiera leyendo únicamente `./store`, el latido regeneraría un estado que dice `never` sobre
# 979 artefactos que existen y están sellados — y lo COMMITEARÍA, que es peor que no regenerarlo:
# un grafo viejo miente con la misma cara que uno fresco, pero uno recién escrito miente con más
# autoridad. El error no sería del disco, sería de haber confundido «dónde están los bytes» con
# «qué está construido».
#
# Así que la presencia se resuelve contra la UNIÓN de dos fuentes: el store local y el MANIFIESTO de
# sellados que la granja deja en `work/farm-sellados.txt`. La unión es monótona a propósito — sólo
# puede evitar un `never`/`debt` falso, nunca inventar uno. El riesgo simétrico (dar por sellado algo
# que ya no está en ningún lado) queda VISIBLE en los totales como `sealed_remoto`, en vez de
# disolverse en el número grande.
# Dos registros, y hacen falta LOS DOS: el de la granja se escribe al cosechar y va por delante,
# pero el 2026-08-09 le faltaban 5 sellados que el respaldo sí tenía ⇒ solo con él, 5 recetas caían
# de `sealed` a `debt` sin que nada se hubiera roto. El del respaldo se refresca con
# `scripts/respaldo-storagebox.sh listar` y es el más completo, pero llega por red y envejece.
MANIFIESTOS = ("farm-sellados.txt", "respaldo-sellados.txt")
def _manifiesto_sellados():
out = set()
for nombre in MANIFIESTOS:
try:
txt = (ROOT / "work" / nombre).read_text()
except OSError:
continue
out |= {l.strip() for l in txt.splitlines() if l.strip() and not l.startswith("#")}
return out
SELLADOS_REMOTOS = _manifiesto_sellados()
# `*-{iname}` del manifiesto, para el fallback de «hay algún sellado histórico» sin tocar el disco.
_REMOTOS_POR_INAME = {}
for _d in SELLADOS_REMOTOS:
_REMOTOS_POR_INAME.setdefault(_d.split("-", 1)[1] if "-" in _d else _d, []).append(_d)
REMOTO_ONLY = set()
def state_of(iname, h):
if h is None:
return "unhashable"
if os.path.isdir(os.path.join(STORE, f"{h[3:]}-{iname}")):
dirname = f"{h[3:]}-{iname}"
if os.path.isdir(os.path.join(STORE, dirname)):
return "sealed"
if dirname in SELLADOS_REMOTOS:
REMOTO_ONLY.add(dirname)
return "sealed"
# ¿hay algún sellado histórico? distingue "cambió" de "nunca construida".
if glob.glob(os.path.join(STORE, f"*-{iname}")):
if glob.glob(os.path.join(STORE, f"*-{iname}")) or _REMOTOS_POR_INAME.get(iname):
return "debt"
return "never"
@@ -282,7 +323,11 @@ def main():
schema="hammer-build-state/1",
# `recipes` sigue contando SÓLO las que tienen fichero (los consumidores lo leen como el
# tamaño del corpus); `nodes` incluye la frontera `wanted`.
totals=dict(recipes=n_recetas, nodes=len(recs), **by_state),
# `sealed_remoto`: de los `sealed`, cuántos NO están en el disco local y se dan por buenos
# por el manifiesto de la granja. Va explícito para que «el store se mudó» no se lea nunca
# como «el corpus creció»; si algún día sube sin que nadie haya sembrado, es la señal.
totals=dict(recipes=n_recetas, nodes=len(recs), **by_state,
sealed_remoto=len(REMOTO_ONLY)),
by_class={k: by_cls[k] for k in sorted(by_cls)},
debt_by_class={k: debt_by_cls[k] for k in sorted(debt_by_cls)},
by_queue={q: dict(c) for q, c in sorted(by_queue.items())},
+21 -6
View File
@@ -77,14 +77,29 @@ else
# bucle por cerrado, y la churn siguió porque **hay DOS rutas que bajan el store** y el latido
# usa ésta. Arreglar una de dos copias y declarar victoria es peor que no arreglar: el síntoma
# se atenúa lo justo para dejar de mirar. Si aparece una tercera copia, va con el mismo exclude.
LEDGER="$ROOT/work/store-gc-superados.txt"
EXCL=""
[ -s "$LEDGER" ] && EXCL="--exclude-from=$LEDGER"
if rsync -az --exclude /.dmerge $EXCL -e "$SSH" "root@$ip:$REMOTE/store/" "$ROOT/store/" 2>&1 | tail -2; then
echo " cosecha ✓"
#
# ── 2026-08-09: EL STORE YA NO BAJA. BAJA EL MANIFIESTO ───────────────────────────────────
# El store se mudó al VOLUMEN de la granja y el laptop se quedó con lo que la granja no puede
# rehacer. Seguir bajando el store acá desharía esa mudanza cada 30 minutos — la misma forma
# exacta del bucle de churn que este bloque documenta arriba, y la razón por la que el exclude
# de abajo dejó de alcanzar: ya no se trata de filtrar QUÉ baja, sino de que no baja.
#
# Lo que el hub sí necesita es SABER qué hay sellado, para que el grafo de estado no reporte
# `never` sobre artefactos que existen. Eso son kilobytes: la lista de nombres. Se acumula en
# el manifiesto (nunca se pisa) porque el volumen es una sola foto y el hub recuerda todas.
if $SSH "root@$ip" "ls $REMOTE/store 2>/dev/null | grep -E '^[0-9a-f]{64}-'" \
> "$ROOT/work/.sellados-worker" 2>/dev/null && [ -s "$ROOT/work/.sellados-worker" ]; then
N_W=$(wc -l < "$ROOT/work/.sellados-worker")
cat "$ROOT/work/farm-sellados.txt" "$ROOT/work/.sellados-worker" 2>/dev/null \
| grep -E '^[0-9a-f]{64}-' | sort -u > "$ROOT/work/farm-sellados.txt.new"
mv "$ROOT/work/farm-sellados.txt.new" "$ROOT/work/farm-sellados.txt"
rm -f "$ROOT/work/.sellados-worker"
echo " manifiesto ✓ (worker: $N_W · total: $(wc -l < "$ROOT/work/farm-sellados.txt"))"
else
echo " ⚠ cosecha falló — sigo"
echo " ⚠ no pude leer el manifiesto del worker — sigo"
fi
# ⚠ Lo que NO cubre esto: los artefactos nuevos quedan SÓLO en el volumen hasta que alguien
# haga una pasada de respaldo. El volumen tiene borrado protegido, pero es UNA copia.
done < "$FLEET"
fi
+28 -2
View File
@@ -24,9 +24,35 @@ SSH="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new"
# viaja hub→worker porque son gigabytes, pero la lista de nombres son kilobytes y evita que el worker
# reintente y falle cada ciclo lo que el hub ya tiene. Se regenera acá, en cada sync, para que no se
# quede vieja sola.
#
# ── ⚠ EL MANIFIESTO NO ES `ls ./store` ─────────────────────────────────────────────────────────
# Lo fue hasta el 2026-08-09, y desde que el store se mudó al VOLUMEN eso pasó a ser una mentira
# cara: el disco local se quedó con 220 artefactos (los de fuente privada + lo aún no respaldado),
# así que `ls ./store` le habría dicho al worker «el hub tiene 220» y el worker habría reconstruido
# ~950 artefactos que ya existen. Es exactamente el bucle que este fichero documenta más abajo, pero
# al revés y multiplicado: no devolver basura, sino rehacer lo hecho.
#
# La pregunta que el worker hace es «¿esto ya está sellado?», no «¿esto está en el disco del hub?».
# Se responde con la UNIÓN de lo que hay en el disco y lo que registran los manifiestos — el mismo
# criterio que usa `build-state.py`, y por la misma razón: la presencia de los bytes y el hecho de
# estar construido dejaron de ser lo mismo el día que el store dejó de vivir acá.
mkdir -p work
ls ./store 2>/dev/null | grep -E '^[0-9a-f]{64}-' > work/farm-sellados.txt || true
echo "==> 0. manifiesto de sellados del hub: $(wc -l < work/farm-sellados.txt) artefactos"
{
ls ./store 2>/dev/null | grep -E '^[0-9a-f]{64}-' || true
# `respaldo-sellados.txt` (lo que hay en el Storage Box) y el propio manifiesto del ciclo anterior,
# que arrastra lo que el worker selló y todavía no llegó a ningún otro registro.
cat work/respaldo-sellados.txt work/farm-sellados.txt 2>/dev/null || true
} | grep -E '^[0-9a-f]{64}-' | sort -u > work/farm-sellados.txt.new
# Guardián: el manifiesto sólo puede CRECER salvo poda explícita. Si encoge, algo se perdió y es más
# barato abortar el sync que mandar al worker a rehacer un corpus.
VIEJO=$(wc -l < work/farm-sellados.txt 2>/dev/null || echo 0)
NUEVO=$(wc -l < work/farm-sellados.txt.new)
if [ "$NUEVO" -lt "$VIEJO" ]; then
echo "⚠ el manifiesto ENCOGIÓ ($VIEJO$NUEVO) — no sincronizo; revisá antes de que el worker rehaga trabajo" >&2
rm -f work/farm-sellados.txt.new; exit 1
fi
mv work/farm-sellados.txt.new work/farm-sellados.txt
echo "==> 0. manifiesto de sellados: $NUEVO artefactos (disco local: $(ls ./store 2>/dev/null | grep -cE '^[0-9a-f]{64}-'))"
echo "==> 1. subiendo código + cola al worker ($VPS:$REMOTE)"
rsync -az --delete -e "$SSH" \
+23
View File
@@ -47,7 +47,14 @@
# deliberada y aparte. El repo sí va con `--delete`, que para eso está git.
#
# Uso: scripts/respaldo-storagebox.sh [--seco]
# scripts/respaldo-storagebox.sh --listar # refresca work/respaldo-sellados.txt y sale
# Relanzalo cuantas veces quieras: continúa donde quedó.
#
# ── `--listar`: el respaldo también es un REGISTRO, no sólo una copia ──────────────────────────
# Desde que el store se mudó al volumen (2026-08-09), `build-state.py` y `farm-sync.sh` responden
# «¿esto está sellado?» con la unión del disco local y los manifiestos, y el del respaldo es el más
# completo de los dos. Sin refrescarlo, recetas que existen y están respaldadas se reportan como
# deuda y el worker las rehace. Es barato (una lista de nombres) y no toca un solo byte de datos.
set -eu
SB_USER="${SB_USER:-u647150}"
@@ -59,6 +66,22 @@ RAIZ="${RAIZ:-/home/sergio/hammer}"
SECO=""
[ "${1:-}" = "--seco" ] && SECO="--dry-run"
if [ "${1:-}" = "--listar" ]; then
cd "$RAIZ"; mkdir -p work
ssh -4 -p "$SB_PORT" -i "$KEY" -o StrictHostKeyChecking=accept-new \
"$SB_USER@$SB_HOST" 'ls hammer/store' 2>/dev/null \
| grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-sellados.txt.new
N=$(wc -l < work/respaldo-sellados.txt.new)
# Un listado vacío es un fallo de red disfrazado de respaldo borrado. No pisa nada.
if [ "$N" -eq 0 ]; then
echo "⚠ el box devolvió 0 artefactos — no piso el manifiesto (¿enlace caído?)" >&2
rm -f work/respaldo-sellados.txt.new; exit 1
fi
mv work/respaldo-sellados.txt.new work/respaldo-sellados.txt
echo "==> manifiesto del respaldo: $N artefactos → work/respaldo-sellados.txt"
exit 0
fi
# `-4` a propósito: el box tiene AAAA y el laptop tiene IPv6 sólo en wlan0, así que sin esto la ruta
# por cable nunca se usaría aunque el cable estuviera enchufado.
SSH_CMD="ssh -4 -p $SB_PORT -i $KEY -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30"