""" Aggressive Caching Shield - JSON-RPC Batch Request Grouper Groups individual RPC calls into batch JSON-RPC requests (where supported). Not all free tier providers support batching, but Helius, QuickNode, and Alchemy do for Solana. For EVM chains, batch support varies. Strategy: - Accumulate calls for up to 50ms window - Maximum 20 requests per batch - Providers that don't support batching fall through to individual calls - Results matched back to callers by request ID - Cache-aware: skip batching for cache hits (routed to cache first) Free tier impact: A single batch request counts as 1 request toward limits but can contain up to 20 sub-requests. This dramatically reduces RPC calls when fetching data for multiple tokens/wallets simultaneously. """ import asyncio import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any logger = logging.getLogger("rpc_batcher") # Maximum requests per batch (provider limits are typically 20-100) MAX_BATCH_SIZE = 20 # Maximum wait time to accumulate before dispatching BATCH_WINDOW_MS = 50 # Providers known to support JSON-RPC batching BATCH_CAPABLE_PROVIDERS = { "helius", "quicknode", "alchemy", "drpc", # EVM "ethereum_publicnode", "llama_rpc", "1rpc", "blastapi", } # Providers that DON'T support batching NO_BATCH_PROVIDERS = {"anvil", "publicnode"} @dataclass class BatchRequest: """A single request within a batch.""" id: int method: str params: list[Any] @dataclass class BatchResult: """Result for a single request within a batch.""" id: int result: Any = None error: str | None = None class RpcBatcher: """Accumulates RPC requests and dispatches them as batch JSON-RPC calls. Usage: batcher = RpcBatcher(rpc_query_fn) result = await batcher.add("getBalance", [address], provider="helius") # Internally: accumulates -> after 50ms or 20 requests -> dispatches batch """ def __init__(self, rpc_query_fn: Callable[..., Awaitable]): """ Args: rpc_query_fn: async function(provider, method, params) -> result This is called to execute the actual batch. """ self._query_fn = rpc_query_fn self._pending: dict[str, list[BatchRequest]] = {} # provider -> pending self._futures: dict[str, dict[int, asyncio.Future]] = {} # provider -> {id: future} self._timers: dict[str, asyncio.Task] = {} # provider -> timer task self._lock = asyncio.Lock() self._next_id = 0 # Stats self.batches_dispatched = 0 self.total_batched = 0 self.total_individual = 0 async def add(self, method: str, params: list[Any], provider: str = "helius") -> Any: """Add a request to the batch queue. Returns the result when dispatched. If the provider doesn't support batching, falls through to individual query. """ if provider in NO_BATCH_PROVIDERS: self.total_individual += 1 return await self._query_fn(provider, method, params) request_id = await self._enqueue(provider, method, params) future = self._futures[provider][request_id] try: result = await asyncio.wait_for(future, timeout=5.0) return result except TimeoutError: logger.warning(f"Batch request timed out for {provider}/{method}, falling back to direct") self.total_individual += 1 return await self._query_fn(provider, method, params) async def _enqueue(self, provider: str, method: str, params: list[Any]) -> int: """Add request to pending queue and return request ID.""" async with self._lock: self._next_id += 1 req_id = self._next_id req = BatchRequest(id=req_id, method=method, params=params) if provider not in self._pending: self._pending[provider] = [] self._futures[provider] = {} self._pending[provider].append(req) self._futures[provider][req_id] = asyncio.Future() # If this is the first item, start the dispatch timer if len(self._pending[provider]) == 1: self._timers[provider] = asyncio.create_task(self._dispatch_after_delay(provider)) # If we hit max batch size, dispatch immediately elif len(self._pending[provider]) >= MAX_BATCH_SIZE: if provider in self._timers: self._timers[provider].cancel() asyncio.create_task(self._dispatch(provider)) return req_id async def _dispatch_after_delay(self, provider: str): """Wait BATCH_WINDOW_MS then dispatch.""" try: await asyncio.sleep(BATCH_WINDOW_MS / 1000.0) await self._dispatch(provider) except asyncio.CancelledError: pass async def _dispatch(self, provider: str): """Send accumulated requests as a single JSON-RPC batch.""" async with self._lock: requests = self._pending.pop(provider, []) futures = self._futures.pop(provider, {}) self._timers.pop(provider, None) if not requests: return batch_payload = [] for req in requests: batch_payload.append( { "jsonrpc": "2.0", "id": req.id, "method": req.method, "params": req.params, } ) self.batches_dispatched += 1 self.total_batched += len(requests) try: results = await self._query_fn(provider, batch_payload, is_batch=True) # Match results back to futures if isinstance(results, list): for item in results: rid = item.get("id") if rid is not None and rid in futures: if "error" in item: futures[rid].set_exception(Exception(item["error"].get("message", "RPC error"))) else: futures[rid].set_result(item.get("result")) elif rid is not None: logger.debug(f"Orphan batch result for id={rid}") # Resolve any unmatched futures with None for rid, fut in futures.items(): # noqa: B007 if not fut.done(): fut.set_result(None) except Exception as e: # Batch failed - fail all futures for rid, fut in futures.items(): # noqa: B007 if not fut.done(): fut.set_exception(e) async def stats(self) -> dict: """Return batcher statistics.""" async with self._lock: pending_count = sum(len(v) for v in self._pending.values()) pending_futures = sum(len(v) for v in self._futures.values()) return { "batches_dispatched": self.batches_dispatched, "total_batched": self.total_batched, "total_individual": self.total_individual, "pending_requests": pending_count, "pending_futures": pending_futures, "batch_saving_ratio": round(self.total_batched / max(1, self.batches_dispatched), 1), }