mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
fix(web-ui): 搜索索引前端超时 15s→120s + 后端单飞去重
问题:另一台机器首次加载报 'signal is aborted without reason'—— 不是取不到数据,是前端 AbortController 15 秒就掐断了请求,但遮罩 却告诉用户等 30-60 秒,自相矛盾。 修复: - 前端 fetchSearchIndex 超时 15s → 120s:遮罩本就是要等的,不能自己掐断 - 后端 _build_search_index 单飞去重:用 asyncio.Future 让 lifespan 预热 与 /security/search-index 端点请求共享同一任务,避免并发各爬一次全名单 (两个任务抢同一把 _execute_lock 反而互相拖慢) - lifespan 预热改为调 _build_search_index(复用单飞),加 120s 超时保护
This commit is contained in:
@@ -68,11 +68,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# 首次 get_security_list_all("all") 要几十次 TDX 协议往返(几十秒),
|
# 首次 get_security_list_all("all") 要几十次 TDX 协议往返(几十秒),
|
||||||
# 后台提前跑,让本地缓存尽早建立。用户打开页面时大概率已就绪。
|
# 后台提前跑,让本地缓存尽早建立。用户打开页面时大概率已就绪。
|
||||||
# 用 create_task 不 await,服务立即可用;预热未完成时前端遮罩接管等待。
|
# 用 create_task 不 await,服务立即可用;预热未完成时前端遮罩接管等待。
|
||||||
|
# 与 /security/search-index 端点共享同一单飞任务(_build_search_index),
|
||||||
|
# 避免预热和首次端点请求并发各爬一次全名单。
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
async def _warmup_security_list() -> None:
|
async def _warmup_security_list() -> None:
|
||||||
try:
|
try:
|
||||||
await client.get_security_list_all(pages="all")
|
from easy_tdx.web.routers.market import _build_search_index
|
||||||
|
|
||||||
|
await asyncio.wait_for(_build_search_index(client), timeout=120)
|
||||||
logger.info("Security list warmup done")
|
logger.info("Security list warmup done")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Security list warmup failed (non-fatal)", exc_info=True)
|
logger.warning("Security list warmup failed (non-fatal)", exc_info=True)
|
||||||
|
|||||||
@@ -57,6 +57,55 @@ async def security_list_all(
|
|||||||
# 进程级缓存:5206 条记录的 {code, name, initials} 只算一次,热重启即丢。
|
# 进程级缓存:5206 条记录的 {code, name, initials} 只算一次,热重启即丢。
|
||||||
# 前端拉一次后模块级缓存,按 code/name.includes/initials.includes 三路过滤。
|
# 前端拉一次后模块级缓存,按 code/name.includes/initials.includes 三路过滤。
|
||||||
_SEARCH_INDEX: list[dict[str, str]] | None = None
|
_SEARCH_INDEX: list[dict[str, str]] | None = None
|
||||||
|
# 单飞 Future:预热(lifespan)与端点请求共享同一任务,避免并发爬两次全名单。
|
||||||
|
# 首次请求会 await 这个 Future;后续请求命中 _SEARCH_INDEX 直接返回。
|
||||||
|
_SEARCH_INDEX_TASK: Any = None # asyncio.Future[list[dict[str, str]]]
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_search_index(client: Any) -> list[dict[str, str]]:
|
||||||
|
"""构建搜索索引:拉全名单 + pypinyin 预计算声母。耗时几十秒(首次)。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
global _SEARCH_INDEX, _SEARCH_INDEX_TASK
|
||||||
|
# 双重检查:等待期间可能已被其他协程填好
|
||||||
|
if _SEARCH_INDEX is not None:
|
||||||
|
return _SEARCH_INDEX
|
||||||
|
# 单飞:已有进行中的任务则复用,避免预热 + 端点请求并发爬两次
|
||||||
|
if _SEARCH_INDEX_TASK is None:
|
||||||
|
_SEARCH_INDEX_TASK = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
async def _do_build() -> None:
|
||||||
|
global _SEARCH_INDEX, _SEARCH_INDEX_TASK
|
||||||
|
from pypinyin import Style, lazy_pinyin
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await client.get_security_list_all(pages="all")
|
||||||
|
index: list[dict[str, str]] = []
|
||||||
|
for row in df.itertuples(index=False):
|
||||||
|
name = str(getattr(row, "name", "") or "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
code = str(getattr(row, "code", ""))
|
||||||
|
initials = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER))
|
||||||
|
index.append({"code": code, "name": name, "initials": initials})
|
||||||
|
_SEARCH_INDEX = index
|
||||||
|
if not _SEARCH_INDEX_TASK.done():
|
||||||
|
_SEARCH_INDEX_TASK.set_result(index)
|
||||||
|
except Exception as e:
|
||||||
|
# 失败清空 task,允许下次重试
|
||||||
|
if not _SEARCH_INDEX_TASK.done():
|
||||||
|
_SEARCH_INDEX_TASK.set_exception(e)
|
||||||
|
_SEARCH_INDEX_TASK = None
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# 成功后清空 task 引用(结果已存 _SEARCH_INDEX)
|
||||||
|
_SEARCH_INDEX_TASK = None
|
||||||
|
|
||||||
|
asyncio.create_task(_do_build())
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
return cast("list[dict[str, str]]", await _SEARCH_INDEX_TASK)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/security/search-index")
|
@router.get("/security/search-index")
|
||||||
@@ -68,24 +117,13 @@ async def security_search_index(
|
|||||||
数据源复用 :meth:`get_security_list_all`(沪深 A 股,已有本地日级缓存)。
|
数据源复用 :meth:`get_security_list_all`(沪深 A 股,已有本地日级缓存)。
|
||||||
声母用 pypinyin ``FIRST_LETTER`` 预计算(如 中际旭创→zjxc)。
|
声母用 pypinyin ``FIRST_LETTER`` 预计算(如 中际旭创→zjxc)。
|
||||||
进程内缓存,首次请求算一次后常驻;强制刷新重启进程即可。
|
进程内缓存,首次请求算一次后常驻;强制刷新重启进程即可。
|
||||||
|
|
||||||
|
与 lifespan 预热共享同一单飞任务(``_build_search_index``),
|
||||||
|
避免预热和首次端点请求并发各爬一次全名单。
|
||||||
"""
|
"""
|
||||||
global _SEARCH_INDEX
|
|
||||||
if _SEARCH_INDEX is not None:
|
if _SEARCH_INDEX is not None:
|
||||||
return {"count": len(_SEARCH_INDEX), "data": _SEARCH_INDEX}
|
return {"count": len(_SEARCH_INDEX), "data": _SEARCH_INDEX}
|
||||||
|
index = await _build_search_index(client)
|
||||||
from pypinyin import Style, lazy_pinyin
|
|
||||||
|
|
||||||
df = await client.get_security_list_all(pages="all")
|
|
||||||
index: list[dict[str, str]] = []
|
|
||||||
for row in df.itertuples(index=False):
|
|
||||||
name = str(getattr(row, "name", "") or "")
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
code = str(getattr(row, "code", ""))
|
|
||||||
initials = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER))
|
|
||||||
index.append({"code": code, "name": name, "initials": initials})
|
|
||||||
|
|
||||||
_SEARCH_INDEX = index
|
|
||||||
return {"count": len(index), "data": index}
|
return {"count": len(index), "data": index}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -105,11 +105,11 @@ export async function fetchBars(
|
|||||||
|
|
||||||
/** 拉取股票搜索索引(code/name/initials,约 5000 条,~150KB)。
|
/** 拉取股票搜索索引(code/name/initials,约 5000 条,~150KB)。
|
||||||
* 前端 useStockSearch 会模块级缓存,整个会话只拉一次。
|
* 前端 useStockSearch 会模块级缓存,整个会话只拉一次。
|
||||||
* 超时 15 秒放弃——后端首次构建索引要走全量 get_security_list_all(几十秒),
|
* 超时 120 秒——后端首次构建索引要走全量 get_security_list_all(几十秒),
|
||||||
* 超时后放弃可避免长时间独占共享连接、阻塞 /bars 行情请求。 */
|
* AppInitOverlay 遮罩期间用户本就在等待,不能过早 abort。 */
|
||||||
export async function fetchSearchIndex(): Promise<StockSearchIndex> {
|
export async function fetchSearchIndex(): Promise<StockSearchIndex> {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = setTimeout(() => controller.abort(), 15_000)
|
const timer = setTimeout(() => controller.abort(), 120_000)
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${BASE}/security/search-index`, { signal: controller.signal })
|
const resp = await fetch(`${BASE}/security/search-index`, { signal: controller.signal })
|
||||||
if (!resp.ok) await throwError(resp)
|
if (!resp.ok) await throwError(resp)
|
||||||
|
|||||||
Reference in New Issue
Block a user