fix: skip ticks that don't advance past the store's interval counter - #424
Conversation
on_tick derives slot/interval from the wall clock (unix_now_ms) while the tick cadence is driven by the monotonic clock (tokio::sleep). When the wall clock drifts behind the monotonic clock (e.g. CLOCK_REALTIME drift inside the Docker Desktop macOS VM), a tick scheduled for the next interval boundary can fire while the wall clock still reads the previous interval, re-running that interval's duties. For a proposer this re-enters propose_block for a slot the state already passed, failing the state transition with StateSlotIsNewer. Guard against it using the store's existing monotonic interval counter (store.time(), which only moves forward in store::on_tick): if a tick does not advance past it, the duties already ran, so skip. The genesis bootstrap (store.time() == 0) is exempt since interval 0 has not run yet.
🤖 Codex Code ReviewFindings No correctness, security, or performance issues stood out in this patch. The new guard at crates/blockchain/src/lib.rs:195 is consistent with the underlying store semantics in crates/blockchain/src/store.rs:195: store-side tick processing only performs work while Residual risk
I could not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is my review of PR #424: PR #424: fix: skip ticks that don't advance past the store's interval counterAuthor: Tomás Grüner | Files changed: 1 | +21 / -1 OverviewThis PR adds an idempotency guard at the top of Correctness AnalysisGuard condition ( if store_time > 0 && tick_interval <= store_time {After
Genesis exception ( Interaction with Minor Points
if let Some(prev_instant) = self.last_tick_instant {
metrics::observe_tick_interval_duration(prev_instant.elapsed());
}
self.last_tick_instant = Some(Instant::now());
// ... guard is checked below ...When a duplicate tick fires and is then skipped, Logging field style ( debug!(
%slot,
%interval,
tick_interval,
store_time,
"Skipping already-processed tick"
);
No new unit test: VerdictThe fix is correct and minimal. Logic, semantics of Automated review by Claude (Anthropic) · sonnet · custom prompt |
Greptile SummaryAdds an idempotency guard inside
Confidence Score: 4/5The guard correctly prevents duplicate block proposals on wall-clock drift; the only concern is minor metric distortion from skipped ticks, which does not affect consensus correctness. The core fix is logically sound — comparing the wall-clock-derived interval against the monotonic store counter cleanly prevents the re-proposal path. The one gap is that The metrics update block at the top of
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/lib.rs | Adds an idempotency guard in on_tick that compares the wall-clock-derived interval against store.time() and skips already-processed ticks; the guard is logically correct but sits after the metrics/timer update, so skipped ticks still emit a near-zero duration sample and reset last_tick_instant. |
Sequence Diagram
sequenceDiagram
participant SC as Scheduler (monotonic)
participant OT as on_tick (wall clock)
participant ST as store::on_tick
SC->>OT: tick fires (interval N boundary, monotonic)
OT->>OT: compute tick_interval from wall clock
OT->>OT: "read store_time = store.time()"
alt "tick_interval > store_time OR store_time == 0"
OT->>ST: store::on_tick(timestamp_ms)
ST->>ST: advance store.time() to tick_interval
OT->>OT: run duties (propose_block, attest, etc.)
else "tick_interval <= store_time AND store_time > 0"
OT->>OT: debug log Skipping already-processed tick
OT-->>SC: return (early exit)
end
Comments Outside Diff (1)
-
crates/blockchain/src/lib.rs, line 176-207 (link)Skipped ticks still update
last_tick_instantand emit a metrics observationlast_tick_instantis set (lines 177–180) before the idempotency guard fires, so when a duplicate tick is skipped,observe_tick_interval_durationrecords the near-zero elapsed time since the legitimate tick, andlast_tick_instantis reset to the duplicate's timestamp. This causes two metric distortions: a spuriously short sample in the histogram and an inflated duration for the next legitimate tick (measured from the skipped tick's instant rather than the prior real one). Moving the update below the guard, or skipping it on early-return, would keep the histogram accurate.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/blockchain/src/lib.rs Line: 176-207 Comment: **Skipped ticks still update `last_tick_instant` and emit a metrics observation** `last_tick_instant` is set (lines 177–180) before the idempotency guard fires, so when a duplicate tick is skipped, `observe_tick_interval_duration` records the near-zero elapsed time since the legitimate tick, and `last_tick_instant` is reset to the duplicate's timestamp. This causes two metric distortions: a spuriously short sample in the histogram and an inflated duration for the *next* legitimate tick (measured from the skipped tick's instant rather than the prior real one). Moving the update below the guard, or skipping it on early-return, would keep the histogram accurate. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
crates/blockchain/src/lib.rs:176-207
**Skipped ticks still update `last_tick_instant` and emit a metrics observation**
`last_tick_instant` is set (lines 177–180) before the idempotency guard fires, so when a duplicate tick is skipped, `observe_tick_interval_duration` records the near-zero elapsed time since the legitimate tick, and `last_tick_instant` is reset to the duplicate's timestamp. This causes two metric distortions: a spuriously short sample in the histogram and an inflated duration for the *next* legitimate tick (measured from the skipped tick's instant rather than the prior real one). Moving the update below the guard, or skipping it on early-return, would keep the histogram accurate.
Reviews (1): Last reviewed commit: "fix: skip ticks that don't advance past ..." | Re-trigger Greptile
A skipped duplicate tick no longer resets last_tick_instant, so the lean_tick_interval_duration histogram measures real tick-to-tick spacing rather than being shortened by spurious wake-ups during clock drift.
Motivation
A 200-slot local devnet surfaced a single
ERROR Failed to build block ... target slot 81 is in the past (current is 81)on the proposing node at slot 81. Investigation traced it to a clock-domain mismatch, not the block-building path.Root cause
on_tickderivesslot/intervalfrom the wall clock (unix_now_ms,SystemTime), but the tick cadence is driven by the monotonic clock (tokio::sleep). When the wall clock drifts behind the monotonic clock, a tick scheduled for the next interval boundary fires while the wall clock still reads the previous interval, soon_tickre-runs that interval's duties.For a proposer at interval 0 this re-enters
propose_blockfor a slot the state has already advanced through, and the state transition rejects it withStateSlotIsNewer. The block itself was built, published, and imported correctly the first time, so impact was limited to a noisy error log (no double block, no fork), but the re-run is incorrect.This is a known behavior of the Docker Desktop macOS VM, whose
CLOCK_REALTIMEruns slow and is periodically corrected, independently ofCLOCK_MONOTONIC:Fix
Guard
on_tickwith the store's existing monotonic interval counter (store.time(), which only moves forward instore::on_tick). If a tick does not advance past it, the interval's duties already ran, so skip. The genesis bootstrap (store.time() == 0) is exempt, since interval 0 has not run yet.No new state is tracked — the guard reuses
store.time(). This is the inverse-safe complement to the forward-only logic already instore::on_tick.Testing
cargo build,cargo clippy -D warnings,cargo fmtpass.Contexthas no existing harness); the guard is a pure comparison overstore.time().