- 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>
211 lines
7.3 KiB
Python
211 lines
7.3 KiB
Python
"""
|
|
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),
|
|
}
|