09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
231 lines
7.2 KiB
Python
231 lines
7.2 KiB
Python
"""Backpressure tests for expensive REST route groups."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi")
|
|
httpx = pytest.importorskip("httpx")
|
|
|
|
from notebooklm._types.chat import AskResult # noqa: E402
|
|
from notebooklm.server._limits import LimitGroup, ServerLimiters # noqa: E402
|
|
from notebooklm.server.app import create_app # noqa: E402
|
|
|
|
from .conftest import TEST_TOKEN # noqa: E402
|
|
from .fakes import FakeClient # noqa: E402
|
|
|
|
|
|
class ActiveHold:
|
|
"""Track concurrent fake-handler entries and hold them until released."""
|
|
|
|
def __init__(self) -> None:
|
|
self.entered = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.active = 0
|
|
self.max_active = 0
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def enter(self) -> None:
|
|
async with self._lock:
|
|
self.active += 1
|
|
self.max_active = max(self.max_active, self.active)
|
|
self.entered.set()
|
|
|
|
async def leave(self) -> None:
|
|
async with self._lock:
|
|
self.active -= 1
|
|
|
|
|
|
def _factory_for(fake_client: FakeClient) -> Any:
|
|
@asynccontextmanager
|
|
async def factory() -> AsyncIterator[FakeClient]:
|
|
yield fake_client
|
|
|
|
return factory
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _async_client(fake_client: FakeClient) -> AsyncIterator[httpx.AsyncClient]:
|
|
app = create_app(client_factory=_factory_for(fake_client))
|
|
headers = {"Authorization": f"Bearer {TEST_TOKEN}", "Host": "127.0.0.1"}
|
|
transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 5555), raise_app_exceptions=False)
|
|
async with (
|
|
app.router.lifespan_context(app),
|
|
httpx.AsyncClient(
|
|
transport=transport, base_url="http://127.0.0.1", headers=headers
|
|
) as client,
|
|
):
|
|
yield client
|
|
|
|
|
|
async def test_chat_backpressure_bounds_concurrency_and_leaves_healthz_free(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NOTEBOOKLM_SERVER_CHAT_CONCURRENCY", "1")
|
|
fake_client = FakeClient()
|
|
hold = ActiveHold()
|
|
|
|
async def slow_ask(
|
|
notebook_id: str, question: str, *, conversation_id: str | None = None
|
|
) -> AskResult:
|
|
await hold.enter()
|
|
try:
|
|
await hold.release.wait()
|
|
return AskResult(
|
|
answer=f"answer to: {question}",
|
|
conversation_id=conversation_id or "conv-1",
|
|
turn_number=1,
|
|
is_follow_up=conversation_id is not None,
|
|
)
|
|
finally:
|
|
await hold.leave()
|
|
|
|
monkeypatch.setattr(fake_client.chat, "ask", slow_ask)
|
|
|
|
async with _async_client(fake_client) as client:
|
|
first = asyncio.create_task(
|
|
client.post("/v1/notebooks/nb-1/chat", json={"question": "one"})
|
|
)
|
|
await asyncio.wait_for(hold.entered.wait(), timeout=1)
|
|
|
|
second = asyncio.create_task(
|
|
client.post("/v1/notebooks/nb-1/chat", json={"question": "two"})
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
health = await asyncio.wait_for(client.get("/healthz"), timeout=1)
|
|
assert health.status_code == 200
|
|
assert health.json() == {"ok": True}
|
|
finally:
|
|
hold.release.set()
|
|
|
|
first_response, second_response = await asyncio.gather(first, second)
|
|
|
|
assert first_response.status_code == 200
|
|
assert second_response.status_code == 200
|
|
assert hold.max_active == 1
|
|
|
|
|
|
async def test_source_mutation_backpressure_bounds_concurrency(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NOTEBOOKLM_SERVER_SOURCE_MUTATION_CONCURRENCY", "1")
|
|
fake_client = FakeClient()
|
|
hold = ActiveHold()
|
|
original_add_text = fake_client.sources.add_text
|
|
|
|
async def slow_add_text(notebook_id: str, title: str, content: str) -> Any:
|
|
await hold.enter()
|
|
try:
|
|
await hold.release.wait()
|
|
return await original_add_text(notebook_id, title, content)
|
|
finally:
|
|
await hold.leave()
|
|
|
|
monkeypatch.setattr(fake_client.sources, "add_text", slow_add_text)
|
|
|
|
async with _async_client(fake_client) as client:
|
|
first = asyncio.create_task(
|
|
client.post(
|
|
"/v1/notebooks/nb-1/sources/text",
|
|
json={"title": "one", "text": "one"},
|
|
)
|
|
)
|
|
await asyncio.wait_for(hold.entered.wait(), timeout=1)
|
|
|
|
second = asyncio.create_task(
|
|
client.post(
|
|
"/v1/notebooks/nb-1/sources/text",
|
|
json={"title": "two", "text": "two"},
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
finally:
|
|
hold.release.set()
|
|
|
|
first_response, second_response = await asyncio.gather(first, second)
|
|
|
|
assert first_response.status_code == 201
|
|
assert second_response.status_code == 201
|
|
assert hold.max_active == 1
|
|
|
|
|
|
async def test_file_upload_backpressure_bounds_concurrency(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NOTEBOOKLM_SERVER_SOURCE_MUTATION_CONCURRENCY", "1")
|
|
fake_client = FakeClient()
|
|
hold = ActiveHold()
|
|
original_add_file = fake_client.sources.add_file
|
|
|
|
async def slow_add_file(
|
|
notebook_id: str,
|
|
path: str,
|
|
mime_type: str | None = None,
|
|
*,
|
|
title: str | None = None,
|
|
) -> Any:
|
|
await hold.enter()
|
|
try:
|
|
await hold.release.wait()
|
|
return await original_add_file(notebook_id, path, mime_type, title=title)
|
|
finally:
|
|
await hold.leave()
|
|
|
|
monkeypatch.setattr(fake_client.sources, "add_file", slow_add_file)
|
|
|
|
async with _async_client(fake_client) as client:
|
|
first = asyncio.create_task(
|
|
client.post(
|
|
"/v1/notebooks/nb-1/sources/file",
|
|
files={"file": ("one.txt", b"one", "text/plain")},
|
|
)
|
|
)
|
|
await asyncio.wait_for(hold.entered.wait(), timeout=1)
|
|
|
|
second = asyncio.create_task(
|
|
client.post(
|
|
"/v1/notebooks/nb-1/sources/file",
|
|
files={"file": ("two.txt", b"two", "text/plain")},
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
finally:
|
|
hold.release.set()
|
|
|
|
first_response, second_response = await asyncio.gather(first, second)
|
|
|
|
assert first_response.status_code == 201
|
|
assert second_response.status_code == 201
|
|
assert hold.max_active == 1
|
|
|
|
|
|
async def test_invalid_backpressure_env_fails_lifespan(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NOTEBOOKLM_SERVER_CHAT_CONCURRENCY", "0")
|
|
app = create_app(client_factory=_factory_for(FakeClient()))
|
|
|
|
with pytest.raises(
|
|
ValueError, match="NOTEBOOKLM_SERVER_CHAT_CONCURRENCY must be an integer >= 1"
|
|
):
|
|
async with app.router.lifespan_context(app):
|
|
pass
|
|
|
|
|
|
async def test_unknown_limit_group_fails_fast() -> None:
|
|
limiters = ServerLimiters.from_env({})
|
|
limiters.set_bound_loop(asyncio.get_running_loop())
|
|
|
|
with pytest.raises(ValueError, match="Unknown limit group: typo"):
|
|
limiters._semaphore_for(cast(LimitGroup, "typo"))
|
|
|
|
assert limiters._chat is None
|