| """ |
| DataBus Cache Layer β Three-Tier Cache with SWR + Per-Type Stats |
| ================================================================ |
| |
| L1: In-memory dict (sub-millisecond, 4096 keys, LRU eviction) + SWR stale buffer |
| L2: Redis (sub-millisecond, shared across processes, TTL-managed) |
| L3: Cloudflare R2 cold storage (RAG permanence, nightly snapshots) |
| |
| Stale-While-Revalidate (SWR): |
| - L1 stores both fresh and stale entries (stale = TTL * 2) |
| - On cache read, if entry is fresh β direct hit |
| - If entry is stale (past TTL but within stale window) β return stale data |
| AND flag for background refresh via cache.stale_refresh_callback |
| - User NEVER waits for a refresh β always gets instant data |
| |
| Per-Type Stats: |
| - Tracks hits/misses per data_type for tuning TTLs |
| - health() flags types with hit_rate < 30% as "increase TTL" |
| """ |
|
|
| import asyncio |
| import contextlib |
| import hashlib |
| import json |
| import logging |
| import os |
| import time |
| from collections import OrderedDict, defaultdict |
| from collections.abc import Callable |
| from typing import Any |
|
|
| from dotenv import load_dotenv |
|
|
| load_dotenv("/app/.env", override=True) |
|
|
| logger = logging.getLogger("databus.cache") |
|
|
| REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis") |
| REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) |
| REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") |
| REDIS_DB = int(os.getenv("REDIS_DB", "0")) |
|
|
|
|
| class L1Cache: |
| """In-memory LRU cache with Stale-While-Revalidate support. |
| |
| Entries are stored with TWO expiry windows: |
| - fresh_expiry: data is fresh, return immediately (normal hit) |
| - stale_expiry: data is stale but usable (SWR hit) |
| Stale entries are returned instantly; the caller triggers background refresh. |
| """ |
|
|
| def __init__(self, max_keys: int = 4096, stale_multiplier: float = 2.5): |
| self._max = max_keys |
| self._stale_mult = stale_multiplier |
| self._store: OrderedDict[str, tuple[Any, float, float]] = OrderedDict() |
| |
| self.hits = 0 |
| self.stale_hits = 0 |
| self.misses = 0 |
| self._lock = asyncio.Lock() |
|
|
| async def get(self, key: str) -> tuple[Any | None, bool]: |
| """Returns (value, is_stale). is_stale=True means background refresh needed.""" |
| async with self._lock: |
| entry = self._store.get(key) |
| if entry is None: |
| self.misses += 1 |
| return None, False |
| value, fresh_expiry, stale_expiry = entry |
| now = time.monotonic() |
| if now > stale_expiry: |
| |
| del self._store[key] |
| self.misses += 1 |
| return None, False |
| if now > fresh_expiry: |
| |
| self._store.move_to_end(key) |
| self.stale_hits += 1 |
| return value, True |
| |
| self._store.move_to_end(key) |
| self.hits += 1 |
| return value, False |
|
|
| async def set(self, key: str, value: Any, ttl: int): |
| async with self._lock: |
| now = time.monotonic() |
| fresh = now + ttl |
| stale = now + int(ttl * self._stale_mult) |
| self._store[key] = (value, fresh, stale) |
| self._store.move_to_end(key) |
| while len(self._store) > self._max: |
| self._store.popitem(last=False) |
|
|
| async def delete(self, key: str): |
| async with self._lock: |
| self._store.pop(key, None) |
|
|
| async def clear(self): |
| async with self._lock: |
| self._store.clear() |
|
|
| def stats(self) -> dict: |
| total = self.hits + self.stale_hits + self.misses |
| return { |
| "keys": len(self._store), |
| "max_keys": self._max, |
| "hits": self.hits, |
| "stale_hits": self.stale_hits, |
| "misses": self.misses, |
| "hit_rate": round((self.hits + self.stale_hits) / total * 100, 1) if total > 0 else 0, |
| "fresh_hit_rate": round(self.hits / total * 100, 1) if total > 0 else 0, |
| } |
|
|
|
|
| class L2RedisCache: |
| """Redis cache. Shared across processes. TTL-managed automatically.""" |
|
|
| def __init__(self): |
| self._redis = None |
| self._available = False |
| self._prefix = "databus:" |
| self.hits = 0 |
| self.misses = 0 |
|
|
| async def _connect(self): |
| if self._redis and self._available: |
| return True |
| try: |
| import redis.asyncio as aioredis |
|
|
| kwargs = { |
| "host": REDIS_HOST, |
| "port": REDIS_PORT, |
| "db": REDIS_DB, |
| "socket_connect_timeout": 2, |
| "socket_timeout": 2, |
| "decode_responses": True, |
| "protocol": 2, |
| } |
| if REDIS_PASSWORD: |
| kwargs["password"] = REDIS_PASSWORD |
| self._redis = aioredis.Redis(**kwargs) |
| await self._redis.ping() |
| self._available = True |
| logger.info("DataBus Cache: Redis connected") |
| return True |
| except Exception as e: |
| logger.warning(f"DataBus Cache: Redis unavailable ({e}), L2 disabled") |
| self._available = False |
| return False |
|
|
| async def get(self, key: str) -> Any | None: |
| if not self._available and not await self._connect(): |
| self.misses += 1 |
| return None |
| try: |
| raw = await self._redis.get(f"{self._prefix}{key}") |
| if raw: |
| self.hits += 1 |
| return json.loads(raw) |
| self.misses += 1 |
| return None |
| except Exception: |
| self._available = False |
| self.misses += 1 |
| return None |
|
|
| async def set(self, key: str, value: Any, ttl: int): |
| if not self._available and not await self._connect(): |
| return |
| try: |
| await self._redis.setex(f"{self._prefix}{key}", ttl, json.dumps(value, default=str)) |
| except Exception: |
| self._available = False |
|
|
| async def delete(self, key: str): |
| if not self._available: |
| return |
| with contextlib.suppress(Exception): |
| await self._redis.delete(f"{self._prefix}{key}") |
|
|
| async def clear(self): |
| if not self._available: |
| return |
| try: |
| async for key in self._redis.scan_iter(f"{self._prefix}*"): |
| await self._redis.delete(key) |
| except Exception: |
| pass |
|
|
| def stats(self) -> dict: |
| total = self.hits + self.misses |
| return { |
| "available": self._available, |
| "hits": self.hits, |
| "misses": self.misses, |
| "hit_rate": round(self.hits / total * 100, 1) if total > 0 else 0, |
| } |
|
|
|
|
| class CacheLayer: |
| """ |
| Three-tier cache with Stale-While-Revalidate + per-type stats. |
| |
| L1 (memory, SWR) β L2 (Redis) β L3 (R2, async, background) |
| |
| Read path: L1 (fresh? β done. stale? β return stale + schedule refresh) β L2 β miss |
| Write path: External API β L1 + L2 (L3 batched via RAG permanence cron) |
| |
| SWR callback: When L1 returns stale data, cache fires stale_refresh_callback |
| so the caller can schedule background re-fetch without blocking the user. |
| """ |
|
|
| def __init__(self): |
| self.l1 = L1Cache(max_keys=4096) |
| self.l2 = L2RedisCache() |
| self._l3_enabled = True |
| |
| self.stale_refresh_callback: Callable | None = None |
| |
| self._type_stats: dict[str, dict[str, int]] = defaultdict(lambda: {"hits": 0, "stale_hits": 0, "misses": 0}) |
| |
| |
| self.ttl_config = { |
| |
| "token_price": 60, |
| "market_overview": 60, |
| "trending": 120, |
| "market_movers": 60, |
| "alerts": 30, |
| |
| "token_detail": 60, |
| "token_meta": 300, |
| "wallet_balance": 30, |
| "wallet_tokens": 300, |
| "wallet_pnl": 120, |
| "tx_history": 60, |
| "dex_data": 60, |
| "holder_data": 120, |
| |
| "risk_scan": 600, |
| "sentinel_deep": 600, |
| "funding_source": 7200, |
| "solana_funding": 7200, |
| "wallet_labels": 86400, |
| "entity_intel": 3600, |
| "socialfi_resolve": 86400, |
| "cross_chain": 3600, |
| "wallet_cluster": 3600, |
| "bundle_detect": 600, |
| |
| |
| "arkham_entity": 600, |
| "arkham_portfolio": 300, |
| "arkham_labels": 7200, |
| "arkham_transfers": 300, |
| "arkham_counterparties": 600, |
| |
| "nansen_labels": 3600, |
| "nansen_smart_money": 1800, |
| "news": 600, |
| "news_intel": 600, |
| "messari_news": 900, |
| "social_feed": 300, |
| "sentiment": 600, |
| "whale_data": 300, |
| "smart_money": 300, |
| "gmgn_smart_money": 300, |
| "launches": 120, |
| "bubble_map": 600, |
| "rugmaps_analysis": 1200, |
| "contract_scan": 3600, |
| "threat_check": 600, |
| "prediction_markets": 120, |
| "prediction_signals": 300, |
| "defi_protocols": 600, |
| "rag_search": 600, |
| "tvl": 300, |
| "wallet_profile": 600, |
| "portfolio": 120, |
| |
| "defillama_tvl": 3600, |
| "defillama_chains": 3600, |
| "blockchair_address": 600, |
| "blockchair_stats": 1800, |
| "birdeye_overview": 120, |
| "birdeye_price": 30, |
| "solana_tracker_price": 15, |
| "solana_tracker_token": 60, |
| "solana_tracker_trending": 120, |
| "dev_activity": 3600, |
| "url_security_scan": 86400, |
| "dune_early_buyers": 14400, |
| "default": 60, |
| } |
|
|
| def _extract_data_type(self, key: str) -> str: |
| """Extract data_type from cache key format 'source:data_type:hash'.""" |
| parts = key.split(":") |
| if len(parts) >= 2: |
| return parts[1] |
| return "default" |
|
|
| async def get(self, key: str, data_type: str = "default") -> tuple[Any | None, bool]: |
| """Get from cache with SWR. Returns (value, is_stale). |
| If is_stale=True, caller should schedule background refresh. |
| """ |
| |
| val, is_stale = await self.l1.get(key) |
| if val is not None: |
| dtype = data_type or self._extract_data_type(key) |
| if is_stale: |
| self._type_stats[dtype]["stale_hits"] += 1 |
| else: |
| self._type_stats[dtype]["hits"] += 1 |
| |
| if is_stale and self.stale_refresh_callback: |
| try: |
| asyncio.create_task(self.stale_refresh_callback(key)) |
| except Exception: |
| pass |
| return val, is_stale |
| |
| val = await self.l2.get(key) |
| if val is not None: |
| |
| await self.l1.set(key, val, 60) |
| dtype = data_type or self._extract_data_type(key) |
| self._type_stats[dtype]["hits"] += 1 |
| return val, False |
| dtype = data_type or self._extract_data_type(key) |
| self._type_stats[dtype]["misses"] += 1 |
| return None, False |
|
|
| async def set(self, key: str, value: Any, ttl: int | None = None, data_type: str = "default"): |
| """Set in cache. Writes to L1 + L2.""" |
| if ttl is None: |
| ttl = self.ttl_config.get(data_type, 60) |
| await self.l1.set(key, value, ttl) |
| await self.l2.set(key, value, ttl) |
|
|
| async def delete(self, key: str): |
| await self.l1.delete(key) |
| await self.l2.delete(key) |
|
|
| async def clear(self): |
| await self.l1.clear() |
| await self.l2.clear() |
|
|
| def make_key(self, source: str, data_type: str, **kwargs) -> str: |
| """Generate a deterministic cache key.""" |
| args_str = json.dumps(kwargs, sort_keys=True, default=str) |
| args_hash = hashlib.sha256(args_str.encode()).hexdigest()[:16] |
| return f"{source}:{data_type}:{args_hash}" |
|
|
| def type_stats(self) -> dict[str, dict]: |
| """Per-data-type hit/miss/stale stats with TTL tuning suggestions.""" |
| result = {} |
| for dtype, counts in self._type_stats.items(): |
| total = counts["hits"] + counts["stale_hits"] + counts["misses"] |
| hit_rate = round((counts["hits"] + counts["stale_hits"]) / total * 100, 1) if total > 0 else 0 |
| entry = { |
| "hits": counts["hits"], |
| "stale_hits": counts["stale_hits"], |
| "misses": counts["misses"], |
| "hit_rate": hit_rate, |
| "current_ttl": self.ttl_config.get(dtype, 60), |
| } |
| |
| if total > 10 and hit_rate < 30: |
| entry["suggestion"] = "increase_ttl" |
| |
| elif total > 10 and hit_rate > 95 and self.ttl_config.get(dtype, 60) > 120: |
| entry["suggestion"] = "decrease_ttl" |
| result[dtype] = entry |
| return result |
|
|
| async def health(self) -> dict: |
| l1 = self.l1.stats() |
| l2 = self.l2.stats() |
| total_hits = l1["hits"] + l1["stale_hits"] + l2["hits"] |
| total_misses = l1["misses"] + l2["misses"] |
| total = total_hits + total_misses |
| return { |
| "status": "ok", |
| "l1_memory": l1, |
| "l2_redis": l2, |
| "l3_r2": {"enabled": self._l3_enabled, "note": "Batched via RAG permanence cron"}, |
| "combined_hit_rate": round(total_hits / total * 100, 1) if total > 0 else 0, |
| "total_hits": total_hits, |
| "total_misses": total_misses, |
| "per_type_stats": self.type_stats(), |
| "ttl_config": self.ttl_config, |
| } |
|
|
|
|
| |
|
|
| _cache: CacheLayer | None = None |
|
|
|
|
| def get_cache() -> CacheLayer: |
| global _cache |
| if _cache is None: |
| _cache = CacheLayer() |
| return _cache |
|
|