Spaces:
Sleeping
Sleeping
| """El trabajo síncrono y caro no puede correr en el bucle de eventos. | |
| `interpretar` es `async`, pero sus dos operaciones más caras son SÍNCRONAS: la recuperación RAG | |
| (embedding bge-m3 + LanceDB + cross-encoder sobre hasta `rag_candidatos` filas, segundos en | |
| cpu-basic) y todo SQLite/scrypt en los endpoints de auth. Llamadas directamente desde una | |
| corrutina, bloquean el proceso ENTERO mientras duran: ni health check, ni el login de otro | |
| veterinario, ni una ingesta del puente. La concurrencia efectiva era 1 en una app que se | |
| anuncia multiusuario. | |
| Estas pruebas miden la propiedad, no la implementación: si alguien sustituye un | |
| `await asyncio.to_thread(...)` por la llamada directa, vuelven a fallar. Los márgenes son | |
| holgados a propósito —miden concurrencia, no latencia— para que no parpadeen en CI. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import time | |
| import pytest | |
| from app.ai import service | |
| from app.ai.base import ErrorModelo # noqa: F401 (documenta la superficie que se falsea) | |
| from app.schemas import InterpretacionClinica, PeticionInterpretacion | |
| BLOQUEO_S = 0.4 | |
| PETICION = { | |
| "paciente": {"especie": "canino"}, | |
| "hallazgos": [ | |
| { | |
| "clave": "hct", | |
| "nombre": "Hematocrito", | |
| "valor": 22.0, | |
| "unidad": "%", | |
| "direccion": "bajo", | |
| "gravedad": "grave", | |
| } | |
| ], | |
| "patrones": [{"nombre": "Anemia", "descripcion": "…", "gravedad": "grave"}], | |
| "imagenes": [], | |
| } | |
| class ClienteInstantaneo: | |
| """Cliente de modelo que responde sin coste: aquí se mide la recuperación, no la generación.""" | |
| nombre = "medgemma-hf" | |
| prosa = True | |
| modelo = "hf-space" | |
| async def interpretar(self, *_a, **_k): | |
| return InterpretacionClinica(interpretacion="ok " * 20, requiere_derivacion=True) | |
| def rag_lento(monkeypatch): | |
| """Retriever síncrono y lento, como el real: `time.sleep` bloquea el hilo que lo ejecute.""" | |
| def _recuperar_bloqueante(*_a, **_k): | |
| time.sleep(BLOQUEO_S) | |
| return [] | |
| for nombre in ("recuperar", "recuperar_multi"): | |
| monkeypatch.setattr(service, nombre, _recuperar_bloqueante) | |
| monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteInstantaneo()) | |
| async def _contar_latidos(tarea: asyncio.Task, intervalo: float = 0.005) -> int: | |
| """Cuántas veces consigue despertarse el bucle mientras `tarea` está en curso. | |
| Es la medición directa de «¿puede el servidor atender a alguien más?». Con el trabajo | |
| bloqueante en el bucle, el contador se queda en ~0. | |
| """ | |
| latidos = 0 | |
| while not tarea.done(): | |
| await asyncio.sleep(intervalo) | |
| latidos += 1 | |
| return latidos | |
| async def test_la_recuperacion_no_congela_el_bucle(rag_lento): | |
| """Durante una interpretación, el bucle sigue despertándose para atender otras cosas.""" | |
| tarea = asyncio.create_task(service.interpretar(PeticionInterpretacion.model_validate(PETICION))) | |
| latidos = await _contar_latidos(tarea) | |
| await tarea | |
| # Con to_thread caben ~80 latidos de 5 ms en 0.4 s; en el bucle serían 0 o 1. | |
| assert latidos > 10, ( | |
| f"sólo {latidos} latidos durante la recuperación: el bucle estuvo bloqueado, " | |
| "la recuperación volvió a ejecutarse sin asyncio.to_thread" | |
| ) | |
| async def test_dos_interpretaciones_se_solapan(rag_lento): | |
| """Dos peticiones concurrentes no se serializan: comparten el tiempo de recuperación.""" | |
| inicio = time.perf_counter() | |
| await asyncio.gather( | |
| service.interpretar(PeticionInterpretacion.model_validate(PETICION)), | |
| service.interpretar(PeticionInterpretacion.model_validate(PETICION)), | |
| ) | |
| transcurrido = time.perf_counter() - inicio | |
| # Serializadas costarían >= 2*BLOQUEO_S; solapadas, algo más de BLOQUEO_S. | |
| assert transcurrido < BLOQUEO_S * 1.7, ( | |
| f"{transcurrido:.2f}s para dos interpretaciones de {BLOQUEO_S}s: se serializaron" | |
| ) | |
| async def test_el_alta_no_congela_el_bucle(alta_abierta): | |
| """scrypt (n=2**14) es caro A PROPÓSITO; en el bucle, cada alta congela el servicio.""" | |
| import httpx | |
| from app import db | |
| from app.main import app | |
| # ASGITransport no dispara el lifespan (TestClient sí), así que la tabla no existiría. | |
| db.inicializar_db() | |
| transporte = httpx.ASGITransport(app=app) | |
| async with httpx.AsyncClient(transport=transporte, base_url="http://test") as cliente: | |
| tarea = asyncio.create_task( | |
| cliente.post( | |
| "/api/auth/registro", | |
| json={ | |
| "nombre": "Hilo", | |
| "apellido": "Vet", | |
| "email": "hilo@example.com", | |
| "password": "clave-segura-1", | |
| }, | |
| ) | |
| ) | |
| latidos = await _contar_latidos(tarea, intervalo=0.002) | |
| resp = await tarea | |
| assert resp.status_code == 200, resp.text | |
| assert latidos > 3, ( | |
| f"sólo {latidos} latidos durante el alta: scrypt volvió a correr en el bucle de eventos" | |
| ) | |