from __future__ import annotations from collections.abc import Awaitable, Callable from .validators import remaining_seconds class FileMetadataCache: """Cache-aside layer for convex.get_file, shared across workers via AsyncCache. Only complete, non-expired records are cached. Misses and expired markers stay uncached so a newly uploaded or just-expired file is never masked. """ def __init__(self, cache, ttl: int = 60, prefix: str = 'nanoshare:filemeta:'): self._cache = cache self._ttl = max(1, int(ttl)) self._prefix = prefix self.hits = 0 self.misses = 0 def _key(self, file_id: str) -> str: return f'{self._prefix}{file_id}' def _effective_ttl(self, file_data: dict) -> int: """Never hold a record past the moment it expires.""" remaining = remaining_seconds(file_data.get('expires_at')) if remaining is None: return self._ttl return min(self._ttl, remaining) def _is_cacheable(self, file_data) -> bool: if not isinstance(file_data, dict) or not file_data: return False if file_data.get('expired'): return False return bool(file_data.get('db_image_url')) async def get(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]) -> dict | None: cached = await self._cache.get(self._key(file_id)) if isinstance(cached, dict) and cached: self.hits += 1 return cached self.misses += 1 file_data = await loader() if self._is_cacheable(file_data): ttl = self._effective_ttl(file_data) if ttl > 0: await self._cache.set(self._key(file_id), file_data, ttl=ttl) return file_data async def invalidate(self, file_id: str) -> None: await self._cache.delete(self._key(file_id))