wandres.dev
TURBOREPO · task graph y caching

Caching local: el hash y el replay

El caching es la razón de ser de Turborepo. Cada tarea se resume en un hash que combina sus archivos, sus variables de entorno, los hashes de sus dependencias internas y su propia configuración. Si ese hash ya existe en la caché local, Turborepo no ejecuta el script: restaura los artefactos y reproduce los logs. Aquí se estudia qué entra en el hash, cómo se ve un acierto FULL TURBO, cómo inspeccionarlo y por qué la correctitud depende de que el hash sea completo.

⏱ 17 min

Todo lo anterior —el grafo, dependsOn, inputs, outputs— existe para habilitar un único acto: no volver a ejecutar una tarea cuyas entradas no cambiaron. El mecanismo es una tabla de memoización indexada por un hash de contenido. Turborepo calcula, para cada nodo del grafo, una huella que resume todo lo que lo determina; si esa huella ya está en la caché local, restaura los archivos que se guardaron y reproduce en pantalla los logs de aquella vez, sin correr un solo comando. A un acierto total el propio Turborepo lo anuncia con dos palabras: FULL TURBO.

🎯 Al terminar esta lección sabrás
  • Enumerar qué entra en el hash de una tarea: archivos, entorno, dependencias y config.
  • Distinguir el hash global del hash por tarea y qué invalida cada uno.
  • Reconocer un acierto de caché, dónde vive la caché local y cómo se reproducen los logs.
  • Inspeccionar el hash con --dry-run, forzar con --force y razonar la correctitud del entorno.

El hash: la huella de una tarea

El hash de una tarea es una función criptográfica sobre todo lo que puede alterar su salida. Turborepo lo compone en dos capas. Hay un hash global, compartido por todas las tareas, que incluye el lockfile de la raíz, los globalDependencies, las globalEnv y la propia versión y config de Turborepo. Y hay un hash por tarea, que añade a lo global los ingredientes locales de ese nodo.

📄

Archivos de entrada

El contenido de los archivos que casan con inputs, o el conjunto por defecto del paquete si no los declaraste.

🌱

Variables de entorno

Las variables listadas en env y globalEnv, más las que Turborepo infiere del framework. Su valor, no solo su nombre.

🔗

Hashes de dependencias

El hash de las tareas de las que esta depende. Si ui#build cambia, el hash de web#build cambia con él, en cascada por el grafo.

⚙️

Configuración de la tarea

La definición de la tarea en turbo.json. Cambiar sus outputs o su dependsOn altera el hash, porque cambia qué significa ejecutarla.

La inclusión de los hashes de las dependencias es lo que propaga la invalidación por el grafo: tocar un archivo de utils cambia el hash de utils#build, que forma parte del hash de ui#build, que forma parte del de web#build. Un solo cambio en una hoja invalida exactamente su cono de dependientes, ni un nodo más. Esa cascada es el grafo de tareas funcionando como una estructura de propagación, no solo de orden.

Conviene subrayar qué no entra en el hash, porque las sorpresas suelen venir de ahí: no entra la hora, ni el orden en que lanzaste las tareas, ni las banderas que no afectan la salida, ni los archivos ignorados por .gitignore. El hash aspira a capturar exactamente los factores causales de la salida y nada más; cada factor de más provoca fallos de caché espurios, y cada factor de menos, aciertos incorrectos.

Acierto de caché: restaurar y reproducir

Con el hash calculado, Turborepo consulta la caché local. Por defecto vive dentro del repositorio, en node_modules/.cache/turbo, y es configurable con el campo cacheDir o la bandera --cache-dir. Cada entrada guarda dos cosas: los artefactos que casaban con outputs y la salida de terminal que la tarea produjo.

En un fallo de caché —hash nuevo— Turborepo ejecuta el script, captura su salida y, si termina bien, archiva outputs y logs bajo ese hash. En un acierto —hash conocido— no ejecuta nada: copia los artefactos de vuelta a su sitio y reproduce los logs guardados en el terminal, de modo que la experiencia es idéntica a haber corrido la tarea, solo que en milisegundos. Cuando todas las tareas de una invocación son aciertos, aparece el célebre cartel.

turbo run build
# ...
#  Tasks:    12 successful, 12 total
# Cached:    12 cached, 12 total
#   Time:    240ms >>> FULL TURBO

