diff --git a/CHANGELOG.md b/CHANGELOG.md index b0439d824a..c7aba100a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -323,6 +323,15 @@ This release closes that milestone with **181 merged pull requests**. ### Fixed +- **gateway:** `GET` and `PUT /api/user-profile` no longer run their + filesystem work on the event loop. Both handlers 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, while every other + handler in the custom-agent router offloads that work with + `asyncio.to_thread`. Under the strict Blockbuster gate the pair raised + `BlockingError`; in production a slow disk stalled every other request on + the worker for the duration. Both handlers now offload the whole + resolve-stat-read / resolve-mkdir-write sequence. ([#5935]) - **docker:** Project-document uploads larger than 1 MB no longer fail with a bare nginx `413` through the unified entry point. `POST /api/projects/{id}/documents` is a multipart upload that Gateway accepts up @@ -6168,4 +6177,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5900]: https://github.com/bytedance/deer-flow/pull/5900 [#5928]: https://github.com/bytedance/deer-flow/pull/5928 [#5934]: https://github.com/bytedance/deer-flow/pull/5934 +[#5935]: https://github.com/bytedance/deer-flow/pull/5935 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 7b4eca26c0..f9abc10d58 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -328,6 +328,12 @@ ### 修复 +- **网关:** `GET` 与 `PUT /api/user-profile` 不再在事件循环上执行文件系统操作。这两个处理器 + 此前在循环上直接解析按用户隔离的 `USER.md` 路径(每次调用都会构造绝对路径)、stat、读取、 + 创建用户目录并写入文件,而自定义智能体路由里的其他所有处理器都通过 `asyncio.to_thread` + 卸载这类工作。在严格的 Blockbuster 门禁下这一对处理器会抛出 `BlockingError`;在生产环境中, + 磁盘变慢时会让该 worker 上的其他所有请求随之停顿。现在两者都把整段 + 解析-stat-读取 / 解析-mkdir-写入 的流程卸载到线程。([#5935]) - **Docker:** 通过统一入口上传超过 1 MB 的项目文档不再被 nginx 直接以 `413` 拒绝。 `POST /api/projects/{id}/documents` 是 multipart 上传,Gateway 接受至多 `uploads.max_file_size`(默认 50 MiB)的文件,但没有任何 nginx location 匹配它,于是请求 @@ -5240,3 +5246,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5900]: https://github.com/bytedance/deer-flow/pull/5900 [#5928]: https://github.com/bytedance/deer-flow/pull/5928 [#5934]: https://github.com/bytedance/deer-flow/pull/5934 +[#5935]: https://github.com/bytedance/deer-flow/pull/5935 diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 836ddfed8d..b52a502a76 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -3,6 +3,7 @@ import asyncio import logging import re +from pathlib import Path from typing import Literal from fastapi import APIRouter, HTTPException, Request @@ -548,12 +549,19 @@ async def get_user_profile(request: Request) -> UserProfileResponse: UserProfileResponse with content=None if USER.md does not exist yet. """ _require_agents_api_enabled() + user_id = get_effective_user_id() + + # Path resolution (``get_paths()`` lazily builds absolute paths) and the + # stat/read are filesystem work; keep them off the event loop like every + # other handler in this router. + def _read_profile() -> str | None: + user_md_path = get_paths().user_md_file(user_id) + if not user_md_path.exists(): + return None + return user_md_path.read_text(encoding="utf-8").strip() try: - user_md_path = get_paths().user_md_file(get_effective_user_id()) - if not user_md_path.exists(): - return UserProfileResponse(content=None) - raw = user_md_path.read_text(encoding="utf-8").strip() + raw = await asyncio.to_thread(_read_profile) return UserProfileResponse(content=raw or None) except Exception as e: logger.error(f"Failed to read user profile: {e}", exc_info=True) @@ -583,12 +591,16 @@ async def update_user_profile(body: UserProfileUpdateRequest, request: Request) UserProfileResponse with the saved content. """ _require_agents_api_enabled() + user_id = get_effective_user_id() - try: - paths = get_paths() - user_md_path = paths.user_md_file(get_effective_user_id()) + def _write_profile() -> Path: + user_md_path = get_paths().user_md_file(user_id) user_md_path.parent.mkdir(parents=True, exist_ok=True) user_md_path.write_text(body.content, encoding="utf-8") + return user_md_path + + try: + user_md_path = await asyncio.to_thread(_write_profile) logger.info(f"Updated USER.md at {user_md_path}") return UserProfileResponse(content=body.content or None) except Exception as e: diff --git a/backend/tests/blocking_io/test_agents_router.py b/backend/tests/blocking_io/test_agents_router.py index 3903f2c6de..02bbc1b082 100644 --- a/backend/tests/blocking_io/test_agents_router.py +++ b/backend/tests/blocking_io/test_agents_router.py @@ -23,12 +23,15 @@ 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 @@ -114,3 +117,26 @@ async def test_update_agent_does_not_block_event_loop(tmp_path: Path, monkeypatc 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({})