CreaRack-SL

Carrera de pytest-xdist: transaction=True vaciaba los SaaSModule sembrados y disparaba falsos 403

Cuándo

Detectado y cerrado el 28-08-2026 (v1.86.19, PR #468, commit 112918e5). Era intermitente desde hacía semanas: los primeros síntomas aislados aparecieron en la versión 1.66.19 (14-08-2026, que ya apartó tests/test_rls.py del pool paralelo) y en el PR #452 (28-08), pero la causa raíz común no se había identificado hasta este commit.

Síntomas visibles

En el CI y en el gate pre-push, un puñado de tests fallaba “al azar” con 403 "Module '...' is not available on your current plan" — un rechazo del middleware de [[concept—saas—module-gating]] que en teoría no debería dispararse en ese test. Repetir el run sin tocar nada solía dejarlo en verde. La víctima cambiaba según el reparto --dist=loadfile de pytest-xdist y el orden de pytest-randomly: 1 fichero en el PR #452, 5 ficheros en la 1.66.19, 29 tests en otra sesión.

Causa raíz

Los tests marcados @pytest.mark.django_db(transaction=True) (tests/api/test_agent_status_metadata.py, test_insight_episode_dedup.py, test_invitation_signup.py, test_terminal_ws_agent_auth.py) hacen FLUSH de todas las tablas al terminar — incluidos los registros de [[entity—core—model—saasmodule]] que siembra la data-migration core/0013. Django solo repone ese volcado al arrancar el siguiente test transaccional (con serialized_rollback=True), nunca para los tests normales que corren después en el mismo worker de xdist. El siguiente test que dependiera del module-gating se encontraba la tabla vacía y recibía el 403.

Reproducido de forma determinista con pytest tests/api/test_agent_status_metadata.py tests/monitoring/test_deuda_274_tanda_c.py -n 1 --dist=loadfile -p no:randomly (3 fallos) y una sonda que contaba los módulos tras el flush: 0.

Es la misma familia de fallo que [[incident—20260804—cache-test-workers-interference]] (aislamiento roto entre workers de pytest-xdist), pero con una causa distinta: aquel era caché Redis compartida entre workers; este es una tabla de base de datos vaciada por un test transaccional y no repuesta.

Fix aplicado

Commit 112918e5 (PR #468), tres piezas, las tres necesarias:

  1. serialized_rollback=True añadido a las seis marcas transaction=True de tests/api/ — hace que pytest-django serialice la BD de test tras las migraciones (sin esto no hay volcado que restaurar).
  2. Hook pytest_runtest_teardown nuevo en tests/conftest.py: al terminar cualquier test transaction=True, restaura ese volcado (deserialize_db_from_string) para todos los tests que sigan en el mismo worker — no solo para el siguiente transaccional.
  3. tests/test_architecture_fitness.py::TestSuiteIsolation (fitness test nuevo): falla el build si aparece django_db(transaction=True) sin serialized_rollback=True fuera de tests/test_rls.py (que corre aparte, en pasada secuencial, desde la 1.66.19).

Tras el fix, la misma sonda cuenta 8 módulos tras el flush y los 10 tests afectados pasan.

Lecciones

  • Un síntoma “aleatorio” en CI casi nunca es aleatorio: depende de un reparto determinista (orden de ficheros/tests) que solo parece azar porque cambia de ejecución en ejecución. Reproducirlo con -p no:randomly y un --dist fijo lo convierte en determinista.
  • transaction=True sin serialized_rollback=True dinamita cualquier dato sembrado por data-migration para el resto del worker — no solo para el propio test.
  • Un fitness test (TestSuiteIsolation) es más barato que confiar en que cada PR futuro recuerde añadir el flag a mano.

Preventivos futuros

El fitness test nuevo (tests/test_architecture_fitness.py::TestSuiteIsolation) bloquea el patrón hacia adelante: cualquier django_db(transaction=True) nuevo sin serialized_rollback=True (fuera de test_rls.py) rompe el CI antes de mergear.

Véase también

  • [[incident—20260804—cache-test-workers-interference]]
  • [[concept—saas—module-gating]]
  • [[decision—20260401—module-gating]]
  • [[entity—core—model—saasmodule]]