From 8aaa1b79d0e70523a6b69003e8ba08f0bc91c923 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 3 Jun 2026 17:16:17 -0300 Subject: [PATCH 1/2] Add lean_block_proposal_* metrics for block-proposal attestation selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the five block-proposal observability metrics from leanSpec PR #753 into the ethlambda block builder: - lean_block_proposal_attestation_build_phase_seconds{phase} — phase-level timing for select_payloads, compact, and stf_simulate - lean_block_proposal_attestation_builds_total — completed selection runs - lean_block_proposal_child_payloads_consumed_total — greedily-picked proofs before compaction - lean_block_proposal_attestation_data_selected — distinct AttestationData in the block body - lean_block_proposal_aggregates_selected — proofs after compaction Instrument build_block to time the three phases and emit the counters and histograms after a successful build. ethlambda projects justification and finalization incrementally during selection and runs the STF once at the end, rather than re-running it inside a fixed-point loop as leanSpec does, so stf_simulate is a single observation per build; this divergence is documented on the phase-label constant. Add a unit test guarding against drift between the phase-label constant and the strings passed at the build_block call sites, and document all five metrics in docs/metrics.md. --- crates/blockchain/src/block_builder.rs | 23 +++- crates/blockchain/src/metrics.rs | 140 +++++++++++++++++++++++++ docs/metrics.md | 5 + 3 files changed, 167 insertions(+), 1 deletion(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index f02d5736..210666f9 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -9,7 +9,10 @@ //! without re-running the STF. The final STF runs once after selection to //! seal `state_root`. -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + time::Instant, +}; use ethlambda_crypto::aggregate_proofs; use ethlambda_state_transition::{ @@ -54,6 +57,7 @@ pub(crate) fn build_block( ) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { info!(slot, proposer_index, "Building block"); + let select_start = Instant::now(); let selected = select_attestations( head_state, slot, @@ -61,10 +65,17 @@ pub(crate) fn build_block( known_block_roots, aggregated_payloads, ); + metrics::observe_block_proposal_phase("select_payloads", select_start.elapsed()); + + // Each entry in `selected` is one greedily-picked child payload, counted + // before compaction merges proofs sharing the same AttestationData. + let child_payloads_consumed = selected.len(); // Compact: merge proofs sharing the same AttestationData via recursive // aggregation so each AttestationData appears at most once (leanSpec #510). + let compact_start = Instant::now(); let compacted = compact_attestations(selected, head_state)?; + metrics::observe_block_proposal_phase("compact", compact_start.elapsed()); let (aggregated_attestations, aggregated_signatures): (Vec<_>, Vec<_>) = compacted.into_iter().unzip(); @@ -80,10 +91,20 @@ pub(crate) fn build_block( body: BlockBody { attestations }, }; let mut post_state = head_state.clone(); + // ethlambda runs the STF once after selection (it projects justification + // incrementally instead of re-running the STF per loop round), so this is + // a single `stf_simulate` observation per build. + let stf_start = Instant::now(); process_slots(&mut post_state, slot)?; process_block(&mut post_state, &final_block)?; + metrics::observe_block_proposal_phase("stf_simulate", stf_start.elapsed()); final_block.state_root = post_state.hash_tree_root(); + metrics::inc_block_proposal_attestation_builds(); + metrics::inc_block_proposal_child_payloads_consumed(child_payloads_consumed as u64); + metrics::observe_block_proposal_attestation_data_selected(final_block.body.attestations.len()); + metrics::observe_block_proposal_aggregates_selected(aggregated_signatures.len()); + let post_checkpoints = PostBlockCheckpoints { justified: post_state.latest_justified, finalized: post_state.latest_finalized, diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 259afb16..35ea4b4f 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -23,6 +23,18 @@ pub const ATTESTATION_AGGREGATE_COVERAGE_SECTIONS: &[&str] = &[ /// locally-aggregated pre-merge (`timely`) payloads. pub const ATTESTATION_AGGREGATE_COVERAGE_DIFF_DIRECTIONS: &[&str] = &["block_only", "timely_only"]; +/// Phase labels for `lean_block_proposal_attestation_build_phase_seconds`. +/// +/// `select_payloads`: greedy per-`AttestationData` proof selection. +/// `compact`: recursive merge of proofs sharing the same `AttestationData`. +/// `stf_simulate`: the single candidate-block state transition that seals the +/// state root. Unlike leanSpec (which re-runs the STF inside a fixed-point +/// loop), ethlambda projects justification/finalization incrementally during +/// `select_payloads` and runs the STF exactly once, so its `stf_simulate` +/// timing is a single observation per build rather than one per loop round. +pub const BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES: &[&str] = + &["select_payloads", "compact", "stf_simulate"]; + // --- Gauges --- static LEAN_HEAD_SLOT: std::sync::LazyLock = std::sync::LazyLock::new(|| { @@ -420,6 +432,61 @@ static LEAN_BLOCK_BUILDING_FAILURES_TOTAL: std::sync::LazyLock = register_int_counter!("lean_block_building_failures_total", "Failed block builds").unwrap() }); +// --- Block Proposal Attestation Selection (build_block fixed-point loop) --- + +static LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram_vec!( + "lean_block_proposal_attestation_build_phase_seconds", + "Phase-level time in block-proposal attestation selection: select_payloads (greedy \ + per-AttestationData proof pick), compact (recursive merge of proofs per \ + AttestationData), stf_simulate (candidate block state transition).", + &["phase"], + vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0 + ] + ) + .unwrap() + }); + +static LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter!( + "lean_block_proposal_attestation_builds_total", + "Completed block-proposal attestation selection runs (one per proposal attempt)." + ) + .unwrap() + }); + +static LEAN_BLOCK_PROPOSAL_CHILD_PAYLOADS_CONSUMED_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter!( + "lean_block_proposal_child_payloads_consumed_total", + "Child aggregated payloads selected during greedy proof picking (before compaction)." + ) + .unwrap() + }); + +static LEAN_BLOCK_PROPOSAL_ATTESTATION_DATA_SELECTED: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_block_proposal_attestation_data_selected", + "Distinct AttestationData entries in the proposal block body", + vec![0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0] + ) + .unwrap() + }); + +static LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_block_proposal_aggregates_selected", + "Aggregated signature proofs in the proposal result after compaction", + vec![0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0] + ) + .unwrap() + }); + // --- Sync Status --- /// Node synchronization status. @@ -512,6 +579,12 @@ pub fn init() { std::sync::LazyLock::force(&LEAN_BLOCK_BUILDING_TIME_SECONDS); std::sync::LazyLock::force(&LEAN_BLOCK_BUILDING_SUCCESS_TOTAL); std::sync::LazyLock::force(&LEAN_BLOCK_BUILDING_FAILURES_TOTAL); + // Block proposal attestation selection + std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS); + std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL); + std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_CHILD_PAYLOADS_CONSUMED_TOTAL); + std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_ATTESTATION_DATA_SELECTED); + std::sync::LazyLock::force(&LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED); // Sync status std::sync::LazyLock::force(&LEAN_NODE_SYNC_STATUS); } @@ -739,6 +812,34 @@ pub fn inc_block_building_failures() { LEAN_BLOCK_BUILDING_FAILURES_TOTAL.inc(); } +/// Observe the duration of a block-proposal attestation-selection phase. +/// `phase` must be one of [`BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES`]. +pub fn observe_block_proposal_phase(phase: &str, elapsed: Duration) { + LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS + .with_label_values(&[phase]) + .observe(elapsed.as_secs_f64()); +} + +/// Increment the completed block-proposal attestation selection runs counter. +pub fn inc_block_proposal_attestation_builds() { + LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL.inc(); +} + +/// Increment the greedily-selected child payloads counter (before compaction). +pub fn inc_block_proposal_child_payloads_consumed(count: u64) { + LEAN_BLOCK_PROPOSAL_CHILD_PAYLOADS_CONSUMED_TOTAL.inc_by(count); +} + +/// Observe the number of distinct `AttestationData` entries in the proposal block body. +pub fn observe_block_proposal_attestation_data_selected(count: usize) { + LEAN_BLOCK_PROPOSAL_ATTESTATION_DATA_SELECTED.observe(count as f64); +} + +/// Observe the number of aggregated signature proofs in the proposal result after compaction. +pub fn observe_block_proposal_aggregates_selected(count: usize) { + LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED.observe(count as f64); +} + /// Set the node sync status. Sets the given status label to 1 and all others to 0. pub fn set_node_sync_status(status: SyncStatus) { let active = status.as_str(); @@ -748,3 +849,42 @@ pub fn set_node_sync_status(status: SyncStatus) { .set(i64::from(*label == active)); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The block-proposal phase metric registers and accepts every label in + /// `BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES`, and the companion + /// counters/histograms are callable. Guards against label drift between the + /// constant and the strings passed at the `build_block` call sites. + #[test] + fn block_proposal_attestation_build_metrics_are_usable() { + for phase in BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES { + observe_block_proposal_phase(phase, Duration::from_millis(1)); + assert_eq!( + LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS + .with_label_values(&[phase]) + .get_sample_count(), + 1, + "phase {phase} should have one observation" + ); + } + + inc_block_proposal_attestation_builds(); + inc_block_proposal_child_payloads_consumed(3); + observe_block_proposal_attestation_data_selected(4); + observe_block_proposal_aggregates_selected(4); + + assert_eq!(LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL.get(), 1); + assert_eq!(LEAN_BLOCK_PROPOSAL_CHILD_PAYLOADS_CONSUMED_TOTAL.get(), 3); + assert_eq!( + LEAN_BLOCK_PROPOSAL_ATTESTATION_DATA_SELECTED.get_sample_count(), + 1 + ); + assert_eq!( + LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED.get_sample_count(), + 1 + ); + } +} diff --git a/docs/metrics.md b/docs/metrics.md index f9314e32..3682765b 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -39,6 +39,11 @@ The exposed metrics follow [the leanMetrics specification](https://github.com/le | `lean_block_building_time_seconds` | Histogram | Time taken to build a block | On block production | | 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1 | ✅ | | `lean_block_building_success_total` | Counter | Successful block builds | On block production | | | ✅ | | `lean_block_building_failures_total` | Counter | Failed block builds (error building the block, signing the block root, or processing it locally) | On block production failure | | | ✅ | +| `lean_block_proposal_attestation_build_phase_seconds` | Histogram | Phase-level time in block-proposal attestation selection | On block production | phase=select_payloads,compact,stf_simulate | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 | ✅ | +| `lean_block_proposal_attestation_builds_total` | Counter | Completed block-proposal attestation selection runs (one per proposal attempt) | On block production | | | ✅ | +| `lean_block_proposal_child_payloads_consumed_total` | Counter | Child aggregated payloads selected during greedy proof picking (before compaction) | On block production | | | ✅ | +| `lean_block_proposal_attestation_data_selected` | Histogram | Distinct `AttestationData` entries in the proposal block body | On block production | | 0, 1, 2, 4, 8, 16, 32 | ✅ | +| `lean_block_proposal_aggregates_selected` | Histogram | Aggregated signature proofs in the proposal result after compaction | On block production | | 0, 1, 2, 4, 8, 16, 32, 64, 128 | ✅ | ## Fork-Choice Metrics From 6bb9f877973e2612be65f2e87a23ec90057f740f Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 3 Jun 2026 17:39:35 -0300 Subject: [PATCH 2/2] Count lean_block_proposal_attestation_builds_total per selected attestation Address review feedback on PR #414: - Move the inc_block_proposal_attestation_builds() call into the select attestations fixed-point loop so it counts each attestation selected (one per round that picks an AttestationData), rather than once per build_block. Update the counter help text and docs/metrics.md accordingly. - Remove the metrics unit test: its absolute-value assertions on process-global Prometheus statics race against the parallel build_block_* tests that now touch the same counters. The build_block_* tests already exercise the metric call paths. - Drop the redundant child_payloads_consumed comment. --- crates/blockchain/src/block_builder.rs | 4 +-- crates/blockchain/src/metrics.rs | 42 ++------------------------ docs/metrics.md | 2 +- 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 210666f9..abf92872 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -67,8 +67,6 @@ pub(crate) fn build_block( ); metrics::observe_block_proposal_phase("select_payloads", select_start.elapsed()); - // Each entry in `selected` is one greedily-picked child payload, counted - // before compaction merges proofs sharing the same AttestationData. let child_payloads_consumed = selected.len(); // Compact: merge proofs sharing the same AttestationData via recursive @@ -100,7 +98,6 @@ pub(crate) fn build_block( metrics::observe_block_proposal_phase("stf_simulate", stf_start.elapsed()); final_block.state_root = post_state.hash_tree_root(); - metrics::inc_block_proposal_attestation_builds(); metrics::inc_block_proposal_child_payloads_consumed(child_payloads_consumed as u64); metrics::observe_block_proposal_attestation_data_selected(final_block.body.attestations.len()); metrics::observe_block_proposal_aggregates_selected(aggregated_signatures.len()); @@ -177,6 +174,7 @@ fn select_attestations( let (att_data, proofs) = &chain.aggregated_payloads[&data_root]; processed_data_roots.insert(data_root); + metrics::inc_block_proposal_attestation_builds(); let before = selected.len(); extend_proofs_greedily(proofs, &mut selected, att_data); diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 35ea4b4f..8a8cbcf3 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -453,7 +453,8 @@ static LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILDS_TOTAL: std::sync::LazyLock