o2o-negosium-original/lps/worker/notify.py
민헌 abcb58ba05 feat(lps): 워커 루프 + LISTEN/NOTIFY — 큐 소비 파이프라인 가동
큐를 실제로 돌린다: 적재 → 워커 claim → 핸들러 실행 → 결과 저장 → DONE.
API(적재)와 워커(소비)를 분리 프로세스로(코드베이스 공유, 독립 스케일).

- worker/notify: 전용 asyncpg LISTEN 리스너. enqueue 에서 pg_notify → 유휴 워커 즉시 기상(폴링 제거)
- worker/runner: Worker(claim→처리, 처리중 heartbeat 로 lease 갱신, complete/fail) + run_reaper
- worker/handlers: job_type 별 핸들러(주입식). SEARCH=소스 어댑터 검색→정규화 결과
- worker_main: API 분리 워커 진입점(브라우저 무거워 기본 동시성 1)
- job_crud.enqueue: 삽입 시 pg_notify (중복 스킵 시엔 미발생)
- tests: drain→DONE·실패→재시도→dead·reaper 회수 후 재처리·NOTIFY 기상 4건 (전체 20/20)
- 라이브 E2E 확인: 적재→워커가 실제 쿠팡 검색(8건)→DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:58:58 +09:00

48 lines
1.5 KiB
Python

"""LISTEN/NOTIFY 리스너 — 잡 적재 시 워커를 즉시 깨운다(폴링 낭비 제거).
전용 asyncpg 연결로 LISTEN 한다(SQLAlchemy 풀과 분리). 알림이 오면 이벤트를 세팅하고,
워커는 claim 이 비었을 때 wait()로 알림 또는 짧은 타임아웃(안전망/reaper)까지 대기한다.
"""
import asyncio
import asyncpg
from config.server_configs import main_db_config
from crud.job_crud import JOB_NOTIFY_CHANNEL
def _dsn() -> str:
c = main_db_config
pw = f":{c.write_pw}" if c.write_pw else ""
return f"postgresql://{c.write_id}{pw}@{c.write_host}:{c.write_port}/{c.name}"
class JobListener:
def __init__(self, channel: str = JOB_NOTIFY_CHANNEL):
self._channel = channel
self._conn: asyncpg.Connection | None = None
self._event = asyncio.Event()
async def start(self):
self._conn = await asyncpg.connect(_dsn())
await self._conn.add_listener(self._channel, self._on_notify)
def _on_notify(self, *_args):
self._event.set()
async def wait(self, timeout: float) -> bool:
"""알림이 오거나 timeout 까지 대기. 알림으로 깨면 True, 타임아웃이면 False."""
try:
await asyncio.wait_for(self._event.wait(), timeout)
return True
except asyncio.TimeoutError:
return False
finally:
self._event.clear()
async def close(self):
if self._conn is not None:
await self._conn.close()
self._conn = None