Saltar al contenido

Políticas de cache en GitLab CI: pull, push y fallback keys

Un cache que funciona todavía pierde tiempo de tres formas: cada job lo vuelve a subir, un bump del lockfile arranca de cero, y los merge requests nunca ven lo que main dejó caliente. Políticas de cache, fallback keys y el cache partido de las ramas protegidas.

3 min de lectura
  • gitlab
  • ci-cd
  • devops

La parte 1 terminó con un cache de dependencias basado en el lockfile y verificado contra logs reales. Ese cache funciona, y aun así sigue perdiendo tiempo de tres formas distintas. Míralo correr unas cuantas veces: cada job vuelve a subir un store que no cambió, un bump de una línea en el lockfile provoca una instalación completa desde cero, y los merge requests corren fríos aunque main acabe de calentar todo.

Tres problemas, tres mecanismos. Los tres son baratos de arreglar una vez que sabes que existen, y el tercero es invisible hasta que sabes dónde mirar.

No todos los jobs deberían subir el cache

El comportamiento por defecto del cache se llama pull-push y hace dos cosas en cada job: descarga el cache al inicio y lo vuelve a archivar y subir al final. Para el job que instala dependencias tiene sentido. Para lint y typecheck es puro desperdicio: no tocaron el store de dependencias y aun así gastan tiempo comprimiéndolo y subiéndolo en cada corrida.

Con cache:policy se reparten los roles. Un solo job escribe el cache y los demás únicamente leen:

install-deps:
  stage: setup
  script: pnpm install --frozen-lockfile
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
    policy: pull-push
 
lint:
  stage: quality
  script: pnpm lint
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
    policy: pull

Un job con policy: pull se salta por completo el paso de archivar y subir. En un archivo real ese bloque repetido iría en un hidden job con extends, como vimos en la parte 1; aquí está escrito dos veces para que se vean las dos políticas lado a lado.

Existe un tercer valor, push, para jobs que generan cache pero nunca necesitan leerlo. El caso típico es un job nocturno que calienta el cache desde cero.

Cuando la key no existe: fallback keys

Basar la key del cache en el lockfile tiene un riesgo: apenas alguien toca pnpm-lock.yaml, la key cambia y el siguiente job arranca con el cache completamente frío. Un bump de una sola dependencia termina pagando la instalación completa.

fallback_keys suaviza ese golpe. Si la key exacta no existe, GitLab prueba las alternativas en orden:

cache:
  key:
    files:
      - pnpm-lock.yaml
  fallback_keys:
    - pnpm-store-main
  paths:
    - .pnpm-store

Si además tienes un job en la rama principal que escribe esa key estable (uno con policy: push queda perfecto), un cambio de lockfile ya no instala desde cero: restaura el store de ayer e instala solo la diferencia. También existe una variante global, la variable CACHE_FALLBACK_KEY, que se prueba al final. El orden completo de búsqueda es: la key exacta, luego cada entrada de fallback_keys, y por último CACHE_FALLBACK_KEY.

El cache partido en dos del que nadie te avisa

Esta nos tocó vivirla. Teníamos el cache caliente en main y frío en todos los merge requests, con keys idénticas en el YAML. La pista terminó apareciendo en los logs: los jobs de main usaban pnpm-store-...-protected y los de los MRs pnpm-store-...-non_protected.

Resulta que GitLab mantiene dos caches por cada key. Los pipelines de ramas y tags protegidos reciben el sufijo -protected en todas sus keys; el resto recibe non_protected. La razón es válida (sin esa separación, cualquiera que pueda abrir un MR podría envenenar un cache que después consumen los builds protegidos de release), pero la consecuencia práctica es que calentar el cache en main no le sirve de nada a las ramas de feature, y el sufijo solo se ve si lees el log del runner con lupa.

Si ese aislamiento no te aporta nada (en nuestro caso el cache era un store de pnpm, determinado por completo por el lockfile), se puede desactivar por proyecto: Settings → CI/CD → General pipelines → desmarca «Use separate caches for protected branches». Tanto el comportamiento como el checkbox están en la documentación de caching, y la discusión original vive en el issue #360910.

Con qué te quedas

El cache ahora tiene un solo escritor y muchos lectores, un fallback que absorbe los bumps del lockfile, y cero sorpresas silenciosas entre ramas protegidas y normales. Mejor que eso, un cache de dependencias no se pone.

Lo que este post no toca es la otra repetición del pipeline: la app que se compila en build-app se sigue compilando de nuevo en cada job que necesita el resultado. La tentación es meter dist/ también al cache. Es la herramienta equivocada, y falla de una forma más traicionera que una instalación fría — de la herramienta correcta, los artifacts, se trata la parte 3.

Referencias