La bajada del store murio con "No space left on device" con 95 G libres y un store remoto de 25 G. Dos causas que se multiplican: - `rsync -a` NO preserva hardlinks (eso es `-H`). El store es CAS y .dmerge hardlinkea los artefactos, asi que los 25 G que mide `du` en el origen -que deduplica por inodo- se escriben en destino como copias enteras, una por enlace. - `.dmerge` es cache pura: hammer la reconstruye sola, no aporta un artefacto y es la parte mas pesada del arbol (132 G en el hub, 111,7 GiB exclusivos). Ademas el fallo a mitad deja directorios creados sin ficheros, que es un cache-hit envenenado: `mise` se cosecho asi, como nombre vacio. Se anade un guardian que barre los vacios al terminar la bajada y avisa de re-correr. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
131 lines
7.9 KiB
Bash
Executable File
131 lines
7.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# farm-sync.sh <user@host> — orquesta el worker VPS desde el HUB (laptop). Una sola pasada:
|
|
# 1. sube código + recipes/incoming/ al worker (rsync, el worker no toca gitea)
|
|
# 2. baja el store/ sellado que el worker construyó
|
|
# 3. promueve+firma lo cosechado (cache-hit instantáneo en los artefactos bajados) + commit/push
|
|
#
|
|
# El acceso es UNO solo: laptop->VPS por SSH (la clave de firma y gitea nunca salen del laptop).
|
|
# Idempotente y seguro de re-correr. Pensado para correr cada vez que el laptop está online.
|
|
#
|
|
# Uso: scripts/farm/farm-sync.sh root@1.2.3.4
|
|
# scripts/farm/farm-sync.sh root@1.2.3.4 --no-promote # sólo sincroniza, no firma
|
|
# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/hammer)
|
|
set -euo pipefail
|
|
|
|
VPS="${1:?uso: farm-sync.sh user@host [--no-promote]}"
|
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
|
|
REMOTE="${REMOTE:-/opt/hammer}"
|
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
cd "$ROOT"
|
|
SSH="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new"
|
|
|
|
# El MANIFIESTO de lo ya sellado en el hub, para que el worker no rehaga trabajo hecho. Es la otra
|
|
# mitad del arreglo del sync unidireccional (ver la nota en `scripts/build-farm.sh`): el store no
|
|
# 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}-' || 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" \
|
|
--include '/work/' --include '/work/farm-sellados.txt' \
|
|
--exclude /work --exclude /store --exclude '/store-*' --exclude /target \
|
|
--exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
|
|
--exclude '*.png' --exclude '/content*' \
|
|
./ "$VPS:$REMOTE/"
|
|
|
|
echo "==> 2. bajando artefactos sellados del worker"
|
|
# ── ⚠ NO BAJAR LO QUE YA PODAMOS: era un BUCLE DE CHURN DE 24 G ────────────────────────────────
|
|
# Medido el 2026-08-07: `store-gc.sh` borró 362 artefactos superados (24 G) por la mañana y por la
|
|
# tarde volvió a borrar **los mismos 362** — solapamiento del 100%. No era casualidad: este rsync
|
|
# bajaba el store del worker ENTERO, y el worker nunca se poda, así que conservaba los superados y
|
|
# nos los devolvía en cada cosecha. Podar → cosechar → vuelven → podar. El disco pagaba 24 G por
|
|
# vuelta y la métrica de espacio libre mentía sobre el progreso.
|
|
#
|
|
# El registro `work/store-gc-superados.txt` es la unión de todos los manifiestos de poda: nombres
|
|
# `<hash>-<paquete>` de artefactos que YA decidimos que sobran. Como el store es CAS —los nombres
|
|
# son inmutables y un artefacto superado no vuelve a ser vigente salvo que una receta retroceda—
|
|
# excluirlos es seguro; y si una receta retrocediera, su artefacto se reconstruye, que es barato.
|
|
LEDGER="work/store-gc-superados.txt"
|
|
# ── ⚠ `-H` Y EXCLUIR `.dmerge`: SIN ESTO LA COSECHA LLENA EL DISCO ─────────────────────────────
|
|
# Medido el 2026-09-01: esta bajada murió con `No space left on device (28)` escribiendo
|
|
# `store/.dmerge/b8bc5ecc…/usr/share/locale/az/LC_MESSAGES/kio6.mo`, con 95 G libres y un store
|
|
# remoto de 25 G. Dos causas que se multiplican:
|
|
#
|
|
# 1. **`rsync -a` NO preserva hardlinks** (eso es `-H`, y no está incluido en `-a`). El store es
|
|
# CAS y `.dmerge` hardlinkea los artefactos: en el worker, 25 G medidos con `du` —que deduplica
|
|
# por inodo— son MUCHOS más en el cable, porque cada enlace se transfiere y se escribe como una
|
|
# copia entera. El tamaño que informa `du` del origen NO es el que hace falta en el destino.
|
|
# 2. **`.dmerge` es CACHÉ PURA y no tiene por qué viajar.** Es la capa de merge que hammer
|
|
# reconstruye sola; copiarla no aporta un solo artefacto y es la parte más pesada del árbol
|
|
# (en el hub llegó a 132 G con 111,7 GiB exclusivos). Cosechar es traerse ARTEFACTOS.
|
|
#
|
|
# El fallo además es a mitad de camino: rsync limpia sus temporales al abortar, pero los directorios
|
|
# que ya creó quedan — y un directorio de artefacto sin ficheros es EXACTAMENTE un cache-hit
|
|
# envenenado (`mise` llegó así en esa pasada). Ver la regla 3 del CLAUDE.md.
|
|
RSOPTS=(-azH --exclude '/.dmerge')
|
|
if [ -s "$LEDGER" ]; then
|
|
echo " (excluyendo $(wc -l < "$LEDGER") artefactos ya podados — ver la nota del bucle de churn)"
|
|
rsync "${RSOPTS[@]}" -e "$SSH" --exclude-from="$LEDGER" "$VPS:$REMOTE/store/" ./store/
|
|
else
|
|
rsync "${RSOPTS[@]}" -e "$SSH" "$VPS:$REMOTE/store/" ./store/
|
|
fi
|
|
|
|
# ── GUARDIÁN: ningún artefacto cosechado puede llegar VACÍO ────────────────────────────────────
|
|
# Un directorio sin ficheros no es un artefacto, es un nombre — y `hammer build` lo lee como
|
|
# presencia y sella sin construir. Barrerlos acá es barato; detectarlos tres eslabones más abajo
|
|
# (manifiesto → grafo → build) cuesta una campaña.
|
|
VACIOS=0
|
|
for d in ./store/*/; do
|
|
case "$d" in ./store/.dmerge/) continue ;; esac
|
|
[ -d "$d" ] || continue
|
|
if [ -z "$(find "$d" -mindepth 1 -print -quit 2>/dev/null)" ]; then
|
|
echo " ⚠ artefacto VACÍO cosechado, lo quito: $d"; rmdir "$d" 2>/dev/null; VACIOS=$((VACIOS+1))
|
|
fi
|
|
done
|
|
[ "$VACIOS" -gt 0 ] && echo " ($VACIOS vacíos barridos — la bajada quedó a medias, re-corré el sync)"
|
|
|
|
if [ "${2:-}" = "--no-promote" ]; then
|
|
echo "✓ store sincronizado (sin promote)"; exit 0
|
|
fi
|
|
|
|
echo "==> 3. promote + firma (cache-hit en lo que el worker selló)"
|
|
scripts/build-farm.sh
|
|
|
|
echo "==> 4. commit + push de la cosecha"
|
|
git add recipes/ tandas/ 2>/dev/null || true
|
|
if ! git diff --cached --quiet; then
|
|
git commit -q -m "Etapa G: cosecha del worker VPS — promote + firma"
|
|
git push -q origin main && echo "✓ pusheado"
|
|
else
|
|
echo "(nada nuevo que commitear)"
|
|
fi
|