- 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>
410 lines
15 KiB
Python
410 lines
15 KiB
Python
"""
|
|
Aggressive Caching Shield - RPC Cache Layer
|
|
Redis-backed cache wrapping ConsensusRpcClient with TTL tiers.
|
|
|
|
Every RPC result is cached before returning. Cache keys are deterministic
|
|
from (method, params, chain). TTLs vary by data freshness requirements.
|
|
Features: stampede protection, TTL jitter, zlib compression, L1+L2 tiers,
|
|
graceful Redis fallback, selective cache bypass.
|
|
|
|
Free tier rate-aware: Default TTLs keep RPC calls at ~0.5-2 req/s
|
|
per data point, well under Helius/QuickNode free limits.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import time
|
|
import zlib
|
|
from dataclasses import dataclass
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
from app.consensus_rpc import ConsensusResult, get_consensus_rpc
|
|
|
|
logger = logging.getLogger("rpc_cache")
|
|
|
|
# ── TTL Configuration ──────────────────────────────────────────────────────
|
|
|
|
TTL_TABLE: dict[str, int] = {
|
|
"getBalance": 10,
|
|
"getTokenAccountBalance": 10,
|
|
"getMultipleAccounts": 10,
|
|
"getAccountInfo": 30,
|
|
"getSignaturesForAddress": 30,
|
|
"getTransaction": 30,
|
|
"getTokenAccountsByOwner": 30,
|
|
"getProgramAccounts": 30,
|
|
"getTokenSupply": 60,
|
|
"getTokenLargestAccounts": 60,
|
|
"getBlock": 60,
|
|
"getBlockTime": 60,
|
|
"getSlot": 120,
|
|
"getEpochInfo": 120,
|
|
"getGenesisHash": 3600,
|
|
"getVersion": 3600,
|
|
"eth_getBalance": 10,
|
|
"eth_getTransactionCount": 10,
|
|
"eth_call": 15,
|
|
"eth_getCode": 300,
|
|
"eth_getStorageAt": 30,
|
|
"eth_getLogs": 60,
|
|
"eth_getBlockByNumber": 60,
|
|
"eth_getTransactionReceipt": 120,
|
|
"eth_chainId": 3600,
|
|
"_default": 15,
|
|
}
|
|
|
|
TTL_JITTER_PCT = 0.15
|
|
MIN_TTL = 3
|
|
MAX_TTL = 86400
|
|
COMPRESS_THRESHOLD = 1024
|
|
CACHE_PREFIX = "rmi:rpc:"
|
|
LOCK_PREFIX = "rmi:rpc_lock:"
|
|
LOCK_TTL = 5
|
|
|
|
|
|
@dataclass
|
|
class CacheStats:
|
|
hits: int = 0
|
|
misses: int = 0
|
|
sets: int = 0
|
|
lock_waits: int = 0
|
|
redis_errors: int = 0
|
|
mem_hits: int = 0
|
|
compress_saved: int = 0
|
|
|
|
@property
|
|
def hit_rate(self) -> float:
|
|
total = self.hits + self.misses
|
|
return (self.hits / total * 100) if total > 0 else 0.0
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"hits": self.hits,
|
|
"misses": self.misses,
|
|
"sets": self.sets,
|
|
"hit_rate_pct": round(self.hit_rate, 1),
|
|
"lock_waits": self.lock_waits,
|
|
"redis_errors": self.redis_errors,
|
|
"mem_hits": self.mem_hits,
|
|
"compress_saved_bytes": self.compress_saved,
|
|
}
|
|
|
|
|
|
class RpcCacheClient:
|
|
"""Redis-backed L1+L2 cache wrapping ConsensusRpcClient.
|
|
|
|
L1: In-memory dict (sub-millisecond, per-process)
|
|
L2: Redis (shared across workers, persistent)
|
|
|
|
Every RPC call goes through: L1 -> L2 -> actual RPC -> store in both.
|
|
Stampede protection prevents duplicate RPC calls for same key.
|
|
"""
|
|
|
|
L1_MAX_SIZE = 1024
|
|
|
|
def __init__(self, redis_url=None, redis_password=None, rpc_client=None):
|
|
self._redis = None
|
|
self._redis_url = redis_url
|
|
self._redis_password = redis_password
|
|
self._redis_failed = False
|
|
self._redis_init_lock = asyncio.Lock()
|
|
self._l1 = {} # key -> (expiry_ts, bytes)
|
|
self._l1_lock = asyncio.Lock()
|
|
self._rpc = rpc_client
|
|
self.stats = CacheStats()
|
|
self._populating = {} # key -> asyncio.Event
|
|
self._populating_lock = asyncio.Lock()
|
|
|
|
# ── Redis Connection ─────────────────────────────────────────────────
|
|
|
|
async def _get_redis(self):
|
|
"""Lazy Redis init. Returns None if unavailable."""
|
|
if self._redis is not None:
|
|
return self._redis
|
|
if self._redis_failed:
|
|
return None
|
|
async with self._redis_init_lock:
|
|
if self._redis is not None:
|
|
return self._redis
|
|
if self._redis_failed:
|
|
return None
|
|
try:
|
|
host = self._redis_url or os.getenv("REDIS_HOST", "rmi-redis")
|
|
port = int(os.getenv("REDIS_PORT", "6379"))
|
|
password = self._redis_password or os.getenv("REDIS_PASSWORD", "")
|
|
url = f"redis://:{password}@{host}:{port}" if password else f"redis://{host}:{port}"
|
|
self._redis = aioredis.from_url(url, socket_connect_timeout=2, socket_timeout=2, decode_responses=False)
|
|
await self._redis.ping()
|
|
logger.info("RpcCache: Redis connected OK")
|
|
return self._redis
|
|
except Exception as e:
|
|
logger.warning(f"RpcCache: Redis unavailable ({e}), using L1 only")
|
|
self._redis_failed = True
|
|
self._redis = None
|
|
return None
|
|
|
|
async def _get_rpc(self):
|
|
"""Get or create underlying ConsensusRpcClient."""
|
|
if self._rpc is None:
|
|
self._rpc = get_consensus_rpc()
|
|
return self._rpc
|
|
|
|
# ── Key Generation ──────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def make_key(method, params, chain="solana"):
|
|
raw = f"{chain}:{method}:{json.dumps(params, sort_keys=True, default=str)}"
|
|
digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
|
|
return f"{CACHE_PREFIX}{chain}:{method}:{digest}"
|
|
|
|
@staticmethod
|
|
def get_ttl(method):
|
|
base = TTL_TABLE.get(method, TTL_TABLE["_default"])
|
|
base = max(MIN_TTL, min(MAX_TTL, base))
|
|
jitter = base * TTL_JITTER_PCT * (random.random() * 2 - 1)
|
|
return max(MIN_TTL, base + jitter)
|
|
|
|
# ── Serialization ───────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def serialize(value, confidence, sources):
|
|
payload = json.dumps(
|
|
{"v": value, "c": confidence, "s": sources, "t": time.time()},
|
|
default=str,
|
|
separators=(",", ":"),
|
|
)
|
|
data = payload.encode("utf-8")
|
|
if len(data) > COMPRESS_THRESHOLD:
|
|
data = b"Z" + zlib.compress(data, level=6)
|
|
return data
|
|
|
|
@staticmethod
|
|
def deserialize(data):
|
|
try:
|
|
if data and data[:1] == b"Z":
|
|
data = zlib.decompress(data[1:])
|
|
return json.loads(data.decode("utf-8"))
|
|
except (json.JSONDecodeError, zlib.error, UnicodeDecodeError):
|
|
return None
|
|
|
|
# ── L1 In-Memory Cache ──────────────────────────────────────────────
|
|
|
|
async def _l1_get(self, key):
|
|
async with self._l1_lock:
|
|
entry = self._l1.get(key)
|
|
if entry is None:
|
|
return None
|
|
expiry, data = entry
|
|
if time.monotonic() > expiry:
|
|
del self._l1[key]
|
|
return None
|
|
self.stats.mem_hits += 1
|
|
return self.deserialize(data)
|
|
|
|
async def _l1_set(self, key, data, ttl):
|
|
async with self._l1_lock:
|
|
if len(self._l1) >= self.L1_MAX_SIZE:
|
|
oldest = min(self._l1.keys(), key=lambda k: self._l1[k][0])
|
|
del self._l1[oldest]
|
|
self._l1[key] = (time.monotonic() + ttl, data)
|
|
|
|
# ── Core Operations ─────────────────────────────────────────────────
|
|
|
|
async def get(self, method, params, chain="solana", bypass_cache=False):
|
|
"""Check L1 then L2 cache. Returns ConsensusResult or None."""
|
|
if bypass_cache:
|
|
return None
|
|
key = self.make_key(method, params, chain)
|
|
cached = await self._l1_get(key)
|
|
if cached:
|
|
self.stats.hits += 1
|
|
return _dict_to_result(cached)
|
|
redis = await self._get_redis()
|
|
if redis:
|
|
try:
|
|
raw = await redis.get(key)
|
|
if raw:
|
|
cached = self.deserialize(raw)
|
|
if cached:
|
|
ttl = self.get_ttl(method)
|
|
await self._l1_set(key, raw, ttl)
|
|
self.stats.hits += 1
|
|
return _dict_to_result(cached)
|
|
except Exception as e:
|
|
self.stats.redis_errors += 1
|
|
logger.debug(f"RpcCache Redis get error: {e}")
|
|
self.stats.misses += 1
|
|
return None
|
|
|
|
async def set(self, method, params, chain, result):
|
|
"""Store result in L1+L2 cache."""
|
|
key = self.make_key(method, params, chain)
|
|
ttl = self.get_ttl(method)
|
|
data = self.serialize(result.value, result.confidence, result.agreed_sources)
|
|
await self._l1_set(key, data, ttl)
|
|
redis = await self._get_redis()
|
|
if redis:
|
|
try:
|
|
await redis.setex(key, int(ttl) + 1, data)
|
|
self.stats.sets += 1
|
|
except Exception as e:
|
|
self.stats.redis_errors += 1
|
|
logger.debug(f"RpcCache Redis set error: {e}")
|
|
|
|
async def query_with_cache(self, method, params, chain="solana", min_agreement=2, bypass_cache=False):
|
|
"""Main entry: cache hit -> return; miss -> query RPC -> cache -> return."""
|
|
key = self.make_key(method, params, chain)
|
|
cached = await self.get(method, params, chain, bypass_cache=bypass_cache)
|
|
if cached:
|
|
return cached
|
|
# Stampede protection
|
|
async with self._populating_lock:
|
|
populating = self._populating.get(key)
|
|
if populating is not None:
|
|
self.stats.lock_waits += 1
|
|
try:
|
|
await asyncio.wait_for(populating.wait(), timeout=LOCK_TTL)
|
|
cached = await self.get(method, params, chain)
|
|
if cached:
|
|
return cached
|
|
except TimeoutError:
|
|
pass
|
|
event = asyncio.Event()
|
|
async with self._populating_lock:
|
|
self._populating[key] = event
|
|
try:
|
|
rpc = await self._get_rpc()
|
|
if chain == "solana":
|
|
result = await rpc.query_with_consensus(method, params, min_agreement)
|
|
else:
|
|
try:
|
|
chain_id = int(chain) if chain.isdigit() else 1
|
|
except ValueError:
|
|
chain_id = 1
|
|
result = await rpc.evm_query_with_consensus(chain_id, method, params)
|
|
if result.confidence > 0:
|
|
await self.set(method, params, chain, result)
|
|
return result
|
|
finally:
|
|
event.set()
|
|
async with self._populating_lock:
|
|
self._populating.pop(key, None)
|
|
|
|
# ── Convenience Methods ─────────────────────────────────────────────
|
|
|
|
async def get_account_info(self, address, chain="solana", bypass_cache=False):
|
|
return await self.query_with_cache(
|
|
"getAccountInfo",
|
|
[address, {"encoding": "jsonParsed"}],
|
|
chain=chain,
|
|
bypass_cache=bypass_cache,
|
|
)
|
|
|
|
async def get_balance(self, address, chain="solana", bypass_cache=False):
|
|
return await self.query_with_cache("getBalance", [address], chain=chain, bypass_cache=bypass_cache)
|
|
|
|
async def get_token_supply(self, mint, chain="solana", bypass_cache=False):
|
|
return await self.query_with_cache("getTokenSupply", [mint], chain=chain, bypass_cache=bypass_cache)
|
|
|
|
async def get_signatures_for_address(self, address, limit=20, chain="solana", bypass_cache=False):
|
|
return await self.query_with_cache(
|
|
"getSignaturesForAddress",
|
|
[address, {"limit": limit}],
|
|
chain=chain,
|
|
bypass_cache=bypass_cache,
|
|
)
|
|
|
|
async def get_token_account_balance(self, token_account, chain="solana", bypass_cache=False):
|
|
return await self.query_with_cache(
|
|
"getTokenAccountBalance", [token_account], chain=chain, bypass_cache=bypass_cache
|
|
)
|
|
|
|
# ── Invalidation ────────────────────────────────────────────────────
|
|
|
|
async def invalidate_address(self, address, chain="solana"):
|
|
"""Invalidate all cached data for a specific address."""
|
|
pattern = f"{CACHE_PREFIX}{chain}:*"
|
|
async with self._l1_lock:
|
|
addr_digest = hashlib.sha256(address.encode()).hexdigest()[:16]
|
|
keys_to_del = [k for k in self._l1 if addr_digest.lower() in k.lower()]
|
|
for k in keys_to_del:
|
|
del self._l1[k]
|
|
redis = await self._get_redis()
|
|
if redis:
|
|
try:
|
|
cursor = 0
|
|
deleted = 0
|
|
while True:
|
|
cursor, keys = await redis.scan(cursor, match=pattern, count=100)
|
|
for key in keys:
|
|
key_str = key.decode() if isinstance(key, bytes) else key
|
|
try:
|
|
raw = await redis.get(key_str)
|
|
if raw:
|
|
cached = self.deserialize(raw)
|
|
if cached and address.lower() in json.dumps(cached, default=str).lower():
|
|
await redis.delete(key_str)
|
|
deleted += 1
|
|
except Exception:
|
|
pass
|
|
if cursor == 0:
|
|
break
|
|
if deleted:
|
|
logger.debug(f"Invalidated {deleted} cache entries for {address[:12]}...")
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Health ──────────────────────────────────────────────────────────
|
|
|
|
async def health(self):
|
|
redis_ok = False
|
|
redis = await self._get_redis()
|
|
if redis:
|
|
try:
|
|
await redis.ping()
|
|
redis_ok = True
|
|
except Exception:
|
|
pass
|
|
async with self._l1_lock:
|
|
l1_size = len(self._l1)
|
|
return {
|
|
"redis_available": redis_ok,
|
|
"l1_size": l1_size,
|
|
"l1_max": self.L1_MAX_SIZE,
|
|
**self.stats.to_dict(),
|
|
}
|
|
|
|
async def clear_l1(self):
|
|
async with self._l1_lock:
|
|
self._l1.clear()
|
|
logger.info("RpcCache: L1 cache cleared")
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _dict_to_result(d):
|
|
return ConsensusResult(
|
|
value=d.get("v"),
|
|
confidence=d.get("c", 0.0),
|
|
agreed_sources=d.get("s", []),
|
|
total_sources=len(d.get("s", [])),
|
|
response_count=len(d.get("s", [])),
|
|
)
|
|
|
|
|
|
# ── Singleton ──────────────────────────────────────────────────────────────
|
|
|
|
_cache_client = None
|
|
|
|
|
|
def get_rpc_cache():
|
|
global _cache_client
|
|
if _cache_client is None:
|
|
_cache_client = RpcCacheClient()
|
|
return _cache_client
|