fix(indexer): bound memory when decoding oversized call traces - #844
fix(indexer): bound memory when decoding oversized call traces#844pk910 wants to merge 2 commits into
Conversation
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.
There was a problem hiding this comment.
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:414— Structural/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 { |
There was a problem hiding this comment.
🟡 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.
SummaryThis 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
Reviewed @ |
Problem
The public instance was OOM-crash-looping. A heap dump taken shortly before a kill shows a single 512 MB object:
That is the
debug_traceBlockByHashread 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:STATICCALLto the identity precompile with 131072 bytes in and out, ~12.3k gas per iteration, repeated until gas runs out.geth's
callTraceremits 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 KiBinput+ 128 KiBoutput≈ 512 KB of hex, hundreds of MB per transaction. The payload is all zero bytes, so it gzips to almost nothing on the wire andhttp.DefaultClienttransparently inflates it — roughly a 1000× amplification from network bytes to indexer heap.Why streaming didn't help
streamDecodeArraystreams array elements, but one element is one transaction's entire trace, andjson.Decoder.readValueneeds a complete value in its buffer before it can decode.refillgrows by2*cap+512and never shrinks. Dora then kept two more copies: the decodedCallTraceCalltree, and the[]FlatCallFrameflattening that aliases it — for every transaction of the block at once, twice per block (callTracerandprestateTracer), 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 insideprunedHex.UnmarshalJSON, where the bytes still aliasdec.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, solen > limitis the pruned flag — no schema change, no extra field on disk.bdbtypes.TrimPrunedPayloadsplits it back on the read side.UI. The internal-tx panel shows
Input (16384+ bytes)with aprunedbadge, 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 aResponseDecodeError(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 no longer scales with the response size.
Not addressed here
CALL≈ 1.75M frames — still hundreds of MB even fully pruned. Deliberately out of scope: dropping frames loses real information.prestateTracerstill usesstreamDecodeArray(same per-element buffering; its values are inherently small).streamRPCCall's ws/ipc fallback still buffers the whole response asjson.RawMessage.checkClient → updateChainSpecs → updateClientSpecs → ChainSpec.ParseAdditive(yaml round-trips per client per reconnect, O(clients²)); and noGOMEMLIMITis set anywhere, so GOGC=100 letNextGCreach 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.