Los logs de cada tarea se guardan además en un archivo por paquete, .turbo/turbo-<tarea>.log, que es literalmente lo que se reproduce en un acierto. Cuánta salida se imprime lo gobierna outputLogs: full reproduce todo, hash-only solo la línea del hash, new-only calla en los aciertos, errors-only solo habla si algo falla, y none enmudece del todo.

💡
FULL TURBO no es magia: es una tabla hash

Cuando veas >>> FULL TURBO no pienses en un optimizador misterioso, sino en la operación más vieja de la informática: una consulta a una tabla indexada por clave. La clave es el hash, el valor son los artefactos y los logs. Turborepo no entiende tu código ni deduce que no cambió; simplemente comprobó que la huella de las entradas coincidía con una que ya tenía archivada. Toda la sofisticación está en calcular bien esa huella, no en el acierto en sí.

Inspeccionar, forzar y afinar

La herramienta para entender por qué una tarea acierta o falla es el --dry-run, que imprime el hash de cada tarea y los ingredientes que lo forman sin ejecutar nada. Comparar dos --dry-run es la forma de descubrir qué cambió para que un hash se moviera.

turbo run build --dry-run             # tabla legible con el hash de cada tarea
turbo run build --dry=json            # el mismo plan en json, para diffs
turbo run build --force               # ignora la cache y reejecuta todo
turbo run build --output-logs=errors-only

--force es la vía para saltarse la caché cuando sospechas que quedó envenenada o quieres medir el tiempo real de un build en frío. Su existencia es también un recordatorio incómodo: si alguna vez necesitas forzar porque la caché sirve algo incorrecto, no tienes un problema de caché sino de hash —algo influía en la salida sin estar declarado en inputs, env o globalDependencies—.

Los modos de outputLogs afinan el ruido en aciertos masivos. En un monorepo grande, ver los logs completos de doscientas tareas cacheadas es inútil; new-only calla en los aciertos y solo habla cuando algo se ejecutó de verdad, que suele ser lo único que quieres leer.

turbo run build --output-logs=new-only     # silencio en los aciertos
turbo run build --output-logs=errors-only  # habla solo si algo falla
turbo run build --summarize                 # escribe un resumen json de la corrida

Un flujo de diagnóstico que vale la pena adoptar: cuando una tarea falla en caché y no entiendes por qué, guarda el --dry=json antes y después del cambio y compara los dos objetos. El hash y el desglose de sus ingredientes te dicen exactamente qué entrada se movió —un archivo, una variable, el hash de una dependencia— sin que tengas que adivinar. Turborepo no esconde el porqué de una invalidación: solo hay que pedírselo.

flowchart TD
start[turbo run build] --> calc[calcular hash de la tarea]
calc --> q{hash en cache}
q -->|si| restore[restaurar outputs y reproducir logs]
q -->|no| exec[ejecutar el script]
exec --> save[archivar outputs y logs bajo el hash]
restore --> done[FULL TURBO]
save --> done2[tarea completada]
style q fill:#f9e2af,color:#11111b
style restore fill:#a6e3a1,color:#11111b
style exec fill:#89b4fa,color:#11111b

Correctitud: el hash debe verlo todo

Un caché solo es tan correcto como completo sea su hash. Si un factor influye en la salida y no entra en la huella, Turborepo servirá artefactos que no corresponden a las entradas reales. El caso más traicionero son las variables de entorno: un build que lee API_URL produce artefactos distintos según su valor, y si no la declaras en env, el hash no la ve y la caché mezcla builds de entornos distintos.

Por eso Turborepo 2.x activa por defecto el modo de entorno estricto: dentro de una tarea, solo están disponibles las variables que declaraste en env, globalEnv o passThroughEnv; el resto se ocultan. Es una restricción deliberadamente incómoda que convierte un bug silencioso —una variable no declarada que envenena la caché— en un fallo ruidoso y temprano —la variable simplemente no existe hasta que la declaras—.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["API_URL", "SENTRY_DSN"],
      "passThroughEnv": ["CI"]
    }
  }
}

El modo estricto tiene un coste de fricción real: la primera vez que lo activas sobre un proyecto viejo, tareas que “funcionaban” empiezan a quejarse de variables ausentes. Esa fricción es el sistema haciendo su trabajo, obligándote a declarar la superficie de entorno que antes consumías a ciegas. Cada variable que añades a env es una pieza del contrato de pureza que faltaba, y el proyecto queda mejor documentado y su caché, de fiar.

