o2o-castad-backend/app/database/like_cache.py

313 lines
11 KiB
Python

"""
좋아요 Redis 캐시 클라이언트
Write-Behind 패턴 적용:
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
**콘텐츠 종류(ctype)** 를 받아 ADO2 영상과 썰박스 콘텐츠 양쪽에 같은 로직을 쓴다.
`ctype` 기본값이 "video" 라서 기존 castad 호출부는 수정 없이 그대로 동작한다.
검증된 Lua 원자 토글은 키를 인자로 받으므로 변경하지 않았다.
Key 패턴:
- {ctype}:like:count:{content_id} INT — 좋아요 카운트
- {ctype}:like:users:{content_id} SET — 좋아요 누른 user_uuid 목록
- video:reaction:dirty SET — DB 동기화 대기 "{ctype}:{content_id}:{user_uuid}"
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
ctype="video" 일 때 카운트/유저 키가 기존과 **완전히 동일**하므로 캐시 이관이 필요 없다.
dirty SET 키 이름도 "video:" 접두를 유지한다 — 배포 순간 큐에 남아 있는 항목을
잃지 않기 위해서다(이름을 바꾸면 그 항목들이 영구 미반영된다).
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
"""
import redis.asyncio as aioredis
from config import db_settings
_client: aioredis.Redis | None = None
# 원자적 토글 Lua 스크립트 — 동시 더블클릭 race condition 방지
_TOGGLE_LIKE_SCRIPT = """
local user_key = KEYS[1]
local count_key = KEYS[2]
local user_uuid = ARGV[1]
if redis.call('SISMEMBER', user_key, user_uuid) == 1 then
redis.call('SREM', user_key, user_uuid)
local c = tonumber(redis.call('DECR', count_key))
if c < 0 then
redis.call('SET', count_key, 0)
c = 0
end
return {0, c}
else
redis.call('SADD', user_key, user_uuid)
local c = tonumber(redis.call('INCR', count_key))
return {1, c}
end
"""
_DIRTY_KEY = "video:reaction:dirty"
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
# ──────────────────────────────────────────────
# 콘텐츠 종류
# ──────────────────────────────────────────────
#: ADO2 영상 (video 테이블 · video_reaction)
CT_VIDEO = "video"
#: 썰박스 콘텐츠 (ssul_content 테이블 · ssul_like)
CT_SSUL = "ssul"
#: dirty 항목 파싱 시 "종류 접두인지" 판별하는 데 쓴다
CONTENT_TYPES: tuple[str, ...] = (CT_VIDEO, CT_SSUL)
def get_like_cache() -> aioredis.Redis:
global _client
if _client is None:
_client = aioredis.Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=2,
decode_responses=True,
)
return _client
async def close_like_cache() -> None:
global _client
if _client:
await _client.aclose()
_client = None
# ──────────────────────────────────────────────
# Key 헬퍼
# ──────────────────────────────────────────────
def _key(content_id: int, ctype: str = CT_VIDEO) -> str:
return f"{ctype}:like:count:{content_id}"
def _user_key(content_id: int, ctype: str = CT_VIDEO) -> str:
return f"{ctype}:like:users:{content_id}"
# ──────────────────────────────────────────────
# 카운트 (기존 API 유지)
# ──────────────────────────────────────────────
async def get_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int | None:
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
val = await get_like_cache().get(_key(content_id, ctype))
if val is None:
return None
return max(int(val), 0)
async def get_like_counts(
content_ids: list[int], *, ctype: str = CT_VIDEO
) -> dict[int, int | None]:
"""여러 콘텐츠의 like_count를 한 번에 조회 (mget).
캐시 미스인 content_id는 None으로 반환."""
if not content_ids:
return {}
keys = [_key(cid, ctype) for cid in content_ids]
values = await get_like_cache().mget(*keys)
return {
cid: max(int(v), 0) if v is not None else None
for cid, v in zip(content_ids, values)
}
async def set_like_count(
content_id: int, count: int, *, ctype: str = CT_VIDEO
) -> None:
"""like_count를 Redis에 저장 (음수 방지)."""
await get_like_cache().set(_key(content_id, ctype), max(count, 0))
async def mset_like_counts(
counts: dict[int, int], *, ctype: str = CT_VIDEO
) -> None:
"""여러 콘텐츠의 like_count를 한 번에 저장 (mset)."""
if not counts:
return
await get_like_cache().mset(
{_key(cid, ctype): max(cnt, 0) for cid, cnt in counts.items()}
)
async def incr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
"""like_count를 1 증가 후 반환."""
return max(int(await get_like_cache().incr(_key(content_id, ctype))), 0)
async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
"""like_count를 1 감소 후 반환 (음수 방지)."""
client = get_like_cache()
key = _key(content_id, ctype)
count = int(await client.decr(key))
if count < 0:
await client.set(key, 0)
return 0
return count
# ──────────────────────────────────────────────
# 유저 SET (is_liked_by_me source of truth)
# ──────────────────────────────────────────────
async def toggle_like_atomic(
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
) -> tuple[bool, int]:
"""Lua 스크립트로 원자적 좋아요 토글.
Returns:
(is_liked, new_count) 튜플
"""
result = await get_like_cache().eval(
_TOGGLE_LIKE_SCRIPT,
2,
_user_key(content_id, ctype),
_key(content_id, ctype),
user_uuid,
)
return bool(result[0]), int(result[1])
async def is_user_liked(
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
) -> bool | None:
"""Redis user-set에서 좋아요 여부 조회.
Returns:
True/False: 조회 성공
None: user-set 키가 없음 (cold-start backfill 필요 신호)
"""
client = get_like_cache()
key = _user_key(content_id, ctype)
if not await client.exists(key):
return None
return bool(await client.sismember(key, user_uuid))
async def is_user_set_exists(content_id: int, *, ctype: str = CT_VIDEO) -> bool:
"""Redis user-set 키 존재 여부 확인."""
return bool(await get_like_cache().exists(_user_key(content_id, ctype)))
async def bulk_is_user_liked(
content_ids: list[int], user_uuid: str, *, ctype: str = CT_VIDEO
) -> dict[int, bool | None]:
"""여러 콘텐츠의 is_liked 여부를 한 번에 조회 (pipeline).
통합 목록처럼 두 종류가 섞인 경우에는 **종류별로 나눠 각각 호출한다** —
반환 키가 content_id 하나여서 종류가 다른 같은 id 를 구분할 수 없다.
Returns:
{content_id: True/False} — user-set 키가 없는 항목은 None
"""
if not content_ids:
return {}
client = get_like_cache()
async with client.pipeline(transaction=False) as pipe:
for cid in content_ids:
pipe.exists(_user_key(cid, ctype))
pipe.sismember(_user_key(cid, ctype), user_uuid)
responses = await pipe.execute()
return {
cid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
for i, cid in enumerate(content_ids)
}
async def backfill_user_set(
content_id: int, user_uuids: list[str], *, ctype: str = CT_VIDEO
) -> None:
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
if user_uuids:
await get_like_cache().sadd(_user_key(content_id, ctype), *user_uuids)
# ──────────────────────────────────────────────
# Dirty SET (Write-Behind 큐)
# ──────────────────────────────────────────────
async def mark_dirty(
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
) -> None:
"""DB 동기화 대기 목록에 추가."""
await get_like_cache().sadd(_DIRTY_KEY, f"{ctype}:{content_id}:{user_uuid}")
def _parse_dirty(member: str) -> tuple[str, int, str] | None:
"""dirty 항목 문자열 → (ctype, content_id, user_uuid).
두 형식을 모두 받는다:
- 현재: "{ctype}:{content_id}:{user_uuid}"
- 구형: "{content_id}:{user_uuid}" ← 종류 도입 전에 큐에 들어간 항목
**구형 관용이 필요한 이유**: 배포 순간 dirty SET 에 구형 항목이 남아 있다.
새 파서가 이를 못 읽으면 그 좋아요는 DB 에 영구 미반영된다.
구형은 ADO2 영상뿐이었으므로 CT_VIDEO 로 해석한다.
형식이 깨진 항목은 None 을 돌려 호출부가 건너뛰게 한다 — 하나 때문에
플러시 전체가 죽으면 큐가 무한히 쌓인다.
"""
parts = member.split(":", 2)
# 길이가 아니라 **첫 토큰이 알려진 종류인지**로 판정한다.
# user_uuid 에 콜론이 있어도 구형이 3조각으로 보일 수 있다.
if len(parts) == 3 and parts[0] in CONTENT_TYPES:
ctype, id_str, user_uuid = parts
else:
ctype = CT_VIDEO
legacy = member.split(":", 1)
if len(legacy) != 2:
return None
id_str, user_uuid = legacy
if not id_str.isdigit() or not user_uuid:
return None
return ctype, int(id_str), user_uuid
async def drain_dirty() -> list[tuple[str, int, str]]:
"""dirty SET을 processing으로 RENAME 후 전체 반환.
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
Returns:
[(ctype, content_id, user_uuid), ...]
"""
client = get_like_cache()
# 이전 크래시 잔여 항목 병합
if await client.exists(_DIRTY_PROCESSING_KEY):
await client.sunionstore(_DIRTY_KEY, _DIRTY_KEY, _DIRTY_PROCESSING_KEY)
await client.delete(_DIRTY_PROCESSING_KEY)
if not await client.exists(_DIRTY_KEY):
return []
# RENAME으로 플러시 중 새로 들어오는 토글과 분리
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
members = await client.smembers(_DIRTY_PROCESSING_KEY)
result: list[tuple[str, int, str]] = []
for member in members:
parsed = _parse_dirty(member)
if parsed is None:
continue
result.append(parsed)
return result
async def commit_dirty_processing() -> None:
"""DB 반영 완료 후 processing SET 삭제."""
await get_like_cache().delete(_DIRTY_PROCESSING_KEY)