deer-flow/backend/tests/blocking_io/test_agents_router.py
Hyeonsang Cho 5b8b69bd45
fix(gateway): keep /api/user-profile filesystem work off the event loop (#5935)
* fix(gateway): keep /api/user-profile filesystem work off the event loop

GET and PUT /api/user-profile resolved the per-user USER.md path (which
builds absolute paths on every call), stat'ed, read, created the user
bucket and wrote the file inline on the event loop, while every other
handler in the custom-agent router offloads that work with
asyncio.to_thread. The strict Blockbuster gate raises BlockingError on
both; in production a slow disk stalls every other request on the worker.

Offload the whole resolve-stat-read and resolve-mkdir-write sequences,
capturing the effective user id on the loop first like the sibling
handlers. The blocking-IO gate test for this router now covers both
profile routes (missing profile, write, read back).

* docs(changelog): reference #5935 in the user-profile blocking I/O entries
2026-09-27 15:06:49 +08:00

143 lines
6.2 KiB
Python

"""Regression anchors: the custom-agent router must not block the event loop.
The custom-agent handlers resolve the active store, access agent directories,
and may perform filesystem or database IO. Create/read/check/update/delete all
offload their complete blocking operations via ``asyncio.to_thread``; if any of
that work regresses onto the event loop, the strict Blockbuster gate raises
``BlockingError`` and these tests fail.
Imports live at module scope so the one-time FastAPI app construction (which
reads files while building OpenAPI schemas) happens at collection time, not on
the event loop under test. Test-side path resolution is itself offloaded with
``asyncio.to_thread`` (matching ``test_uploads_middleware``) so only the
handlers' own filesystem access is exercised on the loop.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from app.gateway.routers.agents import (
AgentCreateRequest,
AgentUpdateRequest,
UserProfileUpdateRequest,
check_agent_name,
create_agent_endpoint,
delete_agent,
get_agent,
get_user_profile,
list_agents,
update_agent,
update_user_profile,
)
from deerflow.config.agents_api_config import load_agents_api_config_from_dict
from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
def _isolate_agent_store_config(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
monkeypatch.setattr("deerflow.config.app_config._legacy_config_candidates", lambda: ())
async def test_create_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
try:
response = await create_agent_endpoint(AgentCreateRequest(name="loop-make-agent", soul="You are a test agent."))
assert response is not None
user_id = get_effective_user_id()
# test-side check (resolution offloaded; not exercised on the loop)
agent_dir = await asyncio.to_thread(get_paths().user_agent_dir, user_id, "loop-make-agent")
assert await asyncio.to_thread((agent_dir / "config.yaml").exists)
finally:
load_agents_api_config_from_dict({})
async def test_delete_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
try:
user_id = get_effective_user_id()
user_id = get_effective_user_id()
# test-side seeding (resolution offloaded; not exercised on the loop)
agent_dir = await asyncio.to_thread(get_paths().user_agent_dir, user_id, "loop-test-agent")
await asyncio.to_thread(agent_dir.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread((agent_dir / "config.yaml").write_text, "name: loop-test-agent\n", encoding="utf-8")
await delete_agent("loop-test-agent")
assert not await asyncio.to_thread(agent_dir.exists)
finally:
load_agents_api_config_from_dict({})
async def test_read_endpoints_do_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
# list/get/check read through the sync agent store; on the db backend each is
# a DB round trip. They must offload via asyncio.to_thread, or the strict
# Blockbuster gate raises BlockingError here (finding: reads on the loop).
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
try:
await create_agent_endpoint(AgentCreateRequest(name="loop-read-agent", soul="You are a test agent."))
listed = await list_agents()
assert any(a.name == "loop-read-agent" for a in listed.agents)
got = await get_agent("loop-read-agent")
assert got.name == "loop-read-agent"
check = await check_agent_name("loop-read-agent")
assert check["available"] is False
assert (await check_agent_name("never-created-agent"))["available"] is True
finally:
load_agents_api_config_from_dict({})
async def test_update_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
try:
await create_agent_endpoint(AgentCreateRequest(name="loop-update-agent", soul="Original soul"))
response = await update_agent("loop-update-agent", AgentUpdateRequest(description="Updated description"))
assert response.description == "Updated description"
finally:
load_agents_api_config_from_dict({})
async def test_user_profile_endpoints_do_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
# GET stats and reads USER.md, PUT creates the user bucket and writes it.
# Every other handler in this router offloads its filesystem work; these
# two ran it on the loop, so the strict gate raises BlockingError here.
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
try:
# Missing profile: the existence check must not block either.
assert (await get_user_profile()).content is None
saved = await update_user_profile(UserProfileUpdateRequest(content="I prefer terse answers.\n"))
assert saved.content == "I prefer terse answers.\n"
assert (await get_user_profile()).content == "I prefer terse answers."
# test-side check (resolution offloaded; not exercised on the loop)
user_md = await asyncio.to_thread(get_paths().user_md_file, get_effective_user_id())
assert await asyncio.to_thread(user_md.read_text, encoding="utf-8") == "I prefer terse answers.\n"
finally:
load_agents_api_config_from_dict({})