"""Cache key-value persistido en SQLite con TTL y lazy eviction.""" import json import sqlite3 import threading import time class CacheStore: """Cache key-value respaldado en SQLite con soporte de TTL y namespaces.""" _schema = """ CREATE TABLE IF NOT EXISTS cache ( namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, created_at REAL NOT NULL, expires_at REAL, PRIMARY KEY (namespace, key) ); """ def __init__(self, db_path: str, namespace: str = "default") -> None: self._db_path = db_path self._namespace = namespace self._hits = 0 self._misses = 0 self._lock = threading.Lock() self._local = threading.local() self._init_db() def _conn(self) -> sqlite3.Connection: """Retorna una conexion SQLite thread-local.""" if not hasattr(self._local, "conn"): conn = sqlite3.connect(self._db_path, check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") self._local.conn = conn return self._local.conn def _init_db(self) -> None: conn = self._conn() conn.execute(self._schema) conn.commit() def _evict_expired(self, conn: sqlite3.Connection) -> None: """Elimina entradas expiradas del namespace actual (lazy eviction).""" now = time.time() conn.execute( "DELETE FROM cache WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?", (self._namespace, now), ) def get(self, key: str) -> object: """Retorna el valor o None si no existe o esta expirado.""" with self._lock: conn = self._conn() self._evict_expired(conn) conn.commit() row = conn.execute( "SELECT value FROM cache WHERE namespace = ? AND key = ?", (self._namespace, key), ).fetchone() if row is None: self._misses += 1 return None self._hits += 1 return json.loads(row[0]) def set(self, key: str, value: object, ttl: float = 0) -> None: """Almacena un valor. ttl en segundos; 0 = sin expiracion.""" now = time.time() expires_at = (now + ttl) if ttl > 0 else None with self._lock: conn = self._conn() conn.execute( """ INSERT INTO cache (namespace, key, value, created_at, expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at, expires_at = excluded.expires_at """, (self._namespace, key, json.dumps(value), now, expires_at), ) conn.commit() def delete(self, key: str) -> bool: """Elimina una entrada. Retorna True si existia.""" with self._lock: conn = self._conn() cursor = conn.execute( "DELETE FROM cache WHERE namespace = ? AND key = ?", (self._namespace, key), ) conn.commit() return cursor.rowcount > 0 def clear(self) -> int: """Elimina todas las entradas del namespace. Retorna filas eliminadas.""" with self._lock: conn = self._conn() cursor = conn.execute( "DELETE FROM cache WHERE namespace = ?", (self._namespace,), ) conn.commit() return cursor.rowcount def stats(self) -> dict: """Retorna estadisticas del store: hits, misses y size actual.""" with self._lock: conn = self._conn() self._evict_expired(conn) conn.commit() row = conn.execute( "SELECT COUNT(*) FROM cache WHERE namespace = ?", (self._namespace,), ).fetchone() size = row[0] if row else 0 return {"hits": self._hits, "misses": self._misses, "size": size} def get_or_set(self, key: str, factory: callable, ttl: float = 0) -> object: """Retorna el valor cacheado o llama factory() y lo almacena.""" value = self.get(key) if value is None: value = factory() self.set(key, value, ttl) return value def cache_to_sqlite(db_path: str, namespace: str = "default") -> CacheStore: """Crea un CacheStore respaldado en SQLite. Args: db_path: Ruta al archivo SQLite (se crea si no existe). namespace: Espacio de nombres logico dentro de la base de datos. Returns: CacheStore con metodos get/set/delete/clear/stats/get_or_set. """ return CacheStore(db_path, namespace)