기존 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>
102 lines
4.1 KiB
Python
102 lines
4.1 KiB
Python
"""LPS 검색 요청 도메인 로직. 요청을 검색 잡으로 큐에 적재하고 상태/통계를 조회한다.
|
|
|
|
라우터는 요청 검증→service 호출→RemoveNoneResponse 만 담당(backend 컨벤션).
|
|
실제 검색/크롤링은 워커가 큐에서 잡을 꺼내 파이프라인으로 수행한다(비동기 분리).
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from fastapi import Depends
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import ErrorType, JobStatus, JobType
|
|
from crud.job_crud import JobQueue
|
|
from crud.price_history import PriceHistory
|
|
from crud.bot_detection import BotDetectionLog
|
|
from router.v1.lps.protocol import (
|
|
EnqueuedItem,
|
|
PricePoint,
|
|
Res_JobStatus,
|
|
Res_PriceHistory,
|
|
Res_QueueStats,
|
|
Res_Search,
|
|
SearchItem,
|
|
)
|
|
|
|
# 요청 유형 → 우선순위(낮을수록 우선). 알 수 없는 유형은 가장 낮은 우선순위(배치와 동급).
|
|
_PRIORITY = {"new_product": 1, "manual": 2, "partner": 3, "batch": 4}
|
|
|
|
|
|
class LpsService:
|
|
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory),
|
|
bot_log: BotDetectionLog = Depends(BotDetectionLog)):
|
|
self.queue = queue
|
|
self.history = history
|
|
self.bot_log = bot_log
|
|
|
|
async def submit_search(self, items: list[SearchItem]) -> Res_Search:
|
|
res = Res_Search()
|
|
for it in items:
|
|
priority = _PRIORITY.get(it.job_type, 4)
|
|
job_id = await self.queue.enqueue(
|
|
JobType.SEARCH.value,
|
|
it.model_dump(),
|
|
priority=priority,
|
|
dedupe_key=f"search-{it.product_code}",
|
|
)
|
|
res.items.append(EnqueuedItem(product_code=it.product_code, job_id=job_id, duplicated=job_id is None))
|
|
res.accepted = sum(1 for i in res.items if i.job_id is not None)
|
|
return res
|
|
|
|
async def get_job(self, job_id_str: str) -> Res_JobStatus:
|
|
res = Res_JobStatus()
|
|
try:
|
|
uuid.UUID(job_id_str) # 잘못된 id 는 DB 조회 전에 컷
|
|
except (ValueError, TypeError):
|
|
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
|
|
return res
|
|
|
|
row = await self.queue.get(job_id_str)
|
|
if row is None:
|
|
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
|
|
return res
|
|
|
|
res.job_id = row["job_id"]
|
|
res.status = JobStatus(row["status"]).name
|
|
res.attempts = row["attempts"]
|
|
res.max_attempts = row["max_attempts"]
|
|
res.output = row.get("result")
|
|
res.last_error = row.get("last_error")
|
|
return res
|
|
|
|
async def stats(self) -> Res_QueueStats:
|
|
res = Res_QueueStats()
|
|
res.counts = await self.queue.counts()
|
|
return res
|
|
|
|
async def ops(self) -> dict:
|
|
"""운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수 + DB 풀 사용률."""
|
|
snap = await self.queue.ops()
|
|
snap["blocks_1h"] = await self.bot_log.recent_count(60)
|
|
pool = DB_SESSION_MNG.pool_status() # API 프로세스 자신의 풀(워커 풀은 워커 ops-monitor 가 감시)
|
|
snap["pool_checked_out"], snap["pool_capacity"], snap["pool_pct"] = pool["checked_out"], pool["capacity"], pool["pct"]
|
|
return snap
|
|
|
|
async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory:
|
|
res = Res_PriceHistory(product_code=product_code)
|
|
rows = await self.history.list_by_product(product_code, limit)
|
|
res.points = [
|
|
PricePoint(
|
|
triggered_at=r["triggered_at"].isoformat(timespec="seconds"),
|
|
outcome=r["outcome"],
|
|
matched_count=r["matched_count"],
|
|
naver=r["naver_lowest"], coupang=r["coupang_lowest"], final=r["final_lowest"],
|
|
final_source=r["final_source"],
|
|
naver_name=r["naver_name"], naver_url=r["naver_url"],
|
|
coupang_name=r["coupang_name"], coupang_url=r["coupang_url"],
|
|
by_mall=r.get("by_mall"),
|
|
)
|
|
for r in rows
|
|
]
|
|
return res
|