- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
543 lines
20 KiB
Python
543 lines
20 KiB
Python
"""
|
|
Multi-Layer Cache Manager - L1 In-Memory + L2 Redis with Smart TTLs.
|
|
|
|
Provides a two-level caching strategy:
|
|
L1: In-memory dict (fast, per-process, bounded size)
|
|
L2: Redis (shared across processes/workers, persistent optional)
|
|
|
|
If REDIS_URL env var is not set, falls back to L1-only mode.
|
|
Key format: {data_type}:{chain}:{address} for easy pattern invalidation.
|
|
|
|
Smart TTLs per data type (seconds):
|
|
token_meta=86400 (24h), deployer=0 (permanent), holders=300 (5min),
|
|
price=10, liquidity=30, social=900 (15min), rpc=60, block=15.
|
|
|
|
Depends on: redis (>=5.0) for L2; falls back gracefully if not installed.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from collections import OrderedDict
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Smart TTLs per Data Type ───────────────────────────────────────────────
|
|
|
|
# fmt: off
|
|
SMART_TTLS: dict[str, int] = {
|
|
"token_meta": 86400, # 24 hours - token name/symbol/logo rarely change
|
|
"deployer": 0, # Permanent (0 = never expire in L1, Redis TTL omitted)
|
|
"contract_code": 3600, # 1 hour - contract code is immutable but may redeploy
|
|
"holders": 300, # 5 minutes - holder count changes frequently
|
|
"holder_list": 120, # 2 minutes - individual holder data can shift
|
|
"price": 10, # 10 seconds - price is highly volatile
|
|
"liquidity": 30, # 30 seconds - liquidity changes but slower than price
|
|
"volume": 30, # 30 seconds - same reasoning
|
|
"market_cap": 60, # 1 minute - derived from price x supply
|
|
"social": 900, # 15 minutes - social media data updates slowly
|
|
"metadata": 3600, # 1 hour - token metadata is semi-static
|
|
"rpc": 60, # 1 minute - generic RPC responses
|
|
"rpc_account": 30, # 30 seconds - account state (balance, etc.)
|
|
"rpc_block": 15, # 15 seconds - block data is fresh
|
|
"rpc_tx": 60, # 1 minute - confirmed transactions
|
|
"scanner": 300, # 5 minutes - scanner results (risk scores, etc.)
|
|
"entity": 3600, # 1 hour - entity labeling by address
|
|
"news": 600, # 10 minutes - news articles
|
|
"trending": 300, # 5 minutes - trending tokens
|
|
"default": 120, # 2 minutes - catch-all
|
|
}
|
|
# fmt: on
|
|
|
|
|
|
# ── Data Classes ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class CacheEntry:
|
|
"""A cached value with metadata."""
|
|
|
|
value: Any
|
|
created_at: float = field(default_factory=time.monotonic)
|
|
ttl: float = 0.0 # 0 = permanent (no expiry)
|
|
access_count: int = 0
|
|
size_bytes: int = 0
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
if self.ttl <= 0:
|
|
return False
|
|
return time.monotonic() - self.created_at > self.ttl
|
|
|
|
@property
|
|
def age_seconds(self) -> float:
|
|
return time.monotonic() - self.created_at
|
|
|
|
|
|
@dataclass
|
|
class CacheStats:
|
|
"""Aggregate cache performance stats."""
|
|
|
|
hit_count: int = 0
|
|
miss_count: int = 0
|
|
total_calls: int = 0
|
|
l1_size: int = 0
|
|
l2_size: int = 0
|
|
l1_hits: int = 0
|
|
l2_hits: int = 0
|
|
invalidations: int = 0
|
|
|
|
@property
|
|
def hit_rate(self) -> float:
|
|
if self.total_calls == 0:
|
|
return 0.0
|
|
return self.hit_count / self.total_calls
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"hit_count": self.hit_count,
|
|
"miss_count": self.miss_count,
|
|
"total_calls": self.total_calls,
|
|
"hit_rate": round(self.hit_rate * 100, 1),
|
|
"l1_size": self.l1_size,
|
|
"l2_size": self.l2_size,
|
|
"l1_hits": self.l1_hits,
|
|
"l2_hits": self.l2_hits,
|
|
"invalidations": self.invalidations,
|
|
}
|
|
|
|
|
|
# ── Cache Key Builder ──────────────────────────────────────────────────────
|
|
|
|
|
|
def build_key(data_type: str, chain: str, address: str) -> str:
|
|
"""Build a standardized cache key.
|
|
|
|
Format: {data_type}:{chain}:{address}
|
|
All segments are lowercased. Addresses longer than 80 chars are truncated
|
|
and hashed for readability.
|
|
|
|
Examples:
|
|
build_key("price", "solana", "EPjFWdd5AufqSSqeM2qN1xzybapC8G5wUGxvZq2moaomYR")
|
|
→ "price:solana:epjfwd..."
|
|
"""
|
|
address = str(address).strip().lower()
|
|
chain = str(chain).strip().lower()
|
|
data_type = str(data_type).strip().lower()
|
|
|
|
if len(address) > 80:
|
|
# Hash long addresses (e.g., complex contract addresses)
|
|
short = hashlib.md5(address.encode()).hexdigest()[:16]
|
|
address = f"{address[:40]}..{short}"
|
|
|
|
return f"{data_type}:{chain}:{address}"
|
|
|
|
|
|
def get_ttl(data_type: str, override: int | None = None) -> int:
|
|
"""Get TTL for a data type, with optional override."""
|
|
if override is not None:
|
|
return override
|
|
return SMART_TTLS.get(data_type.lower(), SMART_TTLS["default"])
|
|
|
|
|
|
# ── Cache Implementation ────────────────────────────────────────────────────
|
|
|
|
|
|
class RpcCache:
|
|
"""Two-layer cache: L1 in-memory (OrderedDict, LRU eviction) + L2 Redis.
|
|
|
|
Usage:
|
|
cache = RpcCache()
|
|
result = await cache.get_or_fetch(
|
|
"price", "solana", token_address,
|
|
factory_fn=lambda: fetch_price(token_address),
|
|
)
|
|
"""
|
|
|
|
# Max entries in L1 memory cache (prevents unbounded growth)
|
|
MAX_L1_SIZE = 10000
|
|
|
|
# Redis key prefix for namespacing
|
|
REDIS_PREFIX = "rmi:cache:"
|
|
|
|
def __init__(
|
|
self,
|
|
l1_max_size: int = 10000,
|
|
redis_url: str | None = None,
|
|
):
|
|
"""Initialize the cache.
|
|
|
|
Args:
|
|
l1_max_size: Maximum entries in L1 (memory) cache
|
|
redis_url: Redis connection URL. If None, reads REDIS_URL env var.
|
|
Set to empty string or False to force L1-only mode.
|
|
"""
|
|
self._l1: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
self._l1_max = l1_max_size
|
|
self._lock = asyncio.Lock()
|
|
self._stats = CacheStats()
|
|
|
|
# Redis L2 setup
|
|
self._redis = None
|
|
self._redis_available = False
|
|
|
|
_url = redis_url if redis_url is not None else os.getenv("REDIS_URL", "")
|
|
if _url and _url.strip():
|
|
try:
|
|
import redis.asyncio as redis_asyncio
|
|
|
|
self._redis = redis_asyncio.from_url(
|
|
_url.strip(),
|
|
decode_responses=False, # We handle serialization ourselves
|
|
socket_connect_timeout=3.0,
|
|
socket_timeout=2.0,
|
|
retry_on_timeout=True,
|
|
health_check_interval=30,
|
|
)
|
|
self._redis_available = True
|
|
logger.info(f"RpcCache: L2 Redis connected (prefix={self.REDIS_PREFIX})")
|
|
except ImportError:
|
|
logger.warning("RpcCache: redis package not installed, L2 disabled")
|
|
except Exception as e:
|
|
logger.warning(f"RpcCache: Redis connection failed ({e}), L2 disabled")
|
|
else:
|
|
logger.info("RpcCache: No REDIS_URL set, L1-only mode")
|
|
|
|
# ── Public API ──────────────────────────────────────────────────────
|
|
|
|
async def get(self, data_type: str, chain: str, address: str) -> Any | None:
|
|
"""Retrieve a cached value. Returns None on miss or expiry.
|
|
|
|
Checks L1 first, then L2 (Redis), then returns None.
|
|
On L2 hit, promotes the value into L1.
|
|
"""
|
|
key = build_key(data_type, chain, address)
|
|
ttl = get_ttl(data_type)
|
|
|
|
# 1. Check L1 (in-memory)
|
|
async with self._lock:
|
|
entry = self._l1.get(key)
|
|
if entry is not None:
|
|
if entry.is_expired:
|
|
del self._l1[key]
|
|
self._stats.l1_size = len(self._l1)
|
|
else:
|
|
entry.access_count += 1
|
|
self._l1.move_to_end(key)
|
|
self._stats.hit_count += 1
|
|
self._stats.total_calls += 1
|
|
self._stats.l1_hits += 1
|
|
self._stats.l1_size = len(self._l1)
|
|
return entry.value
|
|
|
|
# 2. Check L2 (Redis)
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
redis_key = f"{self.REDIS_PREFIX}{key}"
|
|
raw = await self._redis.get(redis_key)
|
|
if raw is not None:
|
|
value = self._deserialize(raw)
|
|
# Promote to L1
|
|
async with self._lock:
|
|
self._stats.hit_count += 1
|
|
self._stats.total_calls += 1
|
|
self._stats.l2_hits += 1
|
|
self._l1[key] = CacheEntry(value=value, ttl=float(ttl))
|
|
self._l1.move_to_end(key)
|
|
self._stats.l1_size = len(self._l1)
|
|
self._evict_l1_if_needed()
|
|
return value
|
|
except Exception as e:
|
|
logger.debug(f"Redis get error for {key}: {e}")
|
|
|
|
# 3. Miss
|
|
async with self._lock:
|
|
self._stats.miss_count += 1
|
|
self._stats.total_calls += 1
|
|
return None
|
|
|
|
async def set(
|
|
self,
|
|
data_type: str,
|
|
chain: str,
|
|
address: str,
|
|
value: Any,
|
|
ttl_override: int | None = None,
|
|
) -> None:
|
|
"""Store a value in both L1 and L2 caches.
|
|
|
|
Args:
|
|
data_type: Type of data (see SMART_TTLS keys)
|
|
chain: Blockchain identifier
|
|
address: Address or identifier
|
|
value: Any JSON-serializable value
|
|
ttl_override: Override the default TTL for this data type
|
|
"""
|
|
key = build_key(data_type, chain, address)
|
|
ttl = get_ttl(data_type, ttl_override)
|
|
|
|
# Serialize for size estimation
|
|
raw = self._serialize(value)
|
|
size = len(raw)
|
|
|
|
entry = CacheEntry(value=value, ttl=float(ttl), size_bytes=size)
|
|
|
|
# Store in L1
|
|
async with self._lock:
|
|
self._l1[key] = entry
|
|
self._l1.move_to_end(key)
|
|
self._stats.l1_size = len(self._l1)
|
|
self._evict_l1_if_needed()
|
|
|
|
# Store in L2
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
redis_key = f"{self.REDIS_PREFIX}{key}"
|
|
if ttl > 0:
|
|
await self._redis.setex(redis_key, ttl, raw)
|
|
else:
|
|
# Permanent - no expiry
|
|
await self._redis.set(redis_key, raw)
|
|
except Exception as e:
|
|
logger.debug(f"Redis set error for {key}: {e}")
|
|
|
|
async def get_or_fetch(
|
|
self,
|
|
data_type: str,
|
|
chain: str,
|
|
address: str,
|
|
factory_fn: Callable[[], Awaitable[Any]],
|
|
ttl_override: int | None = None,
|
|
) -> Any:
|
|
"""Get cached value or fetch fresh via factory function.
|
|
|
|
This is the primary convenience method - it combines get() + set()
|
|
in a single call. If the value is cached and not expired, returns it
|
|
immediately. Otherwise, calls factory_fn(), caches the result, and
|
|
returns it.
|
|
|
|
Args:
|
|
data_type: Type of data (e.g., 'price', 'token_meta')
|
|
chain: Blockchain identifier
|
|
address: Address/identifier
|
|
factory_fn: Async callable that returns the value
|
|
ttl_override: Override TTL for this entry
|
|
|
|
Returns:
|
|
The cached or freshly fetched value, or None if fetch failed.
|
|
"""
|
|
# Try cache first
|
|
cached = await self.get(data_type, chain, address)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
# Fetch fresh
|
|
try:
|
|
value = await factory_fn()
|
|
if value is not None:
|
|
await self.set(data_type, chain, address, value, ttl_override)
|
|
return value
|
|
except Exception as e:
|
|
logger.warning(f"Factory fetch failed for {data_type}:{chain}:{address}: {e}")
|
|
return None
|
|
|
|
async def invalidate(self, key_pattern: str) -> int:
|
|
"""Remove all cached entries matching a key pattern.
|
|
|
|
For L1, uses simple string matching (fnmatch-style with *):
|
|
- "price:*:*" - invalidates all price entries
|
|
- "*:solana:*" - invalidates all Solana entries
|
|
- "price:solana:*" - invalidates a specific token's price
|
|
|
|
For L2, uses Redis SCAN + pattern matching.
|
|
|
|
Returns number of entries invalidated (L1 + L2 count).
|
|
"""
|
|
# Convert glob-like pattern to regex for L1
|
|
regex_pattern = re.escape(key_pattern).replace(r"\*", ".*")
|
|
regex = re.compile(f"^{regex_pattern}$")
|
|
|
|
count = 0
|
|
|
|
# L1 invalidation
|
|
async with self._lock:
|
|
keys_to_remove = [k for k in self._l1 if regex.match(k)]
|
|
for k in keys_to_remove:
|
|
del self._l1[k]
|
|
count += 1
|
|
self._stats.l1_size = len(self._l1)
|
|
|
|
# L2 invalidation
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
redis_pattern = f"{self.REDIS_PREFIX}{key_pattern}"
|
|
cursor = 0
|
|
l2_count = 0
|
|
while True:
|
|
cursor, keys = await self._redis.scan(
|
|
cursor=cursor,
|
|
match=redis_pattern,
|
|
count=100,
|
|
)
|
|
if keys:
|
|
await self._redis.delete(*keys)
|
|
l2_count += len(keys)
|
|
if cursor == 0:
|
|
break
|
|
count += l2_count
|
|
logger.debug(f"Invalidated {l2_count} L2 entries matching '{key_pattern}'")
|
|
except Exception as e:
|
|
logger.warning(f"Redis invalidation error for pattern '{key_pattern}': {e}")
|
|
|
|
async with self._lock:
|
|
self._stats.invalidations += count
|
|
logger.info(f"Cache invalidation: {count} entries removed (pattern='{key_pattern}')")
|
|
return count
|
|
|
|
async def invalidate_address(
|
|
self,
|
|
data_type: str,
|
|
chain: str,
|
|
address: str,
|
|
) -> bool:
|
|
"""Invalidate a specific cache key."""
|
|
key = build_key(data_type, chain, address)
|
|
|
|
found = False
|
|
|
|
async with self._lock:
|
|
if key in self._l1:
|
|
del self._l1[key]
|
|
self._stats.l1_size = len(self._l1)
|
|
self._stats.invalidations += 1
|
|
found = True
|
|
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
redis_key = f"{self.REDIS_PREFIX}{key}"
|
|
deleted = await self._redis.delete(redis_key)
|
|
if deleted:
|
|
found = True
|
|
except Exception as e:
|
|
logger.debug(f"Redis delete error for {key}: {e}")
|
|
|
|
return found
|
|
|
|
async def clear(self) -> int:
|
|
"""Clear all entries from both L1 and L2 caches.
|
|
|
|
Returns total number of entries cleared.
|
|
"""
|
|
count = 0
|
|
|
|
async with self._lock:
|
|
count = len(self._l1)
|
|
self._l1.clear()
|
|
self._stats.l1_size = 0
|
|
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
keys = await self._redis.keys(f"{self.REDIS_PREFIX}*")
|
|
if keys:
|
|
await self._redis.delete(*keys)
|
|
count += len(keys)
|
|
except Exception as e:
|
|
logger.warning(f"Redis clear error: {e}")
|
|
|
|
logger.info(f"Cache cleared: {count} entries removed")
|
|
return count
|
|
|
|
# ── Stats ────────────────────────────────────────────────────────────
|
|
|
|
async def stats(self) -> dict[str, Any]:
|
|
"""Return cache performance statistics."""
|
|
async with self._lock:
|
|
result = self._stats.to_dict()
|
|
# Add Redis L2 info
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
key_count = await self._redis.dbsize()
|
|
result["l2_size"] = key_count
|
|
except Exception:
|
|
result["l2_size"] = -1
|
|
result["redis_available"] = self._redis_available
|
|
result["l1_max"] = self._l1_max
|
|
return result
|
|
|
|
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
|
|
async def close(self):
|
|
"""Close Redis connection if open."""
|
|
if self._redis:
|
|
try:
|
|
await self._redis.aclose()
|
|
self._redis = None
|
|
self._redis_available = False
|
|
logger.info("RpcCache: Redis connection closed")
|
|
except Exception as e:
|
|
logger.debug(f"Redis close error: {e}")
|
|
|
|
# ── Internal Helpers ──────────────────────────────────────────────────
|
|
|
|
def _serialize(self, value: Any) -> bytes:
|
|
"""Serialize a value to bytes for Redis storage. Uses JSON."""
|
|
import json
|
|
|
|
try:
|
|
# Use a custom encoder for special types
|
|
return json.dumps(value, default=str, ensure_ascii=False).encode("utf-8")
|
|
except (TypeError, ValueError) as e:
|
|
logger.warning(f"Serialization fallback for {type(value)}: {e}")
|
|
return json.dumps({"__serialized__": str(value)}).encode("utf-8")
|
|
|
|
def _deserialize(self, raw: bytes) -> Any:
|
|
"""Deserialize bytes from Redis back to Python object."""
|
|
import json
|
|
|
|
try:
|
|
return json.loads(raw.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
logger.warning(f"Deserialization error: {e}")
|
|
return None
|
|
|
|
def _evict_l1_if_needed(self):
|
|
"""Evict oldest entries from L1 if over capacity (LRU eviction)."""
|
|
# Called within self._lock
|
|
while len(self._l1) > self._l1_max:
|
|
self._l1.popitem(last=False) # Remove oldest (first inserted)
|
|
|
|
# ── Health Check ──────────────────────────────────────────────────────
|
|
|
|
async def health(self) -> dict[str, Any]:
|
|
"""Quick health check."""
|
|
status = {
|
|
"l1_ok": True,
|
|
"l1_entries": len(self._l1),
|
|
"l2_ok": False,
|
|
}
|
|
if self._redis_available and self._redis:
|
|
try:
|
|
await self._redis.ping()
|
|
status["l2_ok"] = True
|
|
except Exception:
|
|
status["l2_ok"] = False
|
|
return status
|
|
|
|
|
|
# ── Singleton ──────────────────────────────────────────────────────────────
|
|
|
|
_cache_instance: RpcCache | None = None
|
|
|
|
|
|
def get_rpc_cache() -> RpcCache:
|
|
"""Get the global RpcCache singleton."""
|
|
global _cache_instance
|
|
if _cache_instance is None:
|
|
_cache_instance = RpcCache()
|
|
return _cache_instance
|