wandres.dev
CÓMO FUNCIONA LA CACHÉ · hashing e invalidación

Qué invalida un cache hit: la aritmética del acierto y del fallo

Un cache hit es una afirmación de identidad: esta tarea es indistinguible de una ya calculada. Como esa identidad se decide por igualdad de hashes, cualquier cosa que mueva el hash rompe el acierto. Aquí se traza la frontera exacta entre hit y miss, se diseccionan los fallos aparentemente imposibles —entradas no declaradas y no determinismo que se filtra en la clave— y se aprende a interrogar un miss hasta dar con la entrada culpable.

⏱ 15 min

Un cache hit no es un premio ni una casualidad: es una afirmación de identidad. Cuando la caché devuelve un resultado sin recomputarlo, está diciendo que la tarea que pides ahora es, a todos los efectos que importan, la misma que ya calculó una vez. Y como esa mismidad se decide comparando dos hashes, la invalidación no es un fenómeno misterioso sino la negación mecánica de una igualdad: basta que una entrada cambie para que la clave cambie, y con ella se evapore el acierto. Este nivel traza la frontera precisa entre reutilizar y recomputar, y —lo más útil— explica esos fallos que juras no haber provocado.

🎯 Al terminar esta lección sabrás
  • Formular el acierto como una igualdad de hashes y la invalidación como su negación mecánica.
  • Enumerar qué cambios —archivo, dependencia, variable, flag o tarea río arriba— rompen un hit.
  • Diagnosticar los miss “imposibles”: entradas no declaradas y no determinismo colados en la clave.
  • Depurar un fallo comparando el desglose del hash entre dos corridas hasta aislar la entrada culpable.

El acierto es una igualdad de hashes

Todo se reduce a una comparación. En cada corrida, la herramienta recalcula el hash de la tarea a partir del estado actual de sus entradas y pregunta al almacén si ya existe una entrada bajo esa clave. Si existe, es un hit: se restaura el resultado sin ejecutar nada. Si no existe, es un miss: se ejecuta la tarea y se guarda su salida bajo la clave recién calculada, que a partir de ahí quedará disponible para futuros aciertos.

flowchart TD
A[Nueva corrida de la tarea] --> B[Recalcula el hash de las entradas]
B -->|la clave existe| D[Cache hit: restaura sin ejecutar]
B -->|la clave no existe| E[Cache miss: ejecuta y almacena]
style D fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b

De aquí se sigue todo lo demás. No hay coincidencia aproximada ni “casi igual”: el hash es una clave exacta, y cambiar un solo byte de cualquier hoja del árbol de Merkle reescribe la raíz y produce una clave que el almacén no conoce. La invalidación, entonces, no es una regla aparte que haya que aprender: es el reverso exacto de la composición del hash. Cualquiera de las cinco fuentes que alimentan la clave, al moverse, rompe el acierto.

  • Un archivo fuente declarado como entrada cambia de contenido.
  • Una dependencia se resuelve a una versión distinta en el lockfile.
  • Una variable de entorno declarada toma otro valor.
  • Un flag o la definición de la tarea cambia cómo se la invoca.
  • Una tarea río arriba de la que dependes recomputa su hash, y ese cambio sube por el árbol hasta reescribir el tuyo.

Esa rigidez es una virtud, no un defecto. Es justo lo que hace confiable el contrato “misma clave implica mismo resultado”: si el hash coincidiera de forma laxa, el acierto dejaría de ser una garantía y pasaría a ser una apuesta.

Por qué a veces no cachea cuando lo esperabas

Aquí empieza la fenomenología del desconcierto: “no toqué nada y aun así reconstruyó”. La frase, dicha con honestidad, casi siempre es falsa en un sentido preciso —algo cambió—, solo que ese algo no era lo que tú considerabas una entrada. Hay dos familias de culpables, y conviene tenerlas separadas en la cabeza.

La primera es una entrada que cambió sin que la vieras como entrada: un archivo generado que cae dentro de tu glob de inputs, un .DS_Store o un archivo temporal del editor, un log que la propia tarea escribe en su directorio de fuentes, o un lockfile que una reinstalación reescribió aunque las versiones efectivas no cambiaran. La caché hace exactamente lo que le pediste; el problema es que le pediste vigilar más de lo que creías.

La segunda es más insidiosa: una entrada no determinista, que cambia en cada corrida aunque nadie la toque. Un timestamp incrustado, un identificador de corrida de CI que se cuela en las variables declaradas, un hostname, un orden de ficheros que depende del sistema de archivos, o la salida no determinista de una tarea río arriba que envenena tu hash aguas abajo. Con una entrada así, el acierto es imposible por construcción: la clave es distinta cada vez.

