Dado que el bug es no-determinista (~30%), el objetivo es crear un test que lo dispare de forma reproducible al 100%. Se evaluaron las opciones en orden de preferencia:
# tests/test_recommendations_cache.py — feedback loop principal import pytest, fakeredis, uuid from unittest.mock import patch, MagicMock from app.services.recommendations import get_recommendations @pytest.fixture def fake_redis(): return fakeredis.FakeRedis() def test_cache_hit_returns_non_empty_payload(fake_redis): """Reproduce la race condition: caché escribe antes de que el modelo termine.""" pet_id = str(uuid.uuid4()) # Simula el bug: clave existe en Redis pero con payload vacío fake_redis.setex(f"reco:{pet_id}", 300, b'{"recommendations":[]}') with patch("app.services.recommendations.redis_client", fake_redis): result = get_recommendations(pet_id) # Assertion: nunca debe devolver lista vacía si el perfil de la mascota existe assert result["recommendations"] != [], \ f"BUG: caché devolvió lista vacía para pet_id={pet_id}" # Resultado ANTES del fix: # FAILED tests/test_recommendations_cache.py::test_cache_hit_returns_non_empty_payload # AssertionError: BUG: caché devolvió lista vacía para pet_id=…
AssertionError: BUG: caché devolvió lista vacía.
Podemos pasar a Fase 2.
{ "recommendations": [] }AssertionError: lista vacía para pet_id=…WARNING: cache hit but empty payload también aparece en el test.| Rank | Hipótesis | Predicción falsificable | Veredicto |
|---|---|---|---|
| 1 | Race condition en PetProfileSerializer: el serializer escribe la clave Redis antes de completar la serialización del perfil, resultando en un payload parcial/vacío que queda cacheado con TTL=300s. | Si movemos la escritura al caché después de que el modelo devuelva resultados completos, el bug desaparece. Las requests lentas (340ms) nunca fallan porque el caché no existía. | ✗ CONFIRMADA |
| 2 | Serialización parcial por excepción silenciada: el serializer lanza una excepción que queda capturada, escribe el estado intermedio al caché y continúa. | Si añadimos logging en el bloque except, aparecerían trazas de error. Si desactivamos el try/except, el endpoint devolvería 500 en lugar de payload vacío. | — Eliminada |
| 3 | Bug en la lógica de lectura de caché: el código lee de Redis antes de escribir el perfil completo (orden incorrecto en el refactor). | Si el commit anterior no tenía este orden, el git diff mostrará el cambio de orden. Un print del valor leído mostraría cadena vacía o None. | ~ Parcial (misma causa) |
| 4 | TTL demasiado corto + async background write: un worker asíncrono escribe al caché pero tarda más que el TTL de la primera escritura, por lo que la clave ya expiró antes de completarse. | MONITOR de Redis mostraría SETEX con valor vacío seguido de SETEX con valor correcto segundos después. | — Eliminada |
| 5 | Deserialización incorrecta en la lectura: el nuevo PetProfileSerializer usa un formato de pickle/JSON diferente al que se guardaba antes. | Si esto fuera la causa, TODAS las requests deberían fallar (no solo 30%), ya que no depende del estado de la clave. | — Eliminada |
[DEBUG-c3f7] para poder limpiarlos con un solo grep -r DEBUG-c3f7 . --include="*.py" al finalizar.
class PetProfileSerializer: def serialize_and_cache(self, pet_id: str) -> dict: # [DEBUG-c3f7] Punto A: ANTES de serializar logger.debug("[DEBUG-c3f7] A: pet_id=%s, empezando serialización", pet_id) # BUG: se escribe al caché AQUÍ, con profile todavía sin construir cache_key = f"reco:{pet_id}" self.redis.setex(cache_key, 300, json.dumps({"recommendations": []})) # ← vacío! logger.debug("[DEBUG-c3f7] B: cache escrito CON PAYLOAD VACÍO para %s", pet_id) profile = self._build_profile(pet_id) # tarda ~50ms recommendations = self.model.predict(profile) # tarda ~290ms logger.debug("[DEBUG-c3f7] C: modelo completado, %d recos", len(recommendations)) # Aquí DEBERÍA actualizarse el caché, pero no se hace. return {"recommendations": recommendations}
abc-123: no está en caché → [DEBUG-c3f7] A → escribe {"recommendations":[]} → construye perfil → corre modelo → devuelve 8 recos. ✓abc-123: clave existe en Redis → lee {"recommendations":[]} → devuelve vacío sin tocar el modelo. WARNING: cache hit but empty payloadPetProfileSerializer (commit a7f3c91), la llamada a redis.setex() se movió antes de model.predict(), cacheando un payload vacío durante 300 segundos. Cualquier request que llegue en esa ventana recibe la lista vacía.
def test_cache_never_stores_empty_recommendations(fake_redis): """Regresión: el caché nunca debe persistir una lista vacía.""" pet_id = "test-pet-uuid-001" mock_model = MagicMock() mock_model.predict.return_value = ["kibble-premium", "calmpaw-supplement"] with patch("app.services.recommendations.redis_client", fake_redis), \ patch("app.services.recommendations.model", mock_model): result = get_recommendations(pet_id) # 1. El resultado devuelto nunca está vacío assert len(result["recommendations"]) > 0 # 2. Lo que quedó cacheado tampoco está vacío cached_raw = fake_redis.get(f"reco:{pet_id}") cached = json.loads(cached_raw) assert len(cached["recommendations"]) > 0, \ "El caché no debe guardar una lista vacía" # Resultado DESPUÉS del fix: # PASSED tests/test_recommendations_cache.py::test_cache_never_stores_empty_recommendations (0.71s) # PASSED tests/test_recommendations_cache.py::test_cache_hit_returns_non_empty_payload (0.68s)
[DEBUG-c3f7] eliminados — grep -r "DEBUG-c3f7" . --include="*.py" devuelve cero resultados.serialize_and_cache() mezcla lógica de negocio (predicción) con infraestructura (caché), creando acoplamiento oculto. Separar en dos métodos (predict() + cache_result()) haría imposible este tipo de bug de orden. Además, una property-based test que verifique "si hay perfil válido, las recos nunca pueden estar vacías" en el nivel del servicio habría detectado la regresión en CI.