first commit
This commit is contained in:
128
app/utils/cache_db.py
Normal file
128
app/utils/cache_db.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import threading
|
||||
import functools
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
# ---------- SINGLETON DECORATOR ----------
|
||||
T = Any
|
||||
|
||||
def singleton_loader(func):
|
||||
"""Ensure only one cache instance exists."""
|
||||
cache: dict[str, T] = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs) -> T:
|
||||
with lock:
|
||||
if func.__name__ not in cache:
|
||||
cache[func.__name__] = func(*args, **kwargs)
|
||||
return cache[func.__name__]
|
||||
return wrapper
|
||||
|
||||
# ---------- CACHE CLASS ----------
|
||||
class CacheDB:
|
||||
"""SQLite-backed cache with expiration in minutes, CRUD, auto-cleanup, singleton support."""
|
||||
|
||||
TABLE_NAME = "cache"
|
||||
|
||||
def __init__(self, db_path: str | Path = "cache.db", default_expiration_minutes: int = 1440):
|
||||
"""
|
||||
:param default_expiration_minutes: default expiration in minutes (default 24 hours)
|
||||
"""
|
||||
self.db_path = Path(db_path)
|
||||
self.default_expiration = default_expiration_minutes * 60 # convert minutes -> seconds
|
||||
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self._lock = threading.Lock()
|
||||
self._create_table()
|
||||
|
||||
def _create_table(self):
|
||||
"""Create the cache table if it doesn't exist."""
|
||||
with self._lock:
|
||||
self.conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.TABLE_NAME} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
expires_at INTEGER
|
||||
)
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
def _cleanup_expired(self):
|
||||
"""Delete expired rows."""
|
||||
now = int(time.time())
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
f"DELETE FROM {self.TABLE_NAME} WHERE expires_at IS NOT NULL AND expires_at < ?", (now,)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# ---------- CRUD ----------
|
||||
def create(self, key: str, value: Any, expires_in_minutes: Optional[int] = None):
|
||||
"""Insert or update a cache entry. expires_in_minutes overrides default expiration."""
|
||||
self._cleanup_expired()
|
||||
if expires_in_minutes is None:
|
||||
expires_in_seconds = self.default_expiration
|
||||
else:
|
||||
expires_in_seconds = expires_in_minutes * 60
|
||||
expires_at = int(time.time()) + expires_in_seconds
|
||||
|
||||
value_json = json.dumps(value)
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
f"INSERT OR REPLACE INTO {self.TABLE_NAME} (key, value, expires_at) VALUES (?, ?, ?)",
|
||||
(key, value_json, expires_at)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def read(self, key: str) -> Optional[Any]:
|
||||
"""Read a cache entry. Auto-cleans expired items."""
|
||||
self._cleanup_expired()
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
f"SELECT * FROM {self.TABLE_NAME} WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row["value"])
|
||||
|
||||
def update(self, key: str, value: Any, expires_in_minutes: Optional[int] = None):
|
||||
"""Update a cache entry. Optional expiration in minutes."""
|
||||
if expires_in_minutes is None:
|
||||
expires_in_seconds = self.default_expiration
|
||||
else:
|
||||
expires_in_seconds = expires_in_minutes * 60
|
||||
expires_at = int(time.time()) + expires_in_seconds
|
||||
|
||||
value_json = json.dumps(value)
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
f"UPDATE {self.TABLE_NAME} SET value = ?, expires_at = ? WHERE key = ?",
|
||||
(value_json, expires_at, key)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def delete(self, key: str):
|
||||
with self._lock:
|
||||
self.conn.execute(f"DELETE FROM {self.TABLE_NAME} WHERE key = ?", (key,))
|
||||
self.conn.commit()
|
||||
|
||||
def clear(self):
|
||||
"""Delete all rows from the cache table."""
|
||||
with self._lock:
|
||||
self.conn.execute(f"DELETE FROM {self.TABLE_NAME}")
|
||||
self.conn.commit()
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
|
||||
|
||||
# ---------- SINGLETON INSTANCE ----------
|
||||
@singleton_loader
|
||||
def get_cache(db_path: str = "cache.db", default_expiration_minutes: int = 1440) -> CacheDB:
|
||||
return CacheDB(db_path=db_path, default_expiration_minutes=default_expiration_minutes)
|
||||
Reference in New Issue
Block a user