From 765d73b34286ed9beec06f8eb299fcb886ee4c94 Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Sat, 18 Jul 2026 20:10:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(observability):=20worker=20=E5=BF=83?= =?UTF-8?q?=E8=B7=B3=E5=91=8A=E8=AD=A6=E9=97=AD=E7=8E=AF=20+=20topic=20las?= =?UTF-8?q?t=5Ferror=20=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 worker 故障只记日志:心跳过期只触发 docker restart(无人工告警), topic 抓取失败静默(last_error 被 API 用 CollectionAction.created_at 覆盖掩盖)。运维只能登服务器看日志发现问题。 后端: - apps/worker/main.py:心跳改写共享卷 /app/data/worker_heartbeat.json (pm_data 卷,backend 可读)+ 新增 heartbeat_alert_job(每10min自检): 心跳过期/主题 last_error 非空 → 发告警邮件给 notify_default_to, 带 1h 去重防刷屏(模块级 _last_alerts)。注册到 scheduler(*/10)。 - scripts/worker_healthcheck.py:改读共享卷心跳(与 worker 写入对齐)。 - apps/api/routers/topics.py:list_topics/_topic_dict 修复 last_run_at/ last_error 掩盖——改读 TopicSubscription 真实抓取状态(PR1 存的), CollectionAction.created_at 改作 last_action_at 单独字段。加 ?failed=true 过滤(只返回 last_error 非空的 topic,供面板)。 - apps/api/routers/system.py:新增 /system/worker 端点(读共享卷心跳返 {ts,error,age_seconds,is_stale});system_status 增补 worker_heartbeat + topic_errors 计数。 前端: - types/index.ts:Topic 加 last_error/last_action_at;新增 WorkerHeartbeat 类型;SystemStatus 加 worker_heartbeat/topic_errors。 - services/api.ts:systemApi.worker + topicApi.list(failed) + WorkerHeartbeat 导入。 - pages/Operations.tsx:加 Worker 心跳面板(绿/红时效判定)+ 主题抓取错误 面板(列表 last_error + 重新抓取按钮复用 topicApi.fetch)。mount 时加载。 测试:新增 TestWorkerAlertDedup(去重+无收件人静默)、TestTopicLastErrorExposed (last_error 持久化+成功清空),全套 61 passed。tsc --noEmit 通过。 --- apps/api/routers/system.py | 39 +++++++++ apps/api/routers/topics.py | 23 +++-- apps/worker/main.py | 101 +++++++++++++++++++++- frontend/src/pages/Operations.tsx | 137 +++++++++++++++++++++++++++++- frontend/src/services/api.ts | 5 +- frontend/src/types/index.ts | 11 +++ scripts/worker_healthcheck.py | 8 +- tests/test_repositories.py | 69 +++++++++++++++ 8 files changed, 379 insertions(+), 14 deletions(-) diff --git a/apps/api/routers/system.py b/apps/api/routers/system.py index 72a2d38..49d1afd 100644 --- a/apps/api/routers/system.py +++ b/apps/api/routers/system.py @@ -2,6 +2,9 @@ @author Color2333 """ +import time +from pathlib import Path + from fastapi import APIRouter, Query from apps.api.deps import iso_dt, settings @@ -15,6 +18,28 @@ router = APIRouter() +# worker 心跳文件(pm_data 共享卷,worker 写、backend 读) +_WORKER_HEARTBEAT_FILE = Path("/app/data/worker_heartbeat.json") +_HEARTBEAT_STALE_SECONDS = 1200 # 与 worker healthcheck 一致 + + +def _read_worker_heartbeat() -> dict: + """读共享卷 worker 心跳,返回 {ts, error, age_seconds, is_stale};文件缺失返回 None。""" + import json + + try: + data = json.loads(_WORKER_HEARTBEAT_FILE.read_text()) + ts = float(data.get("ts", 0)) + age = time.time() - ts + return { + "ts": ts, + "error": data.get("error"), + "age_seconds": int(age), + "is_stale": age > _HEARTBEAT_STALE_SECONDS, + } + except (OSError, ValueError, TypeError): + return None + @router.get("/health") def health() -> dict: @@ -28,6 +53,16 @@ def health() -> dict: } +@router.get("/system/worker") +def worker_status() -> dict: + """Worker 心跳状态(可观测性:供 Operations 面板 + 告警自检查询)""" + hb = _read_worker_heartbeat() + return { + "heartbeat": hb, + "stale_threshold": _HEARTBEAT_STALE_SECONDS, + } + + @router.get("/system/status") def system_status() -> dict: with session_scope() as session: @@ -36,6 +71,8 @@ def system_status() -> dict: papers_total = PaperRepository(session).count_all() runs = PipelineRunRepository(session).list_latest(limit=50) failed = [r for r in runs if r.status.value == "failed"] + # 可观测性:附带 worker 心跳摘要 + 主题抓取错误计数 + errored_topics = [t for t in topics if t.last_error] return { "health": health(), "counts": { @@ -45,6 +82,8 @@ def system_status() -> dict: "runs_latest_50": len(runs), "failed_runs_latest_50": len(failed), }, + "worker_heartbeat": _read_worker_heartbeat(), + "topic_errors": len(errored_topics), "latest_run": ( { "pipeline_name": runs[0].pipeline_name, diff --git a/apps/api/routers/topics.py b/apps/api/routers/topics.py index 8f7d121..f451591 100644 --- a/apps/api/routers/topics.py +++ b/apps/api/routers/topics.py @@ -36,8 +36,12 @@ def _topic_dict(t, session=None) -> dict: "enable_date_filter": getattr(t, "enable_date_filter", False), "date_filter_days": getattr(t, "date_filter_days", 7), "paper_count": 0, - "last_run_at": None, + # last_run_at/last_error 读 TopicSubscription 真实抓取状态(PR1 存的), + # 此前被 CollectionAction.created_at 覆盖掩盖了抓取失败 + "last_run_at": t.last_run_at.isoformat() if t.last_run_at else None, + "last_error": t.last_error, "last_run_count": None, + "last_action_at": None, # 最近一次收集行动(与 last_run_at 区分) } if session is not None: from sqlalchemy import func, select @@ -49,7 +53,7 @@ def _topic_dict(t, session=None) -> dict: select(func.count()).select_from(PaperTopic).where(PaperTopic.topic_id == t.id) ) d["paper_count"] = cnt or 0 - # 最近一次行动 + # 最近一次收集行动(单独字段,不再覆盖 last_run_at) last_action = session.execute( select(CollectionAction) .where(CollectionAction.topic_id == t.id) @@ -57,7 +61,7 @@ def _topic_dict(t, session=None) -> dict: .limit(1) ).scalar_one_or_none() if last_action: - d["last_run_at"] = ( + d["last_action_at"] = ( last_action.created_at.isoformat() if last_action.created_at else None ) d["last_run_count"] = last_action.paper_count @@ -65,13 +69,16 @@ def _topic_dict(t, session=None) -> dict: @router.get("/topics") -def list_topics(enabled_only: bool = False) -> dict: +def list_topics(enabled_only: bool = False, failed: bool = False) -> dict: from sqlalchemy import func, select from packages.storage.models import CollectionAction, PaperTopic with session_scope() as session: topics = TopicRepository(session).list_topics(enabled_only=enabled_only) + # failed=true:只返回最近抓取出错的 topic(供可观测性面板) + if failed: + topics = [t for t in topics if t.last_error] if not topics: return {"items": []} topic_ids = [t.id for t in topics] @@ -110,12 +117,16 @@ def list_topics(enabled_only: bool = False) -> dict: "enabled": t.enabled, "created_at": t.created_at.isoformat() if t.created_at else None, "paper_count": paper_counts.get(t.id, 0), - "last_run_at": None, + # last_run_at/last_error 读 TopicSubscription 真实抓取状态(PR1), + # 此前被 CollectionAction.created_at 覆盖,抓取失败被掩盖 + "last_run_at": t.last_run_at.isoformat() if t.last_run_at else None, + "last_error": t.last_error, + "last_action_at": None, "last_run_count": None, } last_action = latest_actions.get(t.id) if last_action: - d["last_run_at"] = ( + d["last_action_at"] = ( last_action.created_at.isoformat() if last_action.created_at else None ) d["last_run_count"] = last_action.paper_count diff --git a/apps/worker/main.py b/apps/worker/main.py index 47f650c..584f85c 100644 --- a/apps/worker/main.py +++ b/apps/worker/main.py @@ -36,9 +36,13 @@ setup_logging() logger = logging.getLogger(__name__) -_HEALTH_FILE = Path("/tmp/worker_heartbeat") +# 心跳改写共享卷 pm_data(/app/data),backend 也能读同一文件暴露 worker 状态。 +# 此前写 /tmp(容器内,后端读不到),可观测性端点无法查询 worker 健康。 +_HEALTH_FILE = Path("/app/data/worker_heartbeat.json") # 心跳健康判定:最近一次心跳距现在超过此秒数视为不健康(捕获 worker 卡死/全部任务失败) _HEARTBEAT_STALE_SECONDS = 1200 # 20 分钟(cron job 最小间隔 30min,留足缓冲) +# 告警去重:同一告警类型在此秒数内不重复发邮件(防刷屏) +_ALERT_DEDUP_SECONDS = 3600 # 1 小时 def _write_heartbeat(error: str | None = None) -> None: @@ -56,6 +60,92 @@ def _write_heartbeat(error: str | None = None) -> None: ) +def _read_heartbeat() -> dict | None: + """读共享卷心跳文件,供自检告警 + 端点查询。文件缺失/损坏返回 None。""" + import json + + try: + return json.loads(_HEALTH_FILE.read_text()) + except (OSError, ValueError, TypeError): + return None + + +# 告警去重状态:{alert_key: last_alert_ts}(进程内,重启后重置——可接受) +_last_alerts: dict[str, float] = {} + + +def _send_alert(subject: str, html: str, alert_key: str) -> None: + """发告警邮件给 notify_default_to,带 1h 去重。SMTP 未配置时静默跳过。""" + now = time.time() + last = _last_alerts.get(alert_key, 0) + if now - last < _ALERT_DEDUP_SECONDS: + logger.debug( + "告警 %s 去重中(距上次 %.0fs < %ds),跳过", + alert_key, + now - last, + _ALERT_DEDUP_SECONDS, + ) + return + from packages.config import get_settings + from packages.integrations.notifier import NotificationService + + recipient = get_settings().notify_default_to + if not recipient: + logger.debug("notify_default_to 未配置,跳过告警 %s", alert_key) + return + ok = NotificationService().send_email_html(recipient, subject, html) + if ok: + _last_alerts[alert_key] = now + logger.info("告警邮件已发送: %s -> %s", alert_key, recipient) + else: + logger.warning("告警邮件发送失败(SMTP 未配置或出错): %s", alert_key) + + +def heartbeat_alert_job() -> None: + """每 10min 自检:心跳过期 + 主题抓取错误 → 发告警邮件(可观测性闭环)。 + + 心跳过期说明 worker 卡死或全部 job 失败;主题 last_error 说明某主题抓取失败。 + 两者此前只记日志无人知,现在发邮件给 notify_default_to(带 1h 去重防刷屏)。 + """ + import html as html_lib + + # 1. 心跳过期检查 + hb = _read_heartbeat() + if hb is None or (time.time() - float(hb.get("ts", 0))) > _HEARTBEAT_STALE_SECONDS: + age = ( + "未知(文件缺失/损坏)" + if hb is None + else f"{int(time.time() - float(hb.get('ts', 0)))}s" + ) + body = ( + f"
心跳距今 {age}(阈值 {_HEARTBEAT_STALE_SECONDS}s)。
" + f"可能原因:worker 卡死、全部 job 失败、或容器异常。
" + f"最近错误:{(hb or {}).get('error') or 'N/A'}
" + f"请检查 worker 容器状态与日志。
" + ) + _send_alert("[PaperMind] Worker 心跳过期告警", body, "heartbeat_stale") + # 2. 主题抓取错误检查 + try: + with session_scope() as session: + topics = TopicRepository(session).list_topics(enabled_only=True) + errored = [t for t in topics if t.last_error] + if errored: + rows = "".join( + f"| 主题 | 最近错误 |
|---|
心跳过期({heartbeat.age_seconds}s > {staleThreshold}s)
+worker 可能卡死或全部任务失败。最近错误:{heartbeat.error || "N/A"}
+worker 每 10min 自检发告警邮件给 notify_default_to。
++ {t.last_run_at ? `最近运行:${new Date(t.last_run_at).toLocaleString()}` : "无运行记录"} +
++ {t.last_error || "未知错误"} +
+x
", "test_key") + assert len(sent) == 1, "首次告警应发送" + # 窗口内再次告警 → 去重,不发送 + wm._send_alert("[PaperMind] Test Alert", "x
", "test_key") + assert len(sent) == 1, "去重窗口内不应重复发送" + wm._last_alerts.clear() + + def test_send_alert_no_recipient_silent(self, monkeypatch): + from apps.worker import main as wm + + wm._last_alerts.clear() + sent: list[str] = [] + + def fake_send(self, recipient, subject, html): + sent.append(subject) + return True + + monkeypatch.setattr( + "packages.integrations.notifier.NotificationService.send_email_html", fake_send + ) + # notify_default_to 未配置 + monkeypatch.setattr( + "packages.config.get_settings", + lambda: type("S", (), {"notify_default_to": None})(), + ) + wm._send_alert("[PaperMind] Test", "x
", "no_recv") + assert len(sent) == 0, "无收件人应静默跳过" + wm._last_alerts.clear() + + +class TestTopicLastErrorExposed: + """主题 last_error 经 repository 可读(可观测性:API 不再掩盖)""" + + def test_last_error_persisted_and_readable(self, db_session): + repo = TopicRepository(db_session) + topic = repo.upsert_topic(name="ErrTopic", query="q") + db_session.flush() + repo.update_run_status(topic.id, error="arxiv 429 限流") + db_session.refresh(topic) + assert topic.last_error == "arxiv 429 限流" + assert topic.last_run_at is not None + # 成功后清空 + repo.update_run_status(topic.id, error=None) + db_session.refresh(topic) + assert topic.last_error is None