mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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
This commit is contained in:
parent
e7576f9f5b
commit
5b8b69bd45
10
CHANGELOG.md
10
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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({})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user