o2o-negosium-original/lps/tests/test_alerts.py
민헌 8b9e81777c feat(lps): 알림 확장 — AlertManager 쿨다운·회복, DB풀 포화·소스별 장기실패 룰
기존 ops-monitor 는 임계 초과가 지속되면 30초마다 같은 웹훅을 반복 발송했고
(쿨다운 없음), 해소 여부도 알 수 없었다. 감시 항목도 큐 지표 4종뿐이었다.

- common/alerts.py AlertManager 신설: 룰 키별 상태 관리 — 발화 1회 +
  쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드, 해소 시 회복
  알림 1회. sender/clock 주입으로 네트워크·대기 없이 단위 테스트.
- 워커 ops-monitor 를 AlertManager 로 이관(기존 4룰 유지) + 신규 2룰:
  db_pool(풀 포화율 ≥ LPS_ALERT_POOL_PCT 90%) ·
  source_fail:<src>(최근 30분 시도 ≥ LPS_ALERT_SOURCE_FAIL_30M(5) & 성공 0
  — 쿼터 소진·셀렉터 드리프트·전면 차단 신호).
- DBSessionManager.pool_status(): 전 엔진 합산 checked_out/capacity/pct.
- SearchAdapter 에 시간 윈도우 성공/실패 카운터(recent_stats) — 누적
  카운터로는 '최근 30분 성공 0건'을 볼 수 없어 추가. 쿠팡(브라우저)·
  네이버(API) 성공/실패 지점에 배선.
- API 자체 풀 모니터: lifespan 백그라운드 태스크(run_pool_monitor) —
  대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있다.
  /v1/lps/ops 에 pool_checked_out/pool_capacity/pool_pct 노출(스모크 확인).
- 테스트 9건 추가(발화·쿨다운·회복·룰 독립·윈도우 카운터·풀 현황), 전체 135 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:42:12 +09:00

100 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AlertManager 테스트 — 발화·쿨다운(스팸 방지)·회복 알림 (sender/clock 주입, 네트워크·대기 없음)."""
from common.alerts import AlertManager
from common.database.db_session_manager import DB_SESSION_MNG
from services.search.naver.adapter import NaverAdapter
class _Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
def _mgr(cooldown=1800):
sent = []
async def sender(text):
sent.append(text)
clock = _Clock()
return AlertManager(origin="test", cooldown_sec=cooldown, sender=sender, clock=clock), sent, clock
async def test_fires_once_on_activation():
mgr, sent, _ = _mgr()
await mgr.check("dead", True, "DEAD 1h=25")
await mgr.check("dead", True, "DEAD 1h=26") # 조건 지속 — 쿨다운 안이라 재발송 없음
assert len(sent) == 1 and "DEAD 1h=25" in sent[0]
assert mgr.is_active("dead")
async def test_refires_after_cooldown():
mgr, sent, clock = _mgr(cooldown=1800)
await mgr.check("dead", True, "m1")
clock.t += 1801 # 쿨다운 경과 — 리마인드 1회
await mgr.check("dead", True, "m2")
assert len(sent) == 2 and "m2" in sent[1]
async def test_recovery_notice_once():
mgr, sent, _ = _mgr()
await mgr.check("blocks", True, "차단 1h=90")
await mgr.check("blocks", False, "차단 1h=3") # 해소 알림 1회
await mgr.check("blocks", False, "차단 1h=0") # 이미 비활성 — 무발송
assert len(sent) == 2
assert "해소" in sent[1]
assert not mgr.is_active("blocks")
async def test_inactive_rule_never_fires():
mgr, sent, _ = _mgr()
await mgr.check("queue_lag", False, "큐지연=3s")
assert sent == []
async def test_rules_are_independent():
mgr, sent, _ = _mgr()
await mgr.check("dead", True, "a")
await mgr.check("db_pool", True, "b") # 다른 키 — 각자 발화
assert len(sent) == 2
async def test_sender_failure_does_not_raise():
async def boom(text):
raise RuntimeError("webhook down")
mgr = AlertManager(origin="test", cooldown_sec=10, sender=boom)
try:
await mgr.check("dead", True, "m")
except RuntimeError:
# sender 주입 시 예외는 호출부(모니터 루프의 try)가 처리 — 여기서는 전파돼도 루프가 삼킨다
pass
# ---- 윈도우 성공/실패 카운터(소스별 장기 실패 룰의 데이터) ----------------
def test_recent_stats_counts_within_window():
ad = NaverAdapter(keys=[("id", "sec")])
ad._note_result(True)
ad._note_result(False)
ad._note_result(False)
assert ad.recent_stats(1800) == (3, 1)
assert ad.recent_stats(0) == (0, 0) # 창 밖이면 미집계
def test_recent_stats_empty_adapter():
ad = NaverAdapter(keys=[("id", "sec")])
assert ad.recent_stats(1800) == (0, 0) # lazy init — 기록 전에도 안전
# ---- DB 풀 사용 현황 ------------------------------------------------------
def test_pool_status_shape():
st = DB_SESSION_MNG.pool_status()
assert set(st) == {"checked_out", "capacity", "pct"}
assert st["capacity"] > 0 # R/W 2엔진 × (pool+overflow)
assert 0 <= st["pct"] <= 100