Queda una carrera abierta que el lock de script NO cierra: `build-farm.sh` corre `xargs -P2` y dos recetas de la misma cola que compartan dep se pisan igual, porque `fetch` nombra el árbol de forma determinista y sin nada del constructor. El fondo no es "falta un lock", es que el árbol cumple DOS papeles que se contradicen bajo concurrencia: caché direccionada por contenido (clave = nombre+sha, re-extraer es caro) y workspace mutable de build (lib.rs lo parchea, aísla el workspace Cargo y vendorea EN EL SITIO, y después el sandbox lo bind-montea). Por eso un lock alrededor de la extracción parece correcto y NO lo es: un segundo proceso puede borrar un árbol que un bwrap ya está compilando. El ADR deja las tres salidas (lock por árbol durante todo el build / árbol privado por build / separar caché inmutable + copia privada) con su contra concreta cada una —incluida que el worker corre ext4, sin reflink, así que la copia de la opción C es real— y los tres números que habría que medir para elegir en vez de opinar. Se ancla en los dos sitios donde alguien va a chocar: el ADR indexado en docs/README.md (de paso se agregan 0009-0011, que faltaban) y un comentario en el propio `fetch_tarball` que dice explícitamente que no se arregle a medias. Va como PENDIENTE y no decidido a propósito: hay otro agente sobre este repo y esto es lo que evita que la carrera se "arregle" de una forma que parece bien y deja el bug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.7 KiB
ADR 0012 — El árbol de fuentes es caché y workspace a la vez: carrera abierta en fetch
- Estado: PENDIENTE — dilema abierto, sin decidir. No implementar sin resolverlo (ver §Opciones).
- Fecha: 2026-07-22
- Frontera:
crates/hammer-build/src/fetch.rs(fetch_tarball/fetch_git),crates/hammer-build/src/lib.rs:208. Mitigación parcial ya en árbol: commit1b436a7.
Contexto
fetch nombra el árbol de fuentes de forma determinista y sin componente por-constructor:
let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; }
std::fs::create_dir_all(&work_tree)?;
// tar -x dentro de work_tree
Dos procesos que construyan la misma receta (típicamente la misma dep alcanzada por dos
caminos) resuelven al MISMO directorio. Uno hace remove_dir_all mientras el otro corre tar -x:
el árbol queda a medias y devuelve Directory not empty (os error 39) — y se queda roto hasta que
alguien lo borra a mano, porque el siguiente intento vuelve a chocar igual.
No es teórico. El 2026-07-22 la campaña de deuda y el worker-loop pidieron mesa a la vez (el loop
lo arrastraba desde la cola KDE al invalidarse libdrm) y se llevó puesta media cascada GUI, con un
error que no nombra la causa.
Lo que YA está mitigado (no rehacer)
Un lock compartido de grano grueso entre campana-deuda.sh y farm-worker-loop.sh
(work/.farm-build.lock, commit 1b436a7): la campaña lo retiene toda su corrida, el loop por ciclo
de cola. Verificado: la carrera entre esos dos scripts desapareció.
Lo que NO cubre
build-farm.sh lanza xargs -P2: dos recetas de la misma cola que compartan dep se siguen
pisando exactamente igual. El lock entre scripts no puede cubrir esto sin serializar el -P2 y
tirar el paralelismo del worker.
El dilema
El árbol cumple dos papeles que se contradicen bajo concurrencia:
- Caché direccionada por contenido — la clave es
(nombre, sha), así que dos builds de la misma dep "deberían" compartirlo, y re-extraer es caro. - Workspace mutable de build —
lib.rs:208-234lo MUTA en el sitio:apply_patches,ensure_cargo_workspace_isolation,vendor_cargo_deps(que para Go/Cargo puede tardar minutos y ocupar decenas de GB), y después el sandbox lo bind-montea para compilar.
Por eso un lock sólo alrededor de la extracción NO alcanza: aunque serialice el tar, un segundo
proceso puede hacer remove_dir_all sobre un árbol que un bwrap está usando para compilar. La
protección tiene que cubrir extracción y build, o los papeles tienen que separarse.
Opciones (ninguna elegida)
A. Lock por árbol sostenido durante TODO el build (extract → patch → vendor → compile → seal).
Correcto y contenido (un lock en fetch + guard hasta el final de build). Contra: serializa
cualquier par de builds que necesiten la misma receta. El grafo es un diamante que converge —el
propio código lo dice: "TODO KF6/Qt converge en qtbase/kcoreaddons, alcanzable por decenas de
rutas"— así que puede degenerar en serializar casi todo y matar el -P2.
B. Árbol privado por build (sufijo único por invocación). Elimina la carrera por construcción,
sin lock ni serialización. Contra: re-extrae y re-vendorea por build. El go mod vendor ya está
documentado como el cuello real del worker (decenas de GB sin tope, el watchdog de disco existe por
eso, DISK_HIGH=82). Esta opción empuja justo donde ya duele.
C. Separar los dos papeles: caché inmutable (sources-cache/<nombre>-<sha>, extraída una vez
bajo lock y nunca mutada) + copia privada por build. Es la respuesta principista: cada papel con
su ciclo de vida. Contra concreto: el worker corre ext4, que no tiene reflink (cp --reflink
es btrfs/xfs), y hardlinkear no sirve porque el build muta en el sitio y corrompería la caché ⇒ la
copia es real, con su coste en disco y tiempo. Reevaluar si el store vivo del SDD 18 (composefs)
cambia el piso.
Qué haría falta para decidir
Medir, no opinar:
- Cuántos pares
(receta, receta)de una misma cola comparten dep de verdad ⇒ cuánto serializaría la opción A en la práctica. - Coste real de re-extraer + re-vendorear las recetas más caras (Go/Cargo grandes) ⇒ cuánto cuesta B por build.
- Espacio pico de la opción C con el corpus actual, contra el
DISK_HIGHdel watchdog.
Sin esos tres números, cualquiera de las tres se elige a ciegas.
Nota de coordinación
Hay otro agente trabajando sobre este repo. Este ADR existe para que la carrera quede registrada y nadie la "arregle" a medias: un lock sólo en la extracción parece correcto y no lo es (§El dilema). Si vas a tocarlo, resolvé el dilema primero.