"""探活快照(`GET /healthz`):**纯内存返回 + 后台线程刷新**,绝不阻塞探活请求。 为什么不能"每次探活都查一次库": * 曲库 69 万行时 `count(*)` 这类全表统计在云盘/FUSE 上要数秒 —— IIS/负载均衡的探活会超时, 还会把它们自己的线程池拖住; * 探活只关心"进程活着、能服务、大致有多少活干",**不需要秒级精确**。 所以:后台守护线程每 `REFRESH_SEC` 秒查一次(只查数据库的**缓存值**,绝不列举媒体目录、 绝不 stat 媒体文件),把结果塞进内存快照;`/healthz` 直接返回快照(微秒级)。 快照字段: * `library`:曲库(songs)条数;`keeps`:歌曲表(keeps)条数; * `downloader`:下载池的**纯内存**状态(跑没跑、暂停没、并发、在跑几首)—— 来自 `Downloader.health()`,不查库、不碰磁盘。 ⚠️ 刷新失败只记一次日志(不刷屏),快照保留上一次的值(探活照样 200:进程还在服务)。 """ from __future__ import annotations import threading import time from . import __version__ # 后台刷新间隔(秒)。探活本身不查库,几十秒的"数字略旧"完全可接受; # 想更快看到数字,调小这里即可(代价是更频繁的全表统计)。 REFRESH_SEC = 30.0 # 首次刷新前的占位(进程刚起来、库还没打开时的兜底值) _EMPTY = {"library": 0, "keeps": 0, "downloader": {}} class HealthSnapshot: def __init__(self, cfg, store, downloader=None, refresh_sec: float = REFRESH_SEC): self.cfg = cfg self.store = store self.downloader = downloader self.refresh_sec = max(1.0, float(refresh_sec or REFRESH_SEC)) self._lock = threading.Lock() self._data = dict(_EMPTY) self._at = 0.0 # 最近一次刷新成功时刻(monotonic) self._started = time.monotonic() # 进程/快照起点:uptime_sec 用 self._stop = threading.Event() self._thread: threading.Thread | None = None self._err_logged = "" # ── 生命周期 ── def start(self) -> None: """启动后台刷新线程(**必须在两个端口监听之后**调用,见 main.py 的启动顺序)。""" if self._thread is not None: return self._stop.clear() self._thread = threading.Thread(target=self._loop, name="health-refresh", daemon=True) self._thread.start() def stop(self) -> None: self._stop.set() def _loop(self) -> None: while not self._stop.is_set(): try: self.refresh() except Exception: # pragma: no cover - refresh 内部已兜底 pass if self._stop.wait(self.refresh_sec): return # ── 刷新(**只有后台线程和测试会调用**;探活路径绝不调用它)── def refresh(self) -> dict: """查一次库/下载池,更新内存快照并返回快照(任何异常都不抛出)。""" data = dict(_EMPTY) try: st = self.store.stats() data["library"] = int(st.get("songs") or 0) data["done"] = int(st.get("done") or 0) data["pending"] = int(st.get("pending") or 0) except Exception as exc: self._note_err(f"曲库统计失败:{exc}") try: data["keeps"] = int(self.store.keep_stats().get("total") or 0) except Exception as exc: self._note_err(f"歌曲表统计失败:{exc}") try: data["downloader"] = self.downloader.health() if self.downloader is not None else {} except Exception as exc: self._note_err(f"下载池状态失败:{exc}") data["downloader"] = {} with self._lock: self._data = data self._at = time.monotonic() return dict(data) def _note_err(self, text: str) -> None: """同一类错误只写一条事件(避免探活把日志刷爆)。""" if text == self._err_logged: return self._err_logged = text try: self.store.event("WARN", f"探活快照刷新失败(沿用上次的值):{text}") except Exception: pass # ── 读(探活路径:纯内存,微秒级)── @property def uptime_sec(self) -> int: return int(max(0.0, time.monotonic() - self._started)) def snapshot(self) -> dict: """当前快照(**不查库、不碰媒体目录、绝不阻塞**)。""" with self._lock: data = dict(self._data) at = self._at data.update({ "ok": True, "version": __version__, "uptime_sec": self.uptime_sec, "snapshot_age_sec": int(max(0.0, time.monotonic() - at)) if at else -1, }) return data # 老接口字段(`songs`/`done`/`pending`/`concurrency`)也一并给出,老脚本不用改 def snapshot_compat(self) -> dict: data = self.snapshot() data.setdefault("songs", data.get("library", 0)) dl = data.get("downloader") or {} data["concurrency"] = int(dl.get("concurrency") or 0) return data