Módulo 5: Rápido y en paralelo — caché y paralelismo

3. Cachear dependencias con `actions/cache`

Descripción

En la lección anterior ubicamos el primer sumidero de tiempo: instalar las dependencias en cada corrida, idénticas, desde cero, aunque requirements.txt no haya cambiado en semanas. El runner arranca limpio —sin tus librerías—, así que cada push ejecuta pip install completo: baja los paquetes de internet, los descomprime, los instala. Es el cocinero del buffet cortando las mismas verduras una y otra vez. Esta lección le pone remedio: cachear las dependencias para reutilizarlas mientras no cambien.

Al terminar vas a entender qué es un caché en el contexto de CI, cómo actions/cache guarda y restaura archivos entre corridas, y —el corazón de la técnica— por qué la clave del caché se deriva del hash de requirements.txt. Vas a ver cómo esa clave hace que el CI reutilice lo instalado cuando la lista es igual (cache hit, rápido) y reinstale todo cuando la lista cambia (cache miss, correcto), sin que tú tengas que decidir manualmente cuándo. Vas a leer el formato del log de una restauración exitosa y de una fallida, y vas a anclar todo eso a algo que puedes ver en tu propia terminal: el Using cached que pip imprime cuando reutiliza un paquete que ya bajó. La caché es la palanca más barata del módulo —casi siempre ahorra sin costarte nada— y es la primera que conviene encender.

Conexión con el módulo: esta lección resuelve el sumidero 1 que la lección 2 diagnosticó. Es contenido de YAML —actions/cache solo tiene sentido dentro de un runner de la nube, y aquí no lo tenemos—, pero anclado a una demo local real: el caché propio de pip. La lección 4 ataca el sumidero 2 con paralelismo, y esa sí se ejecuta de verdad. Juntas, caché y paralelismo, son las dos palancas del módulo. Una nota de frontera: la caché acelera cada celda de la matriz del módulo 4 (cada versión reinstala sus dependencias, así que cada una gana con su propio caché), pero la matriz en sí ya la montaste; aquí solo la hacemos más rápida.

La caja de "ingredientes ya preparados" del buffet

Volvamos al buffet de la lección 1, al cocinero que cortaba las mismas verduras cada media hora. Un cocinero listo hace algo evidente: la primera vez corta, lava y prepara las verduras, y guarda el resultado en una caja etiquetada en el refrigerador. La próxima vez que necesite verduras, no baja al almacén ni corta de nuevo: abre la caja y usa lo que ya preparó. Solo vuelve a cortar cuando el menú cambia y hacen falta ingredientes distintos.

Fíjate en la etiqueta de la caja, porque ahí está toda la inteligencia del sistema. La etiqueta dice, en efecto, "verduras para el menú del martes". Cuando el cocinero va a cocinar, mira el menú de hoy: si es el del martes, la etiqueta coincide, abre la caja y reutiliza. Si el menú cambió al del miércoles, la etiqueta ya no coincide —"esta caja es para otro menú"—, así que la ignora, corta ingredientes nuevos, y guarda una caja nueva con la etiqueta "miércoles". La etiqueta es lo que garantiza que nunca uses ingredientes preparados para un menú distinto del de hoy.

Un caché de CI es exactamente esa caja etiquetada. Los "ingredientes preparados" son tus dependencias ya descargadas. La "etiqueta" es la clave del caché (key). Y el "menú de hoy" es el contenido de requirements.txt: si tu lista de dependencias es la misma que la última vez, la clave coincide, y el CI restaura la caja en lugar de reinstalar. Si cambiaste una versión o agregaste una librería, la clave cambia, el CI ignora la caja vieja y reinstala desde cero —lo correcto, porque el "menú" cambió—.

Un caché de CI guarda un resultado costoso (las dependencias instaladas) bajo una clave. Si la clave de hoy coincide con la de una corrida anterior, el CI restaura el resultado en vez de recalcularlo. La clave se deriva de requirements.txt, así que reutiliza cuando la lista no cambió y reinstala cuando sí.

Anatomía del step de caché

Así se ve el step que cachea las dependencias de pip en un workflow de GitHub Actions. Es contenido —lo escribes y lo entiendes, aquí no corre un runner—, y va antes del step que instala:

- name: Cache pip dependencies
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Desmenucemos cada línea, porque cada una carga un pedazo de la idea:

