Spaces:
Running
Running
File size: 10,104 Bytes
d66d64c 915f989 d66d64c 915f989 d66d64c 915f989 d66d64c 915f989 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | """Blocking Claude, RAG and YouTube work reached from async endpoints runs on a worker thread.
These calls make network round trips and sleep between retries, so running them on the event loop's own
thread freezes every other request. Each test records the thread the blocking function ran on and compares
it with the thread that drives the loop (asyncio.run drives it on the calling test thread).
The last tests cover how _run_hf_blocking bounds those threads: a slot is held until its thread returns,
even when the caller that started the call has been cancelled.
"""
from __future__ import annotations
import asyncio
import gc
import importlib.util
import json
import threading
import time
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import main as main_module
from routes import at_risk_resolution, video_routes
BACKEND_DIR = Path(__file__).resolve().parents[1]
def _load_real_automation_engine():
"""test_api.py swaps sys.modules["automation_engine"] for a stub, so load the real file under its own name."""
spec = importlib.util.spec_from_file_location("automation_engine_under_test", BACKEND_DIR / "automation_engine.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_at_risk_fallback_brief_is_generated_off_the_event_loop() -> None:
loop_thread = threading.get_ident()
ran_on: dict[str, int] = {}
def retrieve(**_options: object) -> list[dict]:
ran_on["retrieval"] = threading.get_ident()
return []
def complete(model: str, system_prompt: str, user_prompt: str, **_options: object) -> str:
ran_on["completion"] = threading.get_ident()
return json.dumps({"summary": "Quadratics review.", "key_concepts": ["factoring"]})
resolved = at_risk_resolution.ResolvedTopic(
topic_id="quadratics",
subject="General Mathematics",
quarter=1,
confidence_score=0.0,
resolution_state="coming_soon",
)
body = at_risk_resolution.ResolveRequest(
uid="student-1",
flagged_topics=[at_risk_resolution.FlaggedTopicInput(topic_id="quadratics", topic_name="Quadratic Equations")],
)
http_request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(role="teacher", uid="teacher-1")))
with (
patch.object(at_risk_resolution.fs, "client", return_value=MagicMock()),
patch.object(at_risk_resolution, "_resolve_topic", return_value=resolved),
patch.object(at_risk_resolution, "is_enabled", return_value=True),
patch.object(at_risk_resolution, "retrieve_curriculum_context", retrieve),
patch.object(at_risk_resolution, "rag_grounded_completion", complete),
):
response = asyncio.run(at_risk_resolution.resolve_at_risk_topics(body, http_request))
assert response.fallback_generated == 1
assert ran_on.keys() == {"retrieval", "completion"}
assert loop_thread not in ran_on.values()
@pytest.mark.parametrize(
"generate",
[
lambda engine: engine._generate_learning_path(["Algebra"], [{"topic": "Factoring", "accuracy": 0.4}], "Grade 11"),
lambda engine: engine._generate_teacher_interventions(
{"Algebra": {"status": "At Risk"}}, [{"topic": "Factoring", "accuracy": 0.4}]
),
],
ids=["learning-path", "teacher-interventions"],
)
def test_automation_engine_chat_calls_run_off_the_event_loop(generate) -> None:
loop_thread = threading.get_ident()
ran_on: list[int] = []
def chat(*_args: object, **_options: object) -> str:
ran_on.append(threading.get_ident())
return "1. Review factoring."
engine = _load_real_automation_engine().MathPulseAutomationEngine()
with patch.object(main_module, "call_hf_chat", chat):
reply = asyncio.run(generate(engine))
assert reply == "1. Review factoring."
assert ran_on
assert loop_thread not in ran_on
def test_video_search_runs_the_blocking_lookup_off_the_event_loop() -> None:
loop_thread = threading.get_ident()
ran_on: list[int] = []
def search(**_options: object) -> dict:
ran_on.append(threading.get_ident())
return {"videos": [], "cached": False}
payload = video_routes.VideoSearchRequest(topic="quadratic equations")
with patch.object(video_routes, "_get_youtube_service", return_value=(search, "test-key")):
response = asyncio.run(video_routes.search_videos(None, payload))
assert response.videos == []
assert ran_on
assert loop_thread not in ran_on
SLOTS = 2
# A worker thread the test never lets return, or a condition that never comes true, fails the test instead of hanging it.
THREAD_GUARD_SECONDS = 5
# How long a call that must stay queued is given to start anyway; the unbounded implementation starts it within a millisecond.
QUEUED_CALL_GRACE_SECONDS = 0.2
class HeldModelCalls:
"""A blocking model call whose worker thread stays busy until the test lets it return."""
def __init__(self) -> None:
self._may_return = threading.Event()
self._failure: Exception | None = None
self._lock = threading.Lock()
self.started = 0
self.running = 0
self.peak_running = 0
def __call__(self, reply: str) -> str:
with self._lock:
self.started += 1
self.running += 1
self.peak_running = max(self.peak_running, self.running)
try:
assert self._may_return.wait(THREAD_GUARD_SECONDS), "the test never let this model call return"
if self._failure is not None:
raise self._failure
return reply
finally:
with self._lock:
self.running -= 1
def let_calls_return(self, failure: Exception | None = None) -> None:
self._failure = failure
self._may_return.set()
async def wait_until(condition: Callable[[], bool]) -> None:
deadline = time.monotonic() + THREAD_GUARD_SECONDS
while not condition():
assert time.monotonic() < deadline, "the condition did not come true in time"
await asyncio.sleep(0.005)
@pytest.fixture
def two_slot_pool(monkeypatch: pytest.MonkeyPatch) -> None:
"""_run_hf_blocking bounded to SLOTS threads, on a semaphore built fresh for this test's event loop."""
monkeypatch.setattr(main_module, "HF_BLOCKING_CALL_CONCURRENCY", SLOTS)
monkeypatch.setattr(main_module, "_hf_call_semaphore", None)
monkeypatch.setattr(main_module, "_hf_call_semaphore_loop", None)
@pytest.mark.asyncio
@pytest.mark.usefixtures("two_slot_pool")
async def test_cancelled_model_calls_keep_their_slots_until_their_threads_return() -> None:
model = HeldModelCalls()
abandoned = [asyncio.create_task(main_module._run_hf_blocking(model, "abandoned")) for _ in range(SLOTS)]
await wait_until(lambda: model.started == SLOTS)
for call in abandoned:
call.cancel()
await asyncio.wait_for(asyncio.gather(*abandoned, return_exceptions=True), THREAD_GUARD_SECONDS)
assert all(call.cancelled() for call in abandoned)
replacement = asyncio.create_task(main_module._run_hf_blocking(model, "replacement"))
await asyncio.sleep(QUEUED_CALL_GRACE_SECONDS)
assert model.started == SLOTS, "a call started while every slot still belonged to a running thread"
model.let_calls_return()
assert await asyncio.wait_for(replacement, THREAD_GUARD_SECONDS) == "replacement"
assert model.peak_running == SLOTS
@pytest.mark.asyncio
@pytest.mark.usefixtures("two_slot_pool")
async def test_a_caller_cancelled_while_queued_neither_leaks_nor_adds_a_slot() -> None:
first_wave = HeldModelCalls()
holders = [asyncio.create_task(main_module._run_hf_blocking(first_wave, "holder")) for _ in range(SLOTS)]
await wait_until(lambda: first_wave.started == SLOTS)
queued = [asyncio.create_task(main_module._run_hf_blocking(first_wave, "queued")) for _ in range(3)]
await asyncio.sleep(0)
assert not any(call.done() for call in queued), "the calls were meant to wait for a slot"
for call in queued:
call.cancel()
await asyncio.wait_for(asyncio.gather(*queued, return_exceptions=True), THREAD_GUARD_SECONDS)
assert first_wave.started == SLOTS
first_wave.let_calls_return()
assert await asyncio.wait_for(asyncio.gather(*holders), THREAD_GUARD_SECONDS) == ["holder"] * SLOTS
second_wave = HeldModelCalls()
later = [asyncio.create_task(main_module._run_hf_blocking(second_wave, "later")) for _ in range(SLOTS + 2)]
await wait_until(lambda: second_wave.started == SLOTS)
await asyncio.sleep(QUEUED_CALL_GRACE_SECONDS)
assert second_wave.started == SLOTS, "the pool no longer holds exactly SLOTS slots"
second_wave.let_calls_return()
assert await asyncio.wait_for(asyncio.gather(*later), THREAD_GUARD_SECONDS) == ["later"] * (SLOTS + 2)
@pytest.mark.asyncio
@pytest.mark.usefixtures("two_slot_pool")
async def test_a_failure_after_its_caller_was_cancelled_is_not_left_unretrieved() -> None:
model = HeldModelCalls()
unretrieved_errors: list[dict[str, object]] = []
loop = asyncio.get_running_loop()
loop.set_exception_handler(lambda _loop, context: unretrieved_errors.append(context))
try:
call = asyncio.create_task(main_module._run_hf_blocking(model, "never delivered"))
await wait_until(lambda: model.started == 1)
call.cancel()
await asyncio.gather(call, return_exceptions=True)
# The cancelled call's traceback would keep the worker's task alive, and unreported, past the check below.
del call
model.let_calls_return(failure=RuntimeError("model unavailable"))
await wait_until(lambda: model.running == 0)
await asyncio.sleep(0.05)
# A finished task that still holds an error nobody read reports it when it is collected.
gc.collect()
await asyncio.sleep(0)
finally:
loop.set_exception_handler(None)
assert unretrieved_errors == []
|