From 152aed99080da18d094bcee8fb84f98cc2c5f583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 6 Aug 2026 16:34:36 +0900 Subject: [PATCH] =?UTF-8?q?fix(lps):=20=ED=95=9C=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EA=B0=80=20=EB=A7=89=ED=98=80=EB=8F=84=20=EC=9E=A1=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EC=83=81=20=EC=A2=85=EB=A3=8C=20=E2=80=94=20'?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EC=A4=91'=EC=97=90=EC=84=9C=20=EB=A9=88?= =?UTF-8?q?=EC=B6=94=EB=8D=98=20=EC=9B=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 로그(2026-08-06)에서 드러난 문제. 쿠팡이 막힌 동안 **네이버가 매번 40건을 가져왔는데도** 잡이 전부 DEAD 로 갔다: fail 3c0bcaff → DEAD ({'coupang': {'error': 환경 차단}, 'naver': {'count': 40}}) 그리고 DEAD 는 price_history 에 행을 남기지 않는다. 이를 폴링하는 negodata 최저가 모달은 받을 결과가 영영 없어 '탐색 중'에서 멈춘다(사용자 화면 확인). 즉 프론트 버그가 아니라 **백엔드가 답을 안 준 것**이다 — 두 증상이 같은 뿌리였다. 원인: 핸들러가 '하나라도 실패했나'(bool)만 보고 무조건 raise 했다. 한 소스가 막혔다고 다른 소스가 멀쩡히 가져온 결과까지 버린 셈이다. 바꾼 것: - _search_round 가 **성공한 소스 목록**을 돌려준다(bool → list). - 살아있는 소스가 하나라도 있으면 그 결과로 진행한다. **전부 죽었을 때만** 재시도한다 ('없음'이라 단정할 수 없는 건 그때뿐이다). - 결과에 sources_ok/sources_failed/partial 을 실어 커버리지를 드러낸다(부분 결과 로그도 남김). - 부분 실패 상태의 not_found 는 **네거티브 캐시에 넣지 않는다** — 막힌 소스엔 있었을 수 있는데 '없음'으로 굳히면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트). - 전부 실패 + 재시도 소진이면 DEAD 대신 outcome='error' 로 **이력을 남기고** 종료한다. 화면이 무한 대기 대신 결과를 받는 게 중요하다. negodata 는 변경 없이 받는다(outcome='error' → success_yn=false, fail_reason='error'). 테스트 5건 추가·1건 갱신(옛 계약 '한 소스 실패 시 raise' 를 새 계약으로 교체), 전체 274 passed. Co-Authored-By: Claude Opus 5 --- lps/tests/test_search_handler.py | 57 ++++++++++++++++++++++++++++++-- lps/worker/handlers.py | 55 ++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/lps/tests/test_search_handler.py b/lps/tests/test_search_handler.py index 63f782a..41ae7c5 100644 --- a/lps/tests/test_search_handler.py +++ b/lps/tests/test_search_handler.py @@ -69,6 +69,15 @@ class FakeNegCache: self.puts.append(key) +class FakeHistory: + """price_history 기록 관찰용 — '행이 남는가'가 프론트의 대기/표시를 가른다.""" + def __init__(self): + self.events = [] + + async def record(self, event): + self.events.append(event) + + def _np(source, price, mall=None): return NormalizedProduct(source=source, name=f"{source}-{price}", price=price, mall_name=mall) @@ -130,10 +139,52 @@ async def test_negative_cache_short_circuits(): assert adapters["naver"].calls == [] # 재검색 안 함 -async def test_technical_failure_with_zero_match_raises(): - adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} +# ── 부분 결과: 한 소스가 막혀도 살아있는 소스로 답한다 (2026-08-06) ────── +# 예전엔 한 소스라도 실패하면 무조건 raise → 재시도 → DEAD 였다. 그런데 DEAD 는 price_history 에 +# 행을 남기지 않아, 이를 폴링하는 negodata 최저가 모달이 결과를 영영 못 받고 '탐색 중'에서 멈췄다 +# (실측: 쿠팡이 막힌 동안 네이버가 40건씩 가져왔는데도 잡이 전부 DEAD). + +async def test_all_sources_failing_still_retries(): + """전부 죽었으면 '없음'이라 단정할 수 없다 — 이때는 기존대로 재시도한다.""" + adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True)} with pytest.raises(RuntimeError): - await build_search_handler(adapters)(_job()) # 0매칭 + 차단 → 기술 재시도 + await build_search_handler(adapters)(_job()) + + +async def test_partial_failure_completes_with_the_surviving_source(): + """쿠팡이 막혀도 네이버가 찾았으면 정상 종료해야 한다(사용자가 결과를 받는 게 우선).""" + adapters = {"coupang": FakeAdapter("coupang", fail=True), + "naver": FakeAdapter("naver", products=[_np("naver", 9000)])} + r = await build_search_handler(adapters, judge=FakeJudge(lambda c: True))(_job()) + assert r["outcome"] == "found" and r["lowest"]["price"] == 9000 + assert r["partial"] is True and r["sources_failed"] == ["coupang"] and r["sources_ok"] == ["naver"] + + +async def test_partial_zero_match_ends_as_not_found_not_dead(): + """살아있는 소스가 0매칭이면 not_found 로 **정상 종료**한다 — 잡을 죽이면 화면이 멈춘다.""" + adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} + r = await build_search_handler(adapters)(_job()) + assert r["outcome"] == "not_found" and r["partial"] is True + + +async def test_partial_not_found_does_not_poison_the_negative_cache(): + """막힌 소스엔 있었을 수 있다 — '없음'으로 굳히면 TTL 동안 재검색이 막힌다.""" + adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} + neg = FakeNegCache() + r = await build_search_handler(adapters, neg_cache=neg)(_job()) + assert r["outcome"] == "not_found" + assert neg.puts == [], "부분 실패 상태의 not_found 는 캐시하지 않는다" + + +async def test_all_sources_failed_on_last_attempt_records_error_instead_of_dying(): + """재시도가 소진되면 DEAD 대신 error 로 기록한다 — 이력이 남아야 화면이 '실패'를 보여준다.""" + adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", fail=True)} + hist = FakeHistory() + job = _job() + job.update(attempts=3, max_attempts=3) # 마지막 시도 + r = await build_search_handler(adapters, history=hist)(job) + assert r["outcome"] == "error" and r["sources_ok"] == [] + assert [e["outcome"] for e in hist.events] == ["error"], "이력이 없으면 프론트가 계속 대기한다" # ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ────────────────────────── diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index fbee1a8..8d6098c 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -109,19 +109,23 @@ def build_search_handler( raise async def _search_round(query: str, metrics: SearchMetrics): - """한 라운드: 모든 소스 동시 검색 → (products, per_source, tech_failed). 소스별 시간/바이트 계측.""" + """한 라운드: 모든 소스 동시 검색 → (products, per_source, ok_sources). 소스별 시간/바이트 계측. + + ok_sources 는 **정상 응답한 소스 목록**이다. 예전엔 '하나라도 실패했나'(bool)만 봤는데, + 그러면 한 소스가 막혔을 때 다른 소스가 멀쩡히 가져온 결과까지 버리게 된다(아래 호출부 참고). + """ results = await asyncio.gather(*[_timed_search(adapters[s], query, s, metrics, False) for s in use], return_exceptions=True) - products, per_source, tech_failed = [], {}, False + products, per_source, ok_sources = [], {}, [] for src, res in zip(use, results): if isinstance(res, Exception): - tech_failed = True per_source[src] = {"error": f"{type(res).__name__}: {res}"} LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}") else: products.extend(res) per_source[src] = {"count": len(res)} - return products, per_source, tech_failed + ok_sources.append(src) + return products, per_source, ok_sources async def _match(target: dict, products: list, base_price, metrics: SearchMetrics): """필터 → (있으면) AI 같은상품 판정 → 매칭 후보. AI 토큰은 metrics 에 누적. @@ -214,12 +218,14 @@ def build_search_handler( rounds_done = 0 last_stages, last_sources = [], {} + last_ok, last_failed = [], [] async for label, query in _round_queries(base_query, target, metrics): if rounds_done >= max_rounds: break rounds_done += 1 - products, per_source, tech_failed = await _search_round(query, metrics) + products, per_source, ok_sources = await _search_round(query, metrics) + failed_sources = [s for s in use if s not in ok_sources] candidates, stages = apply_filters(products, base_price=base_price) if judge is not None and candidates: verdicts = await judge.judge(target, candidates) @@ -228,6 +234,7 @@ def build_search_handler( stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)}) candidates = matched last_stages, last_sources = stages, per_source + last_ok, last_failed = ok_sources, failed_sources if candidates: # 찾음 → 오픈마켓 폴백 보강 후 종료 before = len(candidates) @@ -236,19 +243,45 @@ def build_search_handler( stages.append({"stage": "fallback_crawl", "in": before, "out": len(candidates)}) result = rank_result(candidates, len(products), stages, top_n) result.update(outcome="found", query=query, round=label, rounds_tried=rounds_done, - sources=per_source, metrics=metrics.snapshot()) + sources=per_source, sources_ok=ok_sources, sources_failed=failed_sources, + partial=bool(failed_sources), metrics=metrics.snapshot()) + if failed_sources: + LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다") await _record_history(cache_key, job.get("job_id"), "found", candidates) return result - if tech_failed: # 0매칭인데 소스가 죽어 있었음 → '없음'이라 단정 불가 → 기술 재시도 - raise RuntimeError(f"기술적 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") + # 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** — + # 한 소스가 막혔다고 다른 소스가 멀쩡히 가져온 결과까지 버리면 사용자는 아무것도 못 받는다. + # (실측 2026-08-06: 쿠팡이 막힌 동안 네이버가 40건씩 가져왔는데도 잡이 전부 DEAD 로 갔고, + # DEAD 는 price_history 에 행을 안 남겨 프론트가 '탐색 중'에서 멈췄다) + # 전부 죽었을 때만 '없음'이라 단정할 수 없으므로 재시도한다. + if not ok_sources: + # 마지막 시도면 재시도해도 갈 곳이 없다(다음은 DEAD). DEAD 는 price_history 에 행을 + # 남기지 않아, 이를 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고 + # '탐색 중'에서 멈춘다(2026-08-06 실측). 잡을 죽이는 대신 **실패를 결과로 기록**해 + # 화면이 '검색 실패'를 보여줄 수 있게 한다. + if job.get("attempts", 0) >= job.get("max_attempts", 3): + LOG.w(f"[{cache_key}] 모든 소스 실패 + 재시도 소진 → error 로 종료(이력은 남긴다)") + result = rank_result([], 0, stages, top_n) + result.update(outcome="error", query=query, rounds_tried=rounds_done, + sources=per_source, sources_ok=[], sources_failed=failed_sources, + partial=True, metrics=metrics.snapshot()) + await _record_history(cache_key, job.get("job_id"), "error", []) + return result + raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") - # 모든 라운드 클린 0매칭 → 정상 not_found 종료 - if neg_cache is not None: + # 모든 라운드 0매칭 → not_found 로 정상 종료 + # 단, 일부 소스가 막혀 있었다면 **네거티브 캐시에 넣지 않는다** — 그 소스엔 있었을 수 있는데 + # '없음'으로 굳혀버리면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트). + partial = bool(last_failed) + if neg_cache is not None and not partial: await neg_cache.put(cache_key, reason=f"not_found after {rounds_done} rounds") result = rank_result([], 0, last_stages, top_n) result.update(outcome="not_found", query=base_query, rounds_tried=rounds_done, - sources=last_sources, metrics=metrics.snapshot()) + sources=last_sources, sources_ok=last_ok, sources_failed=last_failed, + partial=partial, metrics=metrics.snapshot()) + if partial: + LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)") await _record_history(cache_key, job.get("job_id"), "not_found", []) return result