Skip to content

Commit 4653568

Browse files
conachepablodeymo
andauthored
feat(node): skip checkpoint sync when recent state data is in the db (#403)
When `--checkpoint-sync-url` is provided, the node currently always downloads a fresh finalized state from the peer, even if a recent state is already on disk. Skip the network round-trip when the persisted state is fresh enough to resume from. This PR introduces a state data freshness threshold - `MAX_RESUMABLE_DB_STATE_AGE = 450` slots (~30 min at 4s/slot) - picked conservatively considering the per-block backfill cost. I couldn't identify in the specs a formal weak-subjectivity period or a method of calculating it, so this is a judgement call; happy to take any suggestions on the value or a better approach for it. ## What Changed - `crates/storage/src/store.rs` — added `Store::from_db_state(backend, expected_genesis_time)`, a no-write constructor that wraps an already-initialized backend. Returns `None` if the backend is empty or its persisted `genesis_time` doesn't match. Added the `MAX_RESUMABLE_DB_STATE_AGE = 450` constant (~30 min at 4s/slot). - `crates/storage/src/lib.rs` — re-exported `MAX_RESUMABLE_DB_STATE_AGE`. - `bin/ethlambda/src/main.rs` — in the checkpoint-sync branch of `fetch_initial_state`, try `Store::from_db_state` first. If the persisted finalized slot is within `MAX_RESUMABLE_DB_STATE_AGE` of wall-clock, return the resumed store and skip checkpoint sync. Otherwise warn and fall through to the existing sync path. ## Correctness / Behavior Guarantees - New short-circuit fires only when: checkpoint URL provided AND DB populated AND persisted `genesis_time` matches AND `current_slot - latest_finalized.slot <= MAX_RESUMABLE_DB_STATE_AGE`. Everything else remains unchanged. - 30 min threshold is conservative for the current `BlocksByRoot`-only backfill cost; can be increased once `BlocksByRange` long-range sync (#351) is added. ## Tests Added / Run Three unit tests in `crates/storage/src/store.rs` covering the `from_db_state` contract: - `from_db_state_returns_none_on_empty_backend` - `from_db_state_returns_some_on_matching_genesis_time` - `from_db_state_returns_none_on_genesis_time_mismatch` ## Related Issues / PRs - Closes #121 - Related to #351 (BlocksByRange long-range sync — once landed, `MAX_RESUMABLE_DB_STATE_AGE` can probably be raised toward `STATES_TO_KEEP`) ## ✅ Verification Checklist - [x] Ran `make fmt` — clean - [x] Ran `make lint` (clippy with `-D warnings`) — clean - [x] Ran `cargo test --workspace --release` — all passing - [x] Local devnet test --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent ea08e26 commit 4653568

3 files changed

Lines changed: 111 additions & 3 deletions

File tree

bin/ethlambda/src/main.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ use std::{
1515
net::{IpAddr, SocketAddr},
1616
path::{Path, PathBuf},
1717
sync::Arc,
18+
time::SystemTime,
1819
};
1920
use tokio_util::sync::CancellationToken;
2021

2122
use clap::Parser;
23+
use ethlambda_blockchain::MILLISECONDS_PER_SLOT;
2224
use ethlambda_blockchain::key_manager::ValidatorKeyPair;
2325
use ethlambda_network_api::{InitBlockChain, InitP2P, ToBlockChainToP2PRef, ToP2PToBlockChainRef};
2426
use ethlambda_p2p::{Bootnode, P2P, PeerId, SwarmConfig, build_swarm, parse_enrs};
@@ -36,7 +38,9 @@ use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt};
3638

3739
use ethlambda_blockchain::BlockChain;
3840
use ethlambda_rpc::RpcConfig;
39-
use ethlambda_storage::{StorageBackend, Store, backend::RocksDBBackend};
41+
use ethlambda_storage::{
42+
MAX_RESUMABLE_DB_STATE_AGE, StorageBackend, Store, backend::RocksDBBackend,
43+
};
4044

4145
const ASCII_ART: &str = r#"
4246
_ _ _ _ _
@@ -635,6 +639,30 @@ async fn fetch_initial_state(
635639
};
636640

637641
// Checkpoint sync path
642+
643+
// Prefer resuming from a fresh on-disk state to avoid re-downloading what we already have.
644+
if let Some(store) = Store::from_db_state(backend.clone(), genesis.genesis_time) {
645+
let now_ms = SystemTime::UNIX_EPOCH
646+
.elapsed()
647+
.expect("already past the unix epoch")
648+
.as_millis() as u64;
649+
let current_slot =
650+
now_ms.saturating_sub(genesis.genesis_time * 1000) / MILLISECONDS_PER_SLOT;
651+
let finalized_slot = store.latest_finalized().slot;
652+
let gap = current_slot.saturating_sub(finalized_slot);
653+
if gap <= MAX_RESUMABLE_DB_STATE_AGE {
654+
info!(
655+
finalized_slot,
656+
current_slot, gap, "Resuming from existing DB state"
657+
);
658+
return Ok(store);
659+
}
660+
warn!(
661+
finalized_slot,
662+
current_slot, gap, "Existing DB state is stale; falling through to checkpoint sync"
663+
);
664+
}
665+
638666
info!(%checkpoint_url, "Starting checkpoint sync");
639667

