1# ADR 0012 — El árbol de fuentes es caché y workspace a la vez: carrera abierta en `fetch`
2
3- **Estado:** **ACEPTADA e IMPLEMENTADA (2026-09-22)** — opción **A precisada**: lock exclusivo por
4 RECETA, sostenido desde el `fetch` hasta el `seal`, con re-chequeo de la tienda tras tomarlo.
5 Ver §La decisión al final. El dilema se resolvió midiendo, no opinando.
6- **Fecha:** 2026-07-22
7- **Frontera:** `crates/hammer-build/src/fetch.rs` (`fetch_tarball` / `fetch_git`),
8 `crates/hammer-build/src/lib.rs:208`. Mitigación parcial ya en árbol: commit `1b436a7`.
9
10## Contexto
11
12`fetch` nombra el árbol de fuentes de forma **determinista y sin componente por-constructor**:
13
14```rust
15let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
16if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; }
17std::fs::create_dir_all(&work_tree)?;
18// tar -x dentro de work_tree
19```
20
21Dos procesos que construyan **la misma receta** (típicamente la misma *dep* alcanzada por dos
22caminos) resuelven al MISMO directorio. Uno hace `remove_dir_all` mientras el otro corre `tar -x`:
23el árbol queda a medias y devuelve `Directory not empty` (os error 39) — y **se queda roto hasta que
24alguien lo borra a mano**, porque el siguiente intento vuelve a chocar igual.
25
26No es teórico. El 2026-07-22 la campaña de deuda y el worker-loop pidieron `mesa` a la vez (el loop
27lo arrastraba desde la cola KDE al invalidarse `libdrm`) y se llevó puesta media cascada GUI, con un
28error que no nombra la causa.
29
30### Lo que YA está mitigado (no rehacer)
31
32Un lock compartido de grano grueso entre `campana-deuda.sh` y `farm-worker-loop.sh`
33(`work/.farm-build.lock`, commit `1b436a7`): la campaña lo retiene toda su corrida, el loop por ciclo
34de cola. Verificado: la carrera entre esos dos scripts desapareció.
35
36### Lo que NO cubre
37
38`build-farm.sh` lanza `xargs -P2`: **dos recetas de la misma cola que compartan dep se siguen
39pisando exactamente igual**. El lock entre scripts no puede cubrir esto sin serializar el `-P2` y
40tirar el paralelismo del worker.
41
42## El dilema
43
44El árbol cumple **dos papeles que se contradicen bajo concurrencia**:
45
461. **Caché direccionada por contenido** — la clave es `(nombre, sha)`, así que dos builds de la
47 misma dep "deberían" compartirlo, y re-extraer es caro.
482. **Workspace mutable de build** — `lib.rs:208-234` lo MUTA en el sitio: `apply_patches`,
49 `ensure_cargo_workspace_isolation`, `vendor_cargo_deps` (que para Go/Cargo puede tardar minutos y
50 ocupar decenas de GB), y después el sandbox lo bind-montea para compilar.
51
52Por eso **un lock sólo alrededor de la extracción NO alcanza**: aunque serialice el `tar`, un segundo
53proceso puede hacer `remove_dir_all` sobre un árbol que un `bwrap` está usando para compilar. La
54protección tiene que cubrir extracción **y** build, o los papeles tienen que separarse.
55
56## Opciones (ninguna elegida)
57
58**A. Lock por árbol sostenido durante TODO el build** (extract → patch → vendor → compile → seal).
59Correcto y contenido (un lock en `fetch` + guard hasta el final de `build`). Contra: serializa
60cualquier par de builds que necesiten la misma receta. El grafo es un diamante que converge —el
61propio código lo dice: "TODO KF6/Qt converge en qtbase/kcoreaddons, alcanzable por decenas de
62rutas"— así que puede degenerar en serializar casi todo y matar el `-P2`.
63
64**B. Árbol privado por build** (sufijo único por invocación). Elimina la carrera por construcción,
65sin lock ni serialización. Contra: re-extrae y **re-vendorea por build**. El `go mod vendor` ya está
66documentado como el cuello real del worker (decenas de GB sin tope, el watchdog de disco existe por
67eso, `DISK_HIGH=82`). Esta opción empuja justo donde ya duele.
68
69**C. Separar los dos papeles**: caché inmutable (`sources-cache/<nombre>-<sha>`, extraída una vez
70bajo lock y **nunca** mutada) + copia privada por build. Es la respuesta principista: cada papel con
71su ciclo de vida. Contra concreto: **el worker corre ext4, que no tiene reflink** (`cp --reflink`
72es btrfs/xfs), y hardlinkear no sirve porque el build muta en el sitio y corrompería la caché ⇒ la
73copia es real, con su coste en disco y tiempo. Reevaluar si el store vivo del SDD 18 (composefs)
74cambia el piso.
75
76## Qué haría falta para decidir
77
78Medir, no opinar:
79
801. Cuántos pares `(receta, receta)` de una misma cola comparten dep de verdad ⇒ cuánto serializaría
81 la opción A en la práctica.
822. Coste real de re-extraer + re-vendorear las recetas más caras (Go/Cargo grandes) ⇒ cuánto cuesta
83 B por build.
843. Espacio pico de la opción C con el corpus actual, contra el `DISK_HIGH` del watchdog.
85
86Sin esos tres números, cualquiera de las tres se elige a ciegas.
87
88## Nota de coordinación
89
90Hay **otro agente trabajando sobre este repo**. Este ADR existe para que la carrera quede
91registrada y nadie la "arregle" a medias: un lock sólo en la extracción parece correcto y **no lo
92es** (§El dilema). Si vas a tocarlo, resolvé el dilema primero.
93
94
95---
96
97# La decisión (2026-09-22): opción A, precisada — y por qué su coste temido no existe
98
99## Las tres mediciones que este ADR pedía
100
101**1. ¿Cuánto serializaría A en la práctica?** La pregunta estaba mal planteada, y verlo es la mitad
102de la decisión. A bloquea **árboles**, y el orden real de `build` (`lib.rs`) es:
103
104```
1051. store.has(h) → si está, vuelve sin tocar NINGÚN árbol
1062. materialize_build_deps() → construye las deps (recursivo) y TERMINA
1073. fetch → patches → vendor → compilar → sellar ← recién acá se toca el árbol PROPIO
108```
109
110De ahí salen dos hechos que el §Opciones no había mirado:
111
112- **Un consumidor NO toca el árbol de su dep**: recibe el ARTEFACTO SELLADO como capa overlay. O sea
113 que el «diamante que converge» —`todo KF6/Qt converge en qtbase`— **no serializa nada**: dos
114 recetas distintas nunca comparten su árbol propio. Lo único que se serializa es construir la MISMA
115 receta dos veces a la vez, que es justo lo que no se debe hacer.
116- **Los locks no se anidan**: cuando un build toma el lock de su árbol, sus deps ya están construidas
117 y sus locks liberados (paso 2 antes del 3) ⇒ **no puede haber deadlock**, sin ordenar nada.
118
119**2. ¿Cuánto cuesta B (re-extraer + re-vendorear por build)?** Medido en el build real de
120`ntpd-rs` (Cargo, 150 crates) del 2026-09-22, con marcas de tiempo del propio log:
121
122| fase | tiempo |
123|---|---|
124| fetch (clone + archive) | **4,0 s** |
125| `cargo vendor` | **5,4 s** |
126| compilar | **6 min 25 s** |
127
128⇒ re-extraer y re-vendorear cuesta **2,4 %** del build. **El tiempo no era el problema de B: el disco
129sí.** Ese árbol pesa **707 MiB**, y B multiplica eso por cada build concurrente.
130
131**3. ¿Espacio pico de C?** La caché de fuentes de este hub son **12,4 GiB en 46 árboles**, con los
132mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). C —caché inmutable + copia
133privada— duplica lo que esté en vuelo: con 4 builds concurrentes de los gordos son ~10 G extra sobre
134un `DISK_HIGH=82`. Y C **no ahorra el vendoreo** (que muta, así que va en la copia): compra sólo los
1354 s del extract, al precio de duplicar el disco.
136
137## La decisión
138
139**Opción A precisada:** un lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por
140RAII hasta que el artefacto se sella (`fetch::GuardaArbol`), más un **re-chequeo de la tienda** tras
141tomarlo (double-checked locking): el que esperó encuentra el artefacto ya sellado y sale por cache
142hit en vez de reconstruirlo.
143
144- **El grano es el NOMBRE y no el árbol** porque el mirror git (`repos/<nombre>.git`) también es
145 estado compartido y su clave es sólo el nombre: dos builds de la misma receta a distinto commit
146 tienen árboles distintos y el MISMO mirror, y un `clone --mirror` sobre un directorio existente
147 falla.
148- **El lock cubre extract + patches + vendor + compile + seal**, que es lo que el §El dilema exigía:
149 un lock sólo alrededor de la extracción no cierra la carrera.
150- **El fd no se hereda.** Rust abre con `O_CLOEXEC`, así que ni `tar`, ni `git`, ni `bwrap`, ni un
151 nieto fugado se quedan con el lock — que es exactamente el modo de fallo que `CLAUDE.md` documenta
152 para el `flock(1)` de alrededor (hora y media de granja parada, en silencio).
153
154## Lo verificado
155
156**Dos builds concurrentes de la misma receta**, con el árbol borrado antes para forzar la extracción:
157
158```
159A: fetch … → sealed
160B: fetch … → «árbol: esperando el lock — otro build tiene el árbol de fuentes de esta receta»
161 → «árbol: lock obtenido tras esperar» (1,27 s)
162 → «caché: otro build lo selló mientras esperábamos el árbol»
163los dos rc=0 · UN artefacto en el store
164```
165
166**Y el control, que es lo que prueba que el lock hace falta.** Modelando la ventana real (extracción
167de segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del
168apagón de KDE del 22/07:
169
170```
171SIN lock: A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes B: OK
172CON lock: A: OK B: OK
173```
174
175Más dos tests en el crate (`fetch::tests`): que dos guardas del mismo nombre no coexisten, con el
176CONTROL de que dos nombres distintos no se bloquean —si eso fallara, el lock serializaría builds que
177no comparten nada—, y que el fichero de lock vive **fuera** de `sources/` (adentro lo barrería el
178propio `remove_dir_all` que estamos cerrando).
179
180## Lo que esto habilita, y lo que NO decide
181
182Habilita subir el paralelismo: `JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos
183**por esta carrera**, y la carrera ya no existe. **Subirlo es otra decisión y no se toma acá**: el
184worker son 6 cores y 16 G con 8 G de swap, y esta misma clase de máquina ya provocó un `global_oom`.
185Lo que cambia es que ahora el límite es la RAM y el disco —medibles y con watchdog— y no la
186corrupción silenciosa de un árbol compartido.
187
188El `flock -o work/.farm-build.lock` de `CLAUDE.md` §1 **sigue haciendo falta por otra razón**: serializa
189contra `farm-worker-loop.sh` y `campana-deuda.sh`, que es control de RECURSOS, no de corrupción.
0012-arbol-de-fuentes-compartido-vs-privado.md
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:
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-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/<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_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/<nombre>.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.