🧹

Archivo colado

Un generado, un temporal del editor o un log dentro de tu glob de inputs entra al hash sin que lo pensaras como fuente.

🔒

Lockfile inestable

Una reinstalación reescribe el lockfile aunque las versiones efectivas no cambien, y el hash de dependencias se mueve con él.

⏱️

No determinismo

Un timestamp, un id de corrida o un hostname declarado como env cambia en cada ejecución y hace el acierto imposible.

🌊

Veneno río arriba

Una tarea de la que dependes produce salida no determinista, y su hash cambiante se propaga hasta invalidar el tuyo.

La moraleja operativa es que un miss inesperado nunca es ruido: es una señal de que tu declaración de entradas y la realidad discrepan. O declaraste de más —y metiste en el hash algo que no debía estar— o tienes una fuente de no determinismo que hay que domesticar. En ambos casos, el fallo te está diciendo algo verdadero sobre tu configuración.

El radio de invalidación

No todos los cambios cuestan lo mismo. Una virtud del árbol de Merkle es que la invalidación tiene un radio —un alcance— que depende de qué hoja se movió y de cuántas tareas cuelgan de ella. Cambiar un archivo que solo consume una tarea invalida esa tarea y nada más; cambiar algo de lo que depende medio monorepo invalida medio monorepo. Entender ese radio es lo que separa un cambio quirúrgico de uno que dispara una recompilación general sin que nadie lo esperara.

Conviene distinguir dos maneras en que una tarea se invalida. La invalidación propia ocurre cuando cambia una de sus entradas directas —su código, su config, una variable que declara—. La invalidación propagada ocurre cuando ninguna de sus entradas directas cambió, pero sí lo hizo el hash de una tarea río arriba, que entra en el suyo. La segunda es la que más despista, porque la tarea “no cambió” y aun así recomputa: lo que cambió fue algo de lo que depende, y el árbol se encargó de transmitirlo sin que nadie lo declarara a mano.

El caso extremo, y el que más sorprende, es el de las entradas globales: aquellas que la herramienta considera que afectan a todas las tareas por igual. Un cambio en el lockfile de la raíz, en la versión del gestor de paquetes o en ciertos archivos de configuración global entra en el hash de cada tarea, así que tocarlos invalida el mundo entero de una vez. Es correcto —esos cambios pueden alterar cualquier salida—, pero explica por qué a veces un cambio de una sola línea desencadena una reconstrucción total.

Qué cambias Qué invalida
un archivo de una sola tarea esa tarea
un archivo de una librería interna esa librería y todo lo que depende de ella
el lockfile o una dependencia global toda tarea que la incluya en su hash
una entrada global de la config el repositorio entero
un flag de invocación de una tarea esa invocación concreta de la tarea

La lectura estratégica de esta tabla es que la posición de un archivo en el grafo de dependencias determina el coste de tocarlo. Los archivos más compartidos —una utilidad de la que todo el mundo tira, un tipo base, la configuración raíz— son los más caros de cambiar, porque su radio de invalidación es enorme. No es una razón para no tocarlos, sino para saber, antes de hacerlo, que el rebuild será ancho, y para no confundir esa amplitud con un fallo de la caché.

💡
Mide el radio antes de tocar lo compartido

Antes de editar un archivo muy compartido, una corrida en seco que liste las tareas afectadas te dice exactamente el tamaño del rebuild que vas a provocar. Es la diferencia entre saber que tocas un cimiento y descubrirlo cuando CI tarda veinte minutos en algo que creías trivial. El radio de invalidación no es un efecto secundario a padecer: es información de diseño que puedes consultar antes de actuar.

Depurar un miss

El instrumento diagnóstico es siempre el mismo: hacer visible el desglose del hash y comparar dos corridas. La técnica de las dos corridas es tan simple como poderosa. Captura el desglose de una corrida A en la que esperabas un hit, captura el de la corrida B que te dio el miss, y compara ambos: la línea que difiere es, literalmente, la entrada culpable.

# Corrida en seco que imprime el hash y su desglose sin ejecutar la tarea.
# En Turborepo:
turbo run build --dry=json > corrida-a.json

# ...repite tras el cambio inexplicado...
turbo run build --dry=json > corrida-b.json

# La diferencia entre ambos desgloses aisla la entrada que movio la clave.
diff corrida-a.json corrida-b.json

Cada ecosistema ofrece su propia versión de esta lente. En Turborepo, el resumen de corrida y el --dry=json exponen los hashes globales y por tarea junto con los archivos, variables y dependencias que entraron. En Nx, el modo --verbose y la inspección de la entrada de caché revelan el mismo desglose. En Bazel, --explain junto con un log de ejecución te dice por qué una acción no fue un hit. La herramienta cambia; el método —dos corridas, un diff, la entrada que salta— no.

