Skip to content

Commit 2677d5e

Browse files
dicethedevgreptile-apps[bot]MegaRedHandpablodeymo
authored
feat(p2p): use BlocksByRange for long-range sync (#351)
## 🗒️ Description / Motivation This PR closes #347 by wiring the `BlocksByRange` protocol added in #348 into the status-response sync path. Previously, when a peer's `Status` response revealed it was ahead of our local head, we had no mechanism to backfill the gap. Now, when the gap exceeds a configurable threshold (`LONG_RANGE_SYNC_THRESHOLD = 2 slots`), we request the missing range using `BlocksByRange` instead of relying on gossip or individual `BlocksByRoot` fetches. For small gaps (1–2 slots), we defer to the existing `FetchBlock` path since roots are typically already available from gossip and `BlocksByRoot` is more precise for that case. --- ## What Changed **`lib.rs`** - Added `LONG_RANGE_SYNC_THRESHOLD: u64 = 2` constant **`req_resp/handlers.rs`** - Updated `handle_status_response` to branch on gap size: - `gap > LONG_RANGE_SYNC_THRESHOLD` → `request_blocks_by_range_from_peer` - `gap ≤ threshold` → defers to gossip / `FetchBlock` --- ## Correctness / Behavior Guarantees - `request_blocks_by_range_from_peer` already batches internally at `MAX_REQUEST_BLOCKS` (1024), so nodes thousands of slots behind are handled correctly across multiple requests with no additional changes - `handle_blocks_by_range_response` (added in #348) already forwards each block to the blockchain layer — the response path is complete - `BlocksByRoot` behavior for individual missing blocks (`FetchBlock`, retry/backoff logic) is unchanged --- ## Tests Added / Run No new tests required. The range response handling and canonical block selection are covered by the test added in #348 (`blocks_by_range_returns_canonical_blocks_in_requested_order`). ## Related Issues / PRs - Closes [Use BlocksByRange for long range syncing #347](#347) - Depends on / builds on [feat(p2p): add inbound BlocksByRange req/resp support #348](#348) - Related to [feat: add BlocksByRange req/resp protocol leanEthereum/leanSpec#691](leanEthereum/leanSpec#691) ## ✅ Verification Checklist - [x] Ran `make fmt` — clean - [x] Ran `make lint` (clippy with `-D warnings`) — clean - [x] Ran `cargo test --workspace --release` — all passing --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com> Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent e9fa1f8 commit 2677d5e

2 files changed

Lines changed: 346 additions & 32 deletions

File tree

crates/net/p2p/src/lib.rs

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::{
22
collections::{HashMap, HashSet},
33
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
4+
ops::Range,
45
time::Duration,
56
};
67

@@ -41,7 +42,7 @@ use crate::{
4142
},
4243
req_resp::{
4344
BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, Codec,
44-
MAX_COMPRESSED_PAYLOAD_SIZE, Request, STATUS_PROTOCOL_V1, build_status,
45+
MAX_COMPRESSED_PAYLOAD_SIZE, MAX_REQUEST_BLOCKS, Request, STATUS_PROTOCOL_V1, build_status,
4546
fetch_block_from_peer,
4647
},
4748
swarm_adapter::SwarmHandle,
@@ -59,12 +60,81 @@ const MAX_FETCH_RETRIES: u32 = 10;
5960
const INITIAL_BACKOFF_MS: u64 = 5;
6061
const BACKOFF_MULTIPLIER: u64 = 2;
6162
const PEER_REDIAL_INTERVAL_SECS: u64 = 12;
63+
const MAX_SYNC_RANGE: u64 = MAX_REQUEST_BLOCKS * 64; // 65,536 slots (~3 days)
6264

6365
pub(crate) struct PendingRequest {
6466
pub(crate) attempts: u32,
6567
pub(crate) failed_peers: HashSet<PeerId>,
6668
}
6769

70+
pub(crate) enum PendingRequestKind {
71+
Root(H256),
72+
Range { start_slot: u64, end_slot: u64 },
73+
}
74+
75+
pub(crate) struct RangeSyncState {
76+
/// Remaining slots to request, with an exclusive end.
77+
pub(crate) current_range: Range<u64>,
78+
/// Latest advertised head slot for each peer.
79+
pub(crate) peer_set: HashMap<PeerId, u64>,
80+
pub(crate) in_flight: bool,
81+
}
82+
83+
impl RangeSyncState {
84+
pub(crate) fn new(current_range: Range<u64>, peer: PeerId, peer_head: u64) -> Self {
85+
Self {
86+
current_range,
87+
peer_set: HashMap::from([(peer, peer_head)]),
88+
in_flight: false,
89+
}
90+
}
91+
92+
pub(crate) fn merge_peer(&mut self, peer: PeerId, peer_head: u64, end_exclusive: u64) {
93+
self.peer_set.insert(peer, peer_head);
94+
self.current_range.end = self.current_range.end.max(end_exclusive);
95+
self.drop_stale_peers();
96+
}
97+
98+
pub(crate) fn next_batch(&self) -> Option<(PeerId, Range<u64>)> {
99+
if self.in_flight || self.current_range.is_empty() {
100+
return None;
101+
}
102+
103+
let (&peer, &peer_head) = self
104+
.peer_set
105+
.iter()
106+
.filter(|(_, head)| **head >= self.current_range.start)
107+
.max_by_key(|(_, head)| **head)?;
108+
let peer_end = peer_head.saturating_add(1);
109+
let batch_end = self
110+
.current_range
111+
.start
112+
.saturating_add(MAX_REQUEST_BLOCKS)
113+
.min(self.current_range.end)
114+
.min(peer_end);
115+
116+
(batch_end > self.current_range.start)
117+
.then_some((peer, self.current_range.start..batch_end))
118+
}
119+
120+
pub(crate) fn complete_batch(&mut self, end_slot: u64) {
121+
self.in_flight = false;
122+
self.current_range.start = self.current_range.start.max(end_slot.saturating_add(1));
123+
self.drop_stale_peers();
124+
}
125+
126+
pub(crate) fn fail_peer(&mut self, peer: &PeerId) {
127+
self.in_flight = false;
128+
self.peer_set.remove(peer);
129+
self.drop_stale_peers();
130+
}
131+
132+
fn drop_stale_peers(&mut self) {
133+
let start_slot = self.current_range.start;
134+
self.peer_set.retain(|_, head| *head >= start_slot);
135+
}
136+
}
137+
68138
// --- Swarm construction ---
69139

70140
/// [libp2p Behaviour](libp2p::swarm::NetworkBehaviour) combining identify, Gossipsub
@@ -300,8 +370,9 @@ impl P2P {
300370
block_topic: built.block_topic,
301371
aggregation_topic: built.aggregation_topic,
302372
connected_peers: HashSet::new(),
303-
pending_requests: HashMap::new(),
304-
request_id_map: HashMap::new(),
373+
pending_root_requests: HashMap::new(),
374+
outbound_requests: HashMap::new(),
375+
range_sync_state: None,
305376
bootnode_addrs: built.bootnode_addrs,
306377
node_names,
307378
};
@@ -336,8 +407,9 @@ pub struct P2PServer {
336407
pub(crate) aggregation_topic: libp2p::gossipsub::IdentTopic,
337408

338409
pub(crate) connected_peers: HashSet<PeerId>,
339-
pub(crate) pending_requests: HashMap<H256, PendingRequest>,
340-
pub(crate) request_id_map: HashMap<OutboundRequestId, H256>,
410+
pub(crate) pending_root_requests: HashMap<H256, PendingRequest>,
411+
pub(crate) outbound_requests: HashMap<OutboundRequestId, PendingRequestKind>,
412+
pub(crate) range_sync_state: Option<RangeSyncState>,
341413
bootnode_addrs: HashMap<PeerId, Multiaddr>,
342414
node_names: HashMap<PeerId, String>,
343415
}
@@ -371,7 +443,7 @@ impl P2PServer {
371443
) {
372444
let root = msg.root;
373445
// Check if still pending (might have succeeded during backoff)
374-
if !self.pending_requests.contains_key(&root) {
446+
if !self.pending_root_requests.contains_key(&root) {
375447
trace!(%root, "Block fetch completed during backoff, skipping retry");
376448
return;
377449
}
@@ -380,7 +452,7 @@ impl P2PServer {
380452

381453
if !fetch_block_from_peer(self, root).await {
382454
tracing::error!(%root, "Failed to retry block fetch, giving up");
383-
self.pending_requests.remove(&root);
455+
self.pending_root_requests.remove(&root);
384456
}
385457
}
386458

@@ -436,7 +508,7 @@ impl Handler<FetchBlock> for P2PServer {
436508
async fn handle(&mut self, msg: FetchBlock, _ctx: &Context<Self>) {
437509
let root = msg.root;
438510
// Deduplicate - if already pending, ignore
439-
if self.pending_requests.contains_key(&root) {
511+
if self.pending_root_requests.contains_key(&root) {
440512
trace!(%root, "Block fetch already in progress, ignoring duplicate");
441513
return;
442514
}
@@ -719,6 +791,54 @@ fn compute_message_id(message: &libp2p::gossipsub::Message) -> libp2p::gossipsub
719791
mod tests {
720792
use super::*;
721793

794+
fn random_peer() -> PeerId {
795+
PeerId::from_public_key(&Keypair::generate_ed25519().public())
796+
}
797+
798+
#[test]
799+
fn range_sync_state_merges_new_peer_ranges() {
800+
let first_peer = random_peer();
801+
let second_peer = random_peer();
802+
let mut state = RangeSyncState::new(10..101, first_peer, 100);
803+
804+
state.merge_peer(second_peer, 150, 151);
805+
806+
assert_eq!(state.current_range, 10..151);
807+
assert_eq!(state.peer_set.get(&first_peer), Some(&100));
808+
assert_eq!(state.peer_set.get(&second_peer), Some(&150));
809+
}
810+
811+
#[test]
812+
fn range_sync_state_allows_only_one_batch_in_flight() {
813+
let first_peer = random_peer();
814+
let second_peer = random_peer();
815+
let mut state = RangeSyncState::new(10..3000, first_peer, 500);
816+
state.merge_peer(second_peer, 2000, 3000);
817+
818+
let (selected_peer, batch) = state.next_batch().expect("batch available");
819+
assert_eq!(selected_peer, second_peer);
820+
assert_eq!(batch, 10..(10 + MAX_REQUEST_BLOCKS));
821+
822+
state.in_flight = true;
823+
assert!(state.next_batch().is_none());
824+
}
825+
826+
#[test]
827+
fn range_sync_state_advances_and_drops_stale_peers() {
828+
let stale_peer = random_peer();
829+
let current_peer = random_peer();
830+
let mut state = RangeSyncState::new(10..3000, stale_peer, 100);
831+
state.merge_peer(current_peer, 2999, 3000);
832+
state.in_flight = true;
833+
834+
state.complete_batch(1033);
835+
836+
assert_eq!(state.current_range, 1034..3000);
837+
assert!(!state.in_flight);
838+
assert!(!state.peer_set.contains_key(&stale_peer));
839+
assert_eq!(state.peer_set.get(&current_peer), Some(&2999));
840+
}
841+
722842
#[test]
723843
fn parse_enrs_extracts_ip_port_and_public_key() {
724844
// Values taken from a local devnet run with lean-quickstart

0 commit comments

Comments
 (0)