"""Idempotency cache + inflight dedup for job execution.""" import asyncio import logging import time from typing import Dict, Optional, Tuple from models import JobResponse logger = logging.getLogger("idempotency") CACHE_TTL = 600 # 10 min for successful results TIMEOUT_TTL = 30 # 30s for timeout results class IdempotencyStore: def __init__(self, max_size: int = 10_000): self._cache: Dict[str, Tuple[JobResponse, float]] = {} self._inflight: Dict[str, asyncio.Future] = {} self._max_size = max_size def get(self, key: str) -> Optional[JobResponse]: entry = self._cache.get(key) if not entry: return None resp, expires = entry if time.time() > expires: self._cache.pop(key, None) return None cached = resp.model_copy() cached.cached = True return cached def put(self, key: str, resp: JobResponse): ttl = TIMEOUT_TTL if resp.status == "timeout" else CACHE_TTL self._cache[key] = (resp, time.time() + ttl) self._evict_if_needed() def _evict_if_needed(self): if len(self._cache) <= self._max_size: return now = time.time() expired = [k for k, (_, exp) in self._cache.items() if now > exp] for k in expired: self._cache.pop(k, None) if len(self._cache) > self._max_size: oldest = sorted(self._cache, key=lambda k: self._cache[k][1]) for k in oldest[:len(self._cache) - self._max_size]: self._cache.pop(k, None) async def acquire_inflight(self, key: str) -> Optional[asyncio.Future]: """If another coroutine is already processing this key, return its future. Otherwise register this coroutine as the processor and return None.""" if key in self._inflight: return self._inflight[key] fut: asyncio.Future = asyncio.get_event_loop().create_future() self._inflight[key] = fut return None def complete_inflight(self, key: str, resp: JobResponse): fut = self._inflight.pop(key, None) if fut and not fut.done(): fut.set_result(resp)