o2o-negosium-original/lps/tests/test_job_queue.py
민헌 ca7f057e41 feat(lps): 알림 룰 4종 추가 — 데드라인·검색원가·포트 고갈·예산 누수
협의로 선정한 조기 신호 4종을 AlertManager 에 추가한다.

- deadline: 최근 1h JobDeadlineExceeded 수 ≥ LPS_ALERT_DEADLINE_1H(5).
  재시도로 살아나면 dead 룰엔 안 잡히는 크롤 행 반복 신호를 별도 집계.
- cost: 최근 1h 완료 잡 검색원가 합 ≥ LPS_ALERT_COST_1H_USD(1.0).
  비용의 87%가 프록시 대역폭 — 리소스차단 풀림·재시도 루프의 조용한
  비용 폭주를 감시. job.result 의 metrics.cost.total_usd JSONB 합산.
- proxy_ports_low: 가용 포트 비율 ≤ LPS_ALERT_PORTS_LOW_PCT(30%).
  쿨다운 격리 누적 — blocks_1h(80건)보다 먼저 우는 대규모 차단 조기
  신호. 워커별 프록시 중 가장 소진된 것 기준(min).
- budget_leak: 최근 6h end_reason=block 세션 ≥ LPS_ALERT_BLOCK_SESSIONS_6H(1).
  요청 예산(3회)을 지켰는데도 차단됨 = 예산 하향 검토 신호.
- deadline_1h·cost_1h_usd 는 queue.ops() 에 편입 → /v1/lps/ops 로도 노출.
  포트·세션 지표는 워커 웹훅 스냅샷에 포함(프록시 상태는 워커에만 있음).
- 테스트 4건 추가(ops 집계 2·포트 스냅샷 2), 전체 145 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:29:08 +09:00

117 lines
4.7 KiB
Python

"""작업 큐 엔진 테스트 — 원자적 claim(이중할당 불가)·lease 회수·재시도/dead-letter·소유권 가드.
실제 lps_db 에 붙어 검증한다(db_engine 이 스키마 보장)."""
import asyncio
import pytest_asyncio
from sqlalchemy import text
from common.enums import JobStatus, JobType
from crud.job_crud import JobQueue, compute_backoff
@pytest_asyncio.fixture
async def q(db_engine):
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE job"))
return JobQueue()
async def test_enqueue_claim_complete(q):
jid = await q.enqueue(JobType.SEARCH.value, {"query": "커피"}, dedupe_key="search-커피")
assert jid
job = await q.claim("w1")
assert job and job["job_id"] == jid
assert job["payload"]["query"] == "커피" and job["attempts"] == 1
assert await q.complete(jid, "w1", {"count": 3}) is True
counts = await q.counts()
assert counts["DONE"] == 1 and counts["PENDING"] == 0
async def test_dedupe_blocks_active_duplicate(q):
a = await q.enqueue(JobType.SEARCH.value, {"q": 1}, dedupe_key="k")
b = await q.enqueue(JobType.SEARCH.value, {"q": 1}, dedupe_key="k")
assert a and b is None # 활성 중복 차단
# 완료로 빠지면 같은 키 재적재 가능
job = await q.claim("w1")
await q.complete(job["job_id"], "w1")
c = await q.enqueue(JobType.SEARCH.value, {"q": 1}, dedupe_key="k")
assert c
async def test_atomic_claim_no_double_assignment(q):
N = 12
for i in range(N):
await q.enqueue(JobType.SEARCH.value, {"i": i})
# 8개 워커가 동시에 claim → 서로 다른 잡만, 이중 할당 0
results = await asyncio.gather(*[q.claim(f"w{i}") for i in range(8)])
claimed = [r["job_id"] for r in results if r]
assert len(claimed) == 8
assert len(set(claimed)) == 8
async def test_priority_and_order(q):
await q.enqueue(JobType.SEARCH.value, {"n": "low"}, priority=100)
await q.enqueue(JobType.SEARCH.value, {"n": "high"}, priority=1)
job = await q.claim("w1")
assert job["payload"]["n"] == "high" # priority 낮은 값 우선
async def test_lease_reclaim_by_reaper(q):
jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"})
job = await q.claim("w1", lease_sec=1)
assert job["job_id"] == jid and job["attempts"] == 1
assert await q.reap() == [] # 아직 lease 유효 → 회수 없음
await asyncio.sleep(1.3) # lease 만료
assert jid in await q.reap() # 회수됨(워커 사망 시나리오)
job2 = await q.claim("w2") # 다시 claim 가능, attempts 누적
assert job2["job_id"] == jid and job2["attempts"] == 2
async def test_retry_then_dead_letter(q):
jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"}, max_attempts=2)
await q.claim("w1")
assert await q.fail(jid, "w1", "boom", backoff_sec=0) == JobStatus.PENDING.value # 1/2 → 재큐
job2 = await q.claim("w1")
assert job2["attempts"] == 2
assert await q.fail(jid, "w1", "boom2", backoff_sec=0) == JobStatus.DEAD.value # 2/2 → dead-letter
assert (await q.counts())["DEAD"] == 1
async def test_transitions_require_ownership(q):
jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"})
await q.claim("w1")
assert await q.complete(jid, "intruder") is False # 소유 아님 → 거부(CAS 가드)
assert await q.fail(jid, "intruder", "no") is None
assert await q.complete(jid, "w1") is True
def test_backoff_is_exponential_capped():
assert compute_backoff(1, base=5) == 5
assert compute_backoff(2, base=5) == 10
assert compute_backoff(3, base=5) == 20
assert compute_backoff(100, base=5, cap=600) == 600
async def test_ops_counts_deadline_and_cost(q):
# 완료 잡 2건의 검색원가 합산 — claim 반환 잡을 완료(순서 의존 제거)
for code, cost in (("b", 0.01), ("c", 0.02)):
await q.enqueue(JobType.SEARCH.value, {"q": code})
job = await q.claim("w1")
await q.complete(job["job_id"], "w1", {"metrics": {"cost": {"total_usd": cost}}})
# 데드라인 강제종료 — 재큐(PENDING)로 살아나도 deadline_1h 에 잡혀야 한다(dead 와 별개 축)
j1 = await q.enqueue(JobType.SEARCH.value, {"q": "a"})
await q.claim("w1")
await q.fail(j1, "w1", "JobDeadlineExceeded: 300s", backoff_sec=0)
snap = await q.ops()
assert snap["deadline_1h"] == 1
assert abs(snap["cost_1h_usd"] - 0.03) < 1e-9
async def test_ops_cost_ignores_jobs_without_metrics(q):
jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"})
await q.claim("w1")
await q.complete(jid, "w1", {"count": 3}) # metrics 없는 결과 — 합산에서 무시(0)
snap = await q.ops()
assert snap["cost_1h_usd"] == 0