uses: actions/cache@v4 — usa la acción oficial de caché de GitHub, pinneada a su versión mayor v4 (el mismo hábito de pinnear que aprendiste con checkout y setup-python). Esta acción sabe hacer dos cosas: al principio del job, restaurar una caja si la clave coincide; al final, guardar una caja nueva si no había.

path: ~/.cache/pipqué se guarda en la caja. ~/.cache/pip es el directorio donde pip guarda los paquetes que descarga en Linux (el runner por defecto). No cacheamos el entorno instalado entero, sino el caché de descargas de pip: los archivos .whl que pip baja de internet. Reutilizarlos convierte un "descargar de internet + instalar" en un "instalar desde disco", que es mucho más rápido porque se salta la parte de red.

key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} — la etiqueta de la caja, la pieza central. Se arma con tres partes unidas por guiones:

  • ${{ runner.os }} — el sistema operativo del runner (Linux, macOS, Windows). Va en la clave porque los paquetes de una plataforma no sirven en otra; no quieres restaurar wheels de Linux en un runner de Windows.
  • pip — un texto fijo que identifica qué caché es este (podrías tener otros: uno de npm, uno de datos). Es higiene de nombres.
  • ${{ hashFiles('**/requirements.txt') }}el hash del contenido de requirements.txt. Esta es la magia. hashFiles calcula una huella digital del archivo: una cadena larga que cambia si cambia cualquier byte del archivo, y que es idéntica si el archivo es idéntico. Mientras requirements.txt no cambie, este hash es el mismo, la clave completa es la misma, y el CI encuentra la caja. En cuanto edites el archivo —subas una versión, agregues una línea—, el hash cambia, la clave cambia, y el CI ya no encuentra esa caja (correctamente: el "menú" cambió).

restore-keys: — la red de seguridad. Si la clave exacta no existe (por ejemplo, cambiaste requirements.txt y es la primera corrida con la lista nueva), en vez de rendirse y bajar todo de internet, el CI busca la caja más parecida que empiece por ${{ runner.os }}-pip-. Restaura esa caja "casi buena" —tiene la mayoría de tus paquetes ya descargados— y pip solo baja lo que falta o cambió. Es un cache miss parcial: no aciertas la caja exacta, pero aprovechas una vieja como punto de partida en vez de empezar de cero.

Por qué la clave es el hash y no algo más simple

Podrías preguntarte: ¿por qué no usar una clave fija, como key: pip-cache, y ya? La respuesta es la razón de ser de toda la técnica, y vale la pena entenderla bien.

Una clave fija nunca cambia. Eso significa que la primera corrida guarda una caja con tus dependencias de ese día, y todas las corridas siguientes restauran esa misma caja para siempre —aunque cambies requirements.txt—. El día que subas pytest de 9.1.1 a 9.2.0, el CI seguiría restaurando la caja vieja (con 9.1.1 descargado) y podrías terminar probando contra la versión equivocada, o con una caja obsoleta que no tiene el paquete nuevo. La caché se volvería incorrecta: rápida, pero mintiendo.

Una clave derivada del hash del archivo resuelve esto de raíz: la clave está atada al contenido de requirements.txt. Misma lista → mismo hash → misma clave → reutiliza (rápido y correcto). Lista distinta → hash distinto → clave distinta → reinstala y guarda una caja nueva (un poco más lento esa vez, pero correcto). El hash es lo que hace que la caché sea automáticamente válida: se invalida sola, exactamente cuando debe, sin que tú te acuerdes de limpiarla. Nunca reutiliza ingredientes preparados para un menú que ya cambió.

Esa es la regla de oro de cualquier caché: la clave debe incluir todo lo que, si cambia, debería invalidar el resultado. Como lo único que decide qué se instala es requirements.txt, hashearlo es exactamente la clave correcta. Si tus dependencias vinieran de varios archivos (requirements.txt más requirements-dev.txt, digamos), los incluirías todos en el hash: hashFiles('**/requirements*.txt').

El mecanismo, en local: el Using cached de pip

Aquí conectamos el contenido con algo que ejecutas de verdad. actions/cache opera a escala de CI (guarda archivos en la nube entre corridas), pero el principio —"no bajes lo que ya tienes"— es el mismo que pip aplica en tu propia máquina con su caché local. Verlo funcionar en tu terminal hace el concepto tangible.

