/config/database/evm-json-rpc-cache.llms.txt
Cache policies
Every repeated upstream call costs money and latency. eRPC intercepts each EVM JSON-RPC request, matches it against your cache policies, and serves the stored response — no extra round-trip. Finalized data is cached forever; realtime data is age-gated against the actual block timestamp so stale chain-tip responses never escape.
What you get
- Parallel fan-out reads across multiple backends — fastest hit wins, peers cancel
- Four finality buckets (
finalized,unfinalized,realtime,unknown) each with independent TTLs - Transparent zstd compression on writes, automatic decompression on reads
- Per-request bypass directive (
X-ERPC-Skip-Cache-Read) for cache-busting without config changes
Quick taste
Illustrative, not a tuned production config — cache finalized data forever in memory:
database: evmJsonRpcCache: connectors: - id: mem driver: memory memory: { maxItems: 100000 } policies: - connector: mem network: "*" method: "*" # finalized blocks are reorg-immune — cache them forever finality: finalized # 0 = no expiry; safe because finalized data never changes ttl: 0Agent reference
Copy one of these prompts into your AI agent session (Claude Code, Cursor, …) — each one points the agent at this page's machine-readable reference so it can do the work correctly:
Prompt Example #1: set up caching from scratch
I want to stop paying for repeated upstream calls by enabling eRPC's EVM JSON-RPC cache. Set up a four-bucket cache (finalized, unfinalized, realtime, unknown) with an in-memory connector so every finality class is covered. Work with my existing eRPC config. Read the full reference first: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #2: add a persistent Redis tier behind in-memory
I already have an in-memory cache connector in my eRPC config but I want to add Redis as a warm durable fallback so cached data survives restarts. Wire both connectors to the same finalized and unfinalized policies so the memory connector wins fan-out races when hot. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #3: debug realtime cache misses (eth_blockNumber always bypasses cache)
My eRPC cache seems to have zero hits for eth_blockNumber and eth_call with "latest". Audit my cache policies in my eRPC config and explain why realtime methods are missing, then add the correct finality: realtime policy with an appropriate TTL. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #4: limit cache to a specific method allowlist to avoid wasted connector RTTs
I want to restrict my cache policies so only the methods my gRPC connector actually indexes are tried (eth_getBlockByNumber, eth_getBlockByHash, eth_getBlockReceipts, eth_getLogs, eth_getTransactionByHash, eth_getTransactionReceipt). Requests for other methods should skip the cache entirely. Update my eRPC config accordingly. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #5: shadow-write to a new connector before enabling reads
I want to migrate to a new PostgreSQL cache connector without risking stale reads. Set up a shadow-write policy (appliesTo: set) on the new connector while keeping reads on the existing one, so I can verify data populates correctly before cutting over. My config is in my eRPC config. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Cache policies — full agent referenceExpand for every option, default, and edge case — or copy this entire section into your AI assistant.
How it works
Policy matching pipeline. At startup eRPC builds an ordered slice of CachePolicy objects from evmJsonRpcCache.policies. Every request passes through findGetPolicies (reads) or findSetPolicies (writes). Both walk the slice and test each policy's network, method, params, and finality fields against the request.
For reads, finality matching is asymmetric: a finalized request also matches unfinalized policies (a block finalized after write may be stored under the unfinalized policy); a realtime request matches only realtime policies; an unknown-finality request matches all policies. For writes, finality must match exactly. findGetPolicies deduplicates by connector so each connector is probed at most once per request.
Cache key derivation. Every lookup computes a partition key {networkId}:{blockRef} and a range key {method}:{sha256(params)}. The block reference comes from ExtractBlockReferenceFromRequest, which inspects the per-method reqRefs path config. If the block reference resolves to * (a wildcard tag like "latest"), the connector is queried via a reverse index rather than the main index. If the block reference is empty — method not recognized or not cacheable — the whole cache lookup is skipped silently.
Get fan-out. Get spawns one goroutine per matching policy. All goroutines race under a shared fanCtx; the first to find an acceptable non-empty hit calls cancelFan() so peers exit without posting to metrics. Results travel through a buffered channel sized to the number of goroutines so no goroutine ever blocks after fanCtx is done — a slow peer that refuses to honour cancellation never pins user-visible latency. A 30-second backstop context wraps the fan-out when the parent carries no deadline, protecting against FD leaks from misbehaving connectors.
Set fan-out. Set fans out using sync.WaitGroup (fire-and-forget per policy). A hard 5-second write timeout is applied per connector, independent of any failsafe configuration. Responses with a JSON-RPC error field are never cached. Empty results for future blocks (beyond the network's confidence head) are never cached regardless of the empty policy setting.
Realtime freshness gate. After a cache hit is retrieved for a realtime request, shouldAcceptCachedResult compares the block's unix timestamp (extracted from the response via ExtractBlockTimestampFromResponse) against the policy TTL. This is block age, not cache-entry wall-clock age: a response cached 5 minutes ago is accepted if its block was produced 3 seconds ago; a response cached 30 seconds ago is rejected if its block is 2 minutes old and TTL is 60 s.
For responses with no block timestamp (eth_blockNumber, eth_gasPrice, eth_getLogs), the gate falls back to the connector's own latest-block timestamp via the CacheHeadReporter interface — currently implemented only by GrpcConnector, which polls every 60 seconds. If neither source is available the gate fails open (accepts the result). When age exceeds TTL the result is reclassified as ttl_rejected and the fan-out loop continues searching peer connectors. Non-realtime data (finalized, unfinalized, unknown) is never age-gated.
Finality buckets. The zero value of the Go DataFinalityState enum is finalized, so omitting finality in YAML silently creates a finalized-only policy.
| Finality | Meaning | Recommended TTL |
|---|---|---|
finalized | Past chain's finalization horizon; reorg-immune | 0 (forever) |
unfinalized | Recent block, still reorgable | 5–15 s (≈ 2× block time) |
realtime | Updates every block (eth_blockNumber, eth_gasPrice, …) | 1–3 s |
unknown | Block reference indeterminate (tx-hash keyed) | 0 (key is immutable) |
To cache all traffic types you need at least four policies — one per finality bucket — or the realtime/unfinalized traffic will always bypass the cache and hit upstreams.
Empty-result handling. The empty field controls three modes:
ignore(default) — empty results are never cached on write; cached empty results are treated as misses on read.allow— cache and serve empty results like any other result.only— cache and serve only empty results; non-empty responses skip this policy.
A result is "emptyish" if the raw JSON bytes are null, "", "0x", "0x0", 0, [], {}, or a hex string whose non-prefix digits are all zeros.
Skip-cache-read directive. Callers can set the X-ERPC-Skip-Cache-Read HTTP header or skip-cache-read query parameter to bypass specific connectors. The value can be "true" (skip all connectors), "false" (normal), or a glob pattern matched against connector IDs.
Compression. When evmJsonRpcCache.compression.enabled = true (the default — auto-created even when the compression: block is omitted), values are compressed with zstd before storage. Only values whose pre-compression size meets or exceeds threshold (default 1024 bytes) are compressed; smaller values are stored raw. If the compressed output is larger than the original it is also stored raw. Detection on read is by the zstd magic bytes 0x28 0xB5 0x2F 0xFD — no explicit flag needed.
Connector failsafe wrapping. When a connector config lists failsafeForGets or failsafeForSets, the raw connector is wrapped in a FailsafeConnector. Retry, static-delay hedge, and circuit-breaker are supported. Consensus and hedge quantile mode are not supported at this scope.
gRPC connector (BDS read-through). GrpcConnector is a read-only connector backed by BDS gRPC servers. It does not support Set. Supported methods: eth_getBlockByNumber, eth_getBlockByHash, eth_getLogs, eth_getTransactionByHash, eth_getTransactionReceipt, eth_getBlockReceipts, eth_chainId, eth_blockNumber. eth_blockNumber is derived by fetching the latest block internally — there is no native BDS gRPC call for it. The gRPC connector performs a fast-miss check: if the request block number is below the connector's earliest known block, it returns ErrRecordNotFound immediately without a network call.
CacheHash derivation (range key). The range key {method}:{sha256(params)} is computed by JsonRpcRequest.CacheHash (common/json_rpc.go:L1385-L1409). It hashes params positionally using SHA-256 over a recursive type-aware hashValue function, producing a hex string prefixed with the method name. The hash is memoized on the request object — multiple policies referencing the same request compute it only once.
reqRefs and respRefs — block reference extraction. These per-method config fields (networks[*].methods.definitions.<method>.reqRefs / respRefs) tell the block-reference extraction logic where in a JSON-RPC request or response to find a block number, tag, or hash. They determine whether a request is cacheable and what partition key to use.
Built-in path variables in common/defaults.go:
| Variable | Path | Used by |
|---|---|---|
FirstParam | [[0]] | eth_getBlockByNumber, eth_getBlockByHash, … |
SecondParam | [[1]] | eth_getStorageAt, eth_getCode, … |
ThirdParam | [[2]] | eth_getProof |
NumberOrHashParam | [["number"], ["hash"]] | response object fields |
BlockNumberOrBlockHashParam | [["blockNumber"], ["blockHash"]] | receipt-like responses |
ArbitraryBlock | [["*"]] | eth_getTransactionByHash, eth_getTransactionReceipt — tx-hash methods |
Path format: each selector is an []interface{} where integers index JSON arrays and strings key into JSON objects. An empty path ([]) means the value at that position IS the scalar. A single-element path ["*"] means "arbitrary block" — produces blockRef = "*" which routes to the reverse index.
reqRefs extraction algorithm (architecture/evm/block_ref.go:L277-L310):
- If
reqRefshas exactly one path of length 1 and that element is"*": returnblockRef="*"immediately. - Otherwise for each path, parse the value as a block parameter (hex number, decimal, or named tag).
- If multiple paths yield different block references (e.g.
eth_getLogswith differentfromBlock/toBlock): setblockRef = "*"(composite range). - Accumulate the highest block number seen across all paths.
- If no path resolves:
blockRef = ""→ cache bypassed entirely, trace attributecache.skip_reason = "empty_block_ref"set.
respRefs extraction algorithm (architecture/evm/block_ref.go:L343-L368): Called only after a response exists. First non-"*" block ref wins; result supplements or overrides the request-derived ref for the cache key.
Custom reqRefs/respRefs in user config override built-in defaults entirely — there is no merge.
Config schema
All fields are under database.evmJsonRpcCache. Duration values accept Go duration strings or bare integers (milliseconds).
database.evmJsonRpcCache (root)
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
database.evmJsonRpcCache | *CacheConfig | nil | When nil (omitted): cache subsystem is entirely disabled — the EvmJsonRpcCache object is never created. SetDefaults on sub-fields is only called when this pointer is non-nil. The auto-generated project (id: "main", created when no projects: block exists) has NO evmJsonRpcCache config. You must provide the key explicitly to enable caching. Source: common/defaults.go:L831-836 |
evmJsonRpcCache.compression
Auto-created even when omitted: CacheConfig.SetDefaults always creates &CompressionConfig{} and calls its SetDefaults() when c.Compression == nil. The only way to opt out is compression.enabled: false.
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
compression.enabled | *bool | true — set by SetDefaults when nil | Enable zstd compression. Footgun: omitting the block silently enables compression. Source: common/defaults.go:L580-582 |
compression.algorithm | string | "zstd" | Only "zstd" is implemented; reserved for future algorithms. Source: common/defaults.go:L585-587 |
compression.zstdLevel | string | "fastest" | Valid: "fastest", "default", "better", "best". Unknown values fall back to fastest with a warning. Source: common/defaults.go:L590-592 |
compression.threshold | int (bytes) | 1024 | Minimum pre-compression size to attempt compression. Footgun: the inline code default is 512 bytes but SetDefaults overrides to 1024; SetDefaults wins at runtime. Source: common/defaults.go:L597-599 |
evmJsonRpcCache.connectors[*]
Connector SetDefaults is called with scope = connectorScopeCache, which sets cache-specific defaults.
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
connectors[*].id | string | "cache-<driver>" | Unique ID referenced by policies. Auto-set to "cache-" + driver when blank. Source: common/defaults.go:L847-849 |
connectors[*].driver | string | required | One of memory, redis, dynamodb, postgresql, grpc. |
connectors[*].memory.maxItems | int | required | Max items in Ristretto in-process cache. |
connectors[*].memory.maxTotalSize | string | required | Max total memory, e.g. "1GB". |
connectors[*].memory.emitMetrics | *bool | nil | Emit Ristretto cost/set-failure metrics when true. |
connectors[*].redis.addr | string | "" | Redis host:port. Mutually exclusive with uri. |
connectors[*].redis.uri | string | "" | Full Redis URI. Preferred over addr. |
connectors[*].redis.password | string | "" | Redacted in JSON/YAML marshal. |
connectors[*].redis.db | int | 0 | Redis DB index. |
connectors[*].redis.connPoolSize | int | 0 (driver default) | Connection pool size. |
connectors[*].redis.initTimeout | Duration | driver default | Timeout for initial connection. |
connectors[*].redis.getTimeout | Duration | driver default | Per-GET timeout. |
connectors[*].redis.setTimeout | Duration | driver default | Per-SET timeout. |
connectors[*].redis.lockRetryInterval | Duration | driver default | Distributed lock retry interval. |
connectors[*].redis.tls | *TLSConfig | nil | Optional TLS for Redis. |
connectors[*].dynamodb.table | string | required | DynamoDB table name. |
connectors[*].dynamodb.region | string | required | AWS region. |
connectors[*].dynamodb.partitionKeyName | string | required | Partition key attribute name. |
connectors[*].dynamodb.rangeKeyName | string | required | Range key attribute name. |
connectors[*].dynamodb.reverseIndexName | string | required | GSI name for reverse index (wildcard block lookups). |
connectors[*].dynamodb.ttlAttributeName | string | required | DynamoDB TTL attribute name. |
connectors[*].dynamodb.initTimeout | Duration | driver default | Init timeout. |
connectors[*].dynamodb.getTimeout | Duration | driver default | Per-GET timeout. |
connectors[*].dynamodb.setTimeout | Duration | driver default | Per-SET timeout. |
connectors[*].dynamodb.maxRetries | int | 0 | AWS SDK max retries. |
connectors[*].dynamodb.statePollInterval | Duration | driver default | State poller interval. |
connectors[*].dynamodb.lockRetryInterval | Duration | driver default | Distributed lock retry interval. |
connectors[*].postgresql.connectionUri | string | required | PostgreSQL connection URI. Redacted in marshal. |
connectors[*].postgresql.table | string | "erpc_json_rpc_cache" (cache scope) | Auto-set to "erpc_json_rpc_cache" when scope=cache and value is blank. Source: common/defaults.go:L1027 |
connectors[*].postgresql.minConns | int32 | 4 (cache scope) | Min pool connections. Source: common/defaults.go:L1034-1040 |
connectors[*].postgresql.maxConns | int32 | 32 (cache scope) | Max pool connections. Source: common/defaults.go:L1041-1047 |
connectors[*].grpc.bootstrap | string | "" | HTTP URL to fetch gRPC server list. |
connectors[*].grpc.servers | []string | nil | Explicit gRPC server URLs. When both bootstrap and servers are provided, explicit list takes precedence and bootstrap is also resolved and appended. Source: data/grpc.go:L85-95 |
connectors[*].grpc.headers | map[string]string | nil | Headers sent with every gRPC request. |
connectors[*].grpc.getTimeout | Duration | 100ms | Per-call timeout. Applied when the existing context deadline is absent or farther in the future than getTimeout. Footgun: older docs incorrectly stated 0 (no override); the correct runtime default is 100ms. Source: common/defaults.go:L927-929 |
connectors[*].failsafeForGets | []*FailsafeConfig | nil | Per-connector failsafe for Get operations. Consensus and hedge-quantile are not supported at this scope. Source: data/cache_executor.go:L31-38 |
connectors[*].failsafeForSets | []*FailsafeConfig | nil | Per-connector failsafe for Set operations. Same restrictions as Gets. |
evmJsonRpcCache.policies[*]
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
policies[*].connector | string | required | ID matching a connectors[*].id. |
policies[*].network | string | "*" | Wildcard/glob network filter against networkId. Source: common/defaults.go:L608-610 |
policies[*].method | string | "*" | Wildcard/glob method filter. Source: common/defaults.go:L605-607 |
policies[*].params | []interface{} | nil | Positional parameter pattern; nil matches any params. Each element: string (wildcard-matched), object (recursive key match), array (index match), or nil (match any/absent). Extra params beyond pattern length are ignored. |
policies[*].finality | DataFinalityState | finalized (zero value of iota) | One of finalized, unfinalized, realtime, unknown. Footgun: omitting this field is byte-identical to finality: finalized. Realtime methods will silently miss unless a separate finality: realtime policy exists. Source: common/data.go:L9-27 |
policies[*].empty | CacheEmptyBehavior | ignore (iota=0) | One of ignore, allow, only. |
policies[*].appliesTo | CachePolicyAppliesTo | "both" | One of "both", "get", "set". Restricts whether the policy is evaluated for reads, writes, or both. Source: common/defaults.go:L611-613 |
policies[*].minItemSize | *string | nil | ByteSize string ("512B", "1KB", "2MB"). Measured as pre-compression length of the JSON-RPC result field bytes. Responses smaller than this are silently skipped (no error, increments erpc_cache_set_skipped_total). Source: architecture/evm/json_rpc_cache.go:L1069-1072 |
policies[*].maxItemSize | *string | nil | ByteSize string. Same format and measurement as minItemSize. Responses whose result byte length exceeds this are silently skipped. Source: data/cache_policy.go:L152-160 |
policies[*].ttl | Duration | 0 (unlimited) | Time-to-live stored with each key. When set on a realtime policy, also used as the freshness window in the age gate. |
Worked examples
All patterns below are distilled from real production fleets; comments explain the non-obvious choices.
1. Two-tier memory connectors: fast unfinalized + large finalized pool. Production fleets separate recent reorgable data from immutable historical data because their access patterns and eviction pressures differ. Unfinalized blocks churn quickly; finalized blocks are read far more and rarely evicted:
database: evmJsonRpcCache: connectors: - id: memory-cache-unfinalized driver: memory memory: # High item count for fast-cycling recent blocks and tx receipts maxItems: 2000000 # Cap at 8GB: recent data is small-ish JSON, 2M items * ~4KB avg maxTotalSize: 8GB - id: memory-cache-finalized driver: memory memory: # Lower count: finalized items are large (full blocks, log ranges) maxItems: 500000 maxTotalSize: 4GB policies: - connector: memory-cache-unfinalized network: "*" # Only methods this fleet actually indexes — wildcard eth_get* wastes RTTs # on guaranteed misses for eth_getBalance, eth_getCode, eth_getProof, etc. method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unfinalized # empty: allow — a null receipt means "not indexed yet"; still worth # caching so rapid polling doesn't hammer upstreams for the same miss empty: allow ttl: 0 - connector: memory-cache-unfinalized network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unknown empty: allow ttl: 0 - connector: memory-cache-finalized network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: finalized empty: allow # 0 = no expiry: finalized data is reorg-immune and never goes stale ttl: 02. gRPC read-through connector with static-delay hedge and per-call timeout. When the
primary cache backend is a remote gRPC server (BDS read-through), add a short static hedge
so a slow node doesn't hold up the request — note that quantile mode is rejected at
connector scope, so the delay must be static. The 400ms timeout keeps the cache miss path
fast enough that going straight to upstreams is always cheaper than waiting on a struggling
connector:
database: evmJsonRpcCache: connectors: - id: grpc-cache driver: grpc grpc: # getTimeout: applied when parent context has no deadline or a farther deadline # 400ms: if the cache read takes longer, skip it and go to upstreams getTimeout: 400ms servers: - https://rpc.example.com:50051 headers: Authorization: "Bearer ${CACHE_API_TOKEN}" failsafeForGets: - matchMethod: "*" timeout: # Hard ceiling on a single get call; hedge fires before this duration: 400ms hedge: # Static: quantile mode is not supported at connector scope delay: 100ms maxCount: 1 retry: maxAttempts: 2 # 0: retry immediately — no point waiting for a slow cache node delay: "0" policies: - connector: grpc-cache network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unfinalized empty: allow # get-only: gRPC connector is read-only, no Set support appliesTo: get ttl: 0 - connector: grpc-cache network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unknown empty: allow appliesTo: get ttl: 0 - connector: grpc-cache network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: finalized empty: allow appliesTo: get ttl: 03. Chain-specific gRPC reader in front of a wildcard fallback pool. Production fleets
often have a high-fidelity per-chain reader colocated in the same cluster alongside a broader
pooled connector that covers every chain. The chain-specific connector wins the connector
dedup for its network; the wildcard connector catches everything else. Per-chain reader policies come
first so their network: "evm:1" entries win before the wildcard network: "*" entry:
database: evmJsonRpcCache: connectors: - id: eth-reader driver: grpc grpc: getTimeout: 400ms servers: - grpc://eth-reader-lb.cache.svc.cluster.local:50051 failsafeForGets: - matchMethod: "*" retry: { maxAttempts: 2, delay: "0" } timeout: { duration: 400ms } hedge: { delay: 100ms, maxCount: 1 } - id: edge-cache-pool driver: grpc grpc: getTimeout: 400ms servers: - https://cache-base.rpc.example.com:50051 - https://cache-arbitrum.rpc.example.com:50051 failsafeForGets: - matchMethod: "*" retry: { maxAttempts: 2, delay: "0" } timeout: { duration: 400ms } hedge: { delay: 100ms, maxCount: 1 } - id: memory-cache-finalized driver: memory memory: { maxItems: 500000, maxTotalSize: 4GB } policies: # Chain-specific per-chain reader policies come first — connector dedup means these # win the evm:1 slot; the wildcard pool never sees evm:1 requests - connector: eth-reader network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unfinalized empty: allow appliesTo: get ttl: 0 - connector: eth-reader network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unknown empty: allow appliesTo: get ttl: 0 - connector: eth-reader network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: finalized empty: allow appliesTo: get ttl: 0 # Wildcard pool covers all other chains - connector: edge-cache-pool network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unfinalized empty: allow appliesTo: get ttl: 0 - connector: edge-cache-pool network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unknown empty: allow appliesTo: get ttl: 0 - connector: edge-cache-pool network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: finalized empty: allow appliesTo: get ttl: 0 # In-memory finalized cache for all chains — written by upstreams, read locally - connector: memory-cache-finalized network: "*" method: "*" finality: finalized ttl: 04. Intentionally no realtime caching. Some fleets deliberately skip the finality: realtime
bucket so eth_blockNumber, eth_getBlockByNumber("latest"), and eth_gasPrice always go
to fresh upstreams. This is the right choice when your cache connector doesn't implement
CacheHeadReporter (all drivers except gRPC) — without a block timestamp the realtime age
gate fails open and you'd serve stale chain-tip data. The three-bucket shape below
makes the intent explicit and avoids the silent miss:
database: evmJsonRpcCache: connectors: - id: mem driver: memory memory: { maxItems: 500000, maxTotalSize: 4GB } policies: # Deliberately no finality: realtime policy — eth_blockNumber and "latest" # calls always bypass cache and go to upstreams for a fresh head - connector: mem network: "*" method: "*" finality: finalized ttl: 0 - connector: mem network: "*" method: "*" finality: unfinalized # ~2x Ethereum block time; adjust to 2x your slowest chain's block time ttl: 24s - connector: mem network: "*" method: "*" finality: unknown ttl: 05. Shadow-write debugging mode. Write cache entries for a new connector without risking
stale reads — appliesTo: set means reads always go to upstreams while you verify the
connector is populating correctly. Flip to appliesTo: both once you've confirmed hit rates
in erpc_cache_set_success_total:
database: evmJsonRpcCache: connectors: - id: new-pg driver: postgresql postgresql: connectionUri: "postgres://${DB_USER}:${DB_PASS}@db-new:5432/erpc" # table auto-set to "erpc_json_rpc_cache" at cache scope when blank policies: - connector: new-pg finality: finalized # set only: reads still hit upstreams; verify connector fills correctly appliesTo: set ttl: 0Request/response behavior
- Cache key structure. Partition key:
{networkId}:{blockRef}. Range key:{method}:{sha256(params)}. WhenblockRef = "*"(tag-based or multi-block range), the connector is queried via the reverse index (ConnectorReverseIndex = "idx_reverse"). WhenblockRef = "", the lookup is bypassed entirely. X-ERPC-Skip-Cache-Readheader /skip-cache-readquery param. Value"true"skips all connectors. A glob pattern (e.g."mem*") skips matching connectors only. Passing an empty connector ID with a pattern always returns false — patterns are only evaluated per-connector.- Responses with a JSON-RPC
errorfield are never cached. Neither are empty results for blocks beyond the network's confidence head, regardless ofemptypolicy setting. - Set has a hard 5-second per-connector write timeout, independent of any configured failsafe. If both are configured, the shorter one fires first.
- Realtime age gate uses block timestamp, not wall-clock cache age. The
erpc_cache_get_age_guard_reject_totalmetric fires when age exceeds TTL; its label ismethodnotcategory— unique among cache metrics.
Best practices
- Always define four policies (one per finality bucket) when you want to cache all traffic. A single catch-all policy with no explicit
finality:silently caches only finalized data.eth_blockNumber,eth_gasPrice,eth_callwith"latest", andeth_getBalancewith"latest"are allrealtimeand will miss unless afinality: realtimepolicy exists. - Keep realtime TTL between 1 and 3 s. Lower than 1 s means the freshness gate will almost always reject on slower connectors; higher than 3 s risks serving chain-tip data that is multiple blocks stale.
- Set
finality: unfinalizedTTL at 2× block time. For Ethereum mainnet (~12 s) use 24 s. This gives enough room for the block to finalize without serving stale data for too long. - Place the fastest connector first in the policy list when sharing a finality bucket. The Get fan-out races all connectors in parallel, but list order determines which connector wins ties and how quickly cancelation propagates. Memory connectors should come before Redis or PostgreSQL.
- Do not set
compression.enabled: falseunless you have profiled actual CPU overhead. zstd at"fastest"level saves 50–80% storage on typical ABI-encoded JSON-RPC results. The benefit almost always outweighs the CPU cost, and the memory connector would double-compress without it (both layers detect magic bytes and short-circuit, but the extra pass is wasted work only when compression is enabled on both). - Use
minItemSizeto avoid caching tiny responses. Responses under 200 B are oftennull, short errors, or simple scalars. Caching them creates key-count pressure on Ristretto with negligible savings. - Monitor
erpc_cache_get_age_guard_reject_totalvserpc_cache_get_success_hit_totalfor realtime policies. A high reject ratio means your realtime TTL is too tight relative to connector polling latency or yourGrpcConnectorhead poll interval (60 s). Raise the TTL or switch to a connector with an embedded block timestamp in responses.
Edge cases & gotchas
-
finality: finalizedis the zero value — intentionally the safest default. The Go comment atcommon/data.go:L10-L15(opens in a new tab) explicitly states finalized gets 0 so that an unspecified finality defaults to the safest sane cache default. Omittingfinalitymeans only finalized requests match; realtime, unfinalized, and unknown requests bypass this policy and always hit upstreams. -
Realtime finality must be stated explicitly — omission silently drops all realtime traffic. Common realtime methods that silently miss without
finality: realtime:eth_blockNumber,eth_gasPrice,eth_callwith"latest",eth_getBalancewith"latest". This is the most common misconfiguration. -
findGetPolicies deduplicates by connector; findSetPolicies does not. A connector can receive multiple writes for the same request if multiple policies reference it. Source:
architecture/evm/json_rpc_cache.go:L926-L978(opens in a new tab). -
Emptyish hit under
empty=ignoreis detected in the fan-out goroutine, not post-fan-out. Without this, the emptyish result would win the race and cancel peers, losing a chance for a peer withempty=allowand non-empty data to win. Source:architecture/evm/json_rpc_cache.go:L343-L351(opens in a new tab). -
The realtime age gate checks block timestamp, not cache-entry age. A response cached 5 minutes ago is accepted if its block was produced 3 seconds ago. A response cached 30 seconds ago is rejected if its block is 2 minutes old and TTL is 60 s. Source:
architecture/evm/json_rpc_cache.go:L841-L920(opens in a new tab). -
eth_blockNumber,eth_gasPrice, andeth_getLogscarry no block timestamp in their response. The realtime age gate falls back to the connector'sCacheLatestBlockTimestamp, which is only implemented byGrpcConnector(polls every 60 seconds). For all other connectors the gate fails open (accepts the cached result). Source:architecture/evm/json_rpc_cache.go:L879-L889(opens in a new tab). -
Compression threshold has two definitions. The inline code default in
NewEvmJsonRpcCacheis 512 bytes, butSetDefaultssets it to 1024 bytes.SetDefaultsalways runs at startup, so the effective default is 1024. The 512 value is only reachable when the cache object is constructed directly withoutSetDefaults(i.e., in unit tests). Source:common/defaults.go:L597-L599(opens in a new tab). -
Set has a hard 5-second timeout per connector in addition to any failsafe timeout. If both are configured, the shorter one fires first. The 5-second fallback protects connectors with no failsafe configured. Source:
architecture/evm/json_rpc_cache.go:L768(opens in a new tab). -
minItemSizeandmaxItemSizemeasure pre-compression result length, not stored blob size. A 4 KB result that compresses to 400 bytes still fails amaxItemSize: 2KBcheck. The measured value isJsonRpcResponse.ResultLength()— raw JSON result bytes, not the full JSON-RPC envelope. Source:architecture/evm/json_rpc_cache.go:L1067-L1072(opens in a new tab). -
Memory connector has independent internal zstd compression. Combined with the
EvmJsonRpcCachelayer's own compression pass, values stored in the memory connector may be double-compressed. Both layers detect already-compressed data by magic bytes and short-circuit. Source:architecture/evm/json_rpc_cache.go:L1150-L1157(opens in a new tab). -
SkipCacheReadaccepts both boolean and string values. Both normalized to string on parse:true→"true",false→"false". The directive is accepted from config, theX-ERPC-Skip-Cache-ReadHTTP header, and theskip-cache-readquery parameter. Passing a glob pattern with an emptyconnectorIdalways returns false. Source:common/request.go:L895-914. -
Policy ordering matters for Get when finality = unknown. All matching policies race in parallel; the fastest connector with data wins. Place the fastest connector first so it most likely wins. For Set, all matching policies receive the write regardless of order.
-
gRPCconnector is read-only. It does not supportSet. Supported methods:eth_getBlockByNumber,eth_getBlockByHash,eth_getLogs,eth_getTransactionByHash,eth_getTransactionReceipt,eth_getBlockReceipts,eth_chainId,eth_blockNumber.eth_blockNumberis derived by fetching the latest block internally. Source:data/grpc.go:L270-278. -
appliesTo: "set"without a paired"get"policy writes succeed but reads always miss — useful for shadow-write debugging but rarely the right production shape. -
reqRefswith multiple conflicting block references collapse to"*".eth_getLogswith differentfromBlock/toBlockvalues routes to the reverse index and the partition key becomes*. Users must cache only finalized data for range-query methods to avoid serving stale reorged data. Source:architecture/evm/block_ref.go:L296-L300(opens in a new tab). -
ErrRecordNotFoundandErrEndpointMissingDataare semantic misses, not errors. They must not incrementerpc_cache_get_error_total. A regression where this happened caused 36k+ spurious errors per 15 minutes. Source:erpc/evm_json_rpc_cache_fanout_test.go(opens in a new tab). -
database.evmJsonRpcCache: nilcompletely disables the cache subsystem. TheEvmJsonRpcCacheobject is never created, saving memory and eliminating block-ref extraction overhead on every request. Source:common/defaults.go:L831-836. -
Compression is auto-enabled — you cannot disable it by omitting the block.
CacheConfig.SetDefaultsalways creates&CompressionConfig{}and callsSetDefaults()on it (setsenabled=true). The only escape is explicitly settingcompression.enabled: false. Source:common/defaults.go:L482-488. -
Cancellation guard uses
fanCtx.Err(), noterrors.Is(err, context.Canceled). An inner failsafe wrapper can produce an opaque typed error thaterrors.Iscannot unwind. The fan-out loop trustsfanCtx.Err()as the authoritative cancellation signal so a losing peer's wrapped error never inflateserpc_cache_get_error_total. Source:erpc/evm_json_rpc_cache_fanout_test.go(opens in a new tab). -
Empty results for future (not-yet-produced) blocks are never cached, regardless of
emptypolicy setting.emptyResultBeyondConfidencecompares the requested block number against the network's latest (or finalized) head. If the block hasn't been produced yet, the empty result is silently discarded — preventing the cache from poisoning responses for blocks that simply don't exist yet. Source:architecture/evm/json_rpc_cache.go:L1079-L1082(opens in a new tab),architecture/evm/common.go:L55-L89(opens in a new tab). -
gRPC connector's block-head timestamp is set to zero when timestamp parse fails. This is intentional: storing a stale timestamp from a previous poll would make
CacheLatestBlockTimestampreport a dangerously stale age. Storing 0 makes the realtime guard fail-open (accept the cached result) rather than reject based on wrong age data. Source:data/grpc.go:L350-L356(opens in a new tab). -
appliesTo: "get"enables independent write/read policies with different TTLs or finality. SettingappliesTo: "get"on a policy means it will never be written to — a separate write-only policy (possibly with a different finality bucket or size limit) can populate the connector while the read-only policy controls what is served. The default"both"means the same policy governs both directions.
Observability
| Metric | Type | Labels | When it fires |
|---|---|---|---|
erpc_cache_get_skipped_total | counter | project, network, category | No matching policy found for request |
erpc_cache_get_success_hit_total | counter | project, network, category, connector, policy, ttl | Cache hit returned to caller |
erpc_cache_get_success_miss_total | counter | project, network, category, connector, policy, ttl | All connectors confirmed miss |
erpc_cache_get_error_total | counter | project, network, category, connector, policy, ttl, error | Connector transport/non-semantic error |
erpc_cache_get_age_guard_reject_total | counter | project, network, method, connector, policy, ttl | Block timestamp age exceeded policy TTL; only realtime requests with non-zero TTL. Label is method not category — unique among cache metrics |
erpc_cache_get_success_hit_duration_seconds | histogram | project, network, category, connector, policy, ttl | Latency of a cache hit |
erpc_cache_get_success_miss_duration_seconds | histogram | project, network, category, connector, policy, ttl | Latency of a cache miss |
erpc_cache_get_error_duration_seconds | histogram | project, network, category, connector, policy, ttl, error | Latency when a connector returns a transport error |
erpc_cache_set_success_total | counter | project, network, category, connector, policy, ttl | Connector Set succeeded |
erpc_cache_set_error_total | counter | project, network, category, connector, policy, ttl, error | Connector Set failed |
erpc_cache_set_skipped_total | counter | project, network, category, connector, policy, ttl | shouldCacheResponse returned false (size limit, error field, empty-ignore, future block) |
erpc_cache_set_original_bytes_total | counter | project, network, category, connector, policy, ttl | Cumulative uncompressed bytes before any compression attempt; emitted for every successful set |
erpc_cache_set_compressed_bytes_total | counter | project, network, category, connector, policy, ttl | Cumulative bytes after zstd compression. Only emitted when compression was actually applied (enabled, value ≥ threshold, compressed < original). A zero value does NOT indicate a bug — it means compression was not applied in that window |
erpc_cache_set_success_duration_seconds | histogram | project, network, category, connector, policy, ttl | Latency of a successful set |
erpc_cache_set_error_duration_seconds | histogram | project, network, category, connector, policy, ttl, error | Latency when a connector Set returns an error |
erpc_ristretto_cache_current_cost | gauge | connector | Memory connector current cost in bytes |
erpc_ristretto_cache_sets_failed_total | counter | connector | Ristretto dropped or rejected a set |
erpc_cache_connector_earliest_block_number | gauge | connector, network | gRPC connector earliest known block number (refreshed every 60 s) |
erpc_cache_connector_latest_block_number | gauge | connector, network | gRPC connector latest block number (refreshed every 60 s) |
erpc_cache_connector_finalized_block_number | gauge | connector, network | gRPC connector finalized block number (refreshed every 60 s) |
erpc_cache_connector_earliest_block_timestamp_seconds | gauge | connector, network | gRPC connector earliest block unix timestamp (refreshed every 60 s) |
erpc_cache_connector_latest_block_timestamp_seconds | gauge | connector, network | gRPC connector latest block unix timestamp (refreshed every 60 s) |
erpc_cache_connector_finalized_block_timestamp_seconds | gauge | connector, network | gRPC connector finalized block unix timestamp (refreshed every 60 s) |
OTel trace spans:
| Span | Source |
|---|---|
Cache.Get | architecture/evm/json_rpc_cache.go:L153 |
Cache.FindGetPolicies | architecture/evm/json_rpc_cache.go:L173 |
Cache.GetForPolicy | architecture/evm/json_rpc_cache.go:L241 |
Cache.Set | architecture/evm/json_rpc_cache.go:L572 |
Evm.ExtractBlockReferenceFromRequest | architecture/evm/block_ref.go:L18 |
Evm.ExtractBlockTimestampFromResponse | architecture/evm/block_ref.go:L190 |
Request.GenerateCacheHash | common/json_rpc.go:L1387 |
Span attributes on Cache.Get: network.id, request.method, request.finality, cache.policies_matched, cache.hit (bool), cache.miss_reason, cache.miss_connector_id, cache.miss_policy.
Span attributes on Cache.GetForPolicy: cache.policy_summary, cache.connector_id, cache.method, cache.get_outcome (found/miss/ttl_rejected/empty_ignored/error/cancelled), cache.block_ref, cache.group_key, cache.request_key.
Debug log messages
These DEBUG-level log messages appear in eRPC output and are useful for tracing cache behavior:
| Message | Condition |
|---|---|
"will not cache the response because we cannot resolve a block reference" | blockRef is empty on Set — method not in the cacheable set |
"skip caching because response contains an error" | shouldCacheResponse skipped due to JSON-RPC error field |
"skip caching because response size does not match policy limits" | result length outside minItemSize/maxItemSize |
"skip caching empty result for a not-yet-produced (future) block" | emptyResultBeyondConfidence — block number is ahead of network head |
"rejecting cached result because block age exceeds policy TTL" | age guard rejection in shouldAcceptCachedResult |
"cached result rejected due to age exceeding TTL" | same, logged at the Get fan-out level |
"cache connector errored during GET" | connector transport error (not a semantic miss) |
"skipping cache connector due to skip-cache-read directive pattern" | SkipCacheRead directive matched a connector ID glob |
"returning cached response" | hit served (Trace level also logs raw result) |
"compressed cache value" | zstd compression applied (includes original/compressed/savings) |
"decompressed cache value" | zstd decompression applied on read |
Source code entry points
architecture/evm/json_rpc_cache.go:L153-L400(opens in a new tab) —EvmJsonRpcCache.Get: parallel fan-out, fanCtx, 30-second backstop, winner selectionarchitecture/evm/json_rpc_cache.go:L572-L800(opens in a new tab) —EvmJsonRpcCache.Set: parallel fan-out, 5-second hard write timeout,shouldCacheResponsearchitecture/evm/json_rpc_cache.go:L841-L920(opens in a new tab) —shouldAcceptCachedResult: realtime age gate, block-timestamp extraction, connector-head fallback, fail-opendata/cache_policy.go:L1-L200(opens in a new tab) —CachePolicystruct;MatchesForGet,MatchesForSet,matchParams,MatchesSizeLimits,GetTTLarchitecture/evm/block_ref.go:L277-L368(opens in a new tab) —ExtractBlockReferenceFromRequest,ExtractBlockReferenceFromResponse: block-ref derivation, reqRefs/respRefs path walkingcommon/defaults.go:L474-L650(opens in a new tab) —CacheConfig.SetDefaults,CachePolicyConfig.SetDefaults,CompressionConfig.SetDefaultscommon/request.go:L895-L914(opens in a new tab) —ShouldSkipCacheRead: header/query parsing, boolean vs glob logicerpc/evm_json_rpc_cache_fanout_test.go(opens in a new tab) — behavior-locking tests: slow-peer non-blocking, miss-as-error classification, cancellation wrapping
Related pages
- Storage connectors — configure the memory, Redis, DynamoDB, PostgreSQL, and gRPC backends referenced by policies.
- Rate limiters — limit how many upstream calls the cache miss path can trigger.
- Failsafe — retry/timeout/circuit-breaker applied per-connector via
failsafeForGets/failsafeForSets. - Reduce RPC costs — the primary use case this feature serves.
- EVM method reference — per-method finality classification and built-in reqRefs/respRefs defaults.