feat(cryo): collect the 16 canonical_execution datasets in-process - #96
Merged
Conversation
Replaces the bash-in-a-ConfigMap cryo pipeline with tracker.BlockProcessor implementations. cryo runs as a subprocess, one invocation per block per group emitting every dataset at once; the parquet is decoded in Go and inserted columnar via ch-go. One invocation per block for all 16 datasets costs 101ms against 628ms for sixteen separate ones, because a single cryo call can serve many datasets from the same RPC responses. A group is therefore also the unit of progress: it succeeds or fails as a whole, so one admin.execution_block row represents one thing that actually happened. Four granularities stay decoupled: fetch is one block per group, decode is one parquet per dataset, tracking is one ledger row per block, and insert is a per-dataset rowbuffer flushed on rows or interval. Data-integrity fixes over the pipeline this replaces: - internal_index is a positional counter over parquet file order keyed on the raw transaction hash, with a null hash counted under the key it is stored under rather than dropped. The pandas groupby it replaces dropped null keys, collapsing every block-reward trace in a block onto one sort key and losing one of the two on pre-merge blocks with uncles. - four_byte_counts gains internal_index, so rows differing only by calldata size stop colliding. - Genuinely-absent values land as NULL rather than as a type default, so "absent" and "zero" stay distinguishable for gas and fee columns. - Every group collects the blocks dataset, as a canary it does not write when it is not a member. cryo answers a block it cannot serve with an empty parquet and exit 0, which is indistinguishable from an empty block; blocks is the one dataset whose row count is knowable in advance, so it turns silence into a retryable error. - --include-columns gas_limit is derived from the datasets rather than configured, so it cannot be omitted and silently zeroed. Correctness details worth knowing: - Uses apache/arrow-go for parquet. parquet-go v0.30.1 mis-decodes cryo's LZ4_RAW pages and returns empty byte arrays with no error on 3 of 16 datasets. Only parquet/file is imported, never pqarrow, which would link gRPC. - Parquet stores unsigned integers in signed physical types, so the decoder reinterprets the bits and rejects genuinely signed columns at schema time. Treating a set sign bit as corruption would have failed every block from 2038-01-19. - --hex renders a zero-length byte string as the literal "0x", so the empty-to-NULL guard tests for both. - The startup schema check compares the ch-go column block against the target table in both directions, because a column the mapping never writes is silently server-defaulted. Also fixes three pre-existing defects found on the way: - Manager.checkGaps obtained the limiter through a type switch, so gap detection silently did nothing for any processor it did not enumerate. BlockProcessor gains GetLimiter and the switch is gone. - MarkBlockComplete hard-coded task_count back to 0, destroying the real count written at enqueue. - QueueBlockManually wrote its own ledger row on top of the processor's, without a complete flag or a task count, leaving manually-queued blocks looking permanently incomplete. It now delegates to ProcessBlock, which deletes the second type switch, enqueueSimpleBlockTask and MarkBlockProcessed. QueueResult loses tasks_created, which the manager no longer measures. execution.Node gains RPCEndpoint so cryo can follow the pool away from an unhealthy node; EmbeddedNode returns empty since it has no endpoint. The RPC URL reaches cryo through ETH_RPC_URL rather than argv, which anything sharing the PID namespace can read from /proc, and is redacted from captured stderr. The image gains a static musl cryo built from a pinned commit with a pinned Rust version; both are load-bearing, as an unpinned rustc fails to compile a dependency. The release build caches that stage per architecture, since it is emulated for arm64. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A column block holds one buffer per column sized for the batch, and flush
rebuilt one every time. At mainnet row counts that was the largest single
allocation in the insert path.
Reset already existed on all sixteen column blocks and was never called, so
recycling them through a sync.Pool needed no new per-dataset code. The pool is
the right shape here because rowbuffer can flush from both its timer and its
size trigger concurrently.
Measured on the heaviest fixture block, 23,598 rows across all sixteen
datasets, decode through to a built column block:
before 88.9 MB/block 12.1 ms/block
after 65.3 MB/block 10.3 ms/block
Recycling turns Reset from dead code into a correctness dependency: a Reset that
misses a column would leave the previous batch's rows in place and the next
flush would build a block whose columns disagree on length. TestColumnBlocks
ResetCompletely fills, resets, refills and compares against a fresh block for
every dataset, so that failure mode is caught rather than inferred.
Adds benchmarks for the stages a task actually spends time in — parquet decode,
mapping to rows, and building the column block — plus a concurrency memory
report. The latter records that live heap stays flat at ~240 MiB from 1 to 32
workers while churn scales linearly, so replica sizing is driven by allocation
rate rather than by working set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mapping helpers returned *string and *uint64, so every non-null value in a
nullable column cost a heap allocation and an indirection, on rows the buffer
holds until the next flush. Returning ch-go's own nullable representation
carries the value inline and removes both.
traces felt this most, having eight nullable columns:
traces map 20,117 -> 3,149 allocs/block 574us -> 359us
logs map 3,722 -> 7 allocs/block
whole block 171,262 -> 149,698 allocs/block
Bytes and wall time per block are unchanged within noise, since the pointers
were small next to the decoded data. The win is allocation count and the
collector work that follows from it, not footprint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lint job tracked `version: latest`, so a golangci-lint release turned an unrelated pull request red: master last passed in February against an older binary, and 2.12.2 reports 55 findings in code neither branch touched. Pinning the version makes the job depend on the code under review rather than on whatever shipped that week. Clearing what it found: goconst is dropped. It flagged Prometheus label names, EVM opcode strings and SQL identifiers — `network` 45 times, `CALL` 43 — where naming them adds indirection without safety, and it was already excluded from three paths for that reason. The remaining findings were the same category, so the exclusions go with it rather than growing a fourth. The two G117 directives on Password fields no longer match anything and are removed. The three gosec findings are annotated where they are deliberate: two goroutines detach from a request context on purpose, and one slice index is bounded by the range it iterates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cryo pads block numbers in the file name to a width it chooses, so a chain whose heights are shorter than mainnet's writes 01647952_to_01647952 where the request said 1647952. The glob asked for the unpadded form and found nothing, which surfaced as ErrParquetMissing on every block. Every fixture is a mainnet height of exactly eight digits, so nothing caught it. Collecting a hoodi block through the real binary did. It would have failed the whole of mainnet below block 10,000,000 and every testnet. The invocation writes into a directory of its own, so the dataset name alone identifies the file and the rest of the name does not need predicting — which also covers the network prefix, already wildcarded because cryo derives it from the chain id and calls hoodi network_560048. Also stops the fetch tests running in parallel. They write an executable and then exec it, and two goroutines doing that at once makes exec fail with ETXTBSY; it reproduced roughly once in ten runs. The tests that do not build a binary stay parallel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the bash-in-a-ConfigMap cryo pipeline with
tracker.BlockProcessorimplementations. cryo runs as a subprocess — one invocation per block per group, emitting every dataset at once — and the parquet is decoded in Go and inserted columnar via ch-go.Why one invocation per block
A single cryo call can serve many datasets from the same RPC responses: 101ms for all 16, against 628ms for sixteen separate invocations. A group is therefore also the unit of progress — it succeeds or fails as a whole, so one
admin.execution_blockrow represents one thing that actually happened.Four granularities stay decoupled: fetch is one block per group, decode is one parquet per dataset, tracking is one ledger row per block, insert is a per-dataset
rowbufferflushed on rows or interval.Data-integrity fixes over the pipeline this replaces
internal_indexis a positional counter over parquet file order keyed on the raw transaction hash, with a null hash counted under the key it is stored under rather than dropped. The pandasgroupbyit replaces dropped null keys, collapsing every reward trace in a block onto one sort key and losing one of the two on pre-merge blocks with uncles.four_byte_countsinternal_index, so rows differing only by calldata size stop colliding.blocksas a canary it does not write when it is not a member; it is the one dataset whose row count is knowable in advance, so silence becomes a retryable error.gas_limit--include-columns gas_limitis derived from the datasets rather than configured, so it cannot be omitted and silently zeroed.Correctness details worth a reviewer's attention
apache/arrow-gofor parquet, notparquet-go. v0.30.1 mis-decodes cryo's LZ4_RAW pages and returns empty byte arrays with no error on 3 of 16 datasets. Onlyparquet/fileis imported, neverpqarrow, which would link gRPC.--hexrenders a zero-length byte string as the literal"0x", so the empty-to-NULL guard tests for both.gas_limit = 0for every row.Pre-existing defects fixed on the way
Manager.checkGapsobtained the limiter through a type switch, so gap detection silently did nothing for any processor it did not enumerate.BlockProcessorgainsGetLimiterand the switch is gone.MarkBlockCompletehard-codedtask_countback to0, destroying the count written at enqueue.QueueBlockManuallywrote its own ledger row on top of the processor's, with nocompleteflag and no task count, leaving manually-queued blocks looking permanently incomplete. It now delegates toProcessBlock, deleting a second type switch,enqueueSimpleBlockTaskandMarkBlockProcessed.Breaking changes
StateProvider.MarkBlockCompletetakes a task count.BlockProcessorgainsGetLimiter.execution.NodegainsRPCEndpoint(returns empty forEmbeddedNode).QueueResultlosestasks_created; the queue-block API responses lose thetasks_createdfield, since the manager no longer enqueues and has nothing to measure.transaction_countremains.Security
The RPC URL reaches cryo through
ETH_RPC_URLrather than argv, which anything sharing the PID namespace can read from/proc, and is redacted from captured stderr.Packaging
The image gains a static musl cryo built from a pinned commit with a pinned Rust version — both load-bearing, as an unpinned rustc fails to compile a dependency. The release build caches that stage per architecture, since it is emulated for arm64.
Verification
LOG0logs, and a pre-merge block with an uncle.UInt256limb order is round-tripped through the server rather than asserted against the code that produced it.Notes for deployment
The
debug_traceBlockByNumber-derived datasets differ slightly between execution clients — measured at block 23000000, reth and erigon disagree onbalance_reads(641 vs 619),four_byte_counts(278 vs 273) andnonce_diffs(139 vs 163). Keep a deployment on one client family, and note that any verification against existing data only holds against the client that produced it.🤖 Generated with Claude Code