wandres.dev
TURBOREPO · task graph y caching

Ejecución selectiva: --filter, --affected y paralelismo

En un monorepo grande casi nunca quieres correr todo. --filter selecciona una región del grafo por nombre, ruta, cambios de git y el operador de tres puntos para dependencias o dependientes. --affected, en Turborepo 2.x, calcula ese subconjunto a partir del diff automáticamente. Y sobre lo seleccionado, Turborepo paraleliza el grafo entero sin violar las precedencias. Aquí se juntan grafo, git y caché en el patrón de CI que define un monorepo moderno.

⏱ 18 min

Un monorepo de cien paquetes no debe reconstruir cien paquetes en cada push. Turborepo ya se salta por caché lo que no cambió, pero antes de eso puede ni siquiera considerar lo irrelevante: --filter recorta el grafo a una región concreta, y --affected la deduce sola del diff. Sobre lo que quede, el planificador lanza en paralelo todos los nodos que no se estorban, respetando solo las aristas reales. Selección por grafo, detección por git y ejecución concurrente: los tres se combinan en el patrón que hace que un CI de monorepo termine en segundos en lugar de en media hora.

🎯 Al terminar esta lección sabrás
  • Seleccionar con --filter por nombre, ruta, cambios de git y el operador ....
  • Usar --affected para derivar el subconjunto afectado sin escribir el selector.
  • Entender cómo Turborepo paraleliza el grafo seleccionado y cómo limitar la concurrencia.
  • Componer el patrón de CI que une selección, caché y paralelismo.

–filter: seleccionar una región del grafo

--filter toma un selector y restringe la ejecución a los paquetes que casan. Su gramática es casi idéntica a la de pnpm, porque ambos razonan sobre el mismo grafo de paquetes.

