Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |