Skip to content

Commit 2738b4a

Browse files
mo4islonacodex
andcommitted
feat(hotblocks): measure fork consensus duration
Co-Authored-By: Codex <[email protected]>
1 parent 2a80c8e commit 2738b4a

4 files changed

Lines changed: 94 additions & 30 deletions

File tree

crates/data-source/src/metrics.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
use std::sync::LazyLock;
1+
use std::{sync::LazyLock, time::Duration};
22

3-
use prometheus_client::metrics::{counter::Counter, family::Family};
3+
use prometheus_client::metrics::{
4+
counter::Counter,
5+
family::Family,
6+
histogram::{exponential_buckets, Histogram}
7+
};
48

59
type Labels = Vec<(&'static str, String)>;
610

@@ -11,6 +15,10 @@ pub static INGEST_SOURCE_ERRORS: LazyLock<Family<Labels, Counter>> = LazyLock::n
1115
/// Fork signals by `source` and whether it held the contested position (`at_tip`/`above_tip`).
1216
pub static INGEST_FORK_SIGNALS: LazyLock<Family<Labels, Counter>> = LazyLock::new(Default::default);
1317

18+
/// Time from the first fork signal until a fork decision, by decision path.
19+
pub static INGEST_FORK_CONSENSUS_DURATION: LazyLock<Family<Labels, Histogram>> =
20+
LazyLock::new(|| Family::new_with_constructor(|| Histogram::new(exponential_buckets(0.001, 2.0, 15))));
21+
1422
/// Public because the pre-ingest head probe lives in `hotblocks` and must feed the same counter:
1523
/// it runs before this crate's stream loop, so a total outage never reaches `on_error`.
1624
pub fn record_ingest_source_error(source: &str, kind: &'static str) {
@@ -27,3 +35,9 @@ pub(crate) fn record_ingest_fork_signal(source: &str, standing: &'static str) {
2735
])
2836
.inc();
2937
}
38+
39+
pub(crate) fn record_ingest_fork_consensus_duration(decision: &'static str, duration: Duration) {
40+
INGEST_FORK_CONSENSUS_DURATION
41+
.get_or_create(&vec![("decision", decision.to_string())])
42+
.observe(duration.as_secs_f64());
43+
}

crates/data-source/src/standard.rs

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use anyhow::Context;
44
use futures::{future::BoxFuture, stream::BoxStream, FutureExt, Stream, StreamExt};
55
use sqd_data_client::{BlockStreamRequest, BlockStreamResponse, DataClient};
66
use sqd_primitives::{Block, BlockNumber, BlockRef};
7-
use tokio::time::Sleep;
7+
use tokio::time::{Instant, Sleep};
88
use tracing::{info, warn};
99

1010
use crate::types::{DataEvent, DataSource};
@@ -44,7 +44,8 @@ struct DataSourceState<F> {
4444
position: BlockStreamRequest,
4545
position_is_canonical: bool,
4646
max_seen_finalized_block: BlockNumber,
47-
fork_consensus_timeout: Option<Pin<Box<Sleep>>>
47+
fork_consensus_timeout: Option<Pin<Box<Sleep>>>,
48+
fork_consensus_started_at: Option<Instant>
4849
}
4950

