feat: make telemetry degradation a first-class state
Telemetry degradation used to be a single warning and a private boolean. In a long-running process that is indistinguishable from telemetry working: issue #15 was only found by hand-reconciling milestone log lines against llm_calls rows, after 19 calls had silently gone unrecorded. The SQLite side was worse — once init failed, every write returned without even a log line. Degradation now has one shared owner. TelemetryStatusTracker holds the state machine (enter/recover/drop/should-retry), announces entry and recovery once each, and repeats the drop count under a row-and-time double threshold so a degraded backend neither floods the log nor goes quiet. Both recorders hold one; both count the rows they drop. For programmatic consumers, TelemetryStatus is a frozen snapshot exposed as telemetry_status on all three clients, resolved through a single isinstance check. It is a separate optional port rather than a member of TelemetryRecorder: that protocol is @runtime_checkable, so adding an attribute would make every implementation that only defines record_llm_call stop satisfying it — downstream isinstance assertions would break on upgrade. The existing assertion in test_ports.py is what keeps that decision honest. Failure criteria are deliberately untouched here: Postgres still treats a pool failure as permanent, only now visibly. `_failed` and the tracker therefore both carry the verdict for the span of this one change; the cooldown rework collapses them into the tracker alone.
This commit is contained in:
@@ -1686,3 +1686,186 @@ class TestTextCapCoversEmbedAndOcrChains:
|
||||
# 占位串 `<ocr:text image_bytes=3>` 共 24 字
|
||||
assert json.loads(row["messages"])[0]["content"] == "<ocr:tex…(略 16 字)"
|
||||
assert row["response"] == "识别结果识别结果…(略 32 字)" # 先经 OCR 自有的 200 字上限
|
||||
|
||||
|
||||
# —— 遥测降级状态(issue #15 C 组): 共用 tracker + 只读快照 + 节流日志 ——
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
"""可手动推进的单调时钟: 冷却与节流都靠它测,用例里绝不真睡。"""
|
||||
|
||||
def __init__(self, start: float = 1_000.0) -> None:
|
||||
self.t = start
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.t
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.t += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured_infos():
|
||||
"""捕获 INFO 及以上;恢复那条 info 是"降级已结束"的唯一外部信号。"""
|
||||
from loguru import logger
|
||||
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="INFO")
|
||||
yield messages
|
||||
logger.remove(sink_id)
|
||||
|
||||
|
||||
class TestTelemetryStatusTracker:
|
||||
"""状态机六字段逐个钉;它是两个 recorder 共用的降级事实源(设计 §3.3)。"""
|
||||
|
||||
def _tracker(self, clock):
|
||||
from polygateway.telemetry.status import TelemetryStatusTracker
|
||||
|
||||
return TelemetryStatusTracker(backend="postgres", now=clock)
|
||||
|
||||
def test_fresh_tracker_reports_no_degradation(self):
|
||||
tracker = self._tracker(_FakeClock())
|
||||
status = tracker.snapshot()
|
||||
assert (status.degraded, status.fatal, status.reason) == (False, False, None)
|
||||
assert status.degraded_for_s is None and status.retry_after_s is None
|
||||
assert status.dropped_rows == 0
|
||||
assert tracker.should_retry() is True # 未降级本就该正常走准备路径
|
||||
|
||||
def test_entering_degraded_reports_reason_and_cooldown(self, captured_warnings):
|
||||
tracker = self._tracker(_FakeClock())
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
status = tracker.snapshot()
|
||||
assert status.degraded is True and status.fatal is False
|
||||
assert status.reason == "连接被拒"
|
||||
assert status.degraded_for_s == pytest.approx(0.0)
|
||||
assert status.retry_after_s == pytest.approx(60.0)
|
||||
assert len(captured_warnings) == 1
|
||||
assert "连接被拒" in captured_warnings[0] and "60" in captured_warnings[0]
|
||||
|
||||
def test_fake_clock_drains_the_cooldown(self):
|
||||
clock = _FakeClock()
|
||||
tracker = self._tracker(clock)
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
clock.advance(25.0)
|
||||
status = tracker.snapshot()
|
||||
assert status.degraded_for_s == pytest.approx(25.0)
|
||||
assert status.retry_after_s == pytest.approx(35.0)
|
||||
assert tracker.should_retry() is False
|
||||
clock.advance(40.0) # 越过冷却窗口
|
||||
assert tracker.snapshot().retry_after_s == pytest.approx(0.0) # 不得为负
|
||||
assert tracker.should_retry() is True
|
||||
|
||||
def test_recover_clears_degradation_but_keeps_dropped_rows(self, captured_infos):
|
||||
tracker = self._tracker(_FakeClock())
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
for _ in range(3):
|
||||
tracker.record_drop("遥测已降级")
|
||||
tracker.recover()
|
||||
status = tracker.snapshot()
|
||||
assert (status.degraded, status.fatal, status.reason) == (False, False, None)
|
||||
assert status.degraded_for_s is None and status.retry_after_s is None
|
||||
# 进程生命周期内单调不减: 恢复不是"没丢过",下游要靠它对账
|
||||
assert status.dropped_rows == 3
|
||||
assert any("3" in message and "恢复" in message for message in captured_infos)
|
||||
|
||||
def test_drop_warnings_are_throttled_by_the_row_constant(self, captured_warnings):
|
||||
from polygateway.telemetry.status import _DROP_REPEAT_EVERY_ROWS
|
||||
|
||||
tracker = self._tracker(_FakeClock()) # 时钟不动: 只有行数阈值能触发复述
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
captured_warnings.clear() # 只数丢弃复述,不数进入降级那条
|
||||
total = _DROP_REPEAT_EVERY_ROWS * 2 + 1
|
||||
for _ in range(total):
|
||||
tracker.record_drop("遥测已降级")
|
||||
# 首条必报,其后每满一个阈值报一次 —— 关系由常量决定,不写死数字
|
||||
assert len(captured_warnings) == 1 + (total - 1) // _DROP_REPEAT_EVERY_ROWS
|
||||
assert tracker.snapshot().dropped_rows == total
|
||||
|
||||
def test_drop_warnings_are_also_throttled_by_time(self, captured_warnings):
|
||||
from polygateway.telemetry.status import _DROP_REPEAT_EVERY_S
|
||||
|
||||
clock = _FakeClock()
|
||||
tracker = self._tracker(clock)
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
captured_warnings.clear()
|
||||
tracker.record_drop("遥测已降级")
|
||||
assert len(captured_warnings) == 1
|
||||
tracker.record_drop("遥测已降级")
|
||||
assert len(captured_warnings) == 1 # 同一窗口内不刷屏
|
||||
clock.advance(_DROP_REPEAT_EVERY_S)
|
||||
tracker.record_drop("遥测已降级")
|
||||
assert len(captured_warnings) == 2 # 长跑进程里也不会静默
|
||||
|
||||
def test_fatal_degradation_never_retries(self, captured_warnings):
|
||||
clock = _FakeClock()
|
||||
tracker = self._tracker(clock)
|
||||
tracker.enter_degraded("DSN 不可解析", fatal=True, cooldown_s=None)
|
||||
clock.advance(1_000_000.0)
|
||||
status = tracker.snapshot()
|
||||
assert status.fatal is True and status.retry_after_s is None
|
||||
assert tracker.should_retry() is False
|
||||
assert "重启" in captured_warnings[0] # 恢复条件必须写在日志里
|
||||
|
||||
def test_repeating_the_same_reason_does_not_spam(self, captured_warnings):
|
||||
"""冷却到期重试再失败会反复进入降级: 同一原因只讲一次,只刷新窗口。"""
|
||||
clock = _FakeClock()
|
||||
tracker = self._tracker(clock)
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
clock.advance(60.0)
|
||||
tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0)
|
||||
assert len(captured_warnings) == 1
|
||||
assert tracker.snapshot().retry_after_s == pytest.approx(60.0) # 窗口已刷新
|
||||
assert tracker.snapshot().degraded_for_s == pytest.approx(60.0) # 但仍是同一段降级
|
||||
|
||||
|
||||
class TestSQLiteStatusVisibility:
|
||||
"""SQLite 侧今天初始化失败后写入静默 return,连一条日志都没有(设计 §1.4)。"""
|
||||
|
||||
def _broken(self, tmp_path):
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("父目录是个文件,mkdir 必然失败")
|
||||
return SQLiteRecorder(blocker / "telemetry.db", auto_migrate=True)
|
||||
|
||||
def test_init_failure_is_degraded_and_fatal(self, tmp_path, captured_warnings):
|
||||
recorder = self._broken(tmp_path)
|
||||
status = recorder.telemetry_status
|
||||
assert status.degraded is True and status.fatal is True
|
||||
assert captured_warnings # 静默降级 ≠ 静默
|
||||
|
||||
async def test_dropped_rows_are_counted_and_visible(self, tmp_path, captured_warnings):
|
||||
recorder = self._broken(tmp_path)
|
||||
captured_warnings.clear()
|
||||
await _record_minimal(recorder) # 不得抛: 遥测绝不冒泡
|
||||
assert recorder.telemetry_status.dropped_rows == 1
|
||||
assert captured_warnings # 丢的第一行必须出声
|
||||
|
||||
def test_healthy_recorder_is_not_degraded(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "ok.db", auto_migrate=True)
|
||||
assert recorder.telemetry_status.degraded is False
|
||||
recorder.close()
|
||||
|
||||
|
||||
class TestPostgresStatusVisibility:
|
||||
"""PG 侧的判死本任务不改判据,只让它经 tracker 变得可见(计划 T2)。"""
|
||||
|
||||
def _recorder(self, conn):
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
|
||||
return PostgresRecorder(
|
||||
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True
|
||||
)
|
||||
|
||||
async def test_unusable_table_shows_up_in_the_status(self, captured_warnings):
|
||||
"""表确定不存在且建不出来 = 既有的判死档;现在它要能被下游查到。"""
|
||||
recorder = self._recorder(_FakePgConn([], fail_create=True))
|
||||
await _record_minimal(recorder)
|
||||
status = recorder.telemetry_status
|
||||
assert status.degraded is True and status.fatal is True
|
||||
assert status.dropped_rows == 1 # 判死那一次调用本身也丢了一行
|
||||
assert recorder._failed is True # 过渡期两份状态并存(T5 收掉 `_failed`)
|
||||
|
||||
async def test_healthy_recorder_is_not_degraded(self):
|
||||
recorder = self._recorder(_FakePgConn(list(_EXPECTED_COLUMNS)))
|
||||
await _record_minimal(recorder)
|
||||
status = recorder.telemetry_status
|
||||
assert status.degraded is False and status.dropped_rows == 0
|
||||
|
||||
Reference in New Issue
Block a user