Skip to content

fix(indexer): bound memory when decoding oversized call traces - #844

Open
pk910 wants to merge 2 commits into
masterfrom
pk910/fix-oom-on-large-traces
Open

fix(indexer): bound memory when decoding oversized call traces#844
pk910 wants to merge 2 commits into
masterfrom
pk910/fix-oom-on-large-traces

Conversation

@pk910

@pk910 pk910 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Problem

The public instance was OOM-crash-looping. A heap dump taken shortly before a kill shows a single 512 MB object:

HeapAlloc 3.05 GB   HeapSys 3.23 GB   NextGC 3.13 GB   MaxRSS 2.79 GB

512.0MB  1 object   encoding/json.(*Decoder).refill
                    ← json.(*Decoder).Decode
                    ← rpc.TraceBlockByHash.streamDecodeArray[...]
                    ← txindexer.fetchBlockTraces  ← processElBlock

That is the debug_traceBlockByHash read buffer.

The trigger

Block 52235 on glamsterdam-devnet-8 contains 8 identical txs to 0x722d316672c8be206c3d228d087d1dc948a61345, each burning the full 16.77M gas. The contract is 96 bytes; its hot loop is:

STATICCALL  gas, 0x04, argsOffset=0, argsSize=0x20000, retOffset=0, retSize=0x20000
GAS  PUSH3 0x0493e0  LT  JUMPI     ; loop while gas > 300k

STATICCALL to the identity precompile with 131072 bytes in and out, ~12.3k gas per iteration, repeated until gas runs out.

geth's callTracer emits a frame for every call including precompiles and copies the payload verbatim (eth/tracers/native/call.go, Input: common.CopyBytes(input), no truncation). So each iteration becomes a JSON frame with 128 KiB input + 128 KiB output512 KB of hex, hundreds of MB per transaction. The payload is all zero bytes, so it gzips to almost nothing on the wire and http.DefaultClient transparently inflates it — roughly a 1000× amplification from network bytes to indexer heap.

Why streaming didn't help

streamDecodeArray streams array elements, but one element is one transaction's entire trace, and json.Decoder.readValue needs a complete value in its buffer before it can decode. refill grows by 2*cap+512 and never shrinks. Dora then kept two more copies: the decoded CallTraceCall tree, and the []FlatCallFrame flattening that aliases it — for every transaction of the block at once, twice per block (callTracer and prestateTracer), retried across up to 3 clients, across N parallel block workers.

Changes

Streaming, pruning decoder (clients/execution/rpc/calltrace_stream.go). The trace tree is now walked token by token, which bounds the decoder buffer by the largest single value in the response rather than by a whole transaction. Pruning happens inside prunedHex.UnmarshalJSON, where the bytes still alias dec.buf, so the oversized part of a payload is discarded without ever being copied.

Truncation marker. bdbtypes.TracePayloadLimit = 16384. An oversized payload is stored as its first limit+1 bytes, so len > limit is the pruned flag — no schema change, no extra field on disk. bdbtypes.TrimPrunedPayload splits it back on the read side.

UI. The internal-tx panel shows Input (16384+ bytes) with a pruned badge, and the hex box renders the retained prefix followed by … (pruned). ASCII view and clipboard follow. ABI decoding is skipped for pruned inputs — dynamic offsets can point past what was kept — while the selector-derived method name and signature still resolve.

Retry classification (shouldRetryOnOtherClient). Stop failing the same tracer call over to two more clients when the shared deadline is already gone, or when the failure is a ResponseDecodeError (the response arrived, its structure is unparseable — the next client returns the same shape). I/O errors and truncated bodies stay retryable.

No frames are dropped. Precompile frames are still recorded.

Measured

200 MiB attack-shaped response, one transaction, 400 identity frames:

peak heap retained
before 482.8 MiB 484.2 MiB
after 14.9 MiB 15.0 MiB

Peak no longer scales with the response size.

Not addressed here

  • No frame-count cap. 175M block gas ÷ ~100 gas per warm CALL ≈ 1.75M frames — still hundreds of MB even fully pruned. Deliberately out of scope: dropping frames loses real information.
  • prestateTracer still uses streamDecodeArray (same per-element buffering; its values are inherently small).
  • streamRPCCall's ws/ipc fallback still buffers the whole response as json.RawMessage.
  • Two findings from the same heap dump, unrelated to this path: 58% of all bytes allocated by the process come from checkClient → updateChainSpecs → updateClientSpecs → ChainSpec.ParseAdditive (yaml round-trips per client per reconnect, O(clients²)); and no GOMEMLIMIT is set anywhere, so GOGC=100 let NextGC reach 3.13 GB before collecting.

Testing

go build ./..., go vet ./..., golangci-lint run --new-from-rev=origin/master ./... (0 issues) and the full test suite pass. New tests cover nested frame decoding, unknown-field skipping, pruning at/over/under the limit, null handling, lenient revert reasons, and the retryable vs non-retryable error split.

