From 9388beba49082d5f83072aa9b9e0d69480b44d3f Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 9 Aug 2026 21:22:36 -0400 Subject: [PATCH] =?UTF-8?q?store=20en=20el=20volumen:=20=C2=ABest=C3=A1=20?= =?UTF-8?q?sellado=C2=BB=20dej=C3=B3=20de=20ser=20=C2=ABest=C3=A1=20en=20e?= =?UTF-8?q?ste=20disco=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/build-state.py | 51 ++++++++++++++++++++++++++++++++-- scripts/farm/cosecha-cron.sh | 27 ++++++++++++++---- scripts/farm/farm-sync.sh | 30 ++++++++++++++++++-- scripts/respaldo-storagebox.sh | 23 +++++++++++++++ 4 files changed, 120 insertions(+), 11 deletions(-) diff --git a/scripts/build-state.py b/scripts/build-state.py index 883171bc..962b7525 100755 --- a/scripts/build-state.py +++ b/scripts/build-state.py @@ -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())}, diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh index ca23d076..c06def11 100755 --- a/scripts/farm/cosecha-cron.sh +++ b/scripts/farm/cosecha-cron.sh @@ -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 diff --git a/scripts/farm/farm-sync.sh b/scripts/farm/farm-sync.sh index 6e49123a..c88ae4a0 100755 --- a/scripts/farm/farm-sync.sh +++ b/scripts/farm/farm-sync.sh @@ -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" \ diff --git a/scripts/respaldo-storagebox.sh b/scripts/respaldo-storagebox.sh index c39dbcee..cf0818b9 100755 --- a/scripts/respaldo-storagebox.sh +++ b/scripts/respaldo-storagebox.sh @@ -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"