Skip to content

feat: write per-block telemetry rows to ClickHouse in Silver's block_events layout - #765

Open
MegaRedHand wants to merge 21 commits into
mainfrom
feat/clickhouse-block-events
Open

MegaRedHand wants to merge 21 commits into
mainfrom
feat/clickhouse-block-events

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Opt-in per-block telemetry: with --telemetry.clickhouse-url (or ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL), a node writes one ClickHouse row per point in each block's life, and per data column arrival, in the block_events table layout Silver uses. Both clients' timings can then be compared from one table and one set of queries, block by block, which the lean_block_import_phase_seconds histogram cannot do (it aggregates the blocks away).

Off by default: without the flag, emit builds nothing.

Piece Where
Row encoding, batching HTTP writer, global install/emit sink new crate crates/common/telemetry
Per-block rows from the existing import report; arrival rows and beacon attestable after the per-arrival head recomputation crates/blockchain (import_timing.rs, lib.rs)
column_recv / column_validated rows crates/net/p2p (beacon/column_checks.rs and callers)
Flags, install before P2P::spawn bin/ethlambda
Docs docs/telemetry.md (new), docs/cli.md, docs/metrics.md, CLAUDE.md

Design choices

  • Silver's table, verbatim. Same DDL, created on first use. Silver's stage names where the meaning matches (received, da_available, el_sent/el_verdict, stf_done, attestable), <section>_start/_end for ethlambda's other import sections. source and verdict keep ethlambda's own values.
  • In process, never blocking. Emit sites try_send into a bounded channel; a tokio task batches and POSTs JSONEachRow to ClickHouse's HTTP interface with reqwest (already a dependency). One insert per tick (1 s) unless a full request is waiting, since ClickHouse writes a part per insert.
  • Bounded failure. A failed request is retried on the next tick from a capped backlog (100k rows, oldest dropped first). A batch ClickHouse rejects as malformed (HTTP 400) is dropped and counted rather than retried forever. Delivery is at least once.
  • attestable means what Silver's does: the new head including the block is computed. Beacon recomputes the head once per arrival, after the cascade, so every block the cascade imported gets that time (only if the head computation succeeded).
  • el_sent/el_verdict carry their own instants (EngineAnswer: sent, answered, verdict), not the import's engine section, which is the time the import waited. The two only coincide while nothing else runs during the request; an import that overlaps the state transition with engine_newPayload keeps correct rows.
  • da_available comes from an exhaustive match on the availability evidence, so a new kind of evidence is a compile error here rather than a silent "nothing required".
  • Credentials stay out of output: the URL is not logged, its Debug shows scheme, host and port only, a rejected value is never echoed in the CLI error, and request errors drop the URL. A startup warning fires when the URL sends credentials over plain http to a host that is not loopback.

Facts checked against a real ClickHouse 25.8 over HTTP: a malformed or wrongly typed row is a 400, an unknown table a 404, bad credentials a 403; an integer of nanoseconds parses into DateTime64(9); a DateTime above u32::MAX seconds is silently wrapped, so the encoder writes NULL there.

Known gaps (also in docs/telemetry.md)

  • A beacon node's own proposals (from a validator client) enter as gossip with near-zero arrival latency; filter them by their missing decode_end row. A dedicated source is a possible follow-up.
  • Gloas blocks have no el_* rows (the payload goes to the execution client with the envelope).
  • Rows still pending when the node exits are lost.

Pilot (mainnet, ethlambda-4)

A mainnet follower on nightly (this PR plus the open perf stack) has written to a local ClickHouse 25.8 since 2026-11-03 18:38Z, with --telemetry.client-name beacon-eth4. The node's own DDL created the table; inserts run at one per second with 0 failures and 0 dropped rows.

Check Result
Stages per block every imported block has received, stf_done, el_verdict and attestable
Duplicates none per (block, stage, column)
Columns every column_validated has its column_recv (8 custody columns per block with blobs)
Same blocks as Silver (its own rows, eth-5, 30 min) 98 shared roots, 0 slot and 0 slot_start_date_time mismatches
Typical block about 60 ms from received to attestable

