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:
serialized_rollback=Trueañadido a las seis marcastransaction=Truedetests/api/— hace que pytest-django serialice la BD de test tras las migraciones (sin esto no hay volcado que restaurar).- Hook
pytest_runtest_teardownnuevo entests/conftest.py: al terminar cualquier testtransaction=True, restaura ese volcado (deserialize_db_from_string) para todos los tests que sigan en el mismo worker — no solo para el siguiente transaccional. tests/test_architecture_fitness.py::TestSuiteIsolation(fitness test nuevo): falla el build si aparecedjango_db(transaction=True)sinserialized_rollback=Truefuera detests/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:randomlyy un--distfijo lo convierte en determinista. transaction=Truesinserialized_rollback=Truedinamita 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]]