beacon-indexer¶
The beacon-indexer is a consensus layer indexer that fetches data from a beacon node REST API and stores it in ClickHouse or Parquet files. It follows an ELT (Extract, Load, Transform) pattern with fork-aware parsing and automatic network detection.
Purpose¶
beacon-indexer handles the acquisition of all consensus layer data:
- Validator sets, balances, and status changes
- Block proposals and attestations
- Sync committee participation
- Rewards and penalties
- Deposits, withdrawals, and voluntary exits
Architecture¶
The indexer operates in two distinct phases:
Phase 1: Load (Extract + Load)
Beacon Node REST API --> Raw JSON --> ClickHouse raw_* tables
Phase 2: Transform
raw_* tables --> Fork-aware parsing --> Structured tables
This ELT separation means raw data is preserved exactly as received from the beacon API. Transformation logic can be updated and re-run independently without re-fetching data from the beacon node.
Storage Backends¶
beacon-indexer supports two storage backends, selected via the STORAGE_BACKEND environment variable:
| Backend | Best For | Setup Required |
|---|---|---|
| ClickHouse | Production systems, real-time analytics, complex queries | Database migration |
| Parquet | Data analysis, ETL pipelines, archival storage, development | None |
The ClickHouse backend provides fork-aware chunk-based processing with concurrent access and monitoring. The Parquet backend produces portable columnar files compatible with pandas, DuckDB, and Spark.
Fork Awareness¶
The beacon chain has undergone multiple protocol upgrades (forks), each introducing new data fields and structures. beacon-indexer automatically detects the fork version for each slot and applies the appropriate parser.
| Fork | Key Changes for Indexing |
|---|---|
| Phase 0 | Base beacon chain: blocks, attestations, deposits, voluntary exits |
| Altair | Sync committee data, participation metrics |
| Bellatrix | Execution payloads (The Merge) |
| Capella | Validator withdrawals, BLS-to-execution changes |
| Deneb | Blob KZG commitments, blob sidecar data |
| Electra | Execution layer requests (deposit, withdrawal, consolidation) |
Supported Networks¶
| Network | Slot Time | Slots per Epoch | Detection |
|---|---|---|---|
| Mainnet | 12 seconds | 32 | Automatic |
| Gnosis Chain | 5 seconds | 16 | Automatic |
| Holesky | 12 seconds | 32 | Automatic |
| Sepolia | 12 seconds | 32 | Automatic |
Network parameters are auto-detected from the beacon chain genesis data. No manual configuration is required.
Operating and recovering¶
How it runs¶
Three workloads, one image, all writing consensus:
| Workload | Runs | Loaders | Beacon credentials |
|---|---|---|---|
| realtime Deployment | load realtime | blocks, rewards, data_column_sidecars | yes |
| transform Deployment | transform run --continuous | derives the transformed tables | no |
| validators CronJob, 02:00 UTC | load backfill | validators | yes |
State is two tables and nothing in the pod: consensus.load_state_chunks (per loader: pending | claimed | completed | failed) and consensus.transformer_progress. Always query both with FINAL — they keep every status transition, so without it you read history, not state.
The 02:00 Job takes ~4h11m; incomplete until ~06:15 is normal. Single writer per loader, Recreate, one replica.
Health¶
The Deployments carry no liveness or readiness probes by design, so a Running pod is not proof of progress. Ground truth is the raw frontier:
SELECT 'raw_blocks' AS t, max(slot) AS max_slot, max(slot_timestamp) AS latest FROM consensus.raw_blocks
UNION ALL SELECT 'raw_rewards', max(slot), max(slot_timestamp) FROM consensus.raw_rewards
UNION ALL SELECT 'raw_data_column_sidecars', max(slot), max(slot_timestamp) FROM consensus.raw_data_column_sidecars
UNION ALL SELECT 'raw_validators', max(slot), max(slot_timestamp) FROM consensus.raw_validators;
raw_blocks should advance ~100 slots every ~8–10 min and sit ~700–800 slots behind head (REALTIME_SLOT_DELAY=700, about 67 minutes at 5 s slots).
Realtime does not self-heal an outage
On restart it sets its resume point to max(slot) across all four raw tables, then rounds up to the next 100-slot boundary. Because the 02:00 validators cron keeps writing near-head snapshots into raw_validators while realtime is down, the resume point is dragged forward to near head and the entire outage window is skipped with no error. This is the single most common way a beacon gap appears while everything looks green. After any realtime outage longer than a few minutes, run the gap check over [frontier_before_outage, current_head] before declaring recovery.
Detecting a gap¶
Every 100-slot chunk on the grid with no completed row, per loader:
SELECT loader, expected_start, expected_start + 99 AS expected_end
FROM (
SELECT arrayJoin(['blocks','rewards','data_column_sidecars']) AS loader,
⟨LO⟩ + number * 100 AS expected_start
FROM numbers(⟨window_width/100⟩)
) AS e
LEFT ANTI JOIN (
SELECT loader_name AS loader, start_slot AS expected_start
FROM consensus.load_state_chunks FINAL
WHERE status = 'completed' AND start_slot >= ⟨LO⟩ AND start_slot < ⟨HI⟩
) AS c USING (loader, expected_start)
ORDER BY loader, expected_start;
⟨LO⟩ must be a multiple of 100. The last chunk of any backfill is truncated to end_slot - 1, so a single non-grid chunk at a previous backfill's boundary can show as a false positive — check it against load_state_chunks before repairing.
Chunks that exist but are not completed — the status tells you which repair applies:
SELECT loader_name, status, count() AS chunks, min(start_slot), max(end_slot)
FROM consensus.load_state_chunks FINAL
WHERE start_slot >= ⟨LO⟩ AND end_slot < ⟨HI⟩
GROUP BY loader_name, status ORDER BY loader_name, status;
Validators are one snapshot at the last slot of each UTC day, so a missing date is a gap:
SELECT toDate(slot_timestamp) AS d, count() AS snapshots
FROM consensus.raw_validators FINAL
WHERE slot_timestamp >= now() - INTERVAL 45 DAY GROUP BY d ORDER BY d;
maintain check --start-slot ⟨LO⟩ --end-slot ⟨HI⟩ --detailed finds failed and completed-but-untransformed chunks. It does not detect missing chunk rows — only the SQL above does.
Repairing¶
| Chunk state | Repair |
|---|---|
failed | maintain fix --start-slot ⟨LO⟩ --end-slot ⟨HI⟩ (--dry-run first) |
missing / pending / claimed | load backfill --start-slot ⟨LO⟩ --end-slot ⟨HI⟩ — maintain fix only ever selects failed |
| validators | trigger the cron; it re-scans 0→head and skips what is complete |
Run these as one-shot jobs cloned from the validators CronJob (how). Both slot arguments are required on every verb. --end-slot is exclusive, and chunk selection is start_slot >= LO AND end_slot <= HI, so a partially overlapping chunk is silently skipped. Keep both on 100-slot boundaries, strictly behind the live frontier.
A --loaders value not in the pod's ENABLED_LOADERS widens the repair to every loader
It does not narrow anything — it removes the loader filter entirely. The validators cron ships ENABLED_LOADERS=validators, so --loaders blocks from a clone of it repairs everything in the range. Always override ENABLED_LOADERS to a superset of the loaders you name.
Corrupt range¶
Raw tables are ReplacingMergeTree ORDER BY (slot, payload_hash), so two different payloads for one slot coexist — they are not deduped against each other. That is the bad-RPC signature:
SELECT slot, count() AS payload_variants FROM consensus.raw_blocks
WHERE slot BETWEEN ⟨LO⟩ AND ⟨HI⟩ GROUP BY slot HAVING payload_variants > 1 ORDER BY slot LIMIT 50;
Repair is maintain fix … --force --loaders blocks, always --dry-run first.
--force deletes first
It deletes the raw rows and every mapped transformed table for the range before reloading — for blocks that is 14 tables. If the beacon node is unreachable you are left with a hole, not the old data. Never widen the range "to be safe".
Never run a --force fix from the transform workload
It has no beacon credentials, so BEACON_NODE_URL falls back to localhost — it deletes first, then every reload fails, leaving the range empty and marked failed. maintain check and maintain reset are ClickHouse-only and safe there.
Verifying a repair: a duplicate scan right after a reload gives false positives, because the raw tables dedupe only at merge time. Judge by chunk status instead:
SELECT loader_name, status, count() FROM consensus.load_state_chunks FINAL
WHERE start_slot >= ⟨LO⟩ AND end_slot < ⟨HI⟩ AND loader_name = 'blocks'
GROUP BY loader_name, status; -- want: all 'completed'
Re-deriving transformed tables without refetching¶
Do not use transform reprocess
It is wired into the CLI but the implementation is a stub that logs "not implemented yet" and returns. It looks successful and changes nothing.
The real procedure is two statements; the third step is nothing, because the running transformer picks the chunks up on its next 10-second poll:
-- 1. delete the wrong rows from ONLY the affected transformed tables, one statement each
ALTER TABLE consensus.blocks DELETE WHERE slot BETWEEN ⟨LO⟩ AND ⟨HI⟩;
-- 2. un-complete the transform bookkeeping for exactly those chunks
INSERT INTO consensus.transformer_progress
(raw_table_name, start_slot, end_slot, status, processed_count, failed_count, error_message, processed_at)
SELECT 'raw_blocks', start_slot, end_slot, 'failed', 0, 0, 'manual scoped reprocess', now()
FROM consensus.load_state_chunks FINAL
WHERE loader_name = 'blocks' AND status = 'completed'
AND start_slot >= ⟨LO⟩ AND end_slot <= ⟨HI⟩;
TRANSFORM_CHUNKS_PER_BATCH is pinned to 1 — that is memory protection, not a tuning oversight, so a large range re-derives slowly. Each validators chunk parses a full ~400k-entry beacon state. Never TRUNCATE transformer_progress to force a re-derive; that is a full refresh of all history.
Transform lagging behind raw¶
SELECT
(SELECT max(end_slot) FROM consensus.load_state_chunks FINAL WHERE loader_name='blocks' AND status='completed') AS raw_frontier,
(SELECT max(end_slot) FROM consensus.transformer_progress FINAL WHERE raw_table_name='raw_blocks' AND status='completed') AS transform_frontier;
Swap the loader to find which one is lagging — the Prometheus alert takes max() across all table labels and hides exactly this.
OOMKilled on the transform workload is the classic stall. Restarting self-heals: on startup it resets any processing row older than 30 minutes back to failed, making those chunks eligible again. Check nobody raised TRANSFORM_CHUNKS_PER_BATCH above 1 before restarting.
Restart — the mandatory reset¶
An unclean kill leaves chunks in claimed, and nothing reaps them. maintain fix cannot see them (it selects failed). After any unclean kill:
# both slot arguments are REQUIRED
python -m src.main maintain reset --start-slot 0 --end-slot 999999999 --status claimed
That is only half the fix
reset leaves the chunks pending, and no running workload ever claims a pending chunk. Follow it with a load backfill over the same range.
Then dbt¶
consensus is a dbt source. Check each intermediate's strategy with context.py first, then follow dbt reprocess.
Expected noise
Waiting for complete chunkmore or less continuously — it only writes whole 100-slot chunks.transformer_progressrows withstatus='completed', processed_count=0, error_message='No data'— a genuinely empty raw slot range.load_state_chunkshaving far more rows withoutFINALthan with it — it keeps every transition.- The 02:00 Job incomplete for hours. Normal until ~06:15.
Internal runbook
runbooks/21-beacon-indexer.md — private repository; carries the cluster-specific commands for this page.
Redeploying¶
On an image roll realtime and transform restart. Apply outside 02:00-06:15 UTC, in the idle gap between chunks. The decisive proof is no chunk hole across the restart and the transform caught up. Procedure: Redeploying a service.
Configuration¶
Required Settings¶
| Variable | Description |
|---|---|
BEACON_NODE_URL | Beacon node REST API endpoint |
STORAGE_BACKEND | clickhouse in production; parquet is a local/dev backend |
ClickHouse Settings (when using ClickHouse backend)¶
| Variable | Default | Description |
|---|---|---|
CLICKHOUSE_HOST | -- | ClickHouse server hostname |
CLICKHOUSE_PASSWORD | -- | Authentication password |
CLICKHOUSE_PORT | 8443 | HTTP port |
CLICKHOUSE_USER | default | Username |
CLICKHOUSE_DATABASE | consensus | Target database |
CLICKHOUSE_SECURE | true | Use HTTPS |
Parquet Settings (when using Parquet backend)¶
| Variable | Default | Description |
|---|---|---|
PARQUET_OUTPUT_DIR | ./parquet_data | Output directory for Parquet files |
Processing Settings¶
| Variable | Description |
|---|---|
ENABLED_LOADERS | Comma-separated loaders (blocks, validators, rewards, data_column_sidecars). Also the filter --loaders is checked against — see the repair warning above |
CHUNK_SIZE | Number of slots per processing chunk |
Data Flow¶
- Load phase -- The backfill or realtime command fetches raw beacon API responses and stores them in
raw_*tables (ClickHouse) or raw Parquet files. - Transform phase -- The transform command reads raw data, detects the fork version for each slot, and writes parsed data into structured tables (blocks, validators, attestations, etc.).
- State tracking -- Chunk-based state tracking records which slot ranges have been loaded and transformed, enabling resumability.
ClickHouse Table Schemas¶
All tables are stored in the consensus database.
Table: consensus.blocks
Engine: ReplacingMergeTree(insert_version) ORDER BY: (slot)
| Column | Type | Notes |
|---|---|---|
slot | UInt64 | Beacon chain slot number |
proposer_index | UInt64 | Validator that proposed the block |
parent_root | String | Parent block root hash |
state_root | String | State root hash |
signature | String | Block signature |
version | String | Fork version (phase0, altair, etc.) |
graffiti | String | Proposer graffiti field |
eth1_deposit_count | UInt64 | Cumulative ETH1 deposit count |
sync_aggregate_participation | UInt64 | Sync committee participation bits |
withdrawals_count | UInt32 | Number of withdrawals in block |
blob_kzg_commitments_count | UInt32 | Number of blob KZG commitments |
slot_timestamp | DateTime64 | Materialized from slot and genesis time |
insert_version | UInt64 | Materialized; deduplication version |
Table: consensus.attestations
Engine: ReplacingMergeTree(insert_version) ORDER BY: (slot, attestation_index, committee_index)
| Column | Type | Notes |
|---|---|---|
slot | UInt64 | Slot the attestation was included in |
attestation_index | UInt64 | Position within the block |
aggregation_bits | String | Bitfield of participating validators |
signature | String | Aggregated BLS signature |
attestation_slot | UInt64 | Slot being attested to |
committee_index | UInt64 | Committee index |
beacon_block_root | String | Attested beacon block root |
source_epoch | UInt64 | Justified checkpoint epoch |
target_epoch | UInt64 | Target checkpoint epoch |
source_root | String | Justified checkpoint root |
target_root | String | Target checkpoint root |
slot_timestamp | DateTime64 | Materialized from slot |
Table: consensus.validators
Engine: ReplacingMergeTree(insert_version) ORDER BY: (slot, validator_index)
| Column | Type | Notes |
|---|---|---|
slot | UInt64 | Snapshot slot |
validator_index | UInt32 | Unique validator index |
balance | UInt64 | Current balance in Gwei |
status | String | Validator lifecycle status |
pubkey | String | BLS public key |
effective_balance | UInt64 | Effective balance in Gwei |
slashed | UInt8 | 1 = slashed, 0 = not slashed |
activation_epoch | UInt64 | Epoch when validator activated |
exit_epoch | UInt64 | Epoch when validator exited |
withdrawable_epoch | UInt64 | Epoch when balance is withdrawable |
slot_timestamp | DateTime64 | Materialized from slot |
Table: consensus.rewards
Engine: ReplacingMergeTree(insert_version) ORDER BY: (slot, proposer_index)
| Column | Type | Notes |
|---|---|---|
slot | UInt64 | Slot number |
proposer_index | UInt64 | Proposing validator index |
total | UInt64 | Total reward amount |
attestations | UInt64 | Reward from attestation inclusion |
sync_aggregate | UInt64 | Reward from sync committee |
proposer_slashings | UInt64 | Reward from proposer slashings |
attester_slashings | UInt64 | Reward from attester slashings |
Table: consensus.raw_blocks / consensus.raw_validators / consensus.raw_rewards
Engine: ReplacingMergeTree ORDER BY: (slot, payload_hash)
| Column | Type | Notes |
|---|---|---|
slot | UInt64 | Beacon chain slot number |
payload | String | Full JSON response from beacon API |
payload_hash | String | Hash of the payload for deduplication |
retrieved_at | DateTime | Timestamp when data was fetched |
Table: consensus.specs
Engine: ReplacingMergeTree(updated_at) ORDER BY: (parameter_name)
| Column | Type | Notes |
|---|---|---|
parameter_name | String | Beacon chain spec parameter name |
parameter_value | String | Parameter value |
updated_at | DateTime64 | Last update timestamp |
Table: consensus.genesis
Engine: ReplacingMergeTree() ORDER BY: (genesis_time)
| Column | Type | Notes |
|---|---|---|
genesis_time | DateTime64 | Chain genesis timestamp |
genesis_validators_root | String | Merkle root of genesis validators |
genesis_fork_version | String | Fork version at genesis |