fix(lps): 워커 로그 노이즈 2건 제거 — 리퍼 네이버 스킵 + 폴백 데드라인 cancel 제거

- browser-reaper가 close_if_idle 없는 어댑터(네이버 httpx)를 건너뛰도록 getattr 가드
  → 30초마다 반복되던 ERROR 로그 제거
- 폴백 데드라인을 wait_for(cancel) → asyncio.wait(버림)으로 변경
  → in-flight page.goto 취소 시 patchright 내부 future가 남기는
    'Future exception was never retrieved' 노이즈 제거, 페이지 어중간 상태 방지
  → 버려진 태스크는 강한 참조 집합(_abandoned_fallbacks)에 보관(GC 중도 파괴 방지),
    종료 시 콜백이 예외 회수 후 집합에서 제거
- 데드라인 테스트에 잔여 태스크 배수(drain) 검증 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-10 08:50:12 +09:00
parent 08a9c52088
commit ddb6f6bd2f
3 changed files with 35 additions and 9 deletions

View File

@ -6,7 +6,7 @@ import pytest
from common.enums import JobType from common.enums import JobType
from services.search.contract import NormalizedProduct, AdapterError from services.search.contract import NormalizedProduct, AdapterError
from worker.handlers import build_search_handler from worker.handlers import build_search_handler, _abandoned_fallbacks
class FakeAdapter: class FakeAdapter:
@ -169,6 +169,10 @@ async def test_fallback_deadline_skips_slow_mall():
assert "11번가" in malls # 빠른 폴백 병합됨 assert "11번가" in malls # 빠른 폴백 병합됨
assert "G마켓" not in malls # 느린 폴백은 데드라인 초과로 스킵 assert "G마켓" not in malls # 느린 폴백은 데드라인 초과로 스킵
assert r["lowest"]["price"] == 3000 # G마켓 1000은 스킵됐으므로 최저가 아님 assert r["lowest"]["price"] == 3000 # G마켓 1000은 스킵됐으므로 최저가 아님
# 버려진 크롤은 cancel 없이 백그라운드 종료된다 — 루프 닫기 전에 배수(pending 태스크 파괴 경고 방지)
assert _abandoned_fallbacks # 느린 폴백이 버려짐
await asyncio.gather(*_abandoned_fallbacks, return_exceptions=True)
assert not _abandoned_fallbacks # 종료 콜백이 집합에서 제거함
async def test_fallback_failure_is_isolated(): async def test_fallback_failure_is_isolated():

View File

@ -25,6 +25,21 @@ from services.search.util import parse_price
from services.pipeline.core import apply_filters, rank_result, summarize_by_mall from services.pipeline.core import apply_filters, rank_result, summarize_by_mall
# 데드라인 초과로 버린 폴백 태스크의 강한 참조(asyncio 는 태스크를 약참조만 유지 — 없으면 GC 로 중도 파괴될 수 있음).
# 완료 시 콜백에서 스스로 제거된다. 테스트는 이 집합을 gather 해 잔여 태스크를 배수(drain)할 수 있다.
_abandoned_fallbacks: set[asyncio.Task] = set()
def _reap_abandoned(task: asyncio.Task):
"""버려진 폴백 태스크 종료 시 예외를 회수 — 'Future exception was never retrieved' 노이즈 방지."""
_abandoned_fallbacks.discard(task)
if task.cancelled():
return
ex = task.exception()
if ex is not None:
LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}")
def _price_snapshot(matched: list[NormalizedProduct]) -> dict: def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
"""매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용).""" """매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용)."""
def lowest(src): def lowest(src):
@ -126,14 +141,18 @@ def build_search_handler(
async def _crawl_match(src, adapter): async def _crawl_match(src, adapter):
# 폴백은 '있으면 좋은' 보강이라 데드라인을 건다 — 초과 시 그 몰만 스킵(전체 지연에 상한). # 폴백은 '있으면 좋은' 보강이라 데드라인을 건다 — 초과 시 그 몰만 스킵(전체 지연에 상한).
try: # cancel 하지 않고 버린다(asyncio.wait): in-flight page.goto 를 취소하면 patchright 내부
crawled = await asyncio.wait_for( # future 가 미회수 예외 노이즈를 남기고 페이지가 어중간한 상태로 남는다. 버려진 크롤은
_timed_search(adapter, query, src, metrics, crawl=True), # 백그라운드에서 자체 타임아웃(goto 40s 등)으로 끝나고 _reap_abandoned 가 예외를 회수한다.
timeout=fallback_deadline_sec, task = asyncio.ensure_future(_timed_search(adapter, query, src, metrics, crawl=True))
) done, _ = await asyncio.wait({task}, timeout=fallback_deadline_sec)
except asyncio.TimeoutError: if not done:
LOG.w(f"[fallback:{src}] 데드라인 {fallback_deadline_sec:.0f}s 초과 → 스킵") _abandoned_fallbacks.add(task)
task.add_done_callback(_reap_abandoned)
LOG.w(f"[fallback:{src}] 데드라인 {fallback_deadline_sec:.0f}s 초과 → 스킵(크롤은 백그라운드 종료)")
return [] return []
try:
crawled = task.result()
except Exception as ex: except Exception as ex:
LOG.w(f"[fallback:{src}] 크롤 실패(무시): {type(ex).__name__}: {ex}") LOG.w(f"[fallback:{src}] 크롤 실패(무시): {type(ex).__name__}: {ex}")
return [] return []

View File

@ -142,8 +142,11 @@ async def run_browser_reaper(adapters, stop, idle_sec: float = 120.0, interval:
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
for ad in adapters: for ad in adapters:
close_if_idle = getattr(ad, "close_if_idle", None)
if close_if_idle is None: # 네이버(httpx) 등 브라우저 없는 어댑터는 정리 대상 아님
continue
try: try:
await ad.close_if_idle(idle_sec) await close_if_idle(idle_sec)
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(f"[browser-reaper] 정리 실패(무시): {ex}") LOG.e_no_callstack(f"[browser-reaper] 정리 실패(무시): {ex}")