# ADR 0012 — El árbol de fuentes es caché y workspace a la vez: carrera abierta en `fetch` - **Estado:** **ACEPTADA e IMPLEMENTADA (2026-09-22)** — opción **A precisada**: lock exclusivo por RECETA, sostenido desde el `fetch` hasta el `seal`, con re-chequeo de la tienda tras tomarlo. Ver §La decisión al final. El dilema se resolvió midiendo, no opinando. - **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: commit `1b436a7`. ## Contexto `fetch` nombra el árbol de fuentes de forma **determinista y sin componente por-constructor**: ```rust 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**: 1. **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. 2. **Workspace mutable de build** — `lib.rs:208-234` lo 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/-`, 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: 1. 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. 2. Coste real de re-extraer + re-vendorear las recetas más caras (Go/Cargo grandes) ⇒ cuánto cuesta B por build. 3. Espacio pico de la opción C con el corpus actual, contra el `DISK_HIGH` del 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. --- # La decisión (2026-09-22): opción A, precisada — y por qué su coste temido no existe ## Las tres mediciones que este ADR pedía **1. ¿Cuánto serializaría A en la práctica?** La pregunta estaba mal planteada, y verlo es la mitad de la decisión. A bloquea **árboles**, y el orden real de `build` (`lib.rs`) es: ``` 1. store.has(h) → si está, vuelve sin tocar NINGÚN árbol 2. materialize_build_deps() → construye las deps (recursivo) y TERMINA 3. fetch → patches → vendor → compilar → sellar ← recién acá se toca el árbol PROPIO ``` De ahí salen dos hechos que el §Opciones no había mirado: - **Un consumidor NO toca el árbol de su dep**: recibe el ARTEFACTO SELLADO como capa overlay. O sea que el «diamante que converge» —`todo KF6/Qt converge en qtbase`— **no serializa nada**: dos recetas distintas nunca comparten su árbol propio. Lo único que se serializa es construir la MISMA receta dos veces a la vez, que es justo lo que no se debe hacer. - **Los locks no se anidan**: cuando un build toma el lock de su árbol, sus deps ya están construidas y sus locks liberados (paso 2 antes del 3) ⇒ **no puede haber deadlock**, sin ordenar nada. **2. ¿Cuánto cuesta B (re-extraer + re-vendorear por build)?** Medido en el build real de `ntpd-rs` (Cargo, 150 crates) del 2026-09-22, con marcas de tiempo del propio log: | fase | tiempo | |---|---| | fetch (clone + archive) | **4,0 s** | | `cargo vendor` | **5,4 s** | | compilar | **6 min 25 s** | ⇒ re-extraer y re-vendorear cuesta **2,4 %** del build. **El tiempo no era el problema de B: el disco sí.** Ese árbol pesa **707 MiB**, y B multiplica eso por cada build concurrente. **3. ¿Espacio pico de C?** La caché de fuentes de este hub son **12,4 GiB en 46 árboles**, con los mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). C —caché inmutable + copia privada— duplica lo que esté en vuelo: con 4 builds concurrentes de los gordos son ~10 G extra sobre un `DISK_HIGH=82`. Y C **no ahorra el vendoreo** (que muta, así que va en la copia): compra sólo los 4 s del extract, al precio de duplicar el disco. ## La decisión **Opción A precisada:** un lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por RAII hasta que el artefacto se sella (`fetch::GuardaArbol`), más un **re-chequeo de la tienda** tras tomarlo (double-checked locking): el que esperó encuentra el artefacto ya sellado y sale por cache hit en vez de reconstruirlo. - **El grano es el NOMBRE y no el árbol** porque el mirror git (`repos/.git`) también es estado compartido y su clave es sólo el nombre: dos builds de la misma receta a distinto commit tienen árboles distintos y el MISMO mirror, y un `clone --mirror` sobre un directorio existente falla. - **El lock cubre extract + patches + vendor + compile + seal**, que es lo que el §El dilema exigía: un lock sólo alrededor de la extracción no cierra la carrera. - **El fd no se hereda.** Rust abre con `O_CLOEXEC`, así que ni `tar`, ni `git`, ni `bwrap`, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que `CLAUDE.md` documenta para el `flock(1)` de alrededor (hora y media de granja parada, en silencio). ## Lo verificado **Dos builds concurrentes de la misma receta**, con el árbol borrado antes para forzar la extracción: ``` A: fetch … → sealed B: fetch … → «árbol: esperando el lock — otro build tiene el árbol de fuentes de esta receta» → «árbol: lock obtenido tras esperar» (1,27 s) → «caché: otro build lo selló mientras esperábamos el árbol» los dos rc=0 · UN artefacto en el store ``` **Y el control, que es lo que prueba que el lock hace falta.** Modelando la ventana real (extracción de segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del apagón de KDE del 22/07: ``` SIN lock: A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes B: OK CON lock: A: OK B: OK ``` Más dos tests en el crate (`fetch::tests`): que dos guardas del mismo nombre no coexisten, con el CONTROL de que dos nombres distintos no se bloquean —si eso fallara, el lock serializaría builds que no comparten nada—, y que el fichero de lock vive **fuera** de `sources/` (adentro lo barrería el propio `remove_dir_all` que estamos cerrando). ## Lo que esto habilita, y lo que NO decide Habilita subir el paralelismo: `JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos **por esta carrera**, y la carrera ya no existe. **Subirlo es otra decisión y no se toma acá**: el worker son 6 cores y 16 G con 8 G de swap, y esta misma clase de máquina ya provocó un `global_oom`. Lo que cambia es que ahora el límite es la RAM y el disco —medibles y con watchdog— y no la corrupción silenciosa de un árbol compartido. El `flock -o work/.farm-build.lock` de `CLAUDE.md` §1 **sigue haciendo falta por otra razón**: serializa contra `farm-worker-loop.sh` y `campana-deuda.sh`, que es control de RECURSOS, no de corrupción.