diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 145f82d2..605327de 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -22,7 +22,7 @@ use spawned_concurrency::error::ActorError; use spawned_concurrency::protocol; use spawned_concurrency::tasks::{Actor, ActorRef, ActorStart, Context, Handler, send_after}; use tokio_util::sync::CancellationToken; -use tracing::{error, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; use crate::store::StoreError; @@ -173,12 +173,6 @@ pub struct BlockChainServer { impl BlockChainServer { async fn on_tick(&mut self, timestamp_ms: u64, ctx: &Context) { - // Observe tick interval duration before any processing - if let Some(prev_instant) = self.last_tick_instant { - metrics::observe_tick_interval_duration(prev_instant.elapsed()); - } - self.last_tick_instant = Some(Instant::now()); - let genesis_time_ms = self.store.config().genesis_time * 1000; // Calculate current slot and interval from milliseconds @@ -186,6 +180,26 @@ impl BlockChainServer { let slot = time_since_genesis_ms / MILLISECONDS_PER_SLOT; let interval = (time_since_genesis_ms % MILLISECONDS_PER_SLOT) / MILLISECONDS_PER_INTERVAL; + // Idempotency guard + // + // `slot`/`interval` come from the wall clock, but the tick cadence is driven + // by the monotonic clock (`tokio::sleep`). The wall clock can drift behind it + // inside VMs, so a tick scheduled for the next interval boundary can fire + // while the wall clock still reads the previous interval. + let tick_interval = time_since_genesis_ms / MILLISECONDS_PER_INTERVAL; + let store_time = self.store.time(); + + if store_time > 0 && tick_interval <= store_time { + debug!( + %slot, + %interval, + tick_interval, + store_time, + "Skipping already-processed tick" + ); + return; + } + // Fail fast: a state with zero validators is invalid and would cause // panics in proposer selection and attestation processing. if self.store.head_state().validators.is_empty() { @@ -193,6 +207,13 @@ impl BlockChainServer { return; } + // Observe tick interval duration. Done after the idempotency guard so a + // skipped duplicate tick doesn't shorten the next real tick's sample. + if let Some(prev_instant) = self.last_tick_instant { + metrics::observe_tick_interval_duration(prev_instant.elapsed()); + } + self.last_tick_instant = Some(Instant::now()); + // Update current slot metric metrics::update_current_slot(slot);