pip guarda cada paquete que descarga en un directorio de caché. En esta máquina:

pip cache dir
/Users/mikenieva/Library/Caches/pip

Cuando instalas un paquete que pip ya bajó antes, no lo vuelve a bajar de internet: lo toma de ahí. Instalé la suite de Reservo en un entorno virtual nuevo y limpio, con pip ya "caliente" (había instalado pytest antes), y esta es la salida real:

pip install -r requirements.txt

Qué esperar:

Collecting pytest==9.1.1 (from -r requirements.txt (line 1))
  Using cached pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
Collecting iniconfig>=1.0.1 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached iniconfig-2.3.0-py3-none-any.whl.metadata (2.5 kB)
Collecting packaging>=22 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached packaging-26.2-py3-none-any.whl.metadata (3.5 kB)
Collecting pluggy<2,>=1.5 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached pluggy-1.6.0-py3-none-any.whl.metadata (4.8 kB)
Collecting pygments>=2.7.2 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached pygments-2.20.0-py3-none-any.whl.metadata (2.5 kB)
Using cached pytest-9.1.1-py3-none-any.whl (386 kB)
Using cached pluggy-1.6.0-py3-none-any.whl (20 kB)
Using cached iniconfig-2.3.0-py3-none-any.whl (7.5 kB)
Using cached packaging-26.2-py3-none-any.whl (100 kB)
Using cached pygments-2.20.0-py3-none-any.whl (1.2 MB)
Installing collected packages: pygments, pluggy, packaging, iniconfig, pytest
Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1

Cada línea Using cached es pip diciéndote "este paquete ya lo tenía descargado, lo tomo del disco en lugar de bajarlo de internet". En una máquina totalmente fresca, sin nada en el caché, esas líneas dirían Downloading pytest-9.1.1-py3-none-any.whl (386 kB) con su barra de progreso, porque tendría que bajar cada archivo. La diferencia entre Downloading y Using cached es, en pequeño, exactamente lo que actions/cache hace en grande: la primera corrida baja todo (cache miss, ves Downloading), y las siguientes reutilizan (cache hit, ves Using cached). El caché de pip es local a tu máquina; actions/cache lleva ese mismo caché a la nube para que persista entre corridas de CI, que de otro modo empezarían siempre en frío.

Cómo se lee el log de la caché en CI

Como el step de caché es contenido, veamos el formato honesto de cómo se leería su log en un runner, para que lo reconozcas cuando montes un pipeline real. Hay dos escenarios.

Cache hit (la clave coincide: encontró la caja exacta). En el step Cache pip dependencies, al inicio del job, verías algo así:

Received 12582912 of 12582912 (100.0%), 12.0 MBs/sec
Cache Size: ~12 MB (12582912 B)
Cache restored successfully
Cache restored from key: Linux-pip-93c4ab5f175d24229e46ec70a8ecaeb6db8b36e3f54d8005808044c060acbc92

Léelo: restauró una caja de ~12 MB, y la línea clave es Cache restored from key: Linux-pip-93c4ab5f.... Ese hash largo es la huella de tu requirements.txt —la etiqueta de la caja—. Después de esto, el step de pip install corre en segundos, porque los paquetes ya están en ~/.cache/pip: pip solo los instala desde disco, no los baja.

Cache miss (la clave no existe: cambiaste requirements.txt, o es la primera corrida). Verías:

Cache not found for input keys: Linux-pip-a1b2c3d4e5f6..., Linux-pip-

No encontró ni la clave exacta ni ninguna de las restore-keys. En ese caso el step no restaura nada, pip install baja todo de internet (más lento esa vez), y al final del job un paso automático guarda la caja nueva:

Cache saved with key: Linux-pip-a1b2c3d4e5f6...

Guardada con la clave nueva. La próxima corrida, si requirements.txt no cambia, será un cache hit y volará. Ese es el ciclo completo: la primera corrida con una lista nueva paga el precio de bajar todo y guarda la caja; todas las siguientes con la misma lista la reutilizan.

Un atajo moderno: setup-python con cache: 'pip'

Vale mencionar que, para el caso común de cachear dependencias de pip, actions/setup-python trae la caché integrada, y es más corta de escribir:

- name: Set up Python
  uses: actions/setup-python@v5
  with:
    python-version: "3.14"
    cache: 'pip'

