修复中控轮询连锁唤醒导致 CPU 居高不下:轮询加最小间隔并取消 board 每轮强制刷新看板。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 心跳间隔).
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user