한 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.
144 lines
4.9 KiB
Python
144 lines
4.9 KiB
Python
"""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)
|