samchun-gemini / workers /queue_processor.py
JHyeok5's picture
Upload folder using huggingface_hub
3eaf1a4 verified
Raw
History Blame Contribute Delete
30 kB
"""
Queue Processor โ€” ๋ฉ”์‹œ์ง€ ํ ๋น„๋™๊ธฐ ์›Œ์ปค
PostgreSQL ํ…Œ์ด๋ธ” ๊ธฐ๋ฐ˜ ๋ฉ”์‹œ์ง€ ํ๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ pollingํ•˜์—ฌ
๋ฐฐ์ง€ ํ™•์ธ, ์ฑŒ๋ฆฐ์ง€ ์ง„ํ–‰๋„, ๋ฐ์ดํ„ฐ ์ •๋ฆฌ ๋“ฑ์„ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ.
HuggingFace Spaces์˜ FastAPI ์„œ๋ฒ„์—์„œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํƒœ์Šคํฌ๋กœ ์‹คํ–‰๋จ.
@changelog
- v1.1.0 (2026-03-07): ์ ์‘ํ˜• ํด๋ง (adaptive backoff)
- ํ ๋น„์–ด์žˆ์œผ๋ฉด ๊ฐ„๊ฒฉ 2๋ฐฐ์”ฉ ์ฆ๊ฐ€ (5s โ†’ 10s โ†’ ... โ†’ 300s max)
- ๋ฉ”์‹œ์ง€ ๋ฐœ๊ฒฌ ์‹œ ์ฆ‰์‹œ ์ตœ์†Œ ๊ฐ„๊ฒฉ์œผ๋กœ ๋ฆฌ์…‹
- ์œ ํœด ์‹œ ์‹œ๊ฐ„๋‹น ~36๊ฑด (๊ธฐ์กด ~2,160๊ฑด ๋Œ€๋น„ 98% ๊ฐ์†Œ)
- v1.0.0 (2026-03-07): ์ดˆ๊ธฐ ๊ตฌํ˜„
- badge_check ํ: ๋ฐฐ์ง€ ์ž๊ฒฉ ํ™•์ธ + ํ† ํฐ ๋ณด์ƒ ์ง€๊ธ‰
- challenge_check ํ: ์ฑŒ๋ฆฐ์ง€ ์ง„ํ–‰๋„ ์—…๋ฐ์ดํŠธ
- data_cleanup ํ: ๋งŒ๋ฃŒ ํ† ํฐ/์บ์‹œ/๋””๋ฐ”์ด์Šค ๋ฐ์ดํ„ฐ ์ •๋ฆฌ
- FOR UPDATE SKIP LOCKED ๊ธฐ๋ฐ˜ ๋™์‹œ ์†Œ๋น„์ž ์•ˆ์ „
- ์žฌ์‹œ๋„ (max 3) + dead letter ์ฒ˜๋ฆฌ
"""
import asyncio
import logging
import os
import time
from typing import Any, Callable, Coroutine, Dict, List, Optional
logger = logging.getLogger(__name__)
# ํ ์„ค์ •
POLL_INTERVAL_MIN = int(os.getenv("MQ_POLL_INTERVAL_MIN", "5"))
POLL_INTERVAL_MAX = int(os.getenv("MQ_POLL_INTERVAL_MAX", "300"))
BATCH_SIZE = int(os.getenv("MQ_BATCH_SIZE", "10"))
VISIBILITY_TIMEOUT_SECONDS = int(os.getenv("MQ_VISIBILITY_TIMEOUT", "60"))
CLEANUP_INTERVAL_HOURS = int(os.getenv("MQ_CLEANUP_INTERVAL_HOURS", "6"))
# ์‹ฑ๊ธ€ํ†ค
_processor: Optional["QueueProcessor"] = None
def get_queue_processor() -> "QueueProcessor":
"""QueueProcessor ์‹ฑ๊ธ€ํ†ค ๋ฐ˜ํ™˜"""
global _processor
if _processor is None:
_processor = QueueProcessor()
return _processor
class QueueProcessor:
"""
๋ฉ”์‹œ์ง€ ํ ์›Œ์ปค ํ”„๋กœ์„ธ์„œ.
FastAPI lifespan์—์„œ start()/stop()์„ ํ˜ธ์ถœํ•˜์—ฌ
๋ฐฑ๊ทธ๋ผ์šด๋“œ ํƒœ์Šคํฌ๋กœ ์‹คํ–‰.
๊ฐ ํ๋ณ„ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋“ฑ๋กํ•˜๊ณ , ์ฃผ๊ธฐ์ ์œผ๋กœ pollingํ•˜์—ฌ
๋ฉ”์‹œ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
"""
def __init__(self):
self._running = False
self._task: Optional[asyncio.Task] = None
self._cleanup_task: Optional[asyncio.Task] = None
self._handlers: Dict[str, Callable] = {}
self._current_interval = POLL_INTERVAL_MIN
self._stats = {
"total_processed": 0,
"total_errors": 0,
"total_dead": 0,
"queues": {},
}
# ๊ธฐ๋ณธ ํ•ธ๋“ค๋Ÿฌ ๋“ฑ๋ก
self.register_handler("badge_check", self._handle_badge_check)
self.register_handler("challenge_check", self._handle_challenge_check)
self.register_handler("data_cleanup", self._handle_data_cleanup)
def register_handler(
self,
queue_name: str,
handler: Callable[
[Dict[str, Any]], Coroutine[Any, Any, None]
],
):
"""ํ๋ณ„ ํ•ธ๋“ค๋Ÿฌ ๋“ฑ๋ก"""
self._handlers[queue_name] = handler
self._stats["queues"][queue_name] = {
"processed": 0,
"errors": 0,
"last_processed_at": None,
}
logger.info(f"[mq] Registered handler for queue: {queue_name}")
def start(self):
"""๋ฐฑ๊ทธ๋ผ์šด๋“œ ์›Œ์ปค ์‹œ์ž‘"""
if self._running:
logger.warning("[mq] Queue processor already running")
return
self._running = True
self._current_interval = POLL_INTERVAL_MIN
self._task = asyncio.create_task(self._poll_loop())
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info(
f"[mq] Queue processor started "
f"(poll={POLL_INTERVAL_MIN}~{POLL_INTERVAL_MAX}s adaptive, "
f"batch={BATCH_SIZE}, timeout={VISIBILITY_TIMEOUT_SECONDS}s)"
)
async def stop(self):
"""๋ฐฑ๊ทธ๋ผ์šด๋“œ ์›Œ์ปค ์ค‘์ง€ (graceful)"""
if not self._running:
return
self._running = False
logger.info("[mq] Queue processor stopping...")
for name, task in [("poll", self._task), ("cleanup", self._cleanup_task)]:
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info(f"[mq] {name} task stopped")
self._task = None
self._cleanup_task = None
logger.info("[mq] Queue processor stopped")
def get_stats(self) -> Dict[str, Any]:
"""์›Œ์ปค ํ†ต๊ณ„ ๋ฐ˜ํ™˜"""
return {
"running": self._running,
"poll_interval_seconds": self._current_interval,
"poll_interval_range": f"{POLL_INTERVAL_MIN}~{POLL_INTERVAL_MAX}s",
"batch_size": BATCH_SIZE,
"registered_queues": list(self._handlers.keys()),
**self._stats,
}
# ==========================================
# ๋ฉ”์ธ ๋ฃจํ”„
# ==========================================
async def _poll_loop(self):
"""์ ์‘ํ˜• ํด๋ง: ๋ฉ”์‹œ์ง€ ์žˆ์œผ๋ฉด ๋น ๋ฅด๊ฒŒ, ์—†์œผ๋ฉด ๊ฐ„๊ฒฉ์„ ์ ์ง„์ ์œผ๋กœ ๋Š˜๋ฆผ"""
# ์„œ๋ฒ„ ์‹œ์ž‘ ํ›„ ์•ˆ์ •ํ™” ๋Œ€๊ธฐ
await asyncio.sleep(10)
while self._running:
found_any = False
try:
for queue_name in self._handlers:
if not self._running:
break
count = await self._process_queue(queue_name)
if count > 0:
found_any = True
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(
f"[mq] Poll loop error (will continue): "
f"{type(e).__name__}: {str(e)[:200]}"
)
# ์ ์‘ํ˜• ๋ฐฑ์˜คํ”„: ๋ฉ”์‹œ์ง€ ๋ฐœ๊ฒฌ ์‹œ ์ตœ์†Œ ๊ฐ„๊ฒฉ์œผ๋กœ ๋ฆฌ์…‹, ์—†์œผ๋ฉด 2๋ฐฐ์”ฉ ์ฆ๊ฐ€
if found_any:
self._current_interval = POLL_INTERVAL_MIN
else:
self._current_interval = min(
self._current_interval * 2, POLL_INTERVAL_MAX
)
await asyncio.sleep(self._current_interval)
async def _cleanup_loop(self):
"""์ฃผ๊ธฐ์ ์œผ๋กœ ์™„๋ฃŒ/dead ๋ฉ”์‹œ์ง€ ์ •๋ฆฌ"""
# ์ฒซ ์ •๋ฆฌ๋Š” 1์‹œ๊ฐ„ ํ›„
await asyncio.sleep(3600)
while self._running:
try:
await self._run_mq_cleanup()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(
f"[mq] Cleanup loop error (will continue): "
f"{type(e).__name__}: {str(e)[:200]}"
)
await asyncio.sleep(CLEANUP_INTERVAL_HOURS * 3600)
# ==========================================
# ํ ์ฒ˜๋ฆฌ ํ•ต์‹ฌ ๋กœ์ง
# ==========================================
async def _process_queue(self, queue_name: str) -> int:
"""ํŠน์ • ํ์˜ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐฐ์น˜๋กœ ์ˆ˜์‹ ํ•˜๊ณ  ์ฒ˜๋ฆฌ. ๋ฐ˜ํ™˜: ์ฒ˜๋ฆฌ๋œ ๋ฉ”์‹œ์ง€ ์ˆ˜"""
from db import get_supabase
supabase = get_supabase()
handler = self._handlers.get(queue_name)
if not handler:
return 0
try:
# mq_receive RPC ํ˜ธ์ถœ โ€” FOR UPDATE SKIP LOCKED
result = supabase.rpc(
"mq_receive",
{
"p_queue_name": queue_name,
"p_batch_size": BATCH_SIZE,
"p_visibility_timeout_seconds": VISIBILITY_TIMEOUT_SECONDS,
},
).execute()
messages = result.data or []
if not messages:
return 0
logger.debug(
f"[mq:{queue_name}] Received {len(messages)} messages"
)
completed_ids: List[int] = []
for msg in messages:
msg_id = msg["msg_id"]
msg_body = msg["msg_message"]
try:
await handler(msg_body)
completed_ids.append(msg_id)
self._stats["total_processed"] += 1
self._stats["queues"][queue_name]["processed"] += 1
except Exception as e:
# ๊ฐœ๋ณ„ ๋ฉ”์‹œ์ง€ ์‹คํŒจ โ€” mq_fail๋กœ ์žฌ์‹œ๋„/dead ์ฒ˜๋ฆฌ
self._stats["total_errors"] += 1
self._stats["queues"][queue_name]["errors"] += 1
error_msg = f"{type(e).__name__}: {str(e)[:500]}"
logger.warning(
f"[mq:{queue_name}] Message {msg_id} failed: {error_msg}"
)
try:
fail_result = supabase.rpc(
"mq_fail",
{
"p_message_id": msg_id,
"p_error_msg": error_msg,
},
).execute()
new_status = fail_result.data
if new_status == "dead":
self._stats["total_dead"] += 1
logger.error(
f"[mq:{queue_name}] Message {msg_id} moved to dead letter"
)
except Exception as fail_err:
logger.error(
f"[mq:{queue_name}] mq_fail call failed for {msg_id}: {fail_err}"
)
# ์„ฑ๊ณตํ•œ ๋ฉ”์‹œ์ง€๋“ค์„ ์ผ๊ด„ ์™„๋ฃŒ ์ฒ˜๋ฆฌ
if completed_ids:
try:
supabase.rpc(
"mq_complete",
{"p_message_ids": completed_ids},
).execute()
self._stats["queues"][queue_name]["last_processed_at"] = (
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
)
logger.info(
f"[mq:{queue_name}] Completed {len(completed_ids)} messages"
)
except Exception as e:
logger.error(
f"[mq:{queue_name}] mq_complete failed: {e}"
)
return len(messages)
except Exception as e:
# mq_receive ์ž์ฒด ์‹คํŒจ (DB ์—ฐ๊ฒฐ ๋“ฑ)
logger.error(
f"[mq:{queue_name}] Failed to receive messages: "
f"{type(e).__name__}: {str(e)[:200]}"
)
return 0
async def _run_mq_cleanup(self):
"""์™„๋ฃŒ/dead ๋ฉ”์‹œ์ง€ ์ •๋ฆฌ RPC ํ˜ธ์ถœ"""
from db import get_supabase
supabase = get_supabase()
try:
result = supabase.rpc(
"mq_cleanup", {"p_max_age_days": 7}
).execute()
cleanup_data = result.data
logger.info(f"[mq] Cleanup completed: {cleanup_data}")
except Exception as e:
logger.error(f"[mq] Cleanup failed: {e}")
# ==========================================
# ํ ํ•ธ๋“ค๋Ÿฌ: badge_check
# ==========================================
async def _handle_badge_check(self, message: Dict[str, Any]):
"""
๋ฐฐ์ง€ ์ž๊ฒฉ ํ™•์ธ โ€” check_and_grant_badges์˜ ๋น„๋™๊ธฐ ๋ฒ„์ „
๊ธฐ์กด ๋™๊ธฐ ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ๋งค INSERT๋งˆ๋‹ค N๊ฐœ ๋ฐฐ์ง€๋ฅผ ์ˆœํšŒํ•˜๋ฉฐ
COUNT ์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•˜๋˜ ๊ฒƒ์„, ํ ์›Œ์ปค์—์„œ ์ฒ˜๋ฆฌ.
์ตœ์ ํ™”:
- ์ด๋ฏธ ๋ณด์œ ํ•œ ๋ฐฐ์ง€ ์ œ์™ธ
- ํ™œ๋™ ์œ ํ˜•์— ํ•ด๋‹นํ•˜๋Š” ๋ฐฐ์ง€๋งŒ ํ™•์ธ
- user_activities ๋Œ€์‹  ๊ฐ€๋Šฅํ•˜๋ฉด ์ง‘๊ณ„ ์บ์‹œ ํ™œ์šฉ
"""
from db import get_supabase
user_id = message.get("user_id")
activity_type = message.get("activity_type")
if not user_id or not activity_type:
logger.warning(f"[mq:badge_check] Invalid message: {message}")
return
supabase = get_supabase()
# activity_type โ†’ condition_type ๋งคํ•‘
activity_to_condition = {
"story_unlocked": "stories",
"course_viewed": "visits",
"course_generated": "courses",
"spot_visited": "spots",
"spot_liked": "spots",
}
condition_type = activity_to_condition.get(activity_type)
if not condition_type:
return # ๋งคํ•‘๋˜์ง€ ์•Š๋Š” ํ™œ๋™์€ ๋ฌด์‹œ
try:
# UUID ํ˜•์‹ ํ™•์ธ
import uuid
user_uuid = str(uuid.UUID(user_id))
except (ValueError, AttributeError):
return
# 1. ํ•ด๋‹น condition_type์˜ ํ™œ์„ฑ ๋ฐฐ์ง€ ์ค‘ ๋ฏธ๋ณด์œ  ๋ฐฐ์ง€๋งŒ ์กฐํšŒ
badges_result = supabase.table("badge_definitions").select("*").eq(
"condition_type", condition_type
).eq("is_active", True).execute()
if not badges_result.data:
return
# ์ด๋ฏธ ๋ณด์œ ํ•œ ๋ฐฐ์ง€ ์กฐํšŒ
owned_result = supabase.table("user_badges").select("badge_type").eq(
"user_id", user_uuid
).execute()
owned_types = {b["badge_type"] for b in (owned_result.data or [])}
# ๋ฏธ๋ณด์œ  ๋ฐฐ์ง€๋งŒ ํ•„ํ„ฐ
candidates = [
b for b in badges_result.data
if b["badge_type"] not in owned_types
]
if not candidates:
return
# 2. ํ™œ๋™ ์นด์šดํŠธ ํ•œ ๋ฒˆ๋งŒ ์กฐํšŒ (condition_type๋ณ„)
count = await self._get_activity_count(
supabase, user_id, condition_type
)
# 3. ์กฐ๊ฑด ์ถฉ์กฑ ๋ฐฐ์ง€ ๋ถ€์—ฌ
for badge in candidates:
if count >= badge["condition_value"]:
await self._grant_badge(supabase, user_uuid, badge)
async def _get_activity_count(
self, supabase, user_id: str, condition_type: str
) -> int:
"""ํ™œ๋™ ์œ ํ˜•๋ณ„ ์นด์šดํŠธ ์กฐํšŒ"""
activity_type_map = {
"stories": ("story_unlocked", False),
"visits": ("course_viewed", False),
"courses": ("course_generated", False),
"spots": ("spot_visited", True), # DISTINCT spot_id
}
act_type, use_distinct = activity_type_map.get(
condition_type, (None, False)
)
if not act_type:
return 0
if use_distinct:
# DISTINCT spot_id ์นด์šดํŠธ๋Š” ์ง์ ‘ ์ฟผ๋ฆฌ๋กœ ํ•ด์•ผ ํ•จ
# PostgREST์—์„œ DISTINCT COUNT๊ฐ€ ์–ด๋ ค์šฐ๋ฏ€๋กœ ์ „์ฒด ์กฐํšŒ ํ›„ ์นด์šดํŠธ
result = supabase.table("user_activities").select(
"details"
).eq(
"user_id", user_id
).eq(
"activity_type", act_type
).execute()
if not result.data:
return 0
spot_ids = set()
for row in result.data:
details = row.get("details") or {}
spot_id = details.get("spot_id")
if spot_id:
spot_ids.add(spot_id)
return len(spot_ids)
else:
# ๋‹จ์ˆœ COUNT
result = supabase.table("user_activities").select(
"id", count="exact"
).eq(
"user_id", user_id
).eq(
"activity_type", act_type
).execute()
return result.count or 0
async def _grant_badge(
self, supabase, user_uuid: str, badge: Dict[str, Any]
):
"""๋ฐฐ์ง€ ๋ถ€์—ฌ + ๋ณด์ƒ ํ† ํฐ ์ง€๊ธ‰"""
try:
# ๋ฐฐ์ง€ ๋ถ€์—ฌ (ON CONFLICT DO NOTHING์œผ๋กœ ์ค‘๋ณต ๋ฐฉ์ง€)
supabase.table("user_badges").upsert(
{
"user_id": user_uuid,
"badge_type": badge["badge_type"],
"badge_name": badge["badge_name"],
"badge_description": badge.get("badge_description", ""),
"badge_icon": badge.get("badge_icon", ""),
"earned_at": "now()",
},
on_conflict="user_id,badge_type",
# DO NOTHING๊ณผ ๋™์ผ ํšจ๊ณผ: ๊ธฐ์กด ํ–‰ ์œ ์ง€
ignore_duplicates=True,
).execute()
logger.info(
f"[mq:badge_check] Granted badge '{badge['badge_type']}' "
f"to user {user_uuid}"
)
# ๋ณด์ƒ ํ† ํฐ ์ง€๊ธ‰
reward = badge.get("reward_tokens", 0)
if reward > 0:
await self._grant_badge_reward(supabase, user_uuid, badge, reward)
except Exception as e:
logger.error(
f"[mq:badge_check] Failed to grant badge "
f"'{badge['badge_type']}' to {user_uuid}: {e}"
)
raise
async def _grant_badge_reward(
self,
supabase,
user_uuid: str,
badge: Dict[str, Any],
reward: int,
):
"""
๋ฐฐ์ง€ ๋ณด์ƒ ํ† ํฐ ์ง€๊ธ‰.
๊ธฐ์กด SQL ํŠธ๋ฆฌ๊ฑฐ์™€ ๋™์ผํ•œ ๋กœ์ง:
1. user_tokens์— balance += reward (์—†์œผ๋ฉด INSERT)
2. token_transactions์— ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๊ธฐ๋ก
"""
try:
# 1. ํ˜„์žฌ ์ž”์•ก ์กฐํšŒ
existing = supabase.table("user_tokens").select(
"balance"
).eq("user_id", user_uuid).maybe_single().execute()
if existing.data:
# ๊ธฐ์กด ์‚ฌ์šฉ์ž: balance += reward
old_balance = existing.data.get("balance", 0)
new_balance = old_balance + reward
supabase.table("user_tokens").update(
{
"balance": new_balance,
"updated_at": "now()",
}
).eq("user_id", user_uuid).execute()
else:
# ์‹ ๊ทœ ์‚ฌ์šฉ์ž: INSERT
new_balance = reward
supabase.table("user_tokens").insert(
{
"user_id": user_uuid,
"balance": new_balance,
"updated_at": "now()",
}
).execute()
# 2. ํ† ํฐ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๊ธฐ๋ก
supabase.table("token_transactions").insert(
{
"user_id": user_uuid,
"type": "bonus",
"amount": reward,
"balance_after": new_balance,
"description": f"๋ฐฐ์ง€ ๋ณด์ƒ: {badge['badge_name']}",
}
).execute()
logger.info(
f"[mq:badge_check] Granted {reward} tokens for badge "
f"'{badge['badge_type']}' to {user_uuid} "
f"(new_balance={new_balance})"
)
except Exception as e:
# ํ† ํฐ ์ง€๊ธ‰ ์‹คํŒจํ•ด๋„ ๋ฐฐ์ง€ ๋ถ€์—ฌ๋Š” ์œ ์ง€ (๊ธฐ์กด ๋™์ž‘๊ณผ ๋™์ผ)
logger.warning(
f"[mq:badge_check] Token grant failed for "
f"{user_uuid}/{badge['badge_type']}: {e}"
)
# ==========================================
# ํ ํ•ธ๋“ค๋Ÿฌ: challenge_check
# ==========================================
async def _handle_challenge_check(self, message: Dict[str, Any]):
"""
์ฑŒ๋ฆฐ์ง€ ์ง„ํ–‰๋„ ๋น„๋™๊ธฐ ์—…๋ฐ์ดํŠธ โ€” update_challenge_progress์˜ ๋น„๋™๊ธฐ ๋ฒ„์ „
"""
from db import get_supabase
user_id = message.get("user_id")
activity_type = message.get("activity_type")
if not user_id or not activity_type:
return
# activity_type โ†’ challenge target_type ๋งคํ•‘
target_type_map = {
"course_completed": "courses_completed",
"spot_visited": "spots_visited",
"story_unlocked": "stories_unlocked",
"review_written": "reviews_written",
"course_shared": "courses_shared",
}
target_type = target_type_map.get(activity_type)
if not target_type:
return
try:
import uuid
user_uuid = str(uuid.UUID(user_id))
except (ValueError, AttributeError):
return
supabase = get_supabase()
# ํ•ด๋‹น target_type์˜ ํ™œ์„ฑ ์ฑŒ๋ฆฐ์ง€ ์กฐํšŒ
challenges_result = supabase.table("challenges").select("*").eq(
"target_type", target_type
).eq("is_active", True).execute()
if not challenges_result.data:
return
from datetime import date
today = date.today().isoformat()
for challenge in challenges_result.data:
# ๊ธฐ๊ฐ„ ํ™•์ธ
start_date = challenge.get("start_date")
end_date = challenge.get("end_date")
if start_date and start_date > today:
continue
if end_date and end_date < today:
continue
challenge_id = challenge["id"]
# user_challenges UPSERT (current_value +1)
try:
# ๊ธฐ์กด ์ง„ํ–‰ ์กฐํšŒ
existing = supabase.table("user_challenges").select("*").eq(
"user_id", user_uuid
).eq("challenge_id", challenge_id).maybe_single().execute()
if existing.data:
current = existing.data
if current.get("is_completed"):
continue # ์ด๋ฏธ ์™„๋ฃŒ๋œ ์ฑŒ๋ฆฐ์ง€ ๊ฑด๋„ˆ๋œ€
new_value = current["current_value"] + 1
update_data = {
"current_value": new_value,
"updated_at": "now()",
}
# ์™„๋ฃŒ ์ฒดํฌ
if new_value >= challenge["target_value"]:
update_data["is_completed"] = True
update_data["completed_at"] = "now()"
supabase.table("user_challenges").update(
update_data
).eq("user_id", user_uuid).eq(
"challenge_id", challenge_id
).execute()
else:
# ์‹ ๊ทœ ์ƒ์„ฑ
new_value = 1
insert_data = {
"user_id": user_uuid,
"challenge_id": challenge_id,
"current_value": new_value,
}
if new_value >= challenge["target_value"]:
insert_data["is_completed"] = True
insert_data["completed_at"] = "now()"
supabase.table("user_challenges").insert(
insert_data
).execute()
logger.debug(
f"[mq:challenge_check] Updated challenge "
f"{challenge_id} for {user_uuid}: value={new_value}"
)
except Exception as e:
logger.warning(
f"[mq:challenge_check] Failed to update challenge "
f"{challenge_id} for {user_uuid}: {e}"
)
# ==========================================
# ํ ํ•ธ๋“ค๋Ÿฌ: data_cleanup
# ==========================================
async def _handle_data_cleanup(self, message: Dict[str, Any]):
"""
๋ฐ์ดํ„ฐ ์ •๋ฆฌ ์ž‘์—… โ€” cleanup_type๋ณ„ ๋ถ„๊ธฐ
cleanup_type:
- "device_data": ์˜ค๋ž˜๋œ ๋””๋ฐ”์ด์Šค ๋ฐ์ดํ„ฐ ์ •๋ฆฌ
- "expired_tokens": ๋งŒ๋ฃŒ ํ† ํฐ ์ฒ˜๋ฆฌ
- "expired_cache": ์บ์‹œ ์ •๋ฆฌ
- "expired_rate_limits": ๋งŒ๋ฃŒ rate limit ์ •๋ฆฌ
- "mq_cleanup": ๋ฉ”์‹œ์ง€ ํ ์ž์ฒด ์ •๋ฆฌ
"""
from db import get_supabase
cleanup_type = message.get("cleanup_type")
if not cleanup_type:
logger.warning(f"[mq:data_cleanup] Missing cleanup_type: {message}")
return
supabase = get_supabase()
try:
if cleanup_type == "device_data":
days = message.get("days_old", 90)
result = supabase.rpc(
"cleanup_old_device_data", {"p_days_old": days}
).execute()
logger.info(
f"[mq:data_cleanup] device_data cleanup: {result.data}"
)
elif cleanup_type == "expired_tokens":
result = supabase.rpc("expire_stale_tokens").execute()
logger.info(
f"[mq:data_cleanup] expired_tokens: {result.data}"
)
elif cleanup_type == "expired_cache":
result = supabase.rpc("cleanup_expired_cache").execute()
logger.info(
f"[mq:data_cleanup] expired_cache: {result.data}"
)
elif cleanup_type == "expired_rate_limits":
result = supabase.rpc(
"cleanup_expired_rate_limits"
).execute()
logger.info(
f"[mq:data_cleanup] expired_rate_limits: {result.data}"
)
elif cleanup_type == "mq_cleanup":
max_age = message.get("max_age_days", 7)
result = supabase.rpc(
"mq_cleanup", {"p_max_age_days": max_age}
).execute()
logger.info(f"[mq:data_cleanup] mq_cleanup: {result.data}")
else:
logger.warning(
f"[mq:data_cleanup] Unknown cleanup_type: {cleanup_type}"
)
except Exception as e:
logger.error(
f"[mq:data_cleanup] {cleanup_type} failed: "
f"{type(e).__name__}: {str(e)[:200]}"
)
raise
class CleanupScheduler:
"""
์ •๊ธฐ ์ •๋ฆฌ ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ.
cleanup ํ์— ์ฃผ๊ธฐ์ ์œผ๋กœ ์ •๋ฆฌ ๋ฉ”์‹œ์ง€๋ฅผ ์ „์†กํ•˜์—ฌ
๋ฐ์ดํ„ฐ ์ •๋ฆฌ ์ž‘์—…์„ ํŠธ๋ฆฌ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.
GitHub Actions cron ๋Œ€์ฒด (B-M6/B-M7 ํ•ด๊ฒฐ).
"""
# ์Šค์ผ€์ค„ ์ •์˜: (cleanup_type, interval_hours, extra_params)
SCHEDULES = [
("device_data", 24, {"days_old": 90}),
("expired_tokens", 12, {}),
("expired_cache", 6, {}),
("expired_rate_limits", 6, {}),
("mq_cleanup", 24, {"max_age_days": 7}),
]
def __init__(self):
self._task: Optional[asyncio.Task] = None
self._running = False
def start(self):
"""์Šค์ผ€์ค„๋Ÿฌ ์‹œ์ž‘"""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._schedule_loop())
logger.info("[mq:scheduler] Cleanup scheduler started")
async def stop(self):
"""์Šค์ผ€์ค„๋Ÿฌ ์ค‘์ง€"""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("[mq:scheduler] Cleanup scheduler stopped")
async def _schedule_loop(self):
"""์Šค์ผ€์ค„์— ๋”ฐ๋ผ cleanup ๋ฉ”์‹œ์ง€ ์ „์†ก"""
# ์„œ๋ฒ„ ์‹œ์ž‘ ํ›„ 5๋ถ„ ๋Œ€๊ธฐ (์•ˆ์ •ํ™”)
await asyncio.sleep(300)
# ๊ฐ ์Šค์ผ€์ค„๋ณ„ ๋งˆ์ง€๋ง‰ ์‹คํ–‰ ์‹œ๊ฐ„ ์ถ”์ 
last_run: Dict[str, float] = {}
while self._running:
try:
now = time.time()
for cleanup_type, interval_hours, params in self.SCHEDULES:
last = last_run.get(cleanup_type, 0)
if now - last >= interval_hours * 3600:
await self._enqueue_cleanup(cleanup_type, params)
last_run[cleanup_type] = now
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(
f"[mq:scheduler] Schedule loop error: "
f"{type(e).__name__}: {str(e)[:200]}"
)
# 1์‹œ๊ฐ„๋งˆ๋‹ค ํ™•์ธ
await asyncio.sleep(3600)
async def _enqueue_cleanup(
self, cleanup_type: str, params: Dict[str, Any]
):
"""cleanup ํ์— ๋ฉ”์‹œ์ง€ ์ „์†ก"""
from db import get_supabase
supabase = get_supabase()
message = {"cleanup_type": cleanup_type, **params}
try:
supabase.rpc(
"mq_send",
{
"p_queue_name": "data_cleanup",
"p_message": message,
"p_priority": 0,
"p_max_retries": 2,
},
).execute()
logger.info(
f"[mq:scheduler] Enqueued cleanup: {cleanup_type}"
)
except Exception as e:
logger.error(
f"[mq:scheduler] Failed to enqueue {cleanup_type}: {e}"
)
# ์Šค์ผ€์ค„๋Ÿฌ ์‹ฑ๊ธ€ํ†ค
_scheduler: Optional[CleanupScheduler] = None
def get_cleanup_scheduler() -> CleanupScheduler:
"""CleanupScheduler ์‹ฑ๊ธ€ํ†ค ๋ฐ˜ํ™˜"""
global _scheduler
if _scheduler is None:
_scheduler = CleanupScheduler()
return _scheduler