From ba9d34fd316d02a6cf74c6c586e01307ee96bf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 11:55:23 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20lps:=20=EB=84=A4=EA=B1=B0=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EC=BA=90=EC=8B=9C=20=ED=9E=88=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20+=20force=20=EC=9E=AC=EA=B2=80=EC=83=89=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 캐시 히트가 price_history 를 남기지 않던 문제 not_found 는 24시간 네거티브 캐시에 들어가는데, 캐시에 걸린 조기 반환 경로만 _record_history 를 호출하지 않았다(다른 모든 경로는 호출). 그 결과 잡은 완료인데 price_history 에 새 행이 없어, 이를 폴링하는 소비자 (negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다. → 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다. 2) force 플래그 negodata 는 이제 수동 트리거 전용인데 is_negative() 가 job_type 을 보지 않아 사람이 직접 누른 재검색까지 캐시가 가로막았다. 게다가 캐시 키가 product_code 라 상품명·모델을 고쳐 재시도해도 동일하게 막힌다. → SearchItem.force=true 면 NegativeCache.drop() 으로 기록을 지우고 실제 검색. 기본 요청은 캐시를 그대로 써서 비용 절감 효과는 유지. 실측: 캐시에 막혀 not_found 만 반복하던 상품이 force 재검색에서 2라운드 만에 found(8,500원). ⚠️ protocol.py 변경은 lps-api 와 lps-worker 를 함께 재빌드해야 반영된다 (API 만 옛 스키마면 pydantic 이 force 를 조용히 버린다 — 실측으로 확인). Co-Authored-By: Claude Opus 5 (1M context) --- lps/crud/negative_cache.py | 15 +++++++++++++++ lps/router/v1/lps/protocol.py | 1 + lps/worker/handlers.py | 12 ++++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lps/crud/negative_cache.py b/lps/crud/negative_cache.py index f657045..ce323e9 100644 --- a/lps/crud/negative_cache.py +++ b/lps/crud/negative_cache.py @@ -26,6 +26,21 @@ class NegativeCache: finally: await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + async def drop(self, key: str): + """key 의 not_found 기록을 지운다 — 사용자가 강제 재검색(force)을 요청했을 때. + 캐시 키가 product_code 라 상품명·모델을 고쳐 다시 찾는 경우에도 이걸로 풀어줘야 한다.""" + if not key: + return + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(text("DELETE FROM search_negative WHERE key = :k"), {"k": key}) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + async def put(self, key: str, ttl_sec: int = 86400, reason: str = "not_found"): """key 를 ttl_sec 동안 not_found 로 기록(upsert).""" if not key: diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 4097f2d..8deae0c 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -17,6 +17,7 @@ class SearchItem(BaseModel): specification: str = Field("", description="규격(용량/개입/수량 등)") company: str = Field("", description="제조사/브랜드") price: str = Field("", description="현재가(참고, 문자열)") + force: bool = Field(False, description="네거티브 캐시(24h not_found)를 무시하고 실제로 재검색할지. 사용자가 '다시 검색'을 누른 경우만 true") class Req_Search(Req_WebPacketProtocol): diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 21be864..523fd27 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -192,8 +192,16 @@ def build_search_handler( cache_key = payload.get("product_code") or base_query metrics = SearchMetrics(ai_model, proxy_cost_per_gb) # 검색 1건의 리소스/비용/시간 계측 - # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략 - if neg_cache is not None and await neg_cache.is_negative(cache_key): + # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략. + # force=True(사용자가 '다시 검색'을 명시적으로 누름)면 기록을 지우고 실제 검색을 돌린다. + # 캐시 키가 product_code 라, 상품명·모델을 고쳐 재시도하는 경우 이 우회가 없으면 영원히 막힌다. + if neg_cache is not None and payload.get("force"): + await neg_cache.drop(cache_key) + if neg_cache is not None and not payload.get("force") and await neg_cache.is_negative(cache_key): + # 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다. 남기지 않으면 잡은 완료인데 + # price_history 에 새 행이 없어, 이를 폴링하는 소비자(negodata 최저가 모달)가 + # 결과를 영영 못 받고 로딩만 돈다(실측 버그). + await _record_history(cache_key, job.get("job_id"), "not_found", []) return {"outcome": "not_found", "cached": True, "query": base_query, "rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {}, "metrics": metrics.snapshot()}