5051
impl<F> DataSourceState<F> {
@@ -78,6 +79,7 @@ impl<F> DataSourceState<F> {
7879
}
7980
Poll::Ready(Ok(BlockStreamResponse::Fork(prev_blocks))) => {
8081
let req = req.clone();
82+
self.fork_consensus_started_at.get_or_insert_with(Instant::now);
8183
ep.on_fork_signal(req.first_block, &prev_blocks);
8284
ep.error_counter = 0;
8385
ep.state = EndpointState::Fork { req, prev_blocks };
@@ -143,7 +145,7 @@ impl<F> DataSourceState<F> {
143145
}
144146
self.position.first_block = block.number() + 1;
145147
self.position_is_canonical = true;
146-
self.fork_consensus_timeout = None;
148+
self.reset_fork_consensus();
147149

148150
if is_final {
149151
set_head(&mut self.finalized_head, block.number(), block.hash());
@@ -152,6 +154,11 @@ impl<F> DataSourceState<F> {
152154
true
153155
}
154156

157+
fn reset_fork_consensus(&mut self) {
158+
self.fork_consensus_timeout = None;
159+
self.fork_consensus_started_at = None;
160+
}
161+
155162
fn on_new_finalized_head(&mut self, new_head: Option<&BlockRef>) -> bool {
156163
let Some(new_head) = new_head else { return false };
157164

@@ -285,7 +292,8 @@ where
285292
},
286293
position_is_canonical: false,
287294
max_seen_finalized_block: 0,
288-
fork_consensus_timeout: None
295+
fork_consensus_timeout: None,
296+
fork_consensus_started_at: None
289297
};
290298

291299
Self { endpoints, state }
@@ -302,21 +310,38 @@ where
302310
let forks = self.endpoints.iter().filter(|ep| ep.is_on_fork()).count();
303311
if forks > 0 {
304312
let active = self.endpoints.iter().filter(|ep| ep.is_active()).count();
305-
if forks > self.endpoints.len() / 2 || forks == active || self.fork_consensus_timeout(cx) {
306-
let chain = self.extract_fork();
307-
info!(
308-
forked_endpoints = forks,
309-
active_endpoints = active,
310-
total_endpoints = self.endpoints.len(),
311-
hint_count = chain.len(),
312-
oldest_hint =? chain.first().map(|b| b.number),
313-
newest_hint =? chain.last().map(|b| b.number),
314-
"fork consensus reached"
315-
);
316-
return Poll::Ready(DataEvent::Fork(chain));
317-
}
313+
let decision = if forks > self.endpoints.len() / 2 {
314+
"majority"
315+
} else if forks == active {
316+
"all_active"
317+
} else if self.fork_consensus_timeout(cx) {
318+
"timeout"
319+
} else {
320+
return Poll::Pending;
321+
};
322+
323+
let consensus_duration = self
324+
.state
325+
.fork_consensus_started_at
326+
.expect("fork consensus must start with the first fork signal")
327+
.elapsed();
328+
crate::metrics::record_ingest_fork_consensus_duration(decision, consensus_duration);
329+
330+
let chain = self.extract_fork();
331+
info!(
332+
decision = decision,
333+
consensus_duration_seconds = consensus_duration.as_secs_f64(),
334+
forked_endpoints = forks,
335+
active_endpoints = active,
336+
total_endpoints = self.endpoints.len(),
337+
hint_count = chain.len(),
338+
oldest_hint =? chain.first().map(|b| b.number),
339+
newest_hint =? chain.last().map(|b| b.number),
340+
"fork consensus reached"
341+
);
342+
return Poll::Ready(DataEvent::Fork(chain));
318343
} else {
319-
self.state.fork_consensus_timeout = None
344+
self.state.reset_fork_consensus()
320345
}
321346

322347
Poll::Pending
@@ -338,7 +363,7 @@ where
338363
}
339364

340365
fn extract_fork(&mut self) -> Vec<BlockRef> {
341-
self.state.fork_consensus_timeout = None;
366+
self.state.reset_fork_consensus();
342367
let mut chain = Vec::new();
343368
for ep in self.endpoints.iter_mut() {
344369
match std::mem::replace(&mut ep.state, EndpointState::Ready) {
@@ -381,6 +406,7 @@ where
381406
self.state.position.set_parent_block_hash(parent_block_hash);
382407
self.state.position_is_canonical = false;
383408
self.state.finalized_head = None;
409+
self.state.reset_fork_consensus();
384410
for ep in self.endpoints.iter_mut() {
385411
ep.state = EndpointState::Ready;
386412
ep.last_committed_block = None;

crates/hotblocks/src/metrics.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,13 @@ pub fn build_metrics_registry() -> Registry {
538538
sqd_data_source::metrics::INGEST_FORK_SIGNALS.clone()
539539
);
540540

541+
registry.register(
542+
"ingest_fork_consensus_duration_seconds",
543+
"Time from the first upstream fork signal until fork consensus, by decision path \
544+
(majority/all_active/timeout)",
545+
sqd_data_source::metrics::INGEST_FORK_CONSENSUS_DURATION.clone()
546+
);
547+
541548
registry.register(
542549
"dataset_epoch_failures",
543550
"Dataset update task failures, by dataset and cause; each one parks ingestion for \

crates/hotblocks/tests/ct4_lagging_source.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,18 +208,13 @@ async fn ct4_a_fork_signal_above_the_tip_and_the_park_it_causes_are_observable()
208208
h.finalize_with_lag(5)?;
209209
h.settle().await?;
210210

211-
for peer in &h.peers {
212-
peer.inject_fault(&h.dataset, |f| f.fork_signal_above_tip = true);
213-
}
211+
h.peers[0].inject_fault(&h.dataset, |f| f.fork_signal_above_tip = true);
214212

215-
for _ in 0..12 {
216-
h.produce_ahead(10)?;
217-
h.finalize_with_lag(5)?;
218-
tokio::time::sleep(Duration::from_millis(50)).await;
219-
}
213+
h.produce_lagging(&[0], 10)?;
214+
h.finalize_with_lag(5)?;
220215

221216
// No `settle` — the epoch is serving out `P-EPOCH-RETRY`, which is the thing being measured.
222-
tokio::time::sleep(Duration::from_secs(1)).await;
217+
tokio::time::sleep(Duration::from_secs(3)).await;
223218
let metrics = h.client.metrics().await?;
224219

225220
let above_tip = metrics
@@ -234,6 +229,28 @@ async fn ct4_a_fork_signal_above_the_tip_and_the_park_it_causes_are_observable()
234229
"no source held the contested position"
235230
);
236231

232+
let consensus_count = metrics
233+
.get(
234+
"hotblocks_ingest_fork_consensus_duration_seconds_count",
235+
Some(("decision", "timeout"))
236+
)
237+
.unwrap_or_default();
238+
assert!(
239+
consensus_count > 0.0,
240+
"the lone fork signal must reach consensus by timeout"
241+
);
242+
243+
let consensus_seconds = metrics
244+
.get(
245+
"hotblocks_ingest_fork_consensus_duration_seconds_sum",
246+
Some(("decision", "timeout"))
247+
)
248+
.unwrap_or_default();
249+
assert!(
250+
consensus_seconds >= 2.0,
251+
"the timeout path must spend at least 2 s in consensus, observed {consensus_seconds} s"
252+
);
253+
237254
let parked = metrics
238255
.get(
239256
"hotblocks_dataset_epoch_failures_total",

0 commit comments

Comments
 (0)