Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ where
let block_time = block_info.block_time_ms();
let block_height = block_info.height();

// Checkpoints are restore points for a running node. Replaying history,
// ten minutes of chain time is a handful of blocks, so this fires dozens
// of times a second and all but the last `keep_n` are deleted again
// immediately — each one a RocksDB checkpoint over the whole database
// plus a copy of the platform state. The node writes its first real
// checkpoint once it reaches the tip.
if crate::utils::is_historical_block(block_time) {
return Ok(None);
Comment on lines +62 to +63

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: A stale chain tip can leave a synchronized node with no checkpoint

Block age is not equivalent to replay status. If a fresh node catches up while the network tip is more than ten minutes old, such as during a consensus halt, this branch skips every checkpoint including the actual tip. No additional block-finalization callback runs when catch-up completes, so the promised first real checkpoint is never created until the network produces another block. This leaves a fully synchronized node unable to serve address full-tree synchronization: prove_address_funds_trunk_query_v0 explicitly selects GroveDBToUse::LatestCheckpoint, whose GroveDB query returns NoCheckpointsAvailable when the registry is empty. Use an actual catch-up/tip signal or otherwise ensure completion creates a checkpoint instead of inferring synchronization state solely from the block timestamp.

source: ['claude']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed in code. The limitation is documented in the replay.rs module doc on head 9dfffa61 (lines 8-11): block age is a proxy for catching up, and a node that finishes syncing during a network halt will treat the tip as historical and skip the checkpoint until the next block arrives. Whether that trade-off is acceptable needs maintainer acceptance, so this thread is left open.


🤖 Posted autonomously by Claude on behalf of pasta.

}

let most_recent_checkpoint_interval_time =
block_time - block_time % checkpoint_interval_milliseconds;

Expand Down Expand Up @@ -95,6 +105,13 @@ mod tests {
use dpp::version::PlatformVersion;
use std::collections::BTreeMap;

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is before the unix epoch")
.as_millis() as u64
}

fn make_block_execution_context(height: u64, block_time_ms: u64) -> BlockExecutionContext {
let platform_version = PlatformVersion::latest();
let platform_state =
Expand Down Expand Up @@ -158,7 +175,10 @@ mod tests {
return;
}

let block_execution_context = make_block_execution_context(1, 1_000_000);
// A block the network has just produced: checkpoints are restore points
// for a running node, so the age of the block decides whether one is worth
// taking, and a fixed fixture timestamp would read as ancient history.
let block_execution_context = make_block_execution_context(1, now_ms());
let result = platform
.should_checkpoint_v0(&block_execution_context, platform_version)
.expect("expected Ok");
Expand All @@ -167,6 +187,38 @@ mod tests {
assert!(result.is_some(), "first block should trigger checkpoint");
}

/// Replaying history, ten minutes of chain time is a handful of blocks, so a
/// checkpoint would be taken dozens of times a second and all but the last
/// few deleted again immediately. A node catching up takes none.
#[test]
fn test_historical_block_does_not_checkpoint() {
let platform_version = PlatformVersion::latest();
if platform_version
.drive_abci
.methods
.block_end
.should_checkpoint
.is_none()
{
return;
}

let platform = TestPlatformBuilder::new()
.build_with_mock_rpc()
.set_genesis_state();

let block_execution_context =
make_block_execution_context(1, now_ms() - 24 * 60 * 60 * 1000);
let result = platform
.should_checkpoint_v0(&block_execution_context, platform_version)
.expect("expected Ok");

assert!(
result.is_none(),
"a day-old block is being replayed, not followed"
);
}

#[test]
fn test_checkpoint_interval_zero_returns_none() {
let platform_version = PlatformVersion::latest();
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-drive-abci/src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
mod replay;
mod serialization;
mod spawn;

pub use replay::is_historical_block;
Comment thread
PastaPastaPasta marked this conversation as resolved.
Outdated
pub use serialization::from_opt_str_or_number;
pub use serialization::from_str_or_number;
pub use spawn::spawn_blocking_task_with_name_if_supported;
55 changes: 55 additions & 0 deletions packages/rs-drive-abci/src/utils/replay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Telling a node that is replaying history from one that is following the tip.
//!
//! Some per-block work only earns its cost at the tip. Creating a GroveDB
//! checkpoint every ten minutes of chain time is useful on a running node and
//! pure waste while catching up, where ten minutes of chain time is a handful of
//! blocks and every checkpoint but the last few is deleted within the second.

/// A block older than this is not one the network just produced. Mainnet aims at
/// about 2.5 minutes a block, so this leaves several blocks of slack for a node
/// that is merely a little behind.
const HISTORICAL_BLOCK_AGE_MS: u64 = 10 * 60 * 1000;

/// True when a block with this timestamp is old enough that the node producing
/// it is clearly replaying history rather than following the tip.
pub fn is_historical_block(block_time_ms: u64) -> bool {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since_epoch| since_epoch.as_millis() as u64)
.unwrap_or(0);
now_ms.saturating_sub(block_time_ms) > HISTORICAL_BLOCK_AGE_MS
}

#[cfg(test)]
mod tests {
use super::*;

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is before the unix epoch")
.as_millis() as u64
}

#[test]
fn a_block_from_a_year_ago_is_historical() {
assert!(is_historical_block(
now_ms() - 365 * 24 * 60 * 60 * 1000
));
}

#[test]
fn a_block_from_a_minute_ago_is_not_historical() {
assert!(!is_historical_block(now_ms() - 60 * 1000));
}

#[test]
fn a_block_at_the_threshold_is_not_yet_historical() {
assert!(!is_historical_block(now_ms() - HISTORICAL_BLOCK_AGE_MS));
}

#[test]
fn a_block_timestamped_in_the_future_is_not_historical() {
assert!(!is_historical_block(now_ms() + 60 * 1000));
}
}
Comment thread
PastaPastaPasta marked this conversation as resolved.
Loading