Skip to content

feat(cryo): collect the 16 canonical_execution datasets in-process - #96

Merged
Savid merged 5 commits into
masterfrom
feat/cryo-processor
Jul 27, 2026
Merged

feat(cryo): collect the 16 canonical_execution datasets in-process#96
Savid merged 5 commits into
masterfrom
feat/cryo-processor

Conversation

@Savid

@Savid Savid commented Jul 27, 2026

Copy link
Copy Markdown
Member

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 — 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_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, insert is a per-dataset rowbuffer flushed on rows or interval.

Data-integrity fixes over the pipeline this replaces

Block-reward traces 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 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.
Absent vs zero Genuinely-absent values land as NULL rather than a type default, so "absent" and "zero" stay distinguishable for gas and fee columns.
Silently-empty blocks cryo answers a block it cannot serve with an empty parquet and exit 0 — indistinguishable from an empty block. Every group now collects blocks as 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_limit is derived from the datasets rather than configured, so it cannot be omitted and silently zeroed.

Correctness details worth a reviewer's attention

  • apache/arrow-go for parquet, not 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. 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 runs in both directions — a column the mapping never writes is silently server-defaulted, which is how staging came to hold gas_limit = 0 for every row.

Pre-existing defects fixed 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 count written at enqueue.
  • QueueBlockManually wrote its own ledger row on top of the processor's, with no complete flag and no task count, leaving manually-queued blocks looking permanently incomplete. It now delegates to ProcessBlock, deleting a second type switch, enqueueSimpleBlockTask and MarkBlockProcessed.

Breaking changes

  • StateProvider.MarkBlockComplete takes a task count.
  • BlockProcessor gains GetLimiter.
  • execution.Node gains RPCEndpoint (returns empty for EmbeddedNode).
  • QueueResult loses tasks_created; the queue-block API responses lose the tasks_created field, since the manager no longer enqueues and has nothing to measure. transaction_count remains.

Security

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.

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

  • 142 tests, including golden parquet fixtures for all 16 datasets at three mainnet blocks chosen for coverage: the general case, one with erc721 transfers and anonymous LOG0 logs, and a pre-merge block with an uncle.
  • Integration tests insert every dataset into a real ClickHouse and read back exact counts; UInt256 limb order is round-tripped through the server rather than asserted against the code that produced it.
  • Run end to end against mainnet: forwards, backwards, and gap detection (hole punched in the ledger, detected and refilled).

Notes for deployment

The debug_traceBlockByNumber-derived datasets differ slightly between execution clients — measured at block 23000000, reth and erigon disagree on balance_reads (641 vs 619), four_byte_counts (278 vs 273) and nonce_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

Savid and others added 5 commits July 27, 2026 14:57
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>
@Savid
Savid merged commit c504f6e into master Jul 27, 2026
4 checks passed
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