0e3fffef44
Co-authored-by: Cursor <cursoragent@cursor.com>
198 lines
6.1 KiB
Python
198 lines
6.1 KiB
Python
"""中控总览热缓存:后台常驻并行 pull,与浏览器是否打开无关;SSE 仅扇出版本通知."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Iterator
|
|
from typing import Any
|
|
|
|
OnPullFn = Callable[[list[dict[str, Any]]], None]
|
|
|
|
|
|
def _poll_interval_sec() -> float:
|
|
try:
|
|
return max(3.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
|
|
except (TypeError, ValueError):
|
|
return 10.0
|
|
|
|
|
|
def _sse_heartbeat_sec() -> float:
|
|
try:
|
|
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
|
|
except (TypeError, ValueError):
|
|
return 25.0
|
|
|
|
|
|
class OverviewStore:
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
self.version = 0
|
|
self.instances: list[dict[str, Any]] = []
|
|
self.pulling = True # 启动至首轮完成前视为拉取中
|
|
self.last_error: str | None = None
|
|
self.updated_at: str = ""
|
|
self._ready = False
|
|
self._subscribers: list[queue.Queue[str | None]] = []
|
|
self._stop = threading.Event()
|
|
self._refresh = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._pull_fn: Callable[[], list[dict[str, Any]]] | None = None
|
|
self._on_pull: OnPullFn | None = None
|
|
|
|
def start(
|
|
self,
|
|
pull_fn: Callable[[], list[dict[str, Any]]],
|
|
*,
|
|
on_pull: OnPullFn | None = None,
|
|
) -> None:
|
|
self._pull_fn = pull_fn
|
|
self._on_pull = on_pull
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop.clear()
|
|
with self._lock:
|
|
self.pulling = True
|
|
self._ready = False
|
|
self._thread = threading.Thread(
|
|
target=self._loop,
|
|
daemon=True,
|
|
name="hub-overview-pull",
|
|
)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
self._refresh.set()
|
|
self._broadcast(close=True)
|
|
|
|
def request_refresh(self) -> None:
|
|
self._refresh.set()
|
|
|
|
def snapshot_dict(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
rows = list(self.instances)
|
|
ver = self.version
|
|
pulling = self.pulling
|
|
err = self.last_error
|
|
updated_at = self.updated_at
|
|
return {
|
|
"ok": True,
|
|
"instances": rows,
|
|
"overview_version": ver,
|
|
"pulling": pulling,
|
|
"updated_at": updated_at,
|
|
"error": err,
|
|
"poll_interval_sec": _poll_interval_sec(),
|
|
"from_cache": True,
|
|
}
|
|
|
|
def event_dict(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
return {
|
|
"overview_version": self.version,
|
|
"updated_at": self.updated_at,
|
|
"pulling": self.pulling,
|
|
"ok": True,
|
|
"error": self.last_error,
|
|
"poll_interval_sec": _poll_interval_sec(),
|
|
}
|
|
|
|
def _loop(self) -> None:
|
|
assert self._pull_fn is not None
|
|
while not self._stop.is_set():
|
|
self._pull_once()
|
|
if self._stop.is_set():
|
|
break
|
|
self._refresh.clear()
|
|
self._refresh.wait(timeout=_poll_interval_sec())
|
|
|
|
def _pull_once(self) -> None:
|
|
assert self._pull_fn is not None
|
|
with self._lock:
|
|
self.pulling = True
|
|
self._broadcast()
|
|
try:
|
|
rows = self._pull_fn()
|
|
if not isinstance(rows, list):
|
|
rows = []
|
|
if self._on_pull is not None:
|
|
try:
|
|
self._on_pull(rows)
|
|
except Exception:
|
|
pass
|
|
with self._lock:
|
|
self.version += 1
|
|
self.instances = rows
|
|
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
self.last_error = None
|
|
self.pulling = False
|
|
self._ready = True
|
|
except Exception as e:
|
|
with self._lock:
|
|
self.version += 1
|
|
self.last_error = str(e) or "pull_failed"
|
|
self.pulling = False
|
|
self._ready = True
|
|
# 保留上一轮成功缓存,避免整页空白
|
|
self._broadcast()
|
|
|
|
def _broadcast(self, *, close: bool = False) -> None:
|
|
with self._lock:
|
|
subs = list(self._subscribers)
|
|
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
|
dead: list[queue.Queue[str | None]] = []
|
|
for q in subs:
|
|
try:
|
|
q.put_nowait(None if close else event)
|
|
except queue.Full:
|
|
try:
|
|
q.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
try:
|
|
q.put_nowait(event)
|
|
except queue.Full:
|
|
dead.append(q)
|
|
except Exception:
|
|
dead.append(q)
|
|
if dead:
|
|
with self._lock:
|
|
for q in dead:
|
|
if q in self._subscribers:
|
|
self._subscribers.remove(q)
|
|
|
|
def iter_sse(self) -> Iterator[str]:
|
|
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
|
with self._lock:
|
|
self._subscribers.append(q)
|
|
try:
|
|
yield _sse_frame(self.event_dict())
|
|
while True:
|
|
try:
|
|
raw = q.get(timeout=_sse_heartbeat_sec())
|
|
except queue.Empty:
|
|
yield ": heartbeat\n\n"
|
|
continue
|
|
if raw is None:
|
|
break
|
|
try:
|
|
data = json.loads(raw)
|
|
except Exception:
|
|
data = self.event_dict()
|
|
yield _sse_frame(data)
|
|
finally:
|
|
with self._lock:
|
|
if q in self._subscribers:
|
|
self._subscribers.remove(q)
|
|
|
|
|
|
def _sse_frame(data: dict[str, Any]) -> str:
|
|
body = json.dumps(data, ensure_ascii=False)
|
|
return f"event: overview\ndata: {body}\n\n"
|
|
|
|
|
|
overview_store = OverviewStore()
|