"""임계 알림 관리자 — 쿨다운(스팸 방지)·회복 알림. 워커(ops-monitor)·API(풀 모니터) 공용. 기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다. AlertManager 는 룰 키별로 상태를 관리한다: 발화: 비활성→활성 전환 시 1회 + 이후 쿨다운([AlertConfig].cooldown_min, 기본 30분)마다 리마인드 회복: 활성→비활성 전환 시 '해소' 알림 1회 채널: WARN/INFO 로그(항상) + Slack 호환 웹훅([AlertConfig].webhook 있을 때만, 실패 무시). sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능. """ import time import httpx from common.logger import LOG from config.server_configs import alert_config class AlertManager: def __init__(self, origin: str = "worker", webhook: str | None = None, cooldown_sec: float | None = None, sender=None, clock=time.monotonic): self.origin = origin # 알림 출처(worker/api) — 메시지에 표기 self._webhook = webhook if webhook is not None else alert_config.webhook self._cooldown = cooldown_sec if cooldown_sec is not None else alert_config.cooldown_min * 60 self._sender = sender # async def(text: str) — 테스트 주입용(없으면 웹훅) self._clock = clock self._state: dict[str, dict] = {} # key → {"active": bool, "last_sent": float} async def check(self, key: str, active: bool, message: str, snap: dict | None = None): """룰 1개 평가. active 가 True 로 지속돼도 쿨다운 안에는 재발송하지 않는다.""" st = self._state.setdefault(key, {"active": False, "last_sent": 0.0}) now = self._clock() if active: due = (not st["active"]) or (now - st["last_sent"] >= self._cooldown) st["active"] = True if due: st["last_sent"] = now LOG.w(f"[alert:{key}] {message}") await self._send(f":rotating_light: LPS({self.origin}) [{key}] {message}", snap) elif st["active"]: st["active"] = False LOG.i(f"[alert:{key}] 해소 — {message}") await self._send(f":white_check_mark: LPS({self.origin}) [{key}] 해소 — {message}", snap) def is_active(self, key: str) -> bool: return self._state.get(key, {}).get("active", False) async def _send(self, text: str, snap: dict | None): if self._sender is not None: await self._sender(text) return if not self._webhook: return try: async with httpx.AsyncClient(timeout=5) as c: await c.post(self._webhook, json={"text": text + (f"\n```{snap}```" if snap else "")}) except Exception: pass # 알림 실패가 모니터 루프를 막지 않는다 async def run_pool_monitor(stop, interval: float = 60.0, alerts: AlertManager | None = None): """DB 커넥션 풀 포화 감시 루프 — API 프로세스용 경량 모니터(lifespan 에서 기동). 대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있어, 워커 ops-monitor 와 별도로 감시한다.""" import asyncio from common.database.db_session_manager import DB_SESSION_MNG alerts = alerts or AlertManager(origin="api") threshold = alert_config.pool_pct while not stop.is_set(): try: st = DB_SESSION_MNG.pool_status() await alerts.check("db_pool", st["pct"] >= threshold, f"DB 풀 포화 {st['pct']}% (checked_out {st['checked_out']}/{st['capacity']})", st) except Exception as ex: LOG.e_no_callstack(f"[pool-monitor] {type(ex).__name__}: {ex}") try: await asyncio.wait_for(stop.wait(), timeout=interval) except asyncio.TimeoutError: pass