⚠️
passThroughEnv no entra en el hash; úsalo con cuidado

env mete la variable en el hash: cambiarla invalida la caché. passThroughEnv deja pasar la variable a la tarea sin incluirla en el hash: úsalo solo para valores que no afectan la salida —un token de telemetría, un CI que no cambia el artefacto—. Meter en passThroughEnv algo que sí determina la salida reintroduce justo el bug que el modo estricto quería matar.

De la caché local a la compartida

La caché local vive en tu máquina, pero el hash es una clave universal: si es un contrato honesto de las entradas, dos máquinas que calculan el mismo hash deben poder intercambiar el mismo artefacto. Esa es la premisa de la caché remota, donde el resultado de una tarea que construyó un compañero —o el propio CI— se descarga en vez de recomputarse. Turborepo la ofrece gestionada o autoalojada, y no cambia nada de lo aprendido: sigue siendo la misma tabla de memoización, solo que su almacén se comparte por la red.

Por eso la disciplina del hash importa aún más al activarla. Un hash local puede tolerar impurezas que se cancelan porque siempre corres en la misma máquina; un hash compartido no perdona ninguna. Si algo específico de tu entorno —una ruta absoluta, una variable no declarada, una versión de herramienta que no entra en globalDependencies— se cuela en la salida sin entrar en el hash, envenenarás la caché de todo el equipo, no solo la tuya. La caché remota convierte cada descuido de frontera de un problema personal en un problema colectivo, y por eso es el mejor examen de si tus tareas son de verdad puras.

El caching local es una tabla de memoización, y su correctitud es un problema de honestidad

Conviene desmitificar del todo lo que hace Turborepo, porque la mística estorba. El caching no es inteligencia: es memoización, la técnica de guardar el resultado de una función pura indexado por sus argumentos para no recomputarlo. El hash es la clave de esa tabla, y su cálculo es donde vive toda la corrección del sistema. La pregunta que decide si tu caché es fiable no es “¿es rápida?” —siempre lo será— sino “¿el hash captura todo lo que determina la salida?”. Si la respuesta es sí, la memoización es indistinguible de ejecutar la tarea, solo que instantánea; si es no, la memoización es peor que inútil, porque sirve resultados incorrectos con total confianza. Todos los mecanismos que rodean al caching —inputs, env, globalDependencies, el modo de entorno estricto, la cascada de hashes por el grafo— son formas de garantizar la completitud de esa clave, de asegurar que dos ejecuciones con el mismo hash son de verdad intercambiables. Y aquí está lo que distingue a un ingeniero que domina la herramienta de uno que solo la usa: entiende que la caché no puede verificar su propia corrección, que Turborepo confía ciegamente en las fronteras que declaraste, y que por tanto cada acierto de caché es tan bueno como honesta fue tu declaración de lo que la tarea consume. Cuando la caché sirve algo rancio, la reacción de un principiante es desconfiar de la caché y añadir --force a todo, matando el beneficio; la de un senior es buscar qué factor real quedó fuera del hash y declararlo, porque sabe que el problema nunca fue el acierto, sino una huella incompleta que hizo pasar por pura una función que no lo era. El mismo razonamiento escala luego a la caché remota, donde la clave de tabla se comparte entre máquinas: solo es seguro compartir aciertos si el hash es un contrato universal, y no un accidente de tu máquina.

⚔️ Interroga a la caché
  1. Ejecuta turbo run build dos veces y localiza >>> FULL TURBO en la segunda; cuenta cuántas tareas dice cached.
  2. Corre turbo run build --dry-run, apunta el hash de una tarea, cambia un archivo de su src/ y vuelve a mirar: confirma que el hash se movió.
  3. Toca un archivo de utils y verifica en el --dry-run que también cambiaron los hashes de sus dependientes, en cascada.
  4. Añade una variable a env, cámbiala entre dos ejecuciones y observa que invalida la caché; muévela a passThroughEnv y comprueba que ya no lo hace.
  5. Borra node_modules/.cache/turbo, ejecuta con y sin --force, y compara los tiempos para medir cuánto te ahorra la caché local.