| """ |
| 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_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 = {} |
| self._l1_lock = asyncio.Lock() |
| self._rpc = rpc_client |
| self.stats = CacheStats() |
| self._populating = {} |
| self._populating_lock = asyncio.Lock() |
|
|
| |
|
|
| 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 |
|
|
| |
|
|
| @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) |
|
|
| |
|
|
| @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 |
|
|
| |
|
|
| 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) |
|
|
| |
|
|
| 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 |
| |
| 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) |
|
|
| |
|
|
| 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 |
| ) |
|
|
| |
|
|
| 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 |
|
|
| |
|
|
| 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") |
|
|
|
|
| |
|
|
|
|
| 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", [])), |
| ) |
|
|
|
|
| |
|
|
| _cache_client = None |
|
|
|
|
| def get_rpc_cache(): |
| global _cache_client |
| if _cache_client is None: |
| _cache_client = RpcCacheClient() |
| return _cache_client |
|
|