Merging into nightly surfaced the two robustness issues fixed in f2eb961: nightly's #755 overlaps engine_newPayload with the state transition (the el_* rows had been read from the wait section), and #760 adds a ColumnsVerified evidence variant that the earlier matches! counted as nothing required.

Test plan

  • make fmt, make lint
  • cargo test -p ethlambda-telemetry --profile release-fast (19, including a mock ClickHouse: batching, retry, cap, refusal, basic auth, slow requests)
  • cargo test -p ethlambda-blockchain --lib --profile release-fast (310)
  • cargo test -p ethlambda-p2p --lib --profile release-fast (365)
  • cargo test -p ethlambda --bins --profile release-fast (272)
  • Pilot: a mainnet follower writing to a real ClickHouse, rows checked against Silver's for the same blocks (above)

ClickHouse-side per-block analysis needs wall-clock rows keyed by slot and
root; the import report only has monotonic instants. The anchor converts
them at encoding time, and the slot offset is left NULL outside the live
window so catch-up imports do not read as late ones.
One insert per tick unless a full request is waiting, since ClickHouse
writes a part per insert; rows read off the channel while a request is
in flight; a bounded backlog that keeps failed rows for the next tick.
A slow or unreachable ClickHouse costs bounded memory and never blocks
an emitter.
Emit sites live in two actors and a blocking task; a global keeps their
spawn signatures unchanged, and building rows lazily keeps the disabled
path free.
The first insert must succeed so the flag is already cleared when the failing one runs.
…t forever

A 400 on an insert means the rows themselves are malformed, so requeueing
them blocked every later insert until they aged out of the backlog. Such a
batch is now counted as dropped and logged with ClickHouse's message; every
other failure still retries.

Also: a slot start past what DateTime holds is NULL (ClickHouse wraps it
silently); TelemetryConfig no longer derives Debug (it would print URL
credentials); failure messages carry the error's source chain and a bounded
response body; the schema statement is best effort so an insert-only user
still inserts; pending rows are counted as dropped when the channel closes;
a client build failure disables telemetry instead of panicking. Tests cover
the rejected batch, immediate follow-up sends, basic auth, preserved query
parameters, a failing schema statement, and a no-op emit when not installed.
…ptions

A table whose column types differ from our rows makes every batch a 400,
which would log a warning every second; only the first of a streak warns.
The module doc, the schema-flag comment and the failure counter's help text
now describe the refusal path, and a schema error identical to the insert's
is no longer repeated in the message.
The import report already holds every boundary a block crosses; this
turns its final pass into rows named like Silver's stages where the
meaning matches, so the two clients' timelines can be compared from one
table.
Silver's attestable means the new head is published, and beacon
recomputes the head once per arrival, after the cascade, so every block
the cascade imported becomes attestable then. Arrival rows are filed
under the arriving block only when it was imported here: a re-delivery
of a stored block reports Imported from an early return that ran
nothing.
…lumns

The availability check is timed for every block inside the DA window,
including gloas blocks (their data gates the envelope) and fulu blocks
with no commitments, so naming its end da_available claimed data was
made available for blocks with nothing to make available. Record
whether the check required columns and name the end da_check_end
otherwise.

Also pin that the arrival is a cascade's first block, share one source
label between blocks and arrivals, and correct two comments that
overstated what the final pass carries and what a test covers.
…ag through the actor

The arrival record was only tested with a hand-built cascade, which did
not pin that it is reset once per on_block rather than per block. Drive
it through on_block with a stored ancestor queued behind the arriving
block, and pin where da_columns_required is set (only for a check that
found columns to require). Reword a comment that said "waited on
columns" for a flag that is also true when the columns were already
there.
Per-column arrival and validation times are what separate a slow data
availability wait from a slow import; Silver's dashboard reads the
first column_recv against da_available. Written after the check, as
Silver records only validated columns, so the duplicates overlapping
range answers deliver write nothing.
…he re-check

