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

8. Mini-proyecto: un CI rápido para Reservo

Descripción

Este es el capstone del módulo. En las siete lecciones anteriores fuiste juntando piezas —por qué la velocidad importa, cachear dependencias, paralelizar con pytest-xdist, dividir la suite, aislar los tests, y el trade-off de cuánto paralelizar—. Ahora las usas todas juntas, tú, de principio a fin, para producir algo entregable: el CI de Reservo, acelerado, con la prueba de que va más rápido y de que sigue siendo correcto. No vas a aprender un concepto nuevo; vas a demostrar que sabes hacer un pipeline rápido sin romperlo.

El entregable tiene cuatro partes, y las construimos juntas: (1) el workflow completo con actions/cache y pytest -n auto, escrito y explicado decisión por decisión; (2) la paridad local que mide el speedup real —corres en tu máquina, de verdad, la suite en serie y en paralelo, y pegas los tiempos para comprobar la aceleración—; (3) el diagnóstico y arreglo del test que se rompía en paralelo —de Calendar compartido a fixture, verificado corriendo antes y después—; y (4) una nota de trade-off y alcance que justifique tus decisiones de velocidad y costo. Esa última parte es tan importante como el YAML: un CI rápido que no sabes por qué es rápido, o cuánto cuesta, es una caja negra.

Conexión con el módulo: esta lección no introduce nada; integra. Cada decisión que tomes aquí —dónde va el step de caché, qué clave usa, -n auto o un número fijo, cómo aislar el test roto— viene de una lección anterior, y la idea es que las apliques sin que te las recuerden. Es también el puente al resto de la guía: la nota de alcance apunta al módulo 6 (las puertas de cobertura, que exigen un umbral que rompe el build) y al módulo 7 (los flaky). Aquí haces el pipeline rápido; esos módulos lo hacen más exigente y más estable.

El examen práctico, otra vez al volante

Como en el mini-proyecto del módulo 2, esto es el examen práctico de manejo, no el escrito. Las lecciones 1 a 7 te tomaron cada pieza por separado: cachear aquí, paralelizar allá, aislar en la otra. Este mini-proyecto te sube al carro y te pide manejar de verdad, tomando tú las decisiones que antes te venían dadas. ¿Enciendo la caché? (Sí, casi siempre paga.) ¿-n auto o -n 4? (Depende del runner.) ¿Cómo arreglo el test que falla en paralelo? (Aislándolo, no apagando xdist.) Nadie te lo dice; lo decides con lo que aprendiste. El objetivo no es la perfección teórica sino la competencia real: al final, un CI de Reservo que corre más rápido, que sigue dando passed en todos los tests, y que —comprobado con la paridad local— hace exactamente lo que dice.

El proyecto que vas a acelerar

Recordemos qué tiene Reservo ahora, porque el workflow se construye alrededor de su estructura. Es un proyecto de Python puro (lógica de reservas, dinero en centavos int) con su código en un paquete reservo/ y su suite repartida así:

reservo/                    ← el codigo del dominio
├── models.py               (Room, Member, Booking con price_cents)
├── pricing.py              (price_cents)
├── refunds.py              (refund_cents)
├── calendar.py             (Calendar)
└── schedule.py             (overlaps, is_available, book, cancel)
tests/
├── test_pricing.py         ← 4 tests rapidos de precios
├── test_refunds.py         ← 3 tests rapidos de reembolso
├── test_availability.py    ← 4 tests rapidos de disponibilidad
└── test_reports.py         ← 12 tests LENTOS del reporte mensual (@pytest.mark.slow)
requirements.txt            ← la lista de dependencias
pyproject.toml              ← config de pytest (pythonpath, testpaths, marcador slow)

Su requirements.txt ahora tiene dos líneas —pytest para probar, y xdist para paralelizar—:

# requirements.txt
pytest==9.1.1
pytest-xdist

Y su pyproject.toml registra el marcador slow que usamos para dividir la suite:

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
markers = [
    "slow: marks tests that take a noticeable amount of time (deselect with '-m \"not slow\"')",
]

Tu trabajo es acelerar el CI que corre estos 23 tests, sin perder ninguno.

Paso 1: escribe el workflow rápido

Crea (o amplía) .github/workflows/tests.yml. Este es el del módulo 2 —checkout, setup-python, install, pytest— con las dos palancas del módulo encendidas: la caché de pip y el paralelismo. Léelo entero; después lo desglosamos:

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v5

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

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

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run the test suite in parallel
        run: pytest -n auto

