fix(search): keep large SSE searches connected (#6186)

This commit is contained in:
jxxghp
2026-07-26 08:55:34 +08:00
parent 1b065cc08b
commit 3d020c8ceb
5 changed files with 208 additions and 11 deletions
+99 -9
View File
@@ -1,6 +1,8 @@
import asyncio import asyncio
import json import json
from typing import List, Any, Optional, AsyncIterator import time
from typing import Any, AsyncIterator, Iterator, List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, Body, Request from fastapi import APIRouter, Depends, Body, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -23,6 +25,12 @@ router = APIRouter()
_SSE_APPEND_FLUSH_INTERVAL = 1 _SSE_APPEND_FLUSH_INTERVAL = 1
_SSE_APPEND_MAX_ITEMS = 48 _SSE_APPEND_MAX_ITEMS = 48
_SSE_HEARTBEAT_INTERVAL = 15
_SSE_REPLACE_MAX_ITEMS = 48
_SSE_RESPONSE_HEADERS = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
def _parse_site_list(sites: Optional[str]) -> Optional[List[int]]: def _parse_site_list(sites: Optional[str]) -> Optional[List[int]]:
@@ -180,11 +188,40 @@ def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict:
return merged_event return merged_event
def _iter_replace_event_batches(event: dict) -> Iterator[dict]:
"""
将超大的最终替换事件拆成有序批次,避免单个 SSE 消息承载全部完整对象。
"""
items = event.get("items")
if (
event.get("type") != "replace"
or not isinstance(items, list)
or len(items) <= _SSE_REPLACE_MAX_ITEMS
):
yield event
return
batch_count = (len(items) + _SSE_REPLACE_MAX_ITEMS - 1) // _SSE_REPLACE_MAX_ITEMS
for batch_index in range(batch_count):
start = batch_index * _SSE_REPLACE_MAX_ITEMS
batch_event = dict(event)
batch_event.update(
{
"type": "replace" if batch_index == 0 else "append",
"items": items[start:start + _SSE_REPLACE_MAX_ITEMS],
"replace_batch": True,
"batch_index": batch_index,
"batch_count": batch_count,
}
)
yield batch_event
async def _iter_batched_search_events( async def _iter_batched_search_events(
event_source: AsyncIterator[dict], event_source: AsyncIterator[dict],
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
""" """
对搜索流事件做轻量批处理,避免站点结果集中返回时产生过密 SSE 对搜索流事件做轻量批处理,并在上游长时间静默时发送心跳
""" """
iterator = event_source.__aiter__() iterator = event_source.__aiter__()
pending_append_event: Optional[dict] = None pending_append_event: Optional[dict] = None
@@ -195,13 +232,19 @@ async def _iter_batched_search_events(
if next_event_task is None: if next_event_task is None:
next_event_task = asyncio.create_task(anext(iterator)) next_event_task = asyncio.create_task(anext(iterator))
timeout = _SSE_APPEND_FLUSH_INTERVAL if pending_append_event else None timeout = (
_SSE_APPEND_FLUSH_INTERVAL
if pending_append_event
else _SSE_HEARTBEAT_INTERVAL
)
done, _ = await asyncio.wait({next_event_task}, timeout=timeout) done, _ = await asyncio.wait({next_event_task}, timeout=timeout)
if not done: if not done:
if pending_append_event: if pending_append_event:
yield pending_append_event yield pending_append_event
pending_append_event = None pending_append_event = None
else:
yield {"type": "heartbeat"}
continue continue
try: try:
@@ -227,7 +270,8 @@ async def _iter_batched_search_events(
yield pending_append_event yield pending_append_event
pending_append_event = None pending_append_event = None
yield event for batched_event in _iter_replace_event_batches(event):
yield batched_event
finally: finally:
if next_event_task and not next_event_task.done(): if next_event_task and not next_event_task.done():
next_event_task.cancel() next_event_task.cancel()
@@ -239,13 +283,29 @@ async def _iter_batched_search_events(
async def _stream_search_events(request: Request, event_source: AsyncIterator[dict]): async def _stream_search_events(request: Request, event_source: AsyncIterator[dict]):
""" """
输出搜索SSE事件 输出搜索 SSE 事件,并记录连接生命周期与传输规模。
""" """
locale = LocaleHelper.get_locale_from_request(request) locale = LocaleHelper.get_locale_from_request(request)
search_id = uuid4().hex[:12]
request_path = getattr(getattr(request, "url", None), "path", "unknown")
started_at = time.monotonic()
event_count = 0
transmitted_bytes = 0
last_event_type = "none"
last_stage = "none"
termination_reason = "source_exhausted"
logger.info(f"渐进式搜索流已建立,搜索ID{search_id},路径:{request_path}")
try: try:
has_sent_final_replace = False has_sent_final_replace = False
async for event in _iter_batched_search_events(event_source): async for event in _iter_batched_search_events(event_source):
last_event_type = event.get("type") or "unknown"
last_stage = event.get("stage") or last_stage
if await request.is_disconnected(): if await request.is_disconnected():
termination_reason = "client_disconnected"
logger.warning(
f"渐进式搜索客户端已断开,搜索ID{search_id},路径:{request_path}"
f"事件:{last_event_type},阶段:{last_stage}"
)
break break
# 精确搜索会先发送 replace,再发送 done。done 再带整包 items 只会重复占用带宽和前端内存。 # 精确搜索会先发送 replace,再发送 done。done 再带整包 items 只会重复占用带宽和前端内存。
if event.get("type") == "replace" and event.get("items"): if event.get("type") == "replace" and event.get("items"):
@@ -257,13 +317,36 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
and event.get("items") and event.get("items")
): ):
event = {key: value for key, value in event.items() if key != "items"} event = {key: value for key, value in event.items() if key != "items"}
yield _sse_event(event, locale=locale) payload = _sse_event(event, locale=locale)
event_count += 1
transmitted_bytes += len(payload.encode("utf-8"))
if event.get("type") == "done":
termination_reason = "completed"
yield payload
except asyncio.CancelledError:
termination_reason = "cancelled"
logger.warning(
f"渐进式搜索流已取消,搜索ID{search_id},路径:{request_path}"
f"事件:{last_event_type},阶段:{last_stage}"
)
raise
except Exception as err: except Exception as err:
termination_reason = "error"
logger.error(f"渐进式搜索出错:{err}", exc_info=True) logger.error(f"渐进式搜索出错:{err}", exc_info=True)
yield _sse_event( payload = _sse_event(
{"type": "error", "success": False, "message": str(err)}, {"type": "error", "success": False, "message": str(err)},
locale=locale, locale=locale,
) )
event_count += 1
transmitted_bytes += len(payload.encode("utf-8"))
yield payload
finally:
elapsed = time.monotonic() - started_at
logger.info(
f"渐进式搜索流结束,搜索ID{search_id},路径:{request_path}"
f"状态:{termination_reason},事件数:{event_count}"
f"发送字节:{transmitted_bytes},耗时:{elapsed:.2f}"
)
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context]) @router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
@@ -319,6 +402,7 @@ async def search_by_id_stream(
search_chain = SearchChain() search_chain = SearchChain()
async def event_source(): async def event_source():
"""解析媒体身份并输出精确搜索流事件。"""
search_params, message = await _resolve_media_search_params( search_params, message = await _resolve_media_search_params(
mediaid=mediaid, mediaid=mediaid,
media_type=media_type, media_type=media_type,
@@ -341,7 +425,9 @@ async def search_by_id_stream(
yield event yield event
return StreamingResponse( return StreamingResponse(
_stream_search_events(request, event_source()), media_type="text/event-stream" _stream_search_events(request, event_source()),
media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
) )
@@ -401,7 +487,9 @@ async def search_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
) )
return StreamingResponse( return StreamingResponse(
_stream_search_events(request, event_source), media_type="text/event-stream" _stream_search_events(request, event_source),
media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
) )
@@ -446,6 +534,7 @@ async def search_subtitle_by_title_stream(
_iter_signed_subtitle_search_events(event_source), _iter_signed_subtitle_search_events(event_source),
), ),
media_type="text/event-stream", media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
) )
@@ -558,6 +647,7 @@ async def search_subtitle_by_id_stream(
_iter_signed_subtitle_search_events(event_source()), _iter_signed_subtitle_search_events(event_source()),
), ),
media_type="text/event-stream", media_type="text/event-stream",
headers=_SSE_RESPONSE_HEADERS,
) )
+2
View File
@@ -142,6 +142,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type``torrent``subtitle` | | GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type``torrent``subtitle` |
| POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices``check_only``force` | | POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices``check_only``force` |
渐进式搜索在无业务事件时每 15 秒发送 `{"type":"heartbeat"}`,客户端应将其仅用于连接保活。超过 48 条的最终 `replace` 会分批发送:首批 `type=replace`,后续批次 `type=append`,所有批次均带 `replace_batch=true`、从 0 开始的 `batch_index``batch_count` 和最终 `total_items`;客户端必须按顺序收齐后再原子替换结果。最终 `done` 在已发送 `replace` 后不重复携带 `items`
#### AniList 榜单 / 探索 #### AniList 榜单 / 探索
AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-chinese` 代理查询。代理不可用时自动回退 AniList 官方 GraphQL,并合并 `anilist-chinese` 每日数据集;媒体标题优先使用项目提供的中文标题,未提供中文标题时回退 AniList 原语言标题。 AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-chinese` 代理查询。代理不可用时自动回退 AniList 官方 GraphQL,并合并 `anilist-chinese` 每日数据集;媒体标题优先使用项目提供的中文标题,未提供中文标题时回退 AniList 原语言标题。
+3 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: moviepilot-api name: moviepilot-api
version: 4 version: 5
description: >- description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media with the bundled Python client. Covers MoviePilot HTTP endpoints across media
@@ -169,6 +169,8 @@ AniList endpoints prefer the `anilist-chinese` proxy and fall back to official A
| GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` | | GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` |
| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` | | POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business events; use it only to keep the connection alive. Final `replace` payloads above 48 items are batched: the first event uses `type=replace`, later events use `type=append`, and every batch includes `replace_batch=true`, zero-based `batch_index`, `batch_count`, and final `total_items`. Collect all batches in order and replace the visible result atomically. After a `replace`, the final `done` event omits duplicate `items`.
### Download (8 endpoints) ### Download (8 endpoints)
| Method | Path | Description | | Method | Path | Description |
+1 -1
View File
@@ -22,7 +22,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。""" """本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = { expected_versions = {
"database-operation": "3", "database-operation": "3",
"moviepilot-api": "4", "moviepilot-api": "5",
"moviepilot-cli": "6", "moviepilot-cli": "6",
"moviepilot-update": "3", "moviepilot-update": "3",
} }
+103
View File
@@ -0,0 +1,103 @@
import asyncio
from types import SimpleNamespace
import app.api.endpoints.search as search_endpoint
def test_large_replace_event_is_split_into_ordered_batches(monkeypatch):
"""超大最终结果应拆成首个 replace 和后续 append 批次。"""
monkeypatch.setattr(search_endpoint, "_SSE_REPLACE_MAX_ITEMS", 2)
source_event = {
"type": "replace",
"stage": "filtered",
"items": [1, 2, 3, 4, 5],
"total_items": 5,
}
async def _collect_events():
"""通过完整批处理适配器收集拆分后的最终结果。"""
async def _source():
"""输出一个超大最终替换事件。"""
yield source_event
return [
event
async for event in search_endpoint._iter_batched_search_events(_source())
]
events = asyncio.run(_collect_events())
assert [event["type"] for event in events] == ["replace", "append", "append"]
assert [event["batch_index"] for event in events] == [0, 1, 2]
assert all(event["batch_count"] == 3 for event in events)
assert all(event["replace_batch"] for event in events)
assert [item for event in events for item in event["items"]] == source_event["items"]
def test_small_replace_event_keeps_original_protocol(monkeypatch):
"""小型最终结果应保持单个 replace 事件,兼容现有客户端。"""
monkeypatch.setattr(search_endpoint, "_SSE_REPLACE_MAX_ITEMS", 2)
source_event = {
"type": "replace",
"items": [1, 2],
"total_items": 2,
}
assert list(search_endpoint._iter_replace_event_batches(source_event)) == [source_event]
def test_batched_search_events_emit_heartbeat_while_source_is_idle(monkeypatch):
"""上游长时间无业务事件时应持续输出心跳,避免连接被空闲超时关闭。"""
monkeypatch.setattr(search_endpoint, "_SSE_HEARTBEAT_INTERVAL", 0.01)
async def _read_heartbeat():
"""读取首个心跳并关闭仍在等待的上游迭代器。"""
blocker = asyncio.Event()
async def _source():
"""模拟长时间处于过滤匹配阶段的事件源。"""
await blocker.wait()
yield {"type": "done"}
events = search_endpoint._iter_batched_search_events(_source())
try:
return await asyncio.wait_for(anext(events), timeout=0.5)
finally:
await events.aclose()
assert asyncio.run(_read_heartbeat()) == {"type": "heartbeat"}
def test_search_stream_response_disables_proxy_buffering(monkeypatch):
"""搜索 SSE 响应应显式禁用缓存和 Nginx 代理缓冲。"""
class FakeSearchChain:
"""提供无需外部依赖的空搜索流。"""
def async_search_by_title_stream(self, **_kwargs):
"""返回立即完成的搜索流。"""
async def _source():
"""输出一个完成事件。"""
yield {"type": "done", "stage": "done", "total_items": 0}
return _source()
monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain)
async def _never_disconnected():
"""模拟始终在线的 SSE 客户端。"""
return False
request = SimpleNamespace(
url=SimpleNamespace(path="/api/v1/search/title/stream"),
is_disconnected=_never_disconnected,
)
response = asyncio.run(
search_endpoint.search_by_title_stream(request=request, keyword="Demo", _=None)
)
assert response.headers["cache-control"] == "no-cache"
assert response.headers["x-accel-buffering"] == "no"