mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-18 21:08:07 +00:00
222 lines
6.2 KiB
Python
222 lines
6.2 KiB
Python
"""Two-tier caching system for ATLAS Terminal.
|
||||
|
|
|
|||
|
|
Tier 1 – ``MemoryCache``: fast in-process dict with per-key TTL.
|
|||
|
|
Tier 2 – ``DBCache``: durable SQLite-backed cache with per-key TTL.
|
|||
|
|
``CacheManager`` orchestrates both tiers (memory-first, DB-second).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
from typing import Any, Dict, Optional
|
|||
|
|
|
|||
|
|
import aiosqlite
|
|||
|
|
|
|||
|
|
from server.db.database import get_db
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Tier 1: In-memory cache
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_DEFAULT_MEMORY_TTL: int = 300 # 5 minutes
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MemoryCache:
|
|||
|
|
"""Thread-*unsafe* in-memory cache backed by a plain dict.
|
|||
|
|
|
|||
|
|
Each entry stores ``(value, expiry_timestamp)``. Expired entries are
|
|||
|
|
lazily evicted on ``get()``.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self._store: Dict[str, tuple[Any, float]] = {}
|
|||
|
|
|
|||
|
|
def get(self, key: str) -> Optional[Any]:
|
|||
|
|
"""Return the cached value for *key*, or ``None`` if missing/expired.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
The stored value, or ``None``.
|
|||
|
|
"""
|
|||
|
|
entry = self._store.get(key)
|
|||
|
|
if entry is None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
value, expires_at = entry
|
|||
|
|
if time.time() > expires_at:
|
|||
|
|
del self._store[key]
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
def set(self, key: str, value: Any, ttl: int = _DEFAULT_MEMORY_TTL) -> None:
|
|||
|
|
"""Store *value* under *key* with the given TTL in seconds.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
value: Arbitrary Python object to cache.
|
|||
|
|
ttl: Time-to-live in seconds (default 300).
|
|||
|
|
"""
|
|||
|
|
self._store[key] = (value, time.time() + ttl)
|
|||
|
|
|
|||
|
|
def delete(self, key: str) -> None:
|
|||
|
|
"""Remove *key* from the cache (no-op if absent).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
"""
|
|||
|
|
self._store.pop(key, None)
|
|||
|
|
|
|||
|
|
def clear(self) -> None:
|
|||
|
|
"""Remove all entries from the cache."""
|
|||
|
|
self._store.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Tier 2: SQLite-backed cache
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_DEFAULT_DB_TTL: int = 86400 # 1 day
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DBCache:
|
|||
|
|
"""Durable cache that persists entries in the ``cache`` SQLite table.
|
|||
|
|
|
|||
|
|
Values are stored as JSON-encoded text so that structured data survives
|
|||
|
|
a round-trip.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
async def get(self, key: str) -> Optional[str]:
|
|||
|
|
"""Return the cached value for *key*, or ``None`` if missing/expired.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
The stored value string, or ``None``.
|
|||
|
|
"""
|
|||
|
|
db: aiosqlite.Connection = await get_db()
|
|||
|
|
cursor = await db.execute(
|
|||
|
|
"SELECT value, expires_at FROM cache WHERE key = ?",
|
|||
|
|
(key,),
|
|||
|
|
)
|
|||
|
|
row = await cursor.fetchone()
|
|||
|
|
|
|||
|
|
if row is None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
value: str = row[0]
|
|||
|
|
expires_at: float = row[1]
|
|||
|
|
|
|||
|
|
if time.time() > expires_at:
|
|||
|
|
await db.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|||
|
|
await db.commit()
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
async def set(self, key: str, value: str, ttl: int = _DEFAULT_DB_TTL) -> None:
|
|||
|
|
"""Store *value* under *key* with the given TTL in seconds.
|
|||
|
|
|
|||
|
|
Uses ``INSERT OR REPLACE`` so existing entries are overwritten.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
value: String value to persist.
|
|||
|
|
ttl: Time-to-live in seconds (default 86400).
|
|||
|
|
"""
|
|||
|
|
db: aiosqlite.Connection = await get_db()
|
|||
|
|
expires_at = time.time() + ttl
|
|||
|
|
await db.execute(
|
|||
|
|
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
|
|||
|
|
(key, value, expires_at),
|
|||
|
|
)
|
|||
|
|
await db.commit()
|
|||
|
|
|
|||
|
|
async def cleanup(self) -> None:
|
|||
|
|
"""Delete all expired entries from the cache table."""
|
|||
|
|
db: aiosqlite.Connection = await get_db()
|
|||
|
|
await db.execute(
|
|||
|
|
"DELETE FROM cache WHERE expires_at < ?",
|
|||
|
|
(time.time(),),
|
|||
|
|
)
|
|||
|
|
await db.commit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Orchestrator
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CacheManager:
|
|||
|
|
"""Two-tier cache that checks memory first, then SQLite.
|
|||
|
|
|
|||
|
|
Usage::
|
|||
|
|
|
|||
|
|
value = await cache_manager.get("key")
|
|||
|
|
await cache_manager.set("key", payload, memory_ttl=60, db_ttl=3600)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self.memory = MemoryCache()
|
|||
|
|
self.db = DBCache()
|
|||
|
|
|
|||
|
|
async def get(self, key: str) -> Optional[Any]:
|
|||
|
|
"""Look up *key* in memory, then in the DB cache.
|
|||
|
|
|
|||
|
|
If the value is found only in the DB tier it is promoted back into
|
|||
|
|
memory with the default memory TTL.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
The cached value (deserialized from JSON when coming from DB),
|
|||
|
|
or ``None``.
|
|||
|
|
"""
|
|||
|
|
# Tier 1
|
|||
|
|
mem_value = self.memory.get(key)
|
|||
|
|
if mem_value is not None:
|
|||
|
|
return mem_value
|
|||
|
|
|
|||
|
|
# Tier 2
|
|||
|
|
db_value = await self.db.get(key)
|
|||
|
|
if db_value is not None:
|
|||
|
|
try:
|
|||
|
|
deserialized = json.loads(db_value)
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
deserialized = db_value
|
|||
|
|
|
|||
|
|
# Promote to memory for faster subsequent access
|
|||
|
|
self.memory.set(key, deserialized)
|
|||
|
|
return deserialized
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def set(
|
|||
|
|
self,
|
|||
|
|
key: str,
|
|||
|
|
value: Any,
|
|||
|
|
memory_ttl: int = _DEFAULT_MEMORY_TTL,
|
|||
|
|
db_ttl: int = _DEFAULT_DB_TTL,
|
|||
|
|
) -> None:
|
|||
|
|
"""Write *value* to both cache tiers.
|
|||
|
|
|
|||
|
|
The value is JSON-serialized before writing to the DB tier.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
key: Cache key.
|
|||
|
|
value: Arbitrary Python object to cache.
|
|||
|
|
memory_ttl: TTL for the in-memory tier (default 300s).
|
|||
|
|
db_ttl: TTL for the SQLite tier (default 86400s).
|
|||
|
|
"""
|
|||
|
|
self.memory.set(key, value, ttl=memory_ttl)
|
|||
|
|
|
|||
|
|
serialized = json.dumps(value, default=str)
|
|||
|
|
await self.db.set(key, serialized, ttl=db_ttl)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Module-level singleton
|
|||
|
|
cache_manager: CacheManager = CacheManager()
|