Medido hoy al encolar `lld21` junto a `rust`: el ciclo decía «2 recetas en recipes/incoming» y
construía UNA. La causa está en el filtro de «ya sellado en el hub»: acumula en `filtrada` uniendo
con ESPACIOS, y más abajo la cola se le pasa a `xargs -I{}`, que trata **una línea = un elemento**.
Unidas por espacios, N recetas llegan como UN SOLO argumento:
printf '%s\n' " a.toml b.toml c.toml" | xargs -I{} sh -c 'echo [$1]' _ {}
recibe: [a.toml b.toml c.toml] ← uno solo
⇒ la fase paralela fallaba SIEMPRE con «No such file», y la fase 1b —el reintento serial— salvaba
exactamente UNA receta: la última, porque `basename` de esa ristra da el último nombre. El log
parecía normal, con su ✗ seguido de un ✓ (reintento serial), y ese patrón está en TODAS las colas
(yambar, spidermonkey, rust…). O sea que el reintento serial, que existe para conciliar colisiones
de concurrencia, venía tapando el bug desde que se escribió el filtro.
El arreglo son dos líneas: acumular con salto de línea y descartar la línea vacía con la que arranca
el acumulador (una línea vacía también es un elemento para `xargs -I{}`). Probado en chiquito, en
los dos sentidos: antes 1 elemento, después 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
12 KiB
Bash
Executable File
179 lines
12 KiB
Bash
Executable File
#!/bin/sh
|
|
# build-farm.sh — Etapa G: GRANJA de build paralela sobre la COLA DE STAGING (recipes/incoming/).
|
|
# El import escala (import-batch ~95% yield, cientos en minutos); el COMPILE es el cuello de botella
|
|
# (1-7 min c/u) pero es embarrassingly-parallel + cacheable por contenido (el store dedup global). Esta
|
|
# es la pieza que convierte "decenas por sesión serial" en "drenar la cola en paralelo": construye N a
|
|
# la vez, y lo que CONSTRUYE+sella lo PROMUEVE incoming/->recipes/ y lo publica al repo firmado; lo que
|
|
# falla queda staged con su motivo (MSRV, dep faltante, …) para revisión.
|
|
#
|
|
# Dos fases: (1) BUILD en paralelo (lo caro, worker pool de $JOBS) — escribe status por paquete; (2)
|
|
# PROMOTE+PUBLISH en SERIE (el index.json del repo es mutable compartido ⇒ no se paraleliza) + firma 1 vez.
|
|
# Idempotente: una receta ya sellada sale por cache-hit; re-correr sólo reintenta lo que falta.
|
|
#
|
|
# Uso: JOBS=4 scripts/build-farm.sh # drena recipes/incoming/*.toml
|
|
# JOBS=6 QUEUE=recipes/incoming scripts/build-farm.sh
|
|
# Env: JOBS (def: nproc-2), QUEUE (def recipes/incoming), STORE (def ./store), REPO (def dist/repo),
|
|
# KEY (clave de firma; def dist/keys/release.ed25519), DISTRO (def dev), HAMMER (cmd del cli),
|
|
# PROMOTE (def 1; =0 sólo construye+mide, no toca recipes/ ni el repo).
|
|
set -eu
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
|
|
QUEUE="${QUEUE:-recipes/incoming}"
|
|
STORE="${STORE:-./store}"
|
|
REPO="${REPO:-$ROOT/dist/repo}"
|
|
KEY="${KEY:-$ROOT/dist/keys/release.ed25519}"
|
|
DISTRO="${DISTRO:-dev}"
|
|
HAMMER="${TAKANA:-${HAMMER:-$ROOT/target/release/takana}}"
|
|
PROMOTE="${PROMOTE:-1}"
|
|
JOBS="${JOBS:-$(( $(nproc) - 2 ))}"; [ "$JOBS" -ge 1 ] || JOBS=1
|
|
|
|
# Un subdir POR COLA: el worker-loop corre varias colas en serie por ciclo y con un dir compartido
|
|
# la cola siguiente PISA los logs/status de la anterior (imposible diagnosticar fallos de la fase go
|
|
# tras la fase clib). basename de $QUEUE ⇒ work/farm/incoming-go/{log,status}, etc.
|
|
FARM="$ROOT/work/farm/$(basename "$QUEUE")"
|
|
rm -rf "$FARM"; mkdir -p "$FARM/log" "$FARM/status"
|
|
|
|
queue=$(ls "$QUEUE"/*.toml 2>/dev/null || true)
|
|
[ -n "$queue" ] || { echo "cola vacía: $QUEUE/*.toml"; exit 0; }
|
|
|
|
# ── NO REHACER LO QUE EL HUB YA SELLÓ ──────────────────────────────────────────────────────────
|
|
# `farm-sync.sh` sincroniza el store en UN SOLO SENTIDO: sube el código EXCLUYENDO /store y baja el
|
|
# store del worker. O sea que el worker nunca recibe los artefactos que se sellan en el hub — y por
|
|
# tanto reintenta cada ciclo, y vuelve a fallar, trabajo que YA ESTÁ HECHO. Medido el 2026-08-07:
|
|
# de 20 «fallos» de incoming-gnome, 17 correspondían a recetas con artefacto vigente en el hub.
|
|
# No eran marcadores rancios (el status se borra en cada ciclo): era CPU quemada de verdad.
|
|
#
|
|
# Mandarle los artefactos costaría gigabytes por un enlace de 8 Mbps. Mandarle la LISTA cuesta unos
|
|
# kilobytes: `work/farm-sellados.txt` son los nombres `<hash>-<paquete>` del store del hub. Si el
|
|
# hash vigente de una receta está ahí, el hub ya la tiene y el worker se la salta.
|
|
SELLADOS="${SELLADOS:-work/farm-sellados.txt}"
|
|
if [ -s "$SELLADOS" ]; then
|
|
saltadas=0; cerradas=0; filtrada=""
|
|
for f in $queue; do
|
|
# ⚠ EL NOMBRE DEL ARTEFACTO ES EL CAMPO `name` DE LA RECETA, NO EL DEL FICHERO. Son distintos en
|
|
# 17 recetas del catálogo — el arquetipo es `recipes/incoming-kde/qtbase.toml`, cuyo `name` es
|
|
# `qt6-qtbase`, y el artefacto en el store se llama `<hash>-qt6-qtbase`. Usar el basename daba
|
|
# «no sellada» para las 17 y las habría mandado a reconstruir: exactamente el desperdicio que
|
|
# este filtro existe para evitar. (Lo tenía mal en la primera versión de este bloque.)
|
|
# ── RECETAS CERRADAS POR DECISIÓN: el marcador vive EN LA RECETA ─────────────────────────────
|
|
# Una receta puede estar sin sellar porque falta trabajo (deuda) o porque se decidió NO hacerla
|
|
# (alcance). La diferencia importa: la deuda invita a reintentar en cada ciclo; la decisión hay
|
|
# que respetarla, o la granja quema CPU para siempre en algo que nadie va a arreglar.
|
|
# El marcador va en la CABECERA DE LA RECETA, no en una lista aparte, justo para que no puedan
|
|
# desincronizarse: quien lee el porqué y quien decide saltarla miran el mismo texto.
|
|
if grep -q "CERRADA POR DECISIÓN" "$f" 2>/dev/null; then
|
|
cerradas=$((cerradas+1)); continue
|
|
fi
|
|
n=$(awk -F'"' '/^name[[:space:]]*=/{print $2; exit}' "$f")
|
|
[ -n "$n" ] || n=$(basename "$f" .toml)
|
|
H=$("$HAMMER" --store "$STORE" hash "$f" 2>/dev/null | tail -1 | sed 's/^b3://')
|
|
if [ -n "$H" ] && grep -qx "$H-$n" "$SELLADOS"; then saltadas=$((saltadas+1)); continue; fi
|
|
# ⚠ SALTO DE LÍNEA Y NO ESPACIO, y esto era un BUG que costó throughput en silencio
|
|
# (medido 2026-09-16): más abajo la cola se le pasa a `xargs -I{}`, que trata **una línea = un
|
|
# elemento**. Unidas por espacios, N recetas llegaban como UN SOLO argumento: la fase paralela
|
|
# fallaba siempre con «No such file» y la fase 1b —el reintento serial— salvaba EXACTAMENTE UNA,
|
|
# la última, porque `basename` de esa ristra da el último nombre. O sea que la granja molía
|
|
# **una receta por cola y por ciclo** en vez de la cola entera, y el log parecía normal: un ✗
|
|
# seguido de un ✓ (reintento serial). Se vio al encolar `lld21` junto a `rust` y descubrir que
|
|
# el ciclo decía «2 recetas» y construía una.
|
|
filtrada="$filtrada
|
|
$f"
|
|
done
|
|
[ "$cerradas" -gt 0 ] && echo " ($cerradas CERRADAS por decisión — ver la cabecera de cada receta)"
|
|
[ "$saltadas" -gt 0 ] && echo " (saltadas $saltadas ya selladas en el hub — ver la nota del sync unidireccional)"
|
|
queue="$filtrada"
|
|
[ -n "$(echo $queue)" ] || { echo "nada que hacer en $QUEUE: todo sellado en el hub"; exit 0; }
|
|
fi
|
|
# `grep -v` porque el acumulador del filtro arranca con una línea vacía, y una línea vacía es un
|
|
# elemento más para `xargs -I{}`: construiría la receta "" y ensuciaría el conteo.
|
|
queue=$(printf '%s\n' "$queue" | grep -v '^[[:space:]]*$' || true)
|
|
n_total=$(printf '%s\n' "$queue" | grep -c . || true)
|
|
echo "==> GRANJA: $n_total recetas en $QUEUE, $JOBS workers en paralelo"
|
|
|
|
# ── Fase 1: BUILD en paralelo. Cada worker construye una receta y deja su status (.ok con hash | .fail
|
|
# con motivo). El store sella atómico y cada RECETA usa sources/<name>-<sha> + out/<hash>-<name> propios.
|
|
# CAVEAT: dos recetas DISTINTAS que comparten una dep transitiva NO sellada (p.ej. gtk4 bajo libadwaita
|
|
# y gtksourceview) la construyen en el MISMO sources/<dep>-<sha>/output ⇒ chocan (meson/tar). Eso lo
|
|
# concilia la Fase 1b (reintento serial). `xargs -P` es el pool.
|
|
printf '%s\n' "$queue" | xargs -P"$JOBS" -I '{}' sh -c '
|
|
f="$1"; n=$(basename "$f" .toml); log="'"$FARM"'/log/$n.log"
|
|
H=$("'"$HAMMER"'" --store "'"$STORE"'" build "$f" 2>"$log" | tail -1 || true)
|
|
if [ "${H#b3:}" != "$H" ] && [ -n "$H" ]; then
|
|
echo "$H" > "'"$FARM"'/status/$n.ok"; printf " ✓ %-18s %s\n" "$n" "$H"
|
|
else
|
|
r=$(grep -oiE "requires rustc [0-9.]+|cannot find -l[a-z0-9]+|could not find .[a-z0-9_-]+.|error\[E[0-9]+\]|not found|unsupported|no such file" "$log" | head -1)
|
|
echo "${r:-fallo (ver log)}" > "'"$FARM"'/status/$n.fail"; printf " ✗ %-18s %s\n" "$n" "${r:-fallo}"
|
|
fi
|
|
' _ '{}'
|
|
|
|
# ── Fase 1b: REINTENTO SERIAL de fallos por COLISIÓN de concurrencia. El pool paralelo puede hacer que
|
|
# dos recetas construyan la MISMA dep transitiva compartida (gtk4/cairo/pango bajo varios consumidores)
|
|
# a la vez en sources/<dep>-<sha>/output ⇒ meson/tar chocan ("Some other Meson process is already using
|
|
# this build directory", "Directory not empty", "No such file"). Tras la Fase 1 la dep ya la selló el
|
|
# ganador de la carrera ⇒ reconstruir el fallo EN SERIE ahora cache-hitea la dep y compila sin colisión.
|
|
# Un solo pase basta: cada `takana build` arrastra sus deps, así que aunque el orden no sea topológico,
|
|
# la dep se construye/sella dentro del propio reintento. Sólo se reintenta lo que tiene FIRMA de colisión
|
|
# (no MSRV/dep-faltante reales, que re-fallarían y sólo gastarían tiempo).
|
|
for s in "$FARM"/status/*.fail; do
|
|
[ -f "$s" ] || continue
|
|
n=$(basename "$s" .fail); log="$FARM/log/$n.log"; f="$QUEUE/$n.toml"
|
|
[ -f "$f" ] || continue
|
|
# ── `File name too long` TAMBIÉN es colisión (2026-08-09) ────────────────────────────────────
|
|
# `vulkan-loader` fallaba con «io: File name too long (os error 36)» en TODOS los ciclos de la
|
|
# granja y sella sin una queja ejecutado en serie — comprobado a mano en el worker. La firma no
|
|
# estaba en esta lista, así que nunca se reintentaba: fallaba, se contaba como deuda, y al ciclo
|
|
# siguiente volvía a fallar igual. Un fallo que se repite idéntico cada vez no es un fallo
|
|
# intermitente, es uno que nadie está reintentando.
|
|
if ! grep -qiE "Some other Meson process|already using this build directory|Directory not empty|No such file or directory|gdkconfig\.h|File name too long|os error 36" "$log" 2>/dev/null; then
|
|
# ── Y LO QUE NO SE REINTENTA, SE DICE ────────────────────────────────────────────────────────
|
|
# El defecto de fondo no era la firma que faltaba sino que faltarla fuese MUDO: la lista sólo
|
|
# puede crecer si alguien se entera de que se quedó corta. Sin esta línea, la única señal de que
|
|
# una receta es reintentable era que a alguien se le ocurriera correrla a mano.
|
|
printf " · %-18s no reintentado (firma no reconocida: %s)\n" "$n" \
|
|
"$(head -c 120 "$log" 2>/dev/null | tr '\n' ' ' | tr -s ' ')"
|
|
continue
|
|
fi
|
|
H=$("$HAMMER" --store "$STORE" build "$f" 2>"$log" | tail -1 || true)
|
|
if [ "${H#b3:}" != "$H" ] && [ -n "$H" ]; then
|
|
rm -f "$s"; echo "$H" > "$FARM/status/$n.ok"; printf " ✓ (reintento serial) %-18s %s\n" "$n" "$H"
|
|
fi
|
|
done
|
|
|
|
# ── Fase 2: PROMOTE + PUBLISH en serie (índice del repo = recurso mutable compartido).
|
|
ok=$(ls "$FARM"/status/*.ok 2>/dev/null | wc -l | tr -d ' ')
|
|
fail=$(ls "$FARM"/status/*.fail 2>/dev/null | wc -l | tr -d ' ')
|
|
echo "==> BUILD-YIELD: $ok/$n_total construyen ($fail fallan)"
|
|
|
|
if [ "$PROMOTE" = 1 ] && [ "$ok" -gt 0 ]; then
|
|
echo "==> promoviendo + publicando $ok al repo firmado…"
|
|
for s in "$FARM"/status/*.ok; do
|
|
n=$(basename "$s" .ok)
|
|
# GUARDA DE COLISIÓN: si ya existe una receta canónica en recipes/ y la cola trae
|
|
# OTRA variante con el mismo nombre, NO la promovemos (mv sobrescribiría lo canónico:
|
|
# p.ej. un `curl` mínimo-para-appstream pisando el curl 8.20 full de la que dependen tools).
|
|
# Queda staged en la cola para triage manual; tampoco re-publicamos sobre lo canónico.
|
|
if [ -f "$QUEUE/$n.toml" ] && [ -f "recipes/$n.toml" ]; then
|
|
printf " = %s (ya existe recipes/%s.toml canónica — NO promovido, queda en %s)\n" "$n" "$n" "$QUEUE"
|
|
continue
|
|
fi
|
|
if [ -f "$QUEUE/$n.toml" ]; then
|
|
# mover la receta Y sus patches referenciados (viven junto al .toml en la cola);
|
|
# si no, `pack` no los encuentra en recipes/ y falla toda receta parcheada.
|
|
for p in $(grep -oE '"[^"]+\.patch"' "$QUEUE/$n.toml" | tr -d '"'); do
|
|
[ -f "$QUEUE/$p" ] && mv -f "$QUEUE/$p" recipes/
|
|
done
|
|
mv "$QUEUE/$n.toml" recipes/
|
|
fi
|
|
"$HAMMER" --store "$STORE" pack "recipes/$n.toml" --repo "$REPO" --distro-version "$DISTRO" --build >/dev/null 2>&1 \
|
|
&& printf " + %s\n" "$n" || printf " ! %s (pack falló)\n" "$n"
|
|
done
|
|
"$HAMMER" repo sign --repo "$REPO" --key "$KEY" >/dev/null 2>&1 && echo "==> release re-firmado"
|
|
fi
|
|
|
|
# ── Reporte de fallos (motivo por paquete) para triage.
|
|
if [ "$fail" -gt 0 ]; then
|
|
echo "==> FALLARON ($fail) — quedan staged en $QUEUE:"
|
|
for s in "$FARM"/status/*.fail; do printf " ✗ %-18s %s\n" "$(basename "$s" .fail)" "$(cat "$s")"; done
|
|
fi
|
|
echo "==> granja: $ok promovidos, $fail staged. logs en $FARM/log/"
|