"""The citation-verification view must not fan out without a bound. `build_view` used to spawn one retrieval task per legal argument with a bare `asyncio.gather`. Each task opens a Voyage embed call, and past ~8 in flight Voyage throttles — so the unbounded version was *slower* than a bounded one (32 arguments: 30.4s unbounded vs 22.1s at 8), and a 69-argument case blew past the 30s proxy timeout and returned 500 while overlapping reloads piled contention into Postgres deadlocks. These tests pin the two properties that fix gave us: the fan-out is bounded, and a timeout yields the results that DID land instead of nothing. """ import asyncio import pytest from legal_mcp.services import case_citation_verification as ccv def test_concurrency_bound_is_set_and_modest(): assert 1 <= ccv._MAX_CONCURRENT_LOOKUPS <= 16, ( "the bound exists to stay under Voyage's throttle point — a large value " "reintroduces the stall this was added to fix" ) def test_retrieval_budget_leaves_room_under_the_proxy_timeout(): """The proxy gives up at 30s; we must cut ourselves off before that.""" assert 0 < ccv._RETRIEVAL_BUDGET_S < 30 @pytest.mark.asyncio async def test_semaphore_actually_caps_in_flight_work(): """A semaphore of N never lets N+1 coroutines run the body at once.""" limit = ccv._MAX_CONCURRENT_LOOKUPS sem = asyncio.Semaphore(limit) in_flight = 0 peak = 0 async def worker(): nonlocal in_flight, peak async with sem: in_flight += 1 peak = max(peak, in_flight) await asyncio.sleep(0.01) in_flight -= 1 await asyncio.gather(*(worker() for _ in range(limit * 4))) assert peak <= limit @pytest.mark.asyncio async def test_timeout_harvests_finished_work_instead_of_discarding_it(): """The regression: wait_for(gather(...)) cancels everything on timeout, so one slow lookup threw away every result that had already succeeded. The harvest pattern must keep them.""" async def quick(i): await asyncio.sleep(0.01) return i async def never(): await asyncio.sleep(30) return "unreachable" tasks = [asyncio.ensure_future(quick(i)) for i in range(5)] tasks.append(asyncio.ensure_future(never())) done, pending = await asyncio.wait(tasks, timeout=0.3) for t in pending: t.cancel() await asyncio.gather(*pending, return_exceptions=True) harvested = [ t.result() if (t in done and not t.cancelled() and t.exception() is None) else None for t in tasks ] assert harvested[:5] == [0, 1, 2, 3, 4], "finished work must survive the timeout" assert harvested[5] is None, "the unfinished one is empty, not fabricated" assert len(pending) == 1