"""Cache service: blockchain data caching with TTL expiry. Uses the BlockchainCache model (PostgreSQL/JSONB) to cache on-chain data like WoT size, Smith size, and TechComm size. Avoids repeated RPC calls to the Duniter node. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.cache import BlockchainCache logger = logging.getLogger(__name__) async def get_cached(key: str, db: AsyncSession) -> dict | None: """Retrieve a cached value by key if it has not expired. Parameters ---------- key: The cache key to look up. db: Async database session. Returns ------- dict | None The cached value as a dict, or None if missing/expired. """ result = await db.execute( select(BlockchainCache).where( BlockchainCache.cache_key == key, BlockchainCache.expires_at > datetime.now(timezone.utc), ) ) entry = result.scalar_one_or_none() if entry is None: logger.debug("Cache miss pour la cle '%s'", key) return None logger.debug("Cache hit pour la cle '%s'", key) return entry.cache_value async def set_cached( key: str, value: dict, db: AsyncSession, ttl_seconds: int = 3600, ) -> None: """Store a value in the cache with the given TTL. If the key already exists, it is replaced (upsert). Parameters ---------- key: The cache key. value: The value to store (must be JSON-serializable). db: Async database session. ttl_seconds: Time-to-live in seconds (default: 1 hour). """ now = datetime.now(timezone.utc) expires_at = now + timedelta(seconds=ttl_seconds) # Check if key already exists result = await db.execute( select(BlockchainCache).where(BlockchainCache.cache_key == key) ) existing = result.scalar_one_or_none() if existing is not None: existing.cache_value = value existing.fetched_at = now existing.expires_at = expires_at logger.debug("Cache mis a jour pour la cle '%s' (TTL=%ds)", key, ttl_seconds) else: entry = BlockchainCache( cache_key=key, cache_value=value, fetched_at=now, expires_at=expires_at, ) db.add(entry) logger.debug("Cache cree pour la cle '%s' (TTL=%ds)", key, ttl_seconds) await db.commit() async def invalidate(key: str, db: AsyncSession) -> None: """Remove a specific cache entry by key. Parameters ---------- key: The cache key to invalidate. db: Async database session. """ await db.execute( delete(BlockchainCache).where(BlockchainCache.cache_key == key) ) await db.commit() logger.debug("Cache invalide pour la cle '%s'", key) async def cleanup_expired(db: AsyncSession) -> int: """Remove all expired cache entries. Parameters ---------- db: Async database session. Returns ------- int Number of entries removed. """ result = await db.execute( delete(BlockchainCache).where( BlockchainCache.expires_at <= datetime.now(timezone.utc) ) ) await db.commit() count = result.rowcount if count > 0: logger.info("Nettoyage cache: %d entrees expirees supprimees", count) return count