Repasa las decisiones nuevas respecto al workflow del módulo 2, todas de este módulo:

  • El step Cache pip dependencies (lección 3) va antes de instalar, para que pip install aproveche la caja restaurada. Su clave, ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}, ata la caché al contenido de requirements.txt: reutiliza mientras la lista no cambie (cache hit, rápido) y reinstala cuando cambie (cache miss, correcto). El restore-keys es la red de seguridad para aprovechar una caja parcial. Encendimos la caché sin dudar porque, como vimos en la lección 7, casi siempre paga y su costo es casi nulo.
  • El step Run the test suite in parallel (lección 4) corre pytest -n auto en vez de pytest a secas: reparte los 23 tests entre los núcleos del runner. Como los runners tienen varios núcleos y no sabemos cuántos de antemano, -n auto los exprime sin que claves un número.

Un detalle de honestidad que vale la pena elegir a conciencia: aquí pusimos un solo job con -n auto, que es lo más simple y suficiente para Reservo. Si quisieras el feedback en capas de la lección 5, partirías en dos steps —pytest -m "not slow" para el veredicto rápido y pytest -m slow -n auto para la parte cara—. Para una suite de 23 tests, un job con -n auto alcanza; la división en dos velocidades brilla cuando la suite crece. Nombrar esa decisión —"elegí un job porque la suite es chica"— es parte del entregable.

Recuerda la regla del módulo: este archivo es contenido que escribiste y entiendes; no vamos a levantar un runner. Lo que sí vamos a hacer —y es la parte que corre de verdad— es medir en local el speedup que este workflow producirá.

Paso 2: la paridad local que mide el speedup (esto corre de verdad)

La idea que sostiene el módulo: lo que el CI le hace a tu suite es lo mismo que le hace tu máquina. Aquí lo comprobamos midiendo el speedup con tus propias manos. Cada comando de abajo se ejecutó de verdad con Python 3.14.0 y pytest 9.1.1; los tiempos son reales.

Primero, la suite completa en serie, como corría antes de este módulo:

python -m pytest

Qué esperar (salida real):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
collected 23 items

tests/test_availability.py ....                                          [ 17%]
tests/test_pricing.py ....                                               [ 34%]
tests/test_refunds.py ...                                                [ 47%]
tests/test_reports.py ............                                       [100%]

============================== 23 passed in 6.10s ==============================

23 passed in 6.10s. Ahora la misma suite con la palanca del workflow, -n auto:

python -m pytest -n auto

Qué esperar (salida real):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
created: 12/12 workers
12 workers [23 items]

........................                                                 [100%]
============================== 23 passed in 1.15s ==============================

23 passed in 1.15s. Ahí está la paridad y el speedup de un vistazo: los mismos 23 tests, todos verdes, de 6.10 s a 1.15 s —unas cinco veces más rápido— con solo agregar -n auto. La única diferencia entre esta corrida y la del runner sería la línea platform (darwin en tu Mac, linux en el runner) y el número de workers (12 aquí, los que el runner tenga allá). El speedup es la evidencia directa de que tu workflow acelerará: acabas de correr, con tus manos, lo que el CI correrá solo. Y algo crucial: el conteo no bajó. 23 passed, no 18 passed con cinco borrados. Aceleraste sin perder cobertura, que era la meta.

Paso 3: diagnostica y arregla el test que se rompe en paralelo

Encender -n auto tiene un requisito, y este paso lo pone a prueba. Reservo tiene, en un rincón, tres tests que comparten un Calendar a nivel de módulo —el anti-patrón de la lección 6—. En serie pasan; en paralelo se rompen. Un CI que corre con -n auto los expondría, así que hay que arreglarlos antes de confiar en el pipeline rápido.

Diagnóstico: reproduce el fallo. Primero confirma que el problema existe. En serie:

python -m pytest isolation_demo/test_shared_calendar.py
============================== 3 passed in 0.01s ===============================

Verde. Ahora en paralelo, como haría el CI con -n auto:

python -m pytest isolation_demo/test_shared_calendar.py -n 3

Qué esperar (salida real, recortada):

created: 3/3 workers
3 workers [3 items]

.FF                                                                      [100%]
...
=========================== short test summary info ============================
FAILED isolation_demo/test_shared_calendar.py::test_third_booking_is_recorded - AssertionError: assert 1 == 3
FAILED isolation_demo/test_shared_calendar.py::test_second_booking_is_recorded - AssertionError: assert 1 == 2
========================= 2 failed, 1 passed in 0.28s ==========================

