큐를 실제로 돌린다: 적재 → 워커 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>
48 lines
1.5 KiB
Python
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
|