Esa línea cache: 'pip' hace por dentro casi lo mismo que el step de actions/cache que desglosamos: cachea el directorio de pip y usa el hash de requirements.txt como clave, sin que escribas el path, la key ni las restore-keys. Para la mayoría de los proyectos es la forma recomendada, por lo simple. Aprendimos primero la versión manual de actions/cache porque entender la clave del hash es lo que importa: cache: 'pip' es el atajo cómodo, pero solo confías en un atajo cuando entiendes qué automatiza. Y para cachear cosas que no son dependencias de pip (datos, artefactos compilados), actions/cache con su clave a mano sigue siendo la herramienta.

Errores comunes

Usar una clave fija que nunca invalida la caché. Qué pasa: alguien escribe key: pip-cache sin el hash, y la caché reutiliza para siempre la primera caja, aunque cambie requirements.txt. Un día sube una dependencia, el CI restaura la caja vieja sin el paquete nuevo, y los tests fallan de forma desconcertante —o peor, pasan contra la versión equivocada—. Por qué pasa: una clave fija se ve más simple y "funciona" al principio. Cómo detectarlo: si tu key no incluye hashFiles(...) de tu archivo de dependencias, tu caché no se invalida cuando debe. Cómo corregirlo: pon el hash en la clave (${{ hashFiles('**/requirements.txt') }}), para que la caja se ate al contenido de la lista y se renueve sola cuando cambie.

Poner el step de caché después del de instalar. Qué pasa: alguien coloca actions/cache después de pip install, y la restauración nunca ayuda porque ya se instaló todo bajando de internet. Por qué pasa: el orden de los steps importa y es fácil equivocarlo si uno piensa "cacheo lo que instalé". Cómo detectarlo: si el pip install sigue tardando lo mismo con caché que sin ella, revisa el orden. Cómo corregirlo: el step de caché va antes del de instalar —restaura la caja primero, para que pip install la aproveche—. (Con setup-python y cache: 'pip' esto se resuelve solo, porque el orden lo maneja la acción.)

Olvidar runner.os en la clave con una matriz de sistemas operativos. Qué pasa: en una matriz que corre en Linux, macOS y Windows, alguien usa una clave sin ${{ runner.os }}, y el CI intenta restaurar en Windows una caja de wheels compilados para Linux, que no sirven —o se pisan entre plataformas—. Por qué pasa: la caché se probó en un solo SO y el problema solo aparece al sumar la dimensión del SO (módulo 4). Cómo detectarlo: fallos de instalación raros que solo ocurren en una plataforma de la matriz, o cachés que "se corrompen" al cruzar sistemas. Cómo corregirlo: incluye ${{ runner.os }} al inicio de la clave, para que cada sistema operativo tenga su propia caja y nunca reutilice la de otro.

Ejercicios

Ejercicio 1 — Predice hit o miss. Para cada situación, di si el step de caché con clave ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} produciría un cache hit o un cache miss, y por qué en una frase. (a) Un segundo push que no toca requirements.txt. (b) Un push que sube pytest==9.1.1 a pytest==9.2.0 en requirements.txt. (c) El primer push del repositorio. (d) Un push que solo cambia un archivo .py de la app, sin tocar requirements.txt.

Ver solución
  • (a) Cache hit. requirements.txt no cambió, así que su hash es el mismo, la clave es la misma, y el CI encuentra la caja de la corrida anterior. Reutiliza: rápido.
  • (b) Cache miss. Cambiar pytest==9.1.1 por 9.2.0 cambia el contenido del archivo, así que el hash cambia y la clave también. No hay caja con esa clave nueva → miss → reinstala y guarda una caja nueva. Correcto: el "menú" cambió.
  • (c) Cache miss. En el primer push no existe ninguna caja todavía. Miss obligatorio; baja todo, y guarda la primera caja para las siguientes corridas.
  • (d) Cache hit. Cambiar un .py no toca requirements.txt, así que su hash es idéntico y la clave coincide. La caché de dependencias sigue válida —las dependencias no cambiaron, solo tu código—: hit.

La moraleja: la clave está atada solo a requirements.txt, así que solo los cambios en la lista de dependencias invalidan la caché. Cambiar tu código no la afecta, que es justo lo que quieres.

