Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

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