eth/ingest: skip typeless debug trace frame instead of crashing the writer - #82
eth/ingest: skip typeless debug trace frame instead of crashing the writer#82SQD-Trevor-Agent wants to merge 1 commit into
Conversation
debug_append() read frame['type'] unconditionally. When a node returns a
typeless error object (e.g. {'error': ...} or {}) for an untraceable tx,
this raised KeyError: 'type' and crash-looped the parquet writer on the
already-persisted raw block, halting the dump. Skip such frames (mirrors
the STOP early-return); unknown non-null types still raise.
|
Independently reproduced this from the One thing to consider about the fix layer, though. The reason a typeless frame reaches the writer at all is that the dump accepted and persisted it: def _validate_debug_trace(result):
for trace in result:
if trace.get('error') == 'execution timeout':
return False
if error := trace.get('error'):
if isinstance(error, dict):
return False
return TrueSo a truncated/typeless frame passes validation → gets written to raw → crash-loops the writer forever. Rejecting it there makes the RPC layer retry / fail over to another endpoint and fetch the correct trace (Alchemy returns it well-formed), so no data is lost. Skipping in Suggested root-layer fix (mirrors the existing def _has_debug_frame_type(frame) -> bool:
# a well formed callTracer frame always carries a 'type'; a missing one means
# a truncated/partial response that would later crash the parquet writer
if not isinstance(frame, dict) or 'type' not in frame:
return False
return all(_has_debug_frame_type(call) for call in frame.get('calls', ()))
def _validate_debug_trace(result):
for trace in result:
if trace.get('error') == 'execution timeout':
return False
if error := trace.get('error'):
if isinstance(error, dict):
return False
continue
if not _has_debug_frame_type(trace.get('result', trace)):
return False
return TrueRegression test (red on current def test_rejects_frame_missing_type():
broken = {'from': '0xa', 'to': '0xb', 'input': '0x', 'gas': '0x1', 'gasUsed': '0x1'} # no 'type'
trace = [{'txHash': '0x1', 'result': {'type': 'CALL', 'calls': [broken]}}]
assert _validate_debug_trace(trace) is False # was True on master -> persisted -> crash
def test_tolerates_tx_level_string_error():
assert _validate_debug_trace([{'txHash': '0x1', 'error': 'out of gas'}]) is TrueEither approach stops the crash; combining the two (reject-and-retry at the dump so good data is stored, plus your writer guard as a belt-and-suspenders safety net) would be strongest. For metis specifically the current raw chunk still needs a re-dump to clear the stuck block, and the dump is separately blocked by the provider (nodies now returns |
|
Same root cause — confirming this fix and adding two evidence corrections from an independent investigation of the still-firing I validated this exact diff red→green against the real crashing record (not a synthetic Two corrections to the PR description so the follow-up is right:
Recommend merge as-is. (Minor: the same latent |
Cause (proven)
ingest-metis-mainnet-0(eth-archive stage-2 parquet writer) crash-loops, firingmetis-mainnet_ingest_no_metrics(ArchiveIngestDown / ArchiveIngestRestarting). Pod logs (eth-archive, clustermain) show the same crash every ~5 min, always at the same point:debug_appendreadframe['type']unconditionally. A trace element for an untraceable tx can come back as a typeless object —{}or{'error': ...}— with notypekey._validate_debug_tracedoes not reject those, so they get persisted into the raw bucket; the writer then re-reads that raw block on every restart and dies onKeyError: 'type', deterministically stuck at block 20765180 (thelast block: 20765179line is identical across all restarts). The dump never advances, so no data is written.Cross-check (rule: is it inherent to the chain?):
debug_traceBlockByNumberfor block0x13cd9fc(20765180) on Alchemy (a different node implementation) returns a well-formed frame withtype: CALL. So the missing-typeframe is provider/persisted-raw corruption, not chain-inherent — the writer must survive it rather than crash-loop.Fix
debug_appendnow readsframe.get('type')and, when it is absent, skips the frame (there is nothing to record — it mirrors the existingSTOPearly-return). Frames with an unknown non-null type stillraise, so genuinely new formats are still surfaced for investigation rather than silently dropped.Test
Added
tests/test_trace_table.py:test_debug_append_skips_typeless_error_frame— feeds{}and{'error': ...}; fails on pre-fix code with the exact productionKeyError: 'type'attables.py:318, passes after.test_debug_append_records_valid_call_frame— a validCALLframe still produces acallrow.test_debug_append_still_raises_on_unknown_nonnull_type— an unknown non-null type still raises.Scope / related
This is the parquet-writer fatality. It is a distinct root cause from the
rpc-ingest-32601fetch-side crash of the same incident, already handled by:-32601to a capable endpoint) andmissing_methods: [debug_traceBlockByHash]on the nodies endpoint).Those fix the fetch path going forward; they cannot un-persist the already-written typeless frame that this writer keeps re-reading, which is why this change is needed too.
Falsification
Wrong if a typeless frame legitimately carries recordable trace data (it does not — no
type, no call/create/suicide semantics) or if a valid tx trace is being dropped (covered by the valid-frame test). A follow-up hardening — rejecting typeless/empty frames in_validate_debug_traceso they are retried on another endpoint before being persisted — is worth considering but is intentionally left out here to keep fetch-time behavior unchanged across all networks.test not applicable: n/a — regression test included and run (red -> green).