修复中控轮询连锁唤醒导致 CPU 居高不下:轮询加最小间隔并取消 board 每轮强制刷新看板。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-25 12:35:41 +08:00
parent 75f50fe083
commit b63f6f0962
8 changed files with 131 additions and 35 deletions
+45
View File
@@ -0,0 +1,45 @@
"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU."""
from __future__ import annotations
import asyncio
import time
async def wait_poll_interval(
*,
refresh: asyncio.Event,
stop: asyncio.Event,
interval_sec: float,
started_at: float,
min_early_wake_sec: float | None = None,
) -> None:
"""距 started_at 至少间隔 interval_sec 再进入下一轮.
期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖).
"""
interval = max(0.05, float(interval_sec))
min_early = (
float(min_early_wake_sec)
if min_early_wake_sec is not None
else min(2.0, interval * 0.4)
)
while not stop.is_set():
left = interval - (time.monotonic() - started_at)
if left <= 0:
return
refresh.clear()
stop_task = asyncio.create_task(stop.wait())
refresh_task = asyncio.create_task(refresh.wait())
done, pending = await asyncio.wait(
{stop_task, refresh_task},
timeout=left,
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
if stop.is_set():
return
if not done:
return
if refresh_task in done and (time.monotonic() - started_at) >= min_early:
return
+1 -2
View File
@@ -336,8 +336,7 @@ async def _run_board_aggregate() -> dict:
await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or []) await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or [])
except Exception: except Exception:
pass pass
# 监控聚合完成即唤醒数据看板,持仓来源与监控 5s 同步. # 看板自有轮询即可;此处再 request_refresh 会与监控锁步,聚合变慢时几乎不睡眠打满 CPU.
dashboard_store.request_refresh()
return {"ok": True, **body} return {"ok": True, **body}
except asyncio.TimeoutError: except asyncio.TimeoutError:
return { return {
+9 -8
View File
@@ -5,9 +5,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any from typing import Any
from lib.hub.hub_poll_wait_lib import wait_poll_interval
HUB_BOARD_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5")) HUB_BOARD_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5"))
HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25")) HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25"))
@@ -79,18 +82,16 @@ class MonitorBoardStore:
async def _loop(self) -> None: async def _loop(self) -> None:
assert self._build_fn is not None assert self._build_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._aggregate_once(self._build_fn) await self._aggregate_once(self._build_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(HUB_BOARD_POLL_INTERVAL)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=HUB_BOARD_POLL_INTERVAL,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None: async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock: async with self._lock:
+7 -8
View File
@@ -11,6 +11,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from hub_board_cache import board_store from hub_board_cache import board_store
from lib.hub.hub_poll_wait_lib import wait_poll_interval
HUB_CHART_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5")) HUB_CHART_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5"))
HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25")) HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25"))
@@ -161,18 +162,16 @@ class ChartPollStore:
async def _loop(self) -> None: async def _loop(self) -> None:
assert self._poll_fn is not None assert self._poll_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._poll_once(self._poll_fn) await self._poll_once(self._poll_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(HUB_CHART_POLL_INTERVAL)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=HUB_CHART_POLL_INTERVAL,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _poll_once(self, poll_fn: PollFn) -> None: async def _poll_once(self, poll_fn: PollFn) -> None:
async with self._lock: async with self._lock:
+8 -8
View File
@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any from typing import Any
from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC
from lib.hub.hub_poll_wait_lib import wait_poll_interval
HUB_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_DASHBOARD_SSE_HEARTBEAT_SEC", "25")) HUB_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_DASHBOARD_SSE_HEARTBEAT_SEC", "25"))
@@ -81,18 +83,16 @@ class DashboardStore:
async def _loop(self) -> None: async def _loop(self) -> None:
assert self._build_fn is not None assert self._build_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._aggregate_once(self._build_fn) await self._aggregate_once(self._build_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(DASHBOARD_POLL_INTERVAL_SEC)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=DASHBOARD_POLL_INTERVAL_SEC,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None: async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock: async with self._lock:
+9 -8
View File
@@ -4,9 +4,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any from typing import Any
from lib.hub.hub_poll_wait_lib import wait_poll_interval
SUPERVISOR_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30")) SUPERVISOR_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30"))
SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25")) SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25"))
@@ -65,18 +68,16 @@ class SupervisorStore:
async def _loop(self) -> None: async def _loop(self) -> None:
assert self._tick_fn is not None assert self._tick_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._tick_once(self._tick_fn) await self._tick_once(self._tick_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(SUPERVISOR_POLL_INTERVAL_SEC)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=SUPERVISOR_POLL_INTERVAL_SEC,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _tick_once(self, tick_fn: TickFn) -> None: async def _tick_once(self, tick_fn: TickFn) -> None:
async with self._lock: async with self._lock:
+1 -1
View File
@@ -20,7 +20,7 @@
1. `hub.py` 启动后 `dashboard_store`**60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照. 1. `hub.py` 启动后 `dashboard_store`**60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照.
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`). 2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`).
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**. 3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**.
4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步. 4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板;常规轮询二者各自按间隔跑,避免连锁打满 CPU.
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合. 5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合.
可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔). 可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔).
+51
View File
@@ -0,0 +1,51 @@
import asyncio
import time
import unittest
from lib.hub.hub_poll_wait_lib import wait_poll_interval
class TestWaitPollInterval(unittest.IsolatedAsyncioTestCase):
async def test_ignores_refresh_storm_until_interval(self):
refresh = asyncio.Event()
stop = asyncio.Event()
started = time.monotonic()
async def storm():
for _ in range(30):
refresh.set()
await asyncio.sleep(0.01)
t = asyncio.create_task(storm())
await wait_poll_interval(
refresh=refresh,
stop=stop,
interval_sec=0.25,
started_at=started,
min_early_wake_sec=0.2,
)
t.cancel()
elapsed = time.monotonic() - started
self.assertGreaterEqual(elapsed, 0.18)
async def test_stop_ends_early(self):
refresh = asyncio.Event()
stop = asyncio.Event()
started = time.monotonic()
async def stopper():
await asyncio.sleep(0.05)
stop.set()
asyncio.create_task(stopper())
await wait_poll_interval(
refresh=refresh,
stop=stop,
interval_sec=2.0,
started_at=started,
)
self.assertLess(time.monotonic() - started, 0.5)
if __name__ == "__main__":
unittest.main()