Ejercicio 2 — Corrige la clave rota. Un compañero tiene este step y se queja de que "la caché nunca se actualiza aunque cambie las dependencias". Encuentra el defecto y corrígelo.

- name: Cache pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-dependencies
Ver solución

El defecto es la clave fija: key: pip-dependencies no cambia nunca. La caché guarda la primera caja y la reutiliza para siempre, sin importar cuántas veces se edite requirements.txt —de ahí que "nunca se actualiza"—. Le falta el hash del archivo de dependencias en la clave.

Corregido:

- name: Cache pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Ahora la clave incluye ${{ hashFiles('**/requirements.txt') }}: cambia el archivo → cambia el hash → cambia la clave → cache miss → reinstala y guarda una caja nueva. También le agregamos ${{ runner.os }} (por si algún día hay matriz de SO) y restore-keys como red de seguridad para aprovechar una caja parcial cuando la exacta no exista. La caché ahora se invalida sola, exactamente cuando las dependencias cambian.

Ejercicio 3 — Traduce el Using cached local al log de CI. En tu terminal, pip install -r requirements.txt imprimió Using cached pytest-9.1.1-py3-none-any.whl (386 kB) para las cinco dependencias. Explica qué escenario de CI (cache hit o cache miss) se parece a esto, qué línea del log de CI sería su equivalente, y qué habrías visto en tu terminal si el escenario fuera el contrario.

Ver solución

Using cached en tu terminal se parece a un cache hit en CI: pip encontró los paquetes ya descargados en su caché local y los reutilizó en vez de bajarlos, igual que actions/cache restauraría la caja de dependencias en vez de bajarlas de internet. Su equivalente en el log de CI sería la línea Cache restored from key: Linux-pip-93c4ab5f... (más el Cache restored successfully): la caja se restauró, y por eso el pip install que sigue no tiene que bajar nada.

Si el escenario fuera el contrario —un cache miss, la máquina totalmente en frío—, tu terminal habría mostrado Downloading pytest-9.1.1-py3-none-any.whl (386 kB) con una barra de progreso, en lugar de Using cached, porque tendría que bajar cada archivo de internet. Y en el log de CI habrías visto Cache not found for input keys: ... al inicio, y Cache saved with key: ... al final, cuando guarda la caja nueva para la próxima vez.

La lección: Using cached (local) y Cache restored from key (CI) son la misma idea a dos escalas —reutilizar lo ya descargado—; Downloading (local) y Cache not found (CI), también.

Resumen y siguiente paso

En esta lección encendiste la primera palanca del módulo: cachear las dependencias para no reinstalarlas idénticas en cada corrida. Lo viste con la caja de ingredientes etiquetada del buffet: preparar las verduras una vez, guardarlas con una etiqueta, y reutilizarlas mientras el menú no cambie. Desglosaste el step de actions/cachepath (qué se guarda: el caché de pip), key (la etiqueta) y restore-keys (la red de seguridad)— y entendiste el corazón de la técnica: la clave se deriva de ${{ hashFiles('**/requirements.txt') }}, de modo que reutiliza cuando la lista es igual (cache hit) y reinstala cuando cambia (cache miss), invalidándose sola exactamente cuando debe. Una clave fija sería rápida pero mentiría; el hash la hace automáticamente válida.

Anclaste todo a una demo local real —el Using cached de pip, el mismo principio a pequeña escala— y aprendiste a leer el formato del log de CI: Cache restored from key: ... en un hit, Cache not found seguido de Cache saved with key: ... en un miss. Y viste el atajo moderno, setup-python con cache: 'pip', que automatiza la versión manual una vez que entiendes qué automatiza.

Antes de avanzar deberías poder: explicar qué es un caché de CI y por qué su clave se deriva del hash de requirements.txt; escribir un step de actions/cache correcto y decir por qué va antes del pip install; predecir hit o miss según qué cambió; y conectar el Using cached de pip con el Cache restored from key del CI.

Lo que sigue, en la lección 4, es la segunda palanca, y esta se ejecuta de verdad: pytest-xdist con -n auto. Vas a repartir la suite de Reservo entre los núcleos de tu procesador y ver el speedup real con tus propios ojos —de 6 segundos a poco más de uno—, leer el header nuevo de pytest (12 workers [23 items]), y entender cómo -n auto elige cuántos procesos arrancar. Cacheaste para no repetir; ahora vas a paralelizar para no correr en fila.

Recursos