turbo run build --filter=@acme/web         # un paquete por nombre
turbo run test --filter=./apps/*           # por ubicacion en disco
turbo run lint --filter=!@acme/legacy      # negacion: todo menos uno
turbo run build --filter=@acme/web...       # web y sus dependencias
turbo run test --filter=...@acme/ui         # ui y sus dependientes

El operador de tres puntos es donde --filter deja de seleccionar carpetas y empieza a razonar sobre aristas. @acme/web... incluye web y todo aquello de lo que depende, aguas abajo —lo que necesitas para construirlo—. ...@acme/ui incluye ui y todo lo que depende de ella, aguas arriba —lo que podrías haber roto al tocarla—. El caret los restringe a solo las dependencias o solo los dependientes, sin el propio nodo: @acme/web^... y ...^@acme/ui.

⬇️

pkg... (aguas abajo)

El paquete y sus dependencias. Para construir algo con todo lo que necesita listo antes.

⬆️

...pkg (aguas arriba)

El paquete y sus dependientes. Para revalidar todo lo que un cambio suyo pudo romper.

🔀

[git-ref]

Los paquetes cuyos archivos cambiaron respecto a una referencia de git, como [origin/main] o [HEAD^1].

Y aquí se combinan las dos naturalezas. El selector de git entre corchetes elige lo que cambió; el operador ... extiende ese resultado por el grafo. La expresión estrella de un CI reúne ambos: --filter=...[origin/main] selecciona todo lo que cambió respecto a main más todos sus dependientes, es decir, lo que tocaste y todo lo que ese cambio podría haber afectado.

turbo run test --filter=...[origin/main]   # lo cambiado y sus dependientes
turbo run build --filter=[HEAD^1]          # solo lo tocado en el ultimo commit

–affected: el diff como filtro automático

Escribir ...[origin/main] a mano funciona, pero exige conocer la rama base y acordarse del operador. Turborepo 2.x introdujo --affected como atajo de alto nivel: calcula por su cuenta el conjunto de paquetes afectados por los cambios, comparando el estado actual con una base.

turbo run build --affected      # equivale a filtrar por lo cambiado y sus dependientes
turbo run lint test --affected  # el mismo subconjunto, varias tareas

Por defecto --affected compara contra main —o la rama por defecto del repo— usando el merge-base, de modo que refleja lo que introduce tu rama y no el ruido de commits ajenos que ya entraron en la base. Cuando el CI necesita otra referencia, dos variables de entorno la sobrescriben sin cambiar el comando: TURBO_SCM_BASE fija el punto de comparación inferior y TURBO_SCM_HEAD el superior.

TURBO_SCM_BASE=origin/main TURBO_SCM_HEAD=HEAD turbo run build --affected
📝
--affected es azúcar sobre --filter, no otra cosa

No hay dos motores de selección. --affected se resuelve internamente a un filtro por cambios de git extendido a los dependientes, exactamente lo que escribirías con ...[base]. La ventaja es ergonómica y de portabilidad entre CIs, no semántica: menos que recordar, y una detección de base que se adapta al proveedor. Si necesitas control fino —limitar a una ruta, ignorar ciertos archivos— vuelve al --filter explícito.

Paralelizar sin violar el orden

Seleccionado el subconjunto, Turborepo lo ejecuta como un grafo, no como una lista. Lanza a la vez todos los nodos cuyas dependencias ya terminaron y va abriendo el frente a medida que se completan aristas. No hay olas rígidas por paquete: un nodo arranca en cuanto sus precedencias concretas están listas, aunque otros paquetes sigan a medias.

flowchart TD
utb[utils build] --> uib[ui build]
utb --> apib[api build]
uib --> webb[web build]
apib --> webb
uib --> docsb[docs build]
style uib fill:#a6e3a1,color:#11111b
style apib fill:#a6e3a1,color:#11111b
style docsb fill:#89b4fa,color:#11111b
style webb fill:#89b4fa,color:#11111b

En ese grafo, en cuanto utils#build termina, ui#build y api#build corren en paralelo; cuando ui#build acaba, docs#build puede empezar sin esperar a api#build, porque no hay arista entre ellos. El tiempo total no es la suma de las tareas, sino la longitud del camino critico —la cadena más larga de precedencias—, y la caché acorta ese camino saltándose los nodos ya memoizados.

turbo run build --concurrency=4     # techo de tareas simultaneas
turbo run build --concurrency=100%  # una por nucleo logico
turbo run lint --parallel           # ignora dependsOn, util solo para tareas independientes

--concurrency pone un techo al número de tareas a la vez, útil en máquinas de CI con pocos núcleos o para no saturar la memoria. --parallel es más agresivo y peligroso: ignora dependsOn y lanza todo de golpe, así que solo es correcto para tareas genuinamente independientes —un lint que no lee artefactos de nadie—, nunca para un build topológico.

Merece la pena separar dos ejes que se confunden. La selección decide qué nodos entran; la concurrencia decide cuántos de esos corren a la vez. Son ortogonales: puedes seleccionar medio grafo y ejecutarlo con concurrencia máxima, o seleccionar todo y limitar la concurrencia a dos en una máquina modesta. --filter y --affected operan sobre el primer eje; --concurrency y --parallel, sobre el segundo. Confundirlos lleva a esperar que --concurrency reduzca qué se construye, cuando solo cambia el ritmo.

💡
Instala todo, filtra la ejecución

Igual que con pnpm, el error clásico en CI es intentar instalar solo el subconjunto filtrado. No lo hagas: instala el workspace entero con pnpm install --frozen-lockfile para que Turborepo conozca el grafo completo, y reserva --filter o --affected para ejecutar. Sin el grafo completo, el operador ... no puede calcular bien los dependientes y --affected subestima lo que hay que revalidar.

El patrón de CI

Todo el nivel converge aquí. Un pipeline de monorepo moderno instala el workspace completo, restaura la caché —local o remota— y ejecuta solo lo afectado, dejando que la memoización se coma el resto.

# 1. instala TODO el workspace: se necesita el grafo entero
pnpm install --frozen-lockfile
# 2. valida solo lo afectado por la rama y sus dependientes
turbo run lint test build --affected
# 3. o, con control explicito de la base
turbo run build --filter=...[origin/main]

La sinergia con la caché es lo que multiplica el efecto. --affected reduce el grafo a los nodos que el diff pudo tocar; de esos, la caché se salta los que aún así resultan tener el mismo hash; y lo que queda se ejecuta en paralelo. Si a esto le sumas caché remota, un compañero o el propio CI pudo haber construido ya ese hash, y tu máquina lo restaura sin ejecutar. El resultado es un CI cuyo tiempo escala con el tamaño del cambio, no con el del repositorio.

Dos banderas redondean el patrón. --continue hace que un fallo en una tarea no aborte el resto del grafo, para que un CI reporte todos los errores de una tanda en vez de parar en el primero. Y --only ejecuta la tarea pedida sin sus dependencias del grafo, cuando ya sabes que están construidas y solo quieres relanzar, por ejemplo, los tests.

turbo run lint test --affected --continue   # no aborta al primer fallo
turbo run test --filter=@acme/web --only    # solo test de web, sin sus deps
⚠️
--affected necesita historia de git y una base correcta

En un CI con shallow clone, --affected puede no ver el commit base y acabar seleccionando de más o de menos. Asegúrate de que el checkout trae suficiente historia —o fija TURBO_SCM_BASE a una referencia que exista— para que el cálculo del diff sea fiel. Un --affected que compara contra una base equivocada es más peligroso que no filtrar: puede omitir validar algo que sí cambió y dejarlo pasar a producción.

Selección, detección y memoización responden tres preguntas distintas del mismo problema

La ejecución selectiva parece un puñado de banderas, pero es la culminación de una idea que atraviesa todo el nivel: hacer que el trabajo de un monorepo escale con lo que cambió y no con lo que existe. Tres preguntas, tres mecanismos, y conviene no confundirlos. La primera es “¿qué región del grafo me interesa?”, y la responde --filter con su pequeño lenguaje de consulta —nombres, rutas, refs de git y el operador de alcance transitivo ...— que no es un atajo para no escribir carpetas sino una forma de razonar sobre la topología: ...[origin/main] es literalmente la proposición “todo lo afectado por lo que cambié”. La segunda es “¿puedo deducir esa región del diff sin escribirla?”, y la responde --affected, que automatiza el caso más común de la primera. La tercera es “¿de lo seleccionado, qué puedo evitar ejecutar?”, y la responde la caché por hash de las lecciones anteriores. Son ortogonales y componibles: la selección recorta el grafo antes de mirar la caché, la caché recorta dentro de lo seleccionado, y el paralelismo ejecuta lo que sobrevive a ambos filtros respetando el camino crítico. El error conceptual que hay que evitar es tratarlas como redundantes —“si tengo caché, ¿para qué filtrar?”—: filtrar ahorra incluso el coste de calcular hashes y consultar la caché de miles de nodos irrelevantes, y sobre todo expresa intención, la de revalidar solo lo que un cambio pudo romper. Cuando un ingeniero interioriza que --filter, --affected y el hash atacan tres cuellos de botella distintos del mismo problema, deja de ver el CI como una cinta transportadora que procesa todo el repo y empieza a verlo como un evaluador incremental de un grafo de cómputo, que es exactamente lo que un monorepo bien orquestado es. Esa es la diferencia entre un pipeline cuyo tiempo crece sin techo con cada paquete nuevo y uno que tarda lo mismo con diez paquetes que con mil, porque su coste lo fija el diff y no el inventario.

⚔️ Recomputa solo lo necesario
  1. Ejecuta turbo run build --filter=@acme/web... y --filter=...@acme/web; explica por qué uno arrastra muchos más paquetes que el otro.
  2. Cambia un archivo en packages/ui, commitea, y corre turbo run test --affected: confirma que entran ui y sus dependientes, no el resto.
  3. Reproduce el mismo subconjunto a mano con --filter=...[HEAD^1] y verifica que coincide con lo que hizo --affected.
  4. Usa --dry-run con un filtro para ver la selección sin ejecutar, y cuenta cuántos nodos entran frente a un turbo run sin filtro.
  5. Baja --concurrency a 1 en un build con caché fría y compáralo con la concurrencia por defecto para medir cuánto te da el paralelismo del grafo.