A fulu sidecar's block root is a header hash, so the rows now compute
it only when a row is built, and once. Whether a sidecar arrived is a
property of ColumnArrival rather than an Option beside a source label,
and the docs say what the rows actually dedup: only an already stored
re-delivery writes none, so a reader takes the earliest row per column
and stage.
A column's row fields were named in two places and a kept fulu column
hashed its header twice. One private builder now names them, and the
arrival row is derived from the validation row.
Both sub-commands take it. The default client name differs from the
bare hostname because another client on the same host (Silver on eth-5)
already writes the table under it.
reqwest::Url's Debug prints the password and the options derive Debug;
clap also echoes a rejected value. A newtype with a host-only Debug and
a parser that builds its own error close both, and refuse non-HTTP
schemes at startup. The name defaults move into a pure function so the
hostname is read only when telemetry is on and needs it.
…n its error

The scheme check refused the old "unparseable" case, so the parse branch
was never exercised. The error now also names the environment variable,
since someone who set only that has no flag to look for.
…eeded

update_head_from_fork_choice marks head_end on its error arm too, so
every block a cascade imported was reported attestable at a head
computation that failed and moved nothing. Carry whether the
computation succeeded and write the attestable rows only then; the
get_head rows stay either way, since the time was spent.
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

⚠️ The review job did not complete (result: failure). See the job log


Automated review by OpenAI Codex

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 765, ClickHouse block telemetry

I read the full diff, writer.rs, event.rs, the lib.rs hooks and the CLI and credential handling. I did not build the code or run any tests. I also did not read the p2p column hooks (column_checks.rs, verdict.rs, handler.rs, handlers.rs) or the import_timing.rs cascade code beyond the first 600 diff lines, so those are unreviewed. I found no blocking correctness or security problems.

What looks good

  • Off by default. emit takes a closure and returns before building anything when the sink isn't installed, so an unconfigured node pays nothing.
  • Never blocks the actors. Emit sites use try_send into a bounded channel and count drops. The writer keeps at most one request in flight, and its backlog is capped at 100k rows, oldest dropped first.
  • Failure handling.
    • An HTTP 400 on insert drops the batch instead of retrying forever.
    • Failed batches go back to the front of the buffer in order.
    • A panicked insert task is counted as dropped rows.
    • needs_schema re-runs the idempotent DDL after any non-400 failure.
  • Credentials.
    • ClickHouseUrl's Debug omits the userinfo and query.
    • TypedValueParser keeps the raw value out of clap's error text.
    • reqwest::Error::without_url strips the URL from request errors.
    • hide_env_values is set, and the tests cover these paths.
  • Cascade bookkeeping. cascade_telemetry is reset in on_block, the only caller of run_import_cascade. imported is appended only for blocks with a source label, so it can't grow without bound.
  • imported_here(). Requiring stf_start.is_some() stops a re-delivered stored block from producing a second set of rows.
  • Timestamp encoding. The DateTime overflow is handled (NULL above u32::MAX seconds), and Instant is converted to wall clock against an anchor with saturating arithmetic.

Minor points, none blocking

  1. writer.rs, run: rows are timestamped by Anchor::now() when the writer task dequeues them, not when they are emitted. Each Instant is converted relative to that anchor, so timestamps stay correct. If the writer task is starved or the channel lags, the anchor's wall-clock reading is taken later, which is only a small clock-sampling skew. This is fine, but the doc comment on Anchor could say the conversion assumes the wall clock does not step between the instant and the anchor.
  2. Credentials go as basic auth. With an http:// URL, a password crosses the network in cleartext. Consider a one-time startup warn! when the URL has userinfo and the scheme is http and the host isn't loopback.
  3. Delivery is at least once. A request that times out client-side (10 s) after ClickHouse committed it gets retried and duplicates its rows. This is documented. A one-line mention that time-out ⇒ duplicate is possible would help people writing queries. Duplicate-tolerant queries (for example DISTINCT on (stage, block_root, event_date_time)) are the practical answer.
  4. telemetry::install ignores a second call with a warn!, but the SINK static makes the crate hard to test end to end. The "no test calls install" comment is a fragile invariant. A #[cfg(test)] guard would be sturdier.
  5. hostname() uses a raw libc::gethostname call. The SAFETY comment is accurate, but a rustix or hostname crate call would avoid new unsafe. Low priority, since libc is already a dependency.
  6. As the PR states, beacon blocks proposed locally enter as gossip with near-zero latency. The stated workaround is filtering on a missing decode_end row. A dedicated source label would make that cleaner, so I'd file the follow-up.

