import asyncio import time from typing import Any, Awaitable, Callable, Dict, Optional, Tuple class AsyncTTLCache: """In-memory async-safe TTL cache with single-flight loader. Concurrent get_or_load() calls for the same key share one loader execution. """ def __init__(self, ttl_seconds: float, settings: Any = None, namespace: Optional[str] = None): self.ttl_seconds = ttl_seconds self.settings = settings self.namespace = namespace self._data: Dict[str, Tuple[float, Any]] = {} self._locks: Dict[str, asyncio.Lock] = {} self._inflight: Dict[str, asyncio.Task] = {} def _is_fresh(self, expires_at: float) -> bool: return time.monotonic() < expires_at def get_fresh(self, key: str) -> Optional[Any]: entry = self._data.get(key) if entry is None: return None expires_at, value = entry if not self._is_fresh(expires_at): return None return value def get_stale(self, key: str) -> Optional[Any]: entry = self._data.get(key) if entry is None: return None _, value = entry if not self._is_cacheable(value): return None return value @staticmethod def _is_cacheable(value: Any) -> bool: if value is None: return False if isinstance(value, dict) and value.get("error"): return False return True async def get_or_load(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any: cached = self.get_fresh(key) if cached is not None: return cached lock = self._locks.setdefault(key, asyncio.Lock()) async with lock: cached = self.get_fresh(key) if cached is not None: return cached task = self._inflight.get(key) if task is None: task = asyncio.create_task(self._load_and_store(key, loader)) self._inflight[key] = task def _forget_inflight(done_task: asyncio.Task) -> None: if self._inflight.get(key) is done_task: self._inflight.pop(key, None) task.add_done_callback(_forget_inflight) return await task async def _load_and_store(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any: cache_key = None if self.settings is not None and self.namespace: try: from bot.infra.redis import cache_get_json, redis_key cache_key = redis_key(self.settings, "cache", self.namespace, key) cached = await cache_get_json(self.settings, cache_key) if cached is not None: if self._is_cacheable(cached): self._data[key] = (time.monotonic() + self.ttl_seconds, cached) return cached except Exception: cache_key = None value = await loader() if self._is_cacheable(value): self._data[key] = (time.monotonic() + self.ttl_seconds, value) if cache_key is not None: try: from bot.infra.redis import cache_set_json await cache_set_json( self.settings, cache_key, value, max(1, int(self.ttl_seconds)), ) except Exception: pass return value def invalidate(self, key: Optional[str] = None) -> None: if key is None: self._data.clear() return self._data.pop(key, None) async def invalidate_remote(self, key: Optional[str] = None) -> None: self.invalidate(key) if self.settings is None or not self.namespace: return try: from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key if key is None: pattern = redis_key(self.settings, "cache", self.namespace, "*") await cache_delete_pattern(self.settings, pattern) return await cache_delete( self.settings, redis_key(self.settings, "cache", self.namespace, key) ) except Exception: return