feat(debug-trace-server): bounded in-memory BlockData cache keyed by block hash - #162
feat(debug-trace-server): bounded in-memory BlockData cache keyed by block hash#162flyq wants to merge 9 commits into
Conversation
…block hash Requests that miss the response cache re-resolved BlockData from scratch every time: production traffic shows debug_traceTransaction calls (never response-cached) clustering on shared blocks — 73% of tx-driven block fetches in a 3.6-day window were repeats of a block fetched moments earlier — plus hundreds of thousands of repeated DB reads for recent blocks. Adds a quick_cache-backed BlockDataCache between the response cache and the DB / RPC tiers, keyed by block hash so entries are immutable facts about that hash: block-number lookups resolve number to hash against the DB or upstream first, so canonicality is never cached and no reorg invalidation is needed. The weigher charges the witness KV/level payload, contract bytecodes, and per-transaction envelope + calldata; entries heavier than a shard's budget are never admitted, so oversized blocks bypass the cache instead of thrashing it. DB hits are written back, and the RPC-path insert lives inside the shared single-flight future so each fetch inserts exactly once even if the primary caller is cancelled. New knob --block-data-cache-max-size / DEBUG_TRACE_SERVER_BLOCK_DATA_CACHE_MAX_SIZE (default 1GB, "0" disables, +1GB RSS budget when enabled). debug_getCacheStatus now always reports both responseCache and blockDataCache sections, and the cache exports hits/misses/entries/bytes under type="block_data" plus a "memory" data-source label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fadd97b4c3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let block = BLOCK_FIXED_OVERHEAD + | ||
| match &val.block.transactions { | ||
| BlockTransactions::Full(txs) => { | ||
| txs.iter().map(|tx| TX_OVERHEAD + tx.inner.input().len() as u64).sum::<u64>() |
There was a problem hiding this comment.
Account for variable transaction fields in cache weights
For blocks containing EIP-2930/1559 access lists or EIP-7702 authorization lists, this charges only a fixed 512 bytes plus calldata even though those lists own variable-size heap allocations. Because quick_cache enforces the configured limit using this reported weight, blocks with large lists can make the supposedly bounded cache retain substantially more memory than --block-data-cache-max-size; include these variable transaction fields in the weight calculation.
AGENTS.md reference: AGENTS.md:L111-L111
Useful? React with 👍 / 👎.
| // and still runs when the primary caller is cancelled while a coalesced | ||
| // waiter drives the future to completion. | ||
| if let Some(cache) = &block_data_cache { | ||
| cache.insert(block_hash, Arc::clone(&data)); |
There was a problem hiding this comment.
Evict fetched data when its witness fails execution
When an upstream returns a syntactically decodable but incomplete or incorrect witness, the fetch and contract-resolution stages can succeed and this inserts the data before any trace attempts to read state. The first trace then fails, but every later request hits the same cached witness instead of retrying the endpoint, so a transient bad response poisons that block until eviction or restart; only promote data after successful execution or remove the entry when execution fails.
Useful? React with 👍 / 👎.
…/block-data-memory-cache # Conflicts: # bin/debug-trace-server/src/data_provider.rs # bin/debug-trace-server/src/rpc_service.rs
…k data that fails execution Addresses review findings on the BlockData memory cache: The weigher now charges EIP-2930 access lists (per item + per storage key) and EIP-7702 authorization lists in addition to calldata, so transactions carrying large lists can no longer make the cache retain substantially more memory than --block-data-cache-max-size accounts for. Execution failures now evict the block's cached data (DataProvider::evict_block_data, wired into all five trace handlers): a syntactically decodable but incomplete witness previously survived the fetch stage, got cached, and then failed every subsequent trace until eviction or restart — dropping it on the first failure lets the next request refetch from the endpoints instead of pinning the poisoned entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18a127ad74
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| .await | ||
| .inspect_err(|_| { | ||
| metrics::record_rpc_error(METHOD_DEBUG_TRACE_BLOCK_BY_NUMBER); | ||
| self.data_provider.evict_block_data(&block_hash); |
There was a problem hiding this comment.
Evict entries for embedded block execution failures
When a decodable but incomplete witness is used with fourByteTracer, muxTracer, or a JS tracer, trace_block converts run_transaction failures into TraceResult::Error elements and still returns Ok (tracing_executor.rs:507-523, with equivalent handling in the other tracer branches). Consequently, this inspect_err never runs, the handler caches the error response, and the bad BlockData remains available to other tracer variants and transaction requests instead of being refetched. Fresh evidence in the final tree is that the added eviction observes only the outer result, not these embedded execution failures; propagate or inspect those failures before retaining the entry.
Useful? React with 👍 / 👎.
| .await | ||
| .inspect_err(|_| { | ||
| metrics::record_rpc_error(METHOD_DEBUG_TRACE_BLOCK_BY_HASH); | ||
| self.data_provider.evict_block_data(&block_hash); |
There was a problem hiding this comment.
Preserve valid cache entries on tracer-specific errors
When a client supplies an invalid muxTracer configuration or JavaScript tracer, compute_debug_trace_block can fail while constructing the inspector before reading any EVM state (tracing_executor.rs:529-537 and 595-608), but this unconditional callback evicts otherwise valid block data. Repeating such requests across blocks can force subsequent callers back through DB decoding or upstream witness and contract fetching, effectively allowing request-specific errors to defeat the cache; eviction should be limited to failures that actually indicate invalid block data or witness state.
Useful? React with 👍 / 👎.
vincent-k2026
left a comment
There was a problem hiding this comment.
Review
In one line: the skeleton is right — keying by block hash, never caching canonicality, inserting exactly once inside the single-flight future all check out. The problems are concentrated in two places: the "evict on execution failure" hook added in the last commit catches none of the cases it was written for while letting any client drop other people's cache entries, and the cache advertises a byte bound that can't be computed from the flag, isn't covered by its test, and depends on the host's CPU count.
What's genuinely good
- Keying by hash is the right answer. An entry is an immutable fact about that hash, by-number lookups resolve number→hash first, and therefore reorgs need no invalidation hook at all — an orphaned hash's entry simply becomes unreachable and ages out. This is exactly the discipline the response cache in #161 should adopt; I've said so over there.
- The "inserts exactly once even if the primary caller is cancelled" claim holds. I walked it: the insert sits inside the shared future after a successful fetch and is guarded by
?(data_provider.rs:641), so failures never insert; a coalesced waiter still drives it to completion if the primary is cancelled; if everyone cancels, the future is dropped andInFlightGuardunconditionally clears the in-flight entry, leaving nothing poisoned. - Three of the new tests actually discriminate: DB hit populating memory and the second read skipping the DB (with
Arc::ptr_eqproving one allocation), a memory hit short-circuiting ahead of a hanging upstream, and the disabled-cache path re-reading the DB. Those fail if the feature breaks.
Blocking
rpc_service.rs:491— the eviction hook's discrimination signal is wrong in both directions.
The "bounded" claim has three holes
Details are in the inline threads; the summary:
- The admission threshold is a function of the host's core count (
block_data_cache.rs:138). quick_cache shards, an entry must fit in a single shard's budget, and the default shard count isavailable_parallelism() * 4. Measured on a 12-core box: one entry needsmax_bytes >= 33x its own weightto be admitted at all. At the 1 GB default that's ~31 MB max cacheable block on 12 cores, ~6 MB on 64 cores, ~4 MB on 96 cores — more cores means a smaller admissible block, and a trace server runs on big machines. Rejected inserts are indistinguishable from ordinary misses. - The per-KV weight constant is a hand-copied approximation of an external crate's type layout (
witness_size.rs:85).SALT_KV_BYTESis 103;size_of::<(SaltKey, Option<SaltValue>)>()measures 104. The 1-byte gap is noise — the point is that this PR promotes an observability-grade estimate into the enforcer of a memory bound, so asaltbump can silently make the cache over-retain with no signal. - Contract bytecode is charged twice (
block_data_cache.rs:72). TheBytecodeinBlockDataand the one inContractCacheare the same refcounted allocation, so evicting aBlockDataentry can free zero bytes. Conservative for RSS, but "1 GB" does not mean "1 GB of block data".
Why the tests didn't catch any of this
eviction_respects_byte_budgetis vacuous — measuredlen=0, weight=0(see the inline thread).- The access-list / authorization-list weight terms added in the last commit have zero coverage — the fixture block has 29 transactions with 0 access-list and 0 authorization-list items, so both new branches always take the
map_or(0, ..)side. - None of the five
evict_block_datacall sites has handler-level coverage; nothing asserts when eviction should and should not happen, which is why both directions of the bug survived.
Operational
- Default memory across the three caches is ~2.5 GB (response 1 GB + block-data 1 GB + contract 512 MB —
ContractCache::newusesContractCacheConfig::default()). The description says "+1GB RSS"; the combined footprint isn't documented anywhere. evict_block_datahas no metric, so the amplification vector above would be invisible.--block-data-cache-max-sizehas no lower bound; combined with hole 1, a too-small value silently disables the cache whilewarn!only fires on exactly0.BLOCK_DATA_CACHE_ESTIMATED_ITEMSis hard-coded at 1024 whilemax_bytesis configurable — inconsistent with the response cache, which exposesestimated_itemsas a flag.
Pre-existing, just noting
get_block_data:378-382 uses if let Ok(Some(hash)) = db.get_block_hash(..), silently dropping a DB Err and falling through to upstream, while its sibling get_block_data_from_db:559-568 warns explicitly and documents why. Untouched by this PR, so not a request here.
Things I checked that are fine
Listing these so it's clear they were looked at:
get_block_data_for_txdoes reach the memory tier (:495), so the PR's headline benefit holds. It still pays one upstreameth_getTransactionByHashper request, so what's saved is the witness fetch plus contract resolution, not the whole round trip.light_witness_memory_bytesis not missing a parent-commitments term — aLightWitnessnever materializes them (witness_size.rs:57-60), so kvs + levels is the correct set.- I suspected the number→hash resolution in
get_block_dataduplicated the one inis_canonicaland should be a shared memoized resolver. It shouldn't: the two treat "DB has no hash for this number" in opposite ways (fall through to upstream vs. treat as canonical below the retained range) and use different deadlines. Merging them would break the canonicality guard.
Suggested landing order
- Required before merge: the discrimination rule at
rpc_service.rs:491. - Cheap and worth doing together: shard/rejection observability, the
size_of-derived constant, an eviction counter. - Tests: make the budget test discriminate, cover the two new weight terms, and add handler-level tests for the eviction rule (that last one is really the acceptance criterion for item 1).
- Docs: combined memory footprint, and what "a cache shard's budget" actually resolves to.
How I verified
cargo nextest run -p debug-trace-server @ 18a127a in a separate target dir: 96 tests, all green. The numbers above come from three instrumented probes in a scratch worktree (entry weight 95,589 B; binary search for the admission threshold; size_of of the salt KV pair; access-list/authorization counts in the fixture), reverted afterwards. I also read every per-transaction failure path in tracing_executor.rs, confirmed ContractCacheConfig::default() is 512 MB, and checked quick_cache 0.6.16's Cache::with / OptionsBuilder::build to confirm the shard default is never overridden here.
Note on stacking: base is still #161's branch, and #161 has open changes requested — worth settling that one's direction before retargeting.
Happy to be corrected anywhere, especially if the eviction hook was aimed at a failure mode I haven't reconstructed.
One eviction seam on RpcContext, CacheStats hoisted to metrics, bytecode_weight shared from stateless-db, entry weights precomputed at insert, memory hits record block distance from a memoized tip instead of a redb read, and the test doubles deduplicated into a single StubBlockStore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/block-data-memory-cache Adopts the hash-keyed response cache + canonical-hash memo redesign under the block-data cache. Conflict resolution notes: the old is_canonical / check_cache_by_number machinery and their tests are gone (by-number handlers now resolve to a canonical hash before any cache lookup); the execution-failure eviction seam (on_trace_failure) moved into compute_debug_trace and the compute_block_trace call sites, covering all five methods with four sites; the shared BlockStore test stub is now server_db::test_support::StubBlockStore (upgraded from StaticHashStore with served block data, tip metas, and read counters), and the data_provider test builders unified into provider_with_tiers / provider_with / provider_at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion for eviction, host-independent cache admission Eviction rule (blocking comment): tracing_executor now returns a typed TraceError — Data (witness/env cannot replay the block: run_transaction and prestate-frame failures abort the trace instead of degrading into cacheable TraceResult::Error entries) vs Request (mux/JS config parse, inspector construction, tracer output). on_trace_failure evicts block data only on Data, so an invalid mux config can no longer drop hot entries, and a bad witness now evicts on every method consistently; each real eviction is counted per method (block_data_evictions_total). Bounded-claim holes: the cache pins 4 shards so the admission ceiling is max_bytes/4 on every host (was available_parallelism()*4 shards), logs the effective sizing at startup, counts non-retained inserts (cache_admission_rejects_total), and scales its estimated-items hint from max_bytes; SALT_KV_BYTES is now size_of-derived so a salt layout change propagates; the weigher's double-charged contract term, budget-vs-RSS caveats, and the ~2.5 GB combined default footprint are documented. Tests: executor-level Data/Request discrimination, handler-level evicts-only-on-data-errors, deterministic single-shard budget assertions (the old test passed on a cache that admitted nothing), oversized-entry rejection counting, and exact access-list/authorization weight arithmetic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude review status
🛠️ Review did not finish Attempted head This round did not publish: MODEL_ACTION_FAILED in phase review_retry. Anything listed below is from the last round that did. Re-run the workflow or push a new commit to try again. |
…er trace-error family, one failure-conversion seam Classification now lives in named constructors instead of ~12 hand-assembled map_err sites: request_error / frame_build_error / tx_replay_failure plus shared mux/js inspector builders, and trace_tx_with_tracing_inspector returns TraceError so its call sites drop their Into conversions. This also fixes a classification drift the constructors made visible: mux frame building fails only via DB::Error (witness reads), so those failures are now data-attributable — aborting and evicting like the plain prestate path — instead of Request at the tx level and a retained per-tx error entry in the block loop. trace_failure_to_rpc_err is now the sole TraceError -> RPC-error conversion (metric + conditional eviction + rendering in one place), replacing the paired-call idiom at four handler sites. The block-data cache startup log moved to main.rs beside the response-cache log, refresh_size_gauges returns the pair it read so stats() stops double-sweeping the shards, and the test scaffolding (hanging_url, noop_contract_cache, fixture_block_data) is shared through data_provider::test_support instead of re-inlined per module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vincent-k2026
left a comment
There was a problem hiding this comment.
Approving — verified against 0ab67b1
Everything from my round-1 review is addressed, and the central item is solved more cleanly than what I asked for.
The eviction rule now keys off a typed discriminant, and it's right in both directions. TraceError::{Request, Data} plus matches!(error, TraceError::Data(_)) at the call site means:
- The case the hook was written for is now actually caught: every tracer's
run_transactionfailure returnsErr(BlockReplayFailed)instead of degrading into anOkfull ofTraceResult::Errorentries. The justification in the comment holds up — a canonical block's transactions all execute (reverts included) against correct state, so a replay failure really does mean the data is bad. - The case that shouldn't have triggered it no longer does: mux/JS config parse and inspector construction map to
TraceError::Request, so a client can't drop hot entries with a cheap invalid request. trace_failure_evicts_block_data_only_for_data_errorspins the rule at the handler layer, in both directions — the coverage whose absence let both directions of the bug survive.- The client-visible consequence (
debug_traceBlock*on unusable data now returns-32000rather than200with per-tx error entries) is called out in the description with its rationale. Agreed it's the right trade: the old shape was a partial wrong answer dressed as success.
A false alarm I chased and am retracting, so nobody "fixes" something that is already correct: I suspected mux and JS still degraded per-tx execution failures, because three TraceResult::Error sites remain in their branches. They're at try_into_mux_frame, the per-tx JsInspector::with_transaction_context, and json_result — tracer-output and per-tx construction failures, none of them data-attributable. Both tracers' run_transaction arms do abort (tracing_executor.rs:603 and :695). The rule is applied consistently; no change wanted.
The rest, checked rather than assumed
- Shard count pinned to 4 with the reasoning written down, so the admission ceiling is
max_bytes / 4(~256 MB) on every host instead of shrinking as machines get bigger. Exactly the fix. - Rejected inserts are now visible:
peekafter insert → counter + metric + debug log, andoversized_entry_is_rejected_and_countedpins the path. - The budget test discriminates now. Single shard so the arithmetic is deterministic, and it asserts exact values (
entry_count == 2,total_bytes == one * 2) instead of inequalities that a cache admitting nothing would also satisfy. - The access-list / authorization terms have real coverage: a synthetic transaction with 3 storage keys and 2 authorizations, asserting the exact weight delta against the constants — it fails if either term is dropped.
SALT_KV_BYTESis nowsize_of::<(SaltKey, Option<SaltValue>)>(), so the bound tracks the type instead of a hand-copied number.block_data_evictions_totalper method;estimated_itemsderived from the byte budget rather than hard-coded; README documents the weigher's under-count and the bytecode double-charge.- The
StaticHashStore→StubBlockStorerefactor with read counters is a good call — "the memory-served read performs zero tip reads" is a much better assertion than "it returned something". cargo nextest run -p debug-trace-server -p stateless-db@0ab67b1: 116 passed, 0 failed.
Non-blocking follow-ups
README.md:126— the combined-memory figure predates the #161 merge and now omits a fourth cache. Details inline.block_data_cache.rs:49— where doesEXPECTED_BLOCK_DATA_BYTEScome from? Details inline.block_data_cache.rs:194— thepeek-based reject detection can miscount a concurrent legitimate eviction; worth a clause in the comment. Details inline.
None of these gate the merge. Good round.
…ur-cache footprint, reject-counter caveat EXPECTED_BLOCK_DATA_BYTES drops from an unsourced 4 MB to a measured 256 KiB: block_data_weight over the mainnet fixture blocks puts quiet blocks (~25 txs) at ~70-360 KB and load-test-era ones (~600 txs) at ~0.5-1.0 MB, so the estimated-items hint at the 1 GB default goes from 256 to 4096, matching the ~4k entries the budget actually holds. The README combined-memory figure now counts the canonical-hash memo the #161 merge added (~3.1 GB across four caches, with the fills-lazily caveat), and the admission-reject counter docs note the rare false positive from a concurrent eviction racing the retention check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds a bounded memory tier between the HTTP response cache and the DB / RPC tiers so requests that miss the response cache — other tracer variants of a block, and especially
debug_traceTransactioncalls, which are never response-cached — reuse one witness fetch and contract resolution instead of repeating it per request. Stacked on #161; retarget tomainafter it merges.Motivation
3.6 days of production logs show 73% of tx-driven block fetches (9,955 of 13,716) re-fetched a block already resolved moments earlier for a different transaction of the same block, plus ~345k repeated DB reads (redb read + witness decode each) for recent blocks; repeat-same-tx is ~0.4%, so a tx-level response cache would absorb nothing — the reusable unit is the block's resolved
BlockData.Design
New
BlockDataCache(bin/debug-trace-server/src/block_data_cache.rs) backed byquick_cache, keyed by block hash: entries are immutable facts about that hash, and by-number lookups resolve number → hash first (via #161's window → memo → upstream chain), so canonicality is never cached and no reorg invalidation is needed. Lookup order is memory → DB → RPC; DB hits are written back, and the RPC-path insert runs inside the shared single-flight future so each fetch inserts exactly once even if the primary caller is cancelled. Memory hits record their block-distance sample from a tip memoized on DB-backed serves, so a pure cache hit opens no redb transaction.Entry weights are computed once at insert (
block_data_weight) and stored with the entry, so the weigher quick_cache re-invokes under its shard locks only reads a number; the weight charges the witness KV/level payload (stateless_common::witness_size::light_witness_memory_bytes, per-KV constant nowsize_of-derived so asaltlayout change propagates), contract bytecodes (via the sharedstateless_db::bytecode_weightformula), and per-transaction envelope overhead plus calldata, access lists, and EIP-7702 authorization lists. The cache pins 4 shards so the admission ceiling for a single entry ismax_bytes / 4on every host (quick_cache's default shard count scales with the core count, which would shrink the largest admissible block on big machines); startup logs the effective per-shard budget, and an insert the cache does not retain is counted (cache_admission_rejects_total) and logged instead of masquerading as a miss.Trace failures are typed (
TraceErrorintracing_executor.rs) and cache hygiene keys off the discriminant: data-attributable failures — a witness that cannot replay the block;run_transactionand prestate-frame failures now abort the trace instead of degrading into cacheableTraceResult::Errorentries — evict the block's entry and are counted per method (block_data_evictions_total), while request-attributable failures (mux/JS config parse, inspector construction, tracer output) never evict, so a client cannot drop hot entries with a cheap invalid request. A visible behavior change rides on this:debug_traceBlock*on a block whose witness cannot replay now returns-32000instead of200with per-tx error entries — consistent with paritytrace_blockand geth's abort behavior, and it keeps the response cache from storing poisoned callTracer responses.New knob
--block-data-cache-max-size/DEBUG_TRACE_SERVER_BLOCK_DATA_CACHE_MAX_SIZE(default 1GB,"0"disables).debug_getCacheStatusalways reports bothresponseCacheandblockDataCachesections, and the cache exports hits/misses/entries/bytes/admission-rejects undertype="block_data"plus amemorydata-source label. Combined default memory across the response, block-data, and contract caches is ~2.5 GB, now stated in the README.Testing
Unit tests on real fixture data: weigher terms including exact access-list/authorization arithmetic, hit/miss/stats counters, deterministic single-shard budget eviction (exactly two of three same-weight entries retained — the earlier form passed on a cache that admitted nothing), oversized-entry rejection counting, DB write-back with
Arc::ptr_eqsharing plus a memoized-tip assertion (the memory-served read performs zero tip reads), memory short-circuit ahead of a hanging upstream, disabled-cache DB re-reads, executor-level Data/Request error discrimination, handler-level evicts-only-on-data-errors, bothdebug_getCacheStatusJSON shapes, and flag default/env/disable parsing. Full suite: 99 bin tests + 39 stateless-common + 17 stateless-db,cargo fmt --check/clippy --workspace --all-targets --all-features/cargo sort --checkall clean.Notes
Deployment is config-free by default (cache on at 1GB); operators can raise, lower, or disable via the env var. Known follow-up: a deterministically bad witness still costs an evict → refetch → fail cycle per request (visible via the eviction counter); negative caching with backoff is deferred. Merge flow per repo convention: after #161 merges, retarget this PR to
main, "Update branch" (merge, no rebase), then squash-merge.🤖 Generated with Claude Code