o2o-negosium-original/lps/tests/test_alerts.py
민헌 3b345929d4 feat(lps): 포트(IP) 장부를 DB 로 올려 멀티 프로세스에서 안전하게 — 프로필 슬롯도 분리
한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰는 전제로 전환한다. 인메모리 장부는
프로세스마다 따로라 (1) 같은 IP 를 동시에 잡고 (2) 한쪽이 태운 IP 를 다른 쪽이 곧바로
집으며 (3) 재시작하면 쿨다운이 통째로 사라졌다.

proxy_port 테이블 = 단일 진실. 상태는 세 시각으로만 표현한다(leased/rest/cooldown_until).
- acquire: 한 UPDATE 안에서 FOR UPDATE SKIP LOCKED 로 후보를 잠그고 임대까지 끝낸다
  (잡 큐와 같은 방식 — SELECT 후 UPDATE 로 나누면 그 틈에 다른 프로세스가 같은 행을 집는다)
- 회전은 LRU(last_used_at). 프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가
  자연히 한 바퀴씩 돈다 → 프로세스별 seed_offset 계산 제거
- 죽은 프로세스 회수: leased_until 만료로 자동 복귀(별도 reaper 불필요)
- 차단·휴식은 전역이라 재시작해도 유지된다

DB 왕복은 비동기라 검색 루프(동기)에서 곧바로 못 한다 → 회전·차단을 pending 에 적어두고
ensure_port(브라우저 재기동 직전, async)에서 한 번에 flush. _close_ctx 에서도 flush 해
종료 시 유실(=태운 IP 를 남이 그대로 집는 상황)을 막는다.

**프로필 슬롯**(services/search/profile_slot): Chrome 은 user_data_dir 당 1 인스턴스다.
예전엔 워커 인덱스로만 갈라서 프로세스 2개면 같은 경로를 잡아 두 번째가 통째로 죽었다
(실측: 잡 3건 중 2건 DEAD, TargetClosedError). 파일 락으로 슬롯을 선점한다 — PID 경로가
아니라 슬롯이라 재시작 시 재사용돼 웜 쿠키(cf_clearance·Akamai)를 버리지 않는다.

검증: 프로세스 2개 동시 acquire 20회 → 중복 배정 0건. 워커 2프로세스 e2e → 잡 3건 모두
DONE(네이버가 삼다수 최저가 획득 8,960 < 13,200). 테스트 14건 추가, 전체 217 passed.
2026-08-05 11:44:54 +09:00

144 lines
4.9 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
# ---- 가용 프록시 포트 현황(포트 고갈 룰의 데이터) --------------------------
async def test_port_pool_status_picks_worst_gateway():
"""게이트웨이가 둘이라 **가장 마른 쪽** 기준으로 알림을 걸어야 한다 —
한쪽만 고갈돼도 그 소스(네이버/쿠팡)는 검색을 못 하므로 평균으로 덮으면 안 된다."""
from worker_main import _port_pool_status
class _Store:
async def snapshot(self, host=None):
return {"gate.decodo.com": {"held": 2, "resting": 0, "cooling": 0, "available": 8, "total": 10},
"kr.decodo.com": {"held": 1, "resting": 2, "cooling": 4, "available": 3, "total": 10}}
class _Proxy:
_store = _Store()
class _Ad:
_proxy = _Proxy()
detail, ports = await _port_pool_status([object(), _Ad()]) # 프록시 없는 어댑터 혼재 OK
assert ports == (3, 10) # kr(=3) 기준
assert detail["kr.decodo.com"]["cooling"] == 4 # 원인 파악용 상세도 함께 실린다
async def test_port_pool_status_none_without_store():
from worker_main import _port_pool_status
assert await _port_pool_status([object()]) == ({}, None)
assert await _port_pool_status(None) == ({}, None)
async def test_port_pool_status_survives_db_error():
"""장부 조회가 실패해도 ops 모니터는 계속 돌아야 한다(알림만 건너뛴다)."""
from worker_main import _port_pool_status
class _Broken:
async def snapshot(self, host=None):
raise RuntimeError("db down")
class _Ad:
class _proxy:
_store = _Broken()
assert await _port_pool_status([_Ad()]) == ({}, None)