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>
63 lines
2.5 KiB
Python
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)
|