640668
// The state and block are fetched in parallel; if the peer advances

crates/storage/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ pub mod backend;
33
mod store;
44

55
pub use api::{ALL_TABLES, StorageBackend, StorageReadView, StorageWriteBatch, Table};
6-
pub use store::{ForkCheckpoints, GetForkchoiceStoreError, Store};
6+
pub use store::{ForkCheckpoints, GetForkchoiceStoreError, MAX_RESUMABLE_DB_STATE_AGE, Store};

crates/storage/src/store.rs

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use ethlambda_types::{
1919
};
2020
use libssz::{SszDecode, SszEncode};
2121
use thiserror::Error;
22-
use tracing::info;
22+
use tracing::{info, warn};
2323

2424
/// Errors returned by [`Store::get_forkchoice_store`].
2525
#[derive(Debug, Error)]
@@ -105,6 +105,9 @@ const BLOCKS_TO_KEEP: usize = 21_600;
105105
/// ~3.3 hours of state history at 4-second slots (12000 / 4 = 3000).
106106
const STATES_TO_KEEP: usize = 3_000;
107107

108+
/// ~30 minutes of resume window at 4-second slots (1800 / 4 = 450).
109+
pub const MAX_RESUMABLE_DB_STATE_AGE: u64 = 450;
110+
108111
const _: () = assert!(
109112
BLOCKS_TO_KEEP >= STATES_TO_KEEP,
110113
"BLOCKS_TO_KEEP must be >= STATES_TO_KEEP"
@@ -550,6 +553,41 @@ impl Store {
550553
))
551554
}
552555

556+
/// Build a Store from the state already persisted in the storage backend.
557+
///
558+
/// Returns `None` if the backend is empty or its persisted `genesis_time`
559+
/// doesn't match `expected_genesis_time`.
560+
pub fn from_db_state(
561+
backend: Arc<dyn StorageBackend>,
562+
expected_genesis_time: u64,
563+
) -> Option<Self> {
564+
let persisted_config = {
565+
let view = backend.begin_read().expect("read view");
566+
let bytes = view.get(Table::Metadata, KEY_CONFIG).expect("get config")?;
567+
// probe KEY_LATEST_FINALIZED
568+
view.get(Table::Metadata, KEY_LATEST_FINALIZED)
569+
.expect("get latest finalized")?;
570+
ChainConfig::from_ssz_bytes(&bytes).expect("valid config")
571+
};
572+
if persisted_config.genesis_time != expected_genesis_time {
573+
warn!(
574+
db_genesis_time = persisted_config.genesis_time,
575+
expected_genesis_time,
576+
"Persisted DB has a different genesis_time; treating as empty"
577+
);
578+
return None;
579+
}
580+
info!("Loaded store from persisted DB state");
581+
Some(Self {
582+
backend,
583+
new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))),
584+
known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))),
585+
gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new(
586+
GOSSIP_SIGNATURE_CAP,
587+
))),
588+
})
589+
}
590+
553591
/// Internal helper to initialize the store with anchor data.
554592
///
555593
/// Header is taken from `anchor_state.latest_block_header`.
@@ -2548,4 +2586,46 @@ mod tests {
25482586
let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![]));
25492587
assert!(store.get_signed_block(&root).is_none());
25502588
}
2589+
2590+
// ============ from_db_state Tests ============
2591+
2592+
#[test]
2593+
fn from_db_state_returns_none_on_empty_backend() {
2594+
let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::new());
2595+
assert!(Store::from_db_state(backend, 12345).is_none());
2596+
}
2597+
2598+
#[test]
2599+
fn from_db_state_returns_some_on_matching_genesis_time() {
2600+
let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::new());
2601+
// Write an initial state to the backend.
2602+
let _ = Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![]));
2603+
assert!(Store::from_db_state(backend, 12345).is_some());
2604+
}
2605+
2606+
#[test]
2607+
fn from_db_state_returns_none_on_genesis_time_mismatch() {
2608+
let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::new());
2609+
// Write an initial state to the backend.
2610+
let _ = Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![]));
2611+
assert!(Store::from_db_state(backend, 99999).is_none());
2612+
}
2613+
2614+
#[test]
2615+
fn from_db_state_returns_none_when_latest_finalized_is_missing() {
2616+
let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::new());
2617+
// Write only KEY_CONFIG, leaving KEY_LATEST_FINALIZED absent.
2618+
let config = ChainConfig {
2619+
genesis_time: 12345,
2620+
};
2621+
let mut batch = backend.begin_write().expect("write batch");
2622+
batch
2623+
.put_batch(
2624+
Table::Metadata,
2625+
vec![(KEY_CONFIG.to_vec(), config.to_ssz())],
2626+
)
2627+
.expect("put config");
2628+
batch.commit().expect("commit");
2629+
assert!(Store::from_db_state(backend, 12345).is_none());
2630+
}
25512631
}

0 commit comments

Comments
 (0)