Ahí está el diagnóstico: 2 failed bajo -n 3, con assert 1 == 2 y assert 1 == 3. El Calendar compartido no viaja entre workers (cada uno es un proceso con su propia memoria), así que los tests que dependían del estado dejado por otro encuentran solo su propia reserva. No es un flaky —falla siempre que paralelizas—; es falta de aislamiento.

Arreglo: dale a cada test su propio mundo. Reemplaza el shared_cal a nivel de módulo por una fixture que crea un Calendar fresco por test, y haz que cada test construya el estado que necesita:

# isolation_demo/test_shared_calendar.py (arreglado)
from datetime import datetime, timedelta

import pytest

from reservo.calendar import Calendar
from reservo.models import Room, Member
from reservo.schedule import book

ROOM = Room(id="r1", name="Focus", capacity=4, hourly_cents=2500)
PRO = Member(id="m2", name="Ben", tier="pro")
DAY = datetime(2026, 8, 1, 9, 0)


@pytest.fixture
def cal():
    # Un Calendar nuevo y vacio para CADA test — sin estado compartido.
    return Calendar()


def test_one_booking_is_recorded(cal):
    book(cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
    assert len(cal.all_bookings()) == 1


def test_two_bookings_are_recorded(cal):
    book(cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
    book(cal, ROOM, PRO, DAY + timedelta(hours=1), DAY + timedelta(hours=2), hours=1)
    assert len(cal.all_bookings()) == 2


def test_three_bookings_are_recorded(cal):
    for i in range(3):
        book(cal, ROOM, PRO, DAY + timedelta(hours=i), DAY + timedelta(hours=i + 1), hours=1)
    assert len(cal.all_bookings()) == 3

Verifica el arreglo: corre en paralelo otra vez.

python -m pytest isolation_demo/test_isolated_calendar.py -n 3

Qué esperar (salida real):

created: 3/3 workers
3 workers [3 items]

...                                                                      [100%]
============================== 3 passed in 0.24s ===============================

3 passed bajo -n 3. Arreglado. Ya no importa en qué worker caiga cada test: cada uno crea su propio Calendar y verifica lo que él mismo construyó. Quitaste la dependencia de estado compartido, y con ella el fallo en paralelo. Ahora el CI puede correr -n auto con confianza, porque toda la suite está aislada. Fíjate en la disciplina del arreglo: no apagaste el paralelismo para esconder el problema —eso habría renunciado al speedup—; aislaste el test, que además lo hace más robusto para todo lo demás.

Paso 4: la nota de trade-off y alcance

El último entregable no es código: es una nota corta que justifica tus decisiones de velocidad y costo, y dice qué hace el CI rápido y qué queda para después. Saber por qué tu CI es rápido —y cuánto cuesta— es parte del oficio. Una nota de ejemplo:

Alcance y trade-off de tests.yml (rápido). El CI corre los 23 tests de Reservo en cada push y pull request, con dos aceleraciones: caché de pip (actions/cache con clave del hash de requirements.txt) para no reinstalar dependencias que no cambiaron, y pytest -n auto para repartir los tests entre los núcleos del runner. Medido en local, esto baja la suite de 6.10 s a 1.15 s (~5×) sin perder ningún test. Decisión de costo: encendí la caché sin dudar (barata, casi siempre paga) y usé -n auto porque el runner es dedicado; en un runner pagado por núcleo, la curva medida sugiere que -n 4 capturaría la mayor parte del speedup (3.3×) a un tercio del costo. Requisito cumplido: aislé el test del Calendar compartido (de global a fixture) para que -n auto no lo rompa; toda la suite pasa en serie y en paralelo. Elegí un solo job con -n auto porque la suite es chica; si crece, la dividiría en un job rápido (-m "not slow") y uno lento (-m slow -n auto). Queda para módulos siguientes: exigir un umbral de cobertura que rompa el build (módulo 6) y manejar los flaky en CI (módulo 7).

Fíjate en lo que hace esa nota: no solo dice qué aceleró, sino por qué con esos números (el 5× medido), cuánto cuesta (la decisión -n auto vs -n 4 según el runner), qué requisito hubo que cumplir (el aislamiento), y qué falta. Eso es honestidad de ingeniería. Un CI que se presenta como "rápido" sin decir a qué costo, o que esconde que un test estaba mal aislado, engaña; uno que dice "va 5× más rápido, encendí la caché porque es gratis, usé -n auto porque el runner es dedicado, y aislé el test que lo rompía" es confiable y deja claro el mapa.

Errores comunes

Encender -n auto sin aislar primero, y culpar a xdist del rojo. Qué pasa: alguien agrega -n auto al workflow, el CI se pone rojo por el test del Calendar compartido, y concluye "el paralelismo rompe mi suite, lo quito". Por qué pasa: el fallo aparece al paralelizar, así que es tentador culpar a la herramienta. Cómo detectarlo: si el rojo es determinista (siempre que corres en paralelo, con assert 1 == 2), es aislamiento, no xdist. Cómo corregirlo: aísla el test (paso 3) en vez de apagar -n auto; el paralelismo no rompió nada, expuso un test que ya estaba mal.

Entregar el workflow sin medir el speedup en local. Qué pasa: alguien escribe el YAML con caché y -n auto, lo sube, y confía en que "seguramente acelera" sin haberlo comprobado. Por qué pasa: el YAML "se ve rápido". Cómo detectarlo: si no corriste pytest y pytest -n auto en tu terminal y comparaste los tiempos, no sabes cuánto acelera —ni si acelera—. Cómo corregirlo: haz la paridad local del paso 2, con los dos tiempos pegados; para una suite ya rápida, -n auto podría no ayudar, y solo midiendo lo sabes.

Escribir la nota de alcance sin la parte del costo. Qué pasa: alguien documenta "el CI usa caché y -n auto" pero no dice por qué -n auto y no -n 4, ni cuánto cuesta. Por qué pasa: la velocidad es visible y se presume buena; el costo es invisible hasta que llega la factura. Cómo detectarlo: si tu nota no menciona el trade-off de recursos (workers vs costo, la curva), le falta la mitad. Cómo corregirlo: incluye la decisión de costo explícita —"-n auto porque el runner es dedicado; -n 4 si se pagara por núcleo"—, que es justo lo que la lección 7 te enseñó a razonar.

Ejercicios

Ejercicio 1 — Detecta los tres defectos. Un compañero te pasa este tests.yml "que debería ser rápido pero no lo es y a veces falla". Tiene tres problemas de lo que aprendiste en el módulo. Encuéntralos y corrígelos.

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: pip install -r requirements.txt
      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-cache
      - run: pytest -n auto
Ver solución

Los tres defectos:

  1. El step de caché va después de pip install. Así la restauración nunca ayuda: para cuando la caché se restaura, ya se instaló todo bajando de internet. El step de actions/cache debe ir antes del pip install.
  2. La clave de la caché es fija (key: pip-cache), sin el hash de requirements.txt. Nunca se invalida: reutiliza la primera caja para siempre, aunque cambien las dependencias. Debe ser ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}.
  3. -n auto sin garantizar aislamiento ("a veces falla"): si la suite tiene tests con estado compartido, -n auto los rompe. Hay que aislarlos (fixture con estado fresco por test) antes de confiar en el paralelismo. (También falta pytest-xdist en requirements.txt, sin el cual -n auto daría unrecognized arguments; cuenta como parte de este defecto.)

Corregido:

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-
      - run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - run: pytest -n auto

Más el aislamiento de cualquier test con estado compartido, y pytest-xdist en requirements.txt. El orden (caché antes de instalar) y la clave (con hash) son los dos defectos de caché; el paralelismo sin aislamiento es el tercero.

Ejercicio 2 — Justifica la paridad del speedup. Un compañero dice: "Ya sé que -n auto acelera, ¿para qué mido serial vs paralelo en local si de todas formas lo pongo en el CI?" Respóndele con lo que la paridad del paso 2 te da que confiar a ciegas no te da.

Ver solución

Medir serial vs -n auto en local te da evidencia concreta de cuánto acelera —o si acelera—, en vez de una suposición. Tres razones:

  • Confirma que hay speedup de verdad. -n auto acelera cuando hay bastante trabajo que repartir. Para una suite ya rápida (0.2 s), arrancar workers cuesta más de lo que ahorra, y -n auto la haría más lenta. Solo midiendo los dos tiempos (6.10 s vs 1.15 s en Reservo) sabes que la tuya cae en el caso que se beneficia.
  • Te da el número para la nota de alcance. "Baja de 6.10 s a 1.15 s (~5×)" es un dato que documentas y que justifica la decisión; "seguramente acelera" no se puede escribir en una nota de ingeniería seria.
  • Expone problemas de aislamiento antes del CI. Correr -n auto en local es también el detector de tests mal aislados. Si un test se rompe en paralelo, lo descubres en tu terminal en segundos, no en el CI rojo después de subir. La paridad mueve el descubrimiento al lugar más barato.

En una frase: la paridad convierte "creo que acelera" en "acelera 5×, medido, y la suite sigue aislada". No es trabajo doble; es la diferencia entre confiar y saber.

Ejercicio 3 — Reescribe la nota para un runner pagado. La nota de alcance del paso 4 usa -n auto porque el runner es dedicado. Reescríbela suponiendo que ahora el CI corre en runners que se pagan por núcleo y el presupuesto es ajustado. Usa la curva medida de la lección 7 para justificar el cambio.

Ver solución

Una nota razonable para un runner pagado por núcleo:

Alcance y trade-off de tests.yml (rápido, presupuesto ajustado). El CI corre los 23 tests de Reservo en cada push y pull request, con caché de pip (clave del hash de requirements.txt) y paralelismo. Decisión de costo: como los runners se pagan por núcleo, no uso -n auto (que abriría todos los núcleos) sino pytest -n 4. La curva medida en la lección 7 muestra que 4 workers capturan un 3.3× del speedup (de 6.11 s a 1.83 s en los tests lentos), mientras que subir a 12 workers solo llega a 5.2× (1.17 s) —el triple de recursos por un 60% más de velocidad—. A un tercio del costo, -n 4 es el codo de la curva: casi toda la aceleración, mucho menos gasto. La caché la mantengo encendida porque su costo es casi nulo y casi siempre paga. Requisito cumplido: toda la suite está aislada (fixtures con estado fresco), así que -n 4 no rompe nada. Queda para módulos siguientes: puertas de cobertura (módulo 6) y flaky (módulo 7).

El cambio central: -n auto-n 4, justificado con la curva —4 workers son el punto donde el retorno se aplana, así que pagar por más núcleos daría poca velocidad extra por mucho costo—. La caché se queda igual (sigue siendo gratis). Esa es la lección 7 aplicada: en recursos pagados, se elige el codo de la curva, no el máximo.

Resumen y siguiente paso

En este mini-proyecto integraste el módulo entero produciendo un entregable real: el CI de Reservo, acelerado. Escribiste el workflow con las dos palancas —actions/cache con la clave del hash (antes del pip install) y pytest -n auto—, justificando cada decisión con la lección de la que viene. Estableciste la paridad local del speedup: corriste la suite en serie (23 passed in 6.10s) y en paralelo (23 passed in 1.15s), midiendo con tus manos el ~5× real y comprobando que el conteo no bajó —aceleraste sin perder cobertura—. Diagnosticaste y arreglaste el test que se rompía en paralelo: confirmaste el fallo bajo -n 3 (2 failed, assert 1 == 2), lo aislaste con una fixture, y verificaste que ahora pasa en paralelo (3 passed), sin apagar el paralelismo. Y escribiste la nota de trade-off y alcance, que documenta el speedup medido, la decisión de costo (-n auto vs -n 4 según el runner), el requisito de aislamiento cumplido, y lo que queda para después.

Con esto cierras el módulo 5. Mira todo lo que puedes hacer ahora que no podías al empezar: cachear dependencias con actions/cache y explicar por qué la clave es el hash de requirements.txt; paralelizar con pytest-xdist y medir el speedup real; dividir la suite en rápida y lenta con marcadores; aislar un test para que corra en paralelo sin romperse; y decidir cuánto paralelizar sopesando velocidad contra costo con la curva en la mano. Hiciste el CI de Reservo cinco veces más rápido —el pipeline que antes se ignoraba por lento ahora da veredicto en un parpadeo— sin borrar un solo test.

Lo que sigue es hacer el pipeline no solo rápido, sino exigente. Hasta ahora, tu CI se pone rojo si un test falla. Pero ¿qué pasa con el código que ningún test toca? Un cambio puede pasar todos los tests y aun así dejar una función entera sin probar, y el pipeline verde no te avisa. El módulo 6 se dedica a las puertas de calidad: un umbral de cobertura que rompe el build cuando el código probado cae por debajo de un mínimo (--cov-fail-under=N), fallar cuando la cobertura baja, y —con la misma honestidad de siempre— cuándo una puerta ayuda y cuándo el 100% se vuelve un fetiche que estorba. Ya sabes hacer el CI rápido; ahora vas a hacerlo exigente.

Recursos