The unchecked test-plan item (pilot against a real ClickHouse and comparison with Silver's rows) is the main remaining validation. The writer is fully tested against a mock server, so I'd treat that pilot as a post-merge check.

Verdict: approve, with the optional cleartext-credentials warning in point 2 as the most useful follow-up.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR for the telemetry system integration. Let me analyze the code across correctness, security, performance, and Rust best practices.

Security

Credential Exposure in Debug — bin/ethlambda/src/cli.rs:22-31

The ClickHouseUrl Debug implementation properly redacts credentials, but there's a subtle issue: reqwest::Url::host_str() returns Option<&str> and port_or_known_default() returns Option<u16>. The Debug derive for Option will print Some("host") and Some(8123), which leaks the Some wrapper. This is minor but inconsistent with the clean redaction intent.

// Current:
.field("host", &self.0.host_str())        // prints Some("host")
.field("port", &self.0.port_or_known_default())  // prints Some(8123)
// Better:
.field("host", &self.0.host_str().unwrap_or("none"))
.field("port", &self.0.port_or_known_default().unwrap_or(0))

URL Password in Display or Error Paths — bin/ethlambda/src/cli.rs:56-68

The reject closure in ClickHouseUrlParser correctly avoids echoing the raw value. However, cmd.clone().error(...) clones the entire Command, which could be expensive. More importantly, the error message format string uses {flag} which will call Arg::to_string() — verify this doesn't leak the argument's value.

Actually, arg.to_string() on an Arg returns the flag name (e.g., --telemetry.clickhouse-url), not the value. This is correct.

hostname() Buffer Safety — bin/ethlambda/src/main.rs:1000-1014

The unsafe block uses libc::gethostname with a 256-byte buffer. The safety comment is accurate but incomplete: it doesn't mention that gethostname may truncate without NUL on Linux if the name exceeds 255 bytes. The position fallback handles this, but the SAFETY comment should mention truncation behavior explicitly.

Critical: String::from_utf8_lossy can produce replacement characters for invalid UTF-8 sequences from the hostname. This is fine for telemetry, but the comment should note this.

Correctness & Consensus Safety

imported_here() Logic — crates/blockchain/src/import_timing.rs:470-473

pub fn imported_here(&self) -> bool {
    self.outcome == "imported" && self.timings.stf_start.is_some()
}

This relies on stf_start being Some only when the state transition actually ran. The test at line 1557 verifies the re-delivery case. However, this is fragile: if stf_start is set but the STF panics or returns early, outcome might still be "imported" from a prior path. The comment says "Not merely outcome == 'imported'" but doesn't explain why stf_start is the reliable discriminator.

Suggestion: Add a dedicated boolean stf_ran rather than overloading stf_start. The current approach works but couples timing collection to semantic state.

da_columns_required Flag — crates/blockchain/src/lib.rs:1787

timings.da_columns_required =
    matches!(evidence, Some(fork_choice::DataAvailability::Columns(_)));

This correctly distinguishes "check ran and found columns" from "check ran and found nothing." But note: DataAvailability::Columns(_) means columns were required, not that they were available. The naming is slightly confusing — da_columns_required is accurate, but the stage name da_available (line 503) claims availability. The check passes (returns Some) only when availability is satisfied, so this is correct in practice.

Cascade Telemetry arrival Race — crates/blockchain/src/lib.rs:2710

self.cascade_telemetry
    .arrival
    .get_or_insert((slot, block_root));

get_or_insert only sets on first call per cascade. The test at line 5554 verifies the first block is preserved. However, process_or_pend_block is called recursively via run_import_cascade — the arrival is reset in on_block at line 2096, but what about process_and_publish_block (local block production)?

Looking at log_import_report (line 2230), local blocks have source: None and thus source_label() returns None, so they're filtered from telemetry. The comment at line 2252 confirms this. Good.

head_ok and attestable Timing — crates/blockchain/src/import_timing.rs:815-825

if cascade.head_ok
    && let Some(head_end) = cascade.head_end
{
    events.extend(
        self.imported
            .iter()
            .map(|&(slot, root, source)| row("attestable", head_end, slot, root, source)),
    );
}

All imported blocks get the same head_end instant. This is correct per the design doc: "every block that cascade imported gets the same time." But this means the attestable timestamp doesn't reflect per-block head computation time — it's the cascade's head computation end. The docs acknowledge this; ensure consumers understand.

Performance

Vec Allocations in telemetry_events() — crates/blockchain/src/import_timing.rs:496-620

The marks vector has 29 fixed elements, then filtered to events. This allocates twice (marks + events) for every block import. Since this is only called when telemetry is installed, it's acceptable. But consider:

// Could use a small array on stack, but filter_map still needs dynamic output

The emit call uses || report.telemetry_events() which builds the vec only when SINK.get() is Some. This is correct per the lazy design.

Anchor::now() in encode — crates/common/telemetry/src/event.rs:175

Anchor::now() is called per event in writer.rs:167:

pending.push_back(encode(&event, Anchor::now(), &config));

This means each event in a batch gets a slightly different anchor instant/wall-clock pair. Events are converted relative to their own anchor, which introduces jitter. For a batch of 10,000 events, the last event's anchor could be milliseconds later than the first.

Impact: time_into_slot_ms could vary by a few ms across a batch for the same logical instant. This is minor for telemetry but worth documenting.

Fix: Use one Anchor per batch flush, not per event.

Channel Capacity — crates/common/telemetry/src/lib.rs:19

const CHANNEL_CAPACITY: usize = 16_384;

With 29+ events per block plus column events, this is ~500 blocks of backlog. At 12s slots, that's ~100 minutes. Reasonable.

drop_past_cap Drops Oldest — crates/common/telemetry/src/writer.rs:244-249

fn drop_past_cap(pending: &mut VecDeque<String>, max_rows: usize) {
    let excess = pending.len().saturating_sub(max_rows);
    if excess > 0 {
        pending.drain(..excess);  // drops FRONT = oldest
        metrics::inc_rows_dropped(excess as u64);
    }
}

Correct: oldest first. But drain(..excess) on a VecDeque may reallocate or shift. With max_pending_rows: 100_000, this is acceptable.

Rust Best Practices

std::time::Instant Serialization — crates/common/telemetry/src/event.rs:30

BlockEvent stores Instant directly, which is only meaningful for subtraction. The encode function converts via Anchor. This is clean separation.

serde_json::to_string in Hot Path — crates/common/telemetry/src/event.rs:178

serde_json::to_string(&row).expect("a row of plain fields always serializes")

This expect is sound — all fields are basic types. But to_string allocates per row. With 10K rows per batch, this is 10K allocations. Consider a buffered writer or serde_json::to_vec into a pre-allocated buffer.

Actually, the rows are joined with "\n" later (writer.rs:316), so individual strings are needed. Acceptable.

tokio::spawn Without Abort Handle — crates/common/telemetry/src/lib.rs:37

tokio::spawn(writer::run(config, rx, writer::Limits::DEFAULT));

No handle stored, no graceful shutdown. The docs say "rows still pending when the node exits are lost." This is acknowledged, but for a blockchain client, consider if we should flush on SIGTERM.

LazyLock vs OnceLock — crates/common/telemetry/src/metrics.rs:10

static ROWS: LazyLock<IntCounterVec> = LazyLock::new(|| { ... });

LazyLock is Rust 1.80+; this is fine if MSRV permits. The init() function forces initialization by touching labels. Good practice for Prometheus.

reqwest::Client Clone — crates/common/telemetry/src/writer.rs:255

let client = client.clone();

reqwest::Client is cheap to clone (Arc internally). Correct.

Error Handling

execute Error Chaining — crates/common/telemetry/src/writer.rs:328-340

let mut message = err.to_string();
let mut source = err.source();
while let Some(cause) = source {
    message.push_str(": ");
    message.push_str(&cause.to_string());
    source = cause.source();
}

Manual error chaining instead of anyhow or eyre. This is fine for a focused crate, but reqwest::Error::without_url() is used correctly.

HTTP 400 Handling — crates/common/telemetry/src/writer.rs:347-351

rejected: is_insert && status == reqwest::StatusCode::BAD_REQUEST,

ClickHouse returns 400 for malformed rows. This is correct per ClickHouse HTTP interface docs. But what about 401/403 (auth failures)? These would retry indefinitely. The needs_schema flag would recreate the table, but auth failures would persist.

Suggestion: Add special handling for 401/403 to log clearly and maybe back off faster.

Memory Safety

VecDeque Growth — crates/common/telemetry/src/writer.rs

pending can grow to max_pending_rows (100K). Each row is a String of ~200-300 bytes. That's ~30MB max. Bounded and acceptable.

InFlight Panic Safety — crates/common/telemetry/src/writer.rs:220-230

async fn join(in_flight: &mut Option<InFlight>) -> Outcome {
    match in_flight {
        Some(InFlight { rows, handle }) => handle.await.unwrap_or_else(|err| {
            metrics::inc_rows_dropped(*rows as u64);
            ...
        }),
        None => std::future::pending().await,
    }
}

If the task panics, handle.await returns Err(JoinError). The unwrap_or_else handles this and counts rows as dropped. Correct.

Code Clarity

verdict_label Function Location — crates/blockchain/src/lib.rs:4209-4217

This is a free function at module level, only used in one place. Consider making it a method on PayloadValidity or moving closer to use site. Minor.

source_label Duplication — crates/blockchain/src/import_timing.rs:700-708

Extracted to shared function. Good refactor.

at() Helper in Tests — crates/blockchain/src/import_timing.rs:1270

fn at(base: Instant, ms: u64) -> Option<Instant> {
    Some(base + Duration::from_millis(ms))
}

Clean test helper. Good.

Documentation

The docs/telemetry.md is comprehensive. One gap: it doesn't specify the block_events table engine. The DDL uses MergeTree without TTL or partitioning. For high-volume telemetry, consider documenting partitioning recommendations.

Minor Issues

  1. Typo in comment: crates/blockchain/src/import_timing.rs:175 — "gloas" should be "gloas" (appears multiple times, seems intentional for "gossip + das" or similar? Actually appears to be a term of art in this codebase.)

  2. SlotClock::slot_start_ms saturation: crates/common/telemetry/src/event.rs:64

    fn slot_start_ms(&self, slot: u64) -> u64 {
        self.genesis_ms
            .saturating_add(slot.saturating_mul(self.slot_ms))
    }

    With slot_ms: 1 (clamped from 0) and large slot, this saturates at u64::MAX. The offset_ms function then computes start_ns as u128::MAX * 1_000_000, which doesn't saturate. But slot_start_date_time filters to u32::MAX seconds, so the overflow is contained.

  3. truncate on char boundary: crates/common/telemetry/src/writer.rs:359-365

    fn truncate(text: &str, max_bytes: usize) -> &str {
        let mut end = max_bytes.min(text.len());
        while !text.is_char_boundary(end) {
            end -= 1;
        }
        &text[..end]
    }

    If max_bytes is 0 and text starts with a multi-byte char, this underflows end since usize wraps. But max_bytes is 512 in practice. Add assert!(max_bytes > 0) or handle end == 0.

Summary

Severity Count Description
Critical 0 No consensus or security critical issues
High 0 No memory safety or DoS vectors
Medium 2 Anchor per-event jitter; truncate underflow
Low 4 Debug formatting; hostname comment; auth error handling; typo

The code is well-structured, security-conscious with credential redaction, and thoroughly tested. The telemetry system correctly avoids impacting performance when disabled and handles backpressure gracefully.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

MegaRedHand added a commit that referenced this pull request Nov 3, 2026
…events layout (#765)

Opt-in per-block telemetry: with --telemetry.clickhouse-url (or
ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL), a node writes one ClickHouse row per
point in each block's life, and per data column arrival, in Silver's
block_events table layout, so both clients' timings can be compared block by
block from one table. Off by default: without the flag, emit builds nothing.
See docs/telemetry.md.

Cherry-picked from feat/clickhouse-block-events at 119e70e.

Adapted to three nightly-only changes the PR's base (main 4992d8d) lacks:

- #755 sends engine_newPayload from a task while the state transition runs
  speculatively, so the `engine` pair is only the wait left afterwards. The
  el_sent and el_verdict rows now come from new engine_sent/engine_answered
  instants that bracket the request itself, and the speculative transition
  writes stf_spec_start/stf_spec_end. docs/telemetry.md describes the overlap.
- #760 answers a block whose stored columns are complete with
  DataAvailability::ColumnsVerified, which also counts as columns required,
  so such a block writes da_available rather than da_check_end.
- #754 checks a block's sidecars as one group with a batched KZG, so the
  column rows are written from GroupVerdicts::record, once each sidecar's
  verdict is known.
…ility exhaustively

Two rows depended on facts the compiler could not keep true:

- el_sent and el_verdict read the `engine` section pair, whose meaning is
  the time the import waited for the answer. That equals the request's own
  round trip only while nothing runs during it; an import that overlaps the
  state transition with the request (as nightly's #755 does) would turn both
  rows into the wait without any type changing. The answer now carries its
  own sent/answered instants and verdict (`EngineAnswer`).
- da_available vs da_check_end came from `matches!(..., Columns(_))`, so a
  new kind of availability evidence silently counts as nothing required.
  Merging into nightly hit exactly that with #760's `ColumnsVerified`. An
  exhaustive match makes a new variant a compile error here.

Also warn at startup when the ClickHouse URL sends credentials over plain
http to a host that is not loopback (suggested in review).
MegaRedHand added a commit that referenced this pull request Nov 3, 2026
…events layout (#765): review fixes

fix(telemetry): give the el rows their own instants and match availability exhaustively

- el_sent and el_verdict come from an `EngineAnswer` (sent, answered,
  verdict) instead of the `engine` section pair, which is the time the
  import waited and, with #755's overlap, not the request's round trip.
- da_available vs da_check_end comes from an exhaustive match on the
  availability evidence, so a new variant is a compile error rather than a
  silent "nothing required".
- A startup warning when the ClickHouse URL sends credentials over plain
  http to a host that is not loopback.

Updates nightly's #765 from feat/clickhouse-block-events at 119e70e to
f2eb961. On nightly the answer's instants are the speculative path's
request (spawned) and answer (received) instants, which replaces the
nightly-only engine_sent/engine_answered fields, and the exhaustive match
also counts #760's ColumnsVerified as columns required.
MegaRedHand added a commit that referenced this pull request Nov 5, 2026
…ient's blob pool (#769)

A fulu block whose custody columns have not all arrived waits on gossip and
on per-slot peer asks. The blob transactions it commits to usually passed
through the local execution client's pool first, so the node can often build
those columns itself, sooner. Fulu's p2p-interface asks honest nodes to do so
with engine_getBlobsV2, as Lighthouse and the other clients already do.

On a fulu block's first data-availability hold, with an execution client
configured, the chain actor spawns a task that asks engine_getBlobsV2 once
(1 s, the spec's timeout, outside the retry ladder: the call races gossip),
builds the custody columns still missing without verifying the KZG proofs
(the spec says to skip verification for local-EL blobs), and hands them to
p2p through the existing check_data_column_sidecars, now tagged with a
SidecarOrigin. For ExecutionLayer sidecars p2p runs no checks; it records
them in the seen cache, publishes them on their subnets, and forwards them
to the chain actor, which stores them and releases the block. Those two
steps are the spec's MUSTs for importing local-EL blobs, and p2p owns both.

V2 only: V3 answers partially, and a column needs a cell from every blob.
Gloas is not covered, since availability moves to the payload envelope.

On nightly, a Parked recheck keeps #765's ColumnArrival::Recheck, and the
CLAUDE.md peer-scoring line keeps #739's slow-peer text. Columns built from
the execution client write no block_events telemetry rows: #765's column
rows cover gossip-accepted and fetched columns only.

Cherry-picked from feat/engine-get-blobs-v2 at 39d5f07 (e0f69b3 plus a
merge of main).

Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
MegaRedHand added a commit that referenced this pull request Nov 5, 2026
…rs (#773)

The chain actor waited for every engine_newPayload inside the import,
so the head moved only after EL + state transition and the actor sat
idle for the EL round trip, handling nothing else. Gloas envelopes did
the same.

A pre-gloas block that is an optimistic candidate now imports as
optimistic as soon as its consensus checks pass, and a gloas envelope
applies with its payload NOT_VALIDATED. The newPayload question goes out
first, from a tokio task that reports back with a NewPayloadAnswered
message, and the answer is applied then as optimistic-sync.md applies a
verdict. Optimism already gates attestation data, so validators still
attest only to validated payloads.

- Payloads are sent parent-before-child, at most 4 in flight, and
  forkchoiceUpdated names the newest payload on the head's chain whose
  request is not queued or in flight (walking the waiting payloads'
  parent hashes), sent again when the head's own answer arrives, so a
  catch-up still feeds the EL a head.
- At most 128 payloads wait for an answer; past that the import is held
  until answers free room, so catch-up cannot run unboundedly ahead.
- No answer after the retry ladder keeps the block optimistic and asks
  again each slot. optimistic-sync.md says such a block MUST NOT be
  applied; it already is, so this is documented as a deviation.
- An envelope answered INVALID with a null latestValidHash (how Engine
  API V2+ reports a block hash mismatch) is taken back out and only that
  copy refused, matching Lighthouse and Prysm. Blocks built on a FULL
  payload wait for its answer, so nothing is built on an envelope that
  gets taken back out.
- A VALID answer for the current head sends `head` again without the
  optimistic flag, for validator clients that react to head events.

Non-candidate blocks (merge transition) and servers without a mailbox
(offline replay, the import benchmark) still wait for the answer inline.

On nightly, that inline wait is #755's: the request goes out from a task
and the block's state transition runs meanwhile, so the inline path keeps
its overlap, speculation metrics and EngineAnswer telemetry rows, and
#773's own inline helper (ask_payload_inline) is dropped. The engine
section of an import that asks without waiting is a recorded zero. An
applied envelope still publishes #748's execution_payload and
execution_payload_available events, now before its answer. Answers that arrive later write no el_sent/el_verdict
telemetry rows (#765): those come only from an import that waited.

Cherry-picked from perf/async-new-payload at 33d1969 (f23010e,
33d1969).

Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
MegaRedHand added a commit that referenced this pull request Nov 5, 2026
…rs (#773): write the engine rows and attestable when the answer is applied

fix(telemetry): time a block's attestable and engine rows by its answer

With #773 a block that may be imported optimistically no longer waits for
engine_newPayload, so #765's block_events rows lost the execution client:
el_sent and el_verdict came only from an import that waited (none on the
live node since), and attestable, written at the head recomputation after
the cascade, now marked a head the block was optimistic in, which
validators cannot attest to. On beacon-eth4 that cut received-to-attestable
from a 75 ms median to 40 ms without any block becoming attestable sooner,
so the Silver comparison read faster than it was.

- Each pre-gloas ask records the import's source and when its request went
  out; the request task stamps when its answer came back.
- Applying the answer writes el_sent and el_verdict at those instants, and
  attestable at the moment a VALID (or NOT_REQUIRED) answer marks the block
  validated. The head recomputation leaves out the attestable of a block
  whose answer is still out. The answer is handled only after the import
  that asked, head recomputation and forkchoiceUpdated included, so the
  applied moment is always the later one; attestable - el_verdict is the
  time the answer waited in the mailbox.
- No answer writes nothing (the redrive's answer does, with its own
  el_sent); an answer for a block that left fork choice writes nothing; a
  SYNCING/ACCEPTED or INVALID answer writes el_sent and el_verdict only.
  Envelopes write no block rows, as before.

docs/telemetry.md says when each row is written on the asynchronous path.

Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB

This branch has not been deployed

No deployments
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