flowchart LR
A[Corrida A: esperabas hit] --> DA[Desglose del hash A]
B[Corrida B: obtuviste miss] --> DB[Desglose del hash B]
DA --> X[Diferencia de desgloses]
DB --> X
X --> C[La entrada culpable]
style C fill:#f38ba8,color:#11111b

Una vez identificada la entrada, viene la decisión de diseño, que es donde de verdad ejerces tu criterio. Si la entrada culpable es legítima —cambió el código, subió una versión—, el miss es correcto y no hay nada que arreglar: la caché hizo su trabajo. Si es ruido —un temporal, un timestamp, un log—, la respuesta es sacarla del hash: afinar el glob de inputs, quitar la variable inestable de la lista declarada o hacer determinista la fuente. Depurar un miss no es silenciar la caché; es reconciliar tu declaración de entradas con lo que la tarea de verdad consume.

El hit imposible, la depuración al revés

Existe un fallo simétrico y más peligroso: obtener un hit donde esperabas un miss. Tocaste algo, esperabas un rebuild y la caché te devolvió, tan campante, el resultado viejo. La técnica es la misma —dos corridas, un diff— pero la conclusión se invierte: si el desglose no cambió pese a que tú sí cambiaste algo, entonces eso que cambiaste no está en el hash, y ahí tienes una fuga de corrección, no de rendimiento. El instrumento diagnóstico no distingue entre los dos casos; lo que cambia es qué haces al encontrar al culpable. Ante un miss espurio, quitas la entrada; ante un hit espurio, la añades, porque descubriste una entrada real que la tarea consume y que tu declaración callaba.

⚠️
El fallo que no llega es peor que el que sobra

Un miss espurio es molesto pero honesto: gastas unos segundos de recompute que sobraban y lo notas enseguida. Su reverso —un hit que se produce cuando debería haber sido miss— es mucho más grave y casi invisible: sirve un artefacto construido con una entrada que cambió sin que la clave se enterara. Por eso, si una tarea no invalida nunca pese a que su mundo cambia, no lo celebres como rendimiento: sospecha que tiene una entrada sin declarar. La caché que jamás falla el acierto no es más rápida, es más peligrosa.

La invalidación es la derivada del hash, y un miss inexplicable es una confesión

Conviene decirlo sin rodeos, porque reordena la forma de mirar toda la caché: la invalidación no es un mecanismo separado que haya que aprender aparte del hashing, sino su derivada exacta. El hash es la función que declara qué determina la salida de una tarea; la invalidación es, simplemente, qué pasa cuando alguna de esas entradas se mueve. No hay dos teorías, hay una sola vista desde dos ángulos. De ahí que la pregunta correcta ante un miss desconcertante no sea nunca “por qué la caché es tonta”, sino “qué cambió que yo no estaba mirando”, porque la caché, por construcción, no puede inventarse un cambio: si recomputó, es que su cuenta de entradas difirió de la corrida anterior, y esa diferencia existe en algún sitio con total certeza. El desglose del hash es la confesión completa y firmada de esa cuenta, y la técnica de las dos corridas no es un truco de depuración sino el acto de leer esa confesión línea por línea. Quien interioriza esto deja de pelearse con la caché y empieza a usarla como instrumento de medida: cada miss inesperado se convierte en un experimento que revela una entrada que no sabías que tenías, y cada hit imposible en una alarma de que una entrada se te escapó de la cuenta. La madurez con las herramientas de build no consiste en que dejen de sorprenderte, sino en que, cuando lo hagan, sepas exactamente dónde mirar y qué esperar encontrar: una entrada que cambió y que tu teoría del hash, hasta ese momento, no contemplaba.

⚔️ Interroga un fallo hasta el fondo
  1. Haz una corrida en seco de una tarea y guarda el desglose completo de su hash en un archivo.
  2. Repite la corrida sin cambiar nada y confirma que el desglose es idéntico byte a byte: el determinismo es la condición del acierto.
  3. Provoca un miss legítimo tocando un archivo fuente y verifica en el diff de desgloses que solo cambió la rama de archivos.
  4. Provoca un miss espurio metiendo un archivo generado dentro de tu glob de inputs; localízalo en el diff y arréglalo afinando la declaración.
  5. Declara una variable inestable —un timestamp o un id de corrida— en env, observa que la tarea deja de acertar nunca, y explica por qué el acierto era imposible desde el momento en que la declaraste.