A contract can turn one transaction's gas into hundreds of megabytes of
callTracer output by looping over calls that pass large memory buffers
around - the identity precompile is the cheapest vehicle, and geth's
callTracer copies every frame's input and output verbatim. The payload is
all zeros, so it gzips to almost nothing on the wire and only expands once
it reaches the indexer.

streamDecodeArray streams array elements, but one element is one
transaction's entire trace and json.Decoder.Decode buffers a complete value
before handing it over, so a single transaction sat in the decoder buffer in
full. Decode the tree token by token instead, which bounds that buffer by the
largest single value in the response, and prune each payload inside
UnmarshalJSON, where the bytes still alias the decoder buffer and the
oversized part is discarded without being copied.

Payloads past TracePayloadLimit are stored as their first limit+1 bytes, so a
stored length above the limit is itself the truncation marker and nothing
changes on disk. The transaction page renders the retained prefix followed by
"... (pruned)" and skips ABI decoding for pruned inputs, whose dynamic offsets
may point past what was kept.

Also stop failing the same tracer call over to two more clients when the
shared deadline is already gone or the response could not be parsed - the
next client returns the same shape.

Measured on a 200 MiB attack-shaped response: peak heap 483 MiB -> 15 MiB.

@redpandabot redpandabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The PR replaces whole-element streaming decode of debug_traceBlockByHash with a token-by-token walk that prunes each frame's input/output/revertReason to 16384+1 bytes, marks truncation via stored length > limit, and surfaces the pruned prefix in the UI with a badge. The decoder, marker scheme, and UI wiring are internally consistent and the memory claim holds; the notable behavioral change is the new failover policy, which now stops retrying across clients on any structural/type decode error.

Issues

  • 🟡 indexer/execution/txindexer/loader.go:414Structural/type decode errors now abort client failover for the whole block — see the thread on that line

Reviewed @ f56d473d
"Adventure is just bad planning, romanticized." — Roald Amundsen

// shared by all attempts is already gone, and a decode failure means the
// response arrived but could not be parsed - the next client returns the same
// shape and would only burn another timeout on it.
func shouldRetryOnOtherClient(ctx context.Context, err error) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Structural/type decode errors now abort client failover for the whole block

asDecodeError funnels json.SyntaxError and json.UnmarshalTypeError into ResponseDecodeError, and shouldRetryOnOtherClient treats any of those as permanent (break). Previously every decode error tried the next client, which recovered blocks when the first-priority node (primary unless Besu) returned a transient or divergent response — e.g. a 200-with-garbage body (SyntaxError) or a client whose frame shape differs in one field's encoding (UnmarshalTypeError). That block's traces and state diffs (both loops) are now skipped outright. I could not verify any specific EL emits a divergent frame shape (no cross-repo available), so this is a failover-policy regression worth confirming against the supported client set rather than a confirmed break.

@redpandabot

redpandabot Bot commented Aug 21, 2026

Copy link
Copy Markdown

Summary

This PR replaces the per-element debug_traceBlockByHash stream decoder with a token-by-token callTracer walker that prunes oversized input/output/revert-hex payloads to 16384 bytes (+1 marker byte) as they stream in, bounding decoder heap against the trace-payload OOM, and adds symmetric trimming/ABI-skip on the read/UI side. The memory fix is sound (retained bytes are always freshly allocated, never aliasing the decoder buffer). The main caveats are that the new error classification silently disables client failover on decode errors, and the len>limit truncation marker has a boundary collision at exactly limit+1 bytes.

Issues

  • 🟡 indexer/execution/txindexer/loader.godecode-class errors now abort failover in heterogeneous fleets — fetchBlockTraces and fetchBlockStateDiffs previously failed over to the next client on ANY error; now a ResponseDecodeError (raised by asDecodeError for json.SyntaxError/UnmarshalTypeError in the shared streamDecodeArray used by TraceBlockStateDiffsByHash) triggers break and silently drops traces/diffs. The justification ("the next client returns the same shape") only holds for a homogeneous RPC fleet; dora runs against geth/besu/nethermind/erigon whose tracer output shape differs, so a schema mismatch on one client now discards otherwise-recoverable trace/state-diff data on the others.
  • 🟢 clients/execution/rpc/calltrace_stream.go:308off-by-one: payloads of exactly limit+1 bytes are falsely marked pruned — store() only clips when len(body) > 2*(limit+1), so a payload of exactly 16385 bytes is stored complete, yet TrimPrunedPayload's len>limit marker flags it pruned: the UI shows only 16384 bytes with a "pruned" badge and ABI decode is skipped even though the full payload is present on disk. Rare but a real false positive in the central marker scheme.

Reviewed @ 63d8c7d1
"Adventure is just bad planning, romanticized." — Roald Amundsen

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant