o2o-negosium-original/lps/crud/negative_cache.py
민헌 ba9d34fd31 [fix] lps: 네거티브 캐시 히트 시 이력 누락 수정 + force 재검색 지원
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) <noreply@anthropic.com>
2026-07-28 11:55:23 +09:00

63 lines
2.5 KiB
Python

"""네거티브 캐시 CRUD — not_found 결론을 TTL 동안 기억.
같은 상품 재요청이 즉시 재검색(브라우저+API+LLM 비용)하는 것을 막는다.
TTL 만료 후에는 다시 검색 허용(입고 가능성). key 는 보통 product_code."""
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
class NegativeCache:
DB = DBType.MAIN.value
async def is_negative(self, key: str) -> bool:
"""key 가 아직 유효한 not_found 로 캐시돼 있으면 True."""
if not key:
return False
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
row = (await s.execute(
text("SELECT 1 FROM search_negative WHERE key = :k AND until > now()"),
{"k": key},
)).first()
return row is not None
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:
return
sql = text("""
INSERT INTO search_negative (key, until, reason)
VALUES (:k, now() + make_interval(secs => :ttl), :r)
ON CONFLICT (key) DO UPDATE
SET until = EXCLUDED.until, reason = EXCLUDED.reason, created_at = now()
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
try:
await s.execute(sql, {"k": key, "ttl": ttl_sec, "r": reason[:200]})
await s.commit()
except Exception:
await s.rollback()
raise
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)