Skip to main content

urma_node/
peer_discovery.rs

1//! Bitcoin-style peer discovery. Seeds bootstrap a sparse book; afterwards
2//! connected peers exchange verified addresses. A dial proves only that a
3//! Noise identity on this chain answered at an address: never record
4//! availability or honesty. Each Service round performs at most three
5//! network operations and checks cancellation between them.
6use crate::{
7    Error,
8    engine::Engine,
9    model::{BookEntry, DiscoveryStatus},
10    peer::{PeerNetwork, PeerSource},
11    peer_protocol::GossipPeer,
12    peerbook::PeerBook,
13    recorded::{RecordedError, read, unix_seconds as now},
14};
15use libp2p::{Multiaddr, PeerId};
16use rand::seq::SliceRandom;
17use std::{
18    path::PathBuf,
19    sync::{
20        Arc, Mutex, MutexGuard,
21        atomic::{AtomicBool, AtomicU64, Ordering},
22    },
23    time::{Duration, Instant},
24};
25
26pub struct Discovery {
27    network: Arc<PeerNetwork>,
28    book: Arc<Mutex<PeerBook>>,
29    path: PathBuf,
30    round: AtomicU64,
31    seed_error: Mutex<Option<RecordedError>>,
32    gossip_error: Mutex<Option<RecordedError>>,
33    sync_backoff: Mutex<Vec<String>>,
34}
35
36/// Network operations left in this round; stop ends the round early.
37struct Budget<'a> {
38    left: usize,
39    stop: &'a AtomicBool,
40}
41
42impl Budget<'_> {
43    fn take(&mut self) -> bool {
44        if self.left == 0 || self.stop.load(Ordering::Acquire) {
45            return false;
46        }
47        self.left -= 1;
48        true
49    }
50}
51
52fn poisoned() -> Error {
53    Error::Invalid("peer discovery state poisoned".into())
54}
55
56impl Discovery {
57    /// Connected peers the round tries to reach.
58    pub const TARGET: usize = 8;
59    pub const DIALS_PER_ROUND: usize = 2;
60    pub const OPERATIONS_PER_ROUND: usize = 3;
61    /// Rounds between address requests to one random connected peer.
62    pub const GOSSIP_EVERY: u64 = 30;
63    pub const GOSSIP_LIMIT: u32 = 32;
64    /// Rounds between seed passes while below target but connected.
65    pub const SEED_EVERY: u64 = 600;
66    /// At startup seeds are dialed while fewer entries than this were ever reached.
67    pub const BOOTSTRAP_REACHED: u64 = 4;
68    /// Wall-clock bound of one dial or address request.
69    pub const OPERATION_TIME: Duration = Duration::from_secs(10);
70
71    /// `path` is where the book is saved after a round changes it.
72    pub fn new(network: Arc<PeerNetwork>, book: Arc<Mutex<PeerBook>>, path: PathBuf) -> Self {
73        Self {
74            network,
75            book,
76            path,
77            round: AtomicU64::new(0),
78            seed_error: Mutex::new(None),
79            gossip_error: Mutex::new(None),
80            sync_backoff: Mutex::new(Vec::new()),
81        }
82    }
83
84    /// Peers inventory sync is holding off (evicted, idle or blamed): neither
85    /// seed passes nor candidate dials reconnect them until they leave this set.
86    pub fn hold_off(&self, peers: Vec<String>) -> Result<(), Error> {
87        *self.sync_backoff.lock().map_err(|_| poisoned())? = peers;
88        Ok(())
89    }
90
91    fn lock(&self) -> Result<MutexGuard<'_, PeerBook>, Error> {
92        self.book.lock().map_err(|_| poisoned())
93    }
94
95    /// Inventory peers from the book: identified, non-omitting, one per peer.
96    pub fn sync_sources(&self, omitting: &[String]) -> Result<Vec<(String, PeerSource)>, Error> {
97        let addresses = self.lock()?.sync_addresses(omitting);
98        Ok(addresses
99            .into_iter()
100            .map(|address| (address.peer_id().to_string(), self.network.source(address)))
101            .collect())
102    }
103
104    /// One bounded round: merge peers learned through identify, refresh the
105    /// served gossip snapshot, seeds when due, up to two dials while below
106    /// target, every thirtieth round one address request.
107    pub fn step(&self, engine: &Engine, stop: &AtomicBool) -> Result<(), Error> {
108        let round = self.round.fetch_add(1, Ordering::Relaxed);
109        let omitting = engine.omitting_peers()?;
110        let mut changed = self.merge_identified(&omitting)?;
111        self.publish(&omitting)?;
112        let mut budget = Budget {
113            left: Self::OPERATIONS_PER_ROUND,
114            stop,
115        };
116        let mut avoid = omitting.clone();
117        avoid.extend(
118            self.sync_backoff
119                .lock()
120                .map_err(|_| poisoned())?
121                .iter()
122                .cloned(),
123        );
124        if self.seeds_due(round)? {
125            changed |= self.dial_seeds(&avoid, &mut budget, round == 0)?;
126        }
127        changed |= self.dial_candidates(&avoid, &mut budget)?;
128        if round % Self::GOSSIP_EVERY == Self::GOSSIP_EVERY - 1 {
129            changed |= self.ask_for_peers(&omitting, &mut budget)?;
130        }
131        if changed {
132            self.lock()?.save(&self.path)?;
133        }
134        Ok(())
135    }
136
137    /// Peers that identified themselves to us, with public literal addresses,
138    /// enter the book as gossip under the same caps; a local operation.
139    fn merge_identified(&self, omitting: &[String]) -> Result<bool, Error> {
140        let at = now()?;
141        let peers: Vec<GossipPeer> = self
142            .network
143            .drain_identified()
144            .into_iter()
145            .map(|(peer_id, addrs)| GossipPeer {
146                peer_id,
147                addrs,
148                last_seen: at,
149            })
150            .collect();
151        if peers.is_empty() {
152            return Ok(false);
153        }
154        let local = self.network.peer_id();
155        Ok(self.lock()?.merge_identified(&peers, local, omitting) > 0)
156    }
157
158    /// Verified book entries; this node itself is learned through identify.
159    fn publish(&self, omitting: &[String]) -> Result<(), Error> {
160        let limit = usize::try_from(Self::GOSSIP_LIMIT)
161            .map_err(|error| Error::Invalid(format!("gossip limit: {error}")))?;
162        let at = now()?;
163        let mut peers = Vec::new();
164        for mut peer in self.lock()?.gossip(limit, omitting) {
165            peer.last_seen = peer.last_seen.min(at);
166            peers.push(peer);
167        }
168        self.network.set_gossip(peers);
169        Ok(())
170    }
171
172    /// Isolated nodes retry seeds every round, limited by each seed's backoff.
173    fn seeds_due(&self, round: u64) -> Result<bool, Error> {
174        let connected = self.network.connected_peer_ids().len();
175        if connected >= Self::TARGET {
176            return Ok(false);
177        }
178        if connected == 0 {
179            return Ok(true);
180        }
181        let reached_ever = self.lock()?.stats().reached_ever;
182        Ok((round == 0 && reached_ever < Self::BOOTSTRAP_REACHED)
183            || (round > 0 && round % Self::SEED_EVERY == 0))
184    }
185
186    /// Dial one address outside the book lock and record the outcome.
187    fn dial(&self, address: &Multiaddr, stop: &AtomicBool) -> Result<Result<PeerId, Error>, Error> {
188        let deadline = Instant::now() + Self::OPERATION_TIME;
189        let outcome = self.network.dial(address.clone(), stop, deadline);
190        if stop.load(Ordering::Acquire) {
191            return Ok(outcome);
192        }
193        let at = now()?;
194        let mut book = self.lock()?;
195        match &outcome {
196            Ok(peer) => book.succeeded(address, *peer, self.network.peer_id(), at),
197            // Our own capacity or a busy peer says nothing about the address.
198            Err(Error::DialDenied(_) | Error::Busy(_) | Error::PeerBusy) => (),
199            Err(_failure) => book.failed(address, at),
200        }
201        Ok(outcome)
202    }
203
204    /// The startup pass ignores backoff persisted by an earlier process.
205    fn dial_seeds(
206        &self,
207        omitting: &[String],
208        budget: &mut Budget,
209        startup: bool,
210    ) -> Result<bool, Error> {
211        let backoff_at = if startup { None } else { Some(now()?) };
212        let seeds = self.lock()?.seeds(omitting, backoff_at);
213        let mut dialed = false;
214        let mut error = None;
215        let mut reached = false;
216        for seed in seeds {
217            if !budget.take() {
218                break;
219            }
220            dialed = true;
221            match self.dial(&seed, budget.stop)? {
222                Ok(_peer) => reached = true,
223                Err(failure) => error = Some(format!("{seed}: {failure}")),
224            }
225        }
226        if dialed {
227            let recorded = if reached {
228                None
229            } else {
230                error.map(RecordedError::now)
231            };
232            *self.seed_error.lock().map_err(|_| poisoned())? = recorded;
233        }
234        Ok(dialed)
235    }
236
237    fn dial_candidates(&self, omitting: &[String], budget: &mut Budget) -> Result<bool, Error> {
238        let connected = self.network.connected_peer_ids();
239        if connected.len() >= Self::TARGET {
240            return Ok(false);
241        }
242        let candidates =
243            self.lock()?
244                .candidates(now()?, &connected, omitting, Self::DIALS_PER_ROUND);
245        let mut dialed = false;
246        for candidate in candidates {
247            if !budget.take() {
248                break;
249            }
250            dialed = true;
251            // Either outcome is already recorded in the book (success or backoff).
252            match self.dial(&candidate, budget.stop)? {
253                Ok(_reached) => (),
254                Err(_backed_off) => (),
255            }
256        }
257        Ok(dialed)
258    }
259
260    /// Ask one random connected, non-omitting peer for addresses and merge
261    /// them under the book's caps. Lock order: the book lock is released
262    /// before `gossip_error` is taken; the two are never held together.
263    fn ask_for_peers(&self, omitting: &[String], budget: &mut Budget) -> Result<bool, Error> {
264        let connected: Vec<PeerId> = self
265            .network
266            .connected_peer_ids()
267            .into_iter()
268            .filter(|peer| !omitting.contains(&peer.to_string()))
269            .collect();
270        let Some(peer) = connected.choose(&mut rand::thread_rng()).copied() else {
271            return Ok(false);
272        };
273        if !budget.take() {
274            return Ok(false);
275        }
276        let deadline = Instant::now() + Self::OPERATION_TIME;
277        let outcome = self
278            .network
279            .peers_of(peer, Self::GOSSIP_LIMIT, budget.stop, deadline);
280        let (changed, recorded) = match outcome {
281            Ok(peers) => {
282                let local = self.network.peer_id();
283                (self.lock()?.merge(&peers, local, omitting) > 0, None)
284            }
285            Err(failure) => (
286                false,
287                Some(RecordedError::now(format!("{peer}: {failure}"))),
288            ),
289        };
290        *self.gossip_error.lock().map_err(|_| poisoned())? = recorded;
291        Ok(changed)
292    }
293
294    pub fn status(&self) -> Result<DiscoveryStatus, Error> {
295        let stats = self.lock()?.stats();
296        let (last_seed_round_error, last_seed_round_error_at) =
297            RecordedError::split(read(&self.seed_error, "seed round")?);
298        let (last_gossip_error, last_gossip_error_at) =
299            RecordedError::split(read(&self.gossip_error, "gossip")?);
300        Ok(DiscoveryStatus {
301            seeds: stats.seeds,
302            book: stats.book,
303            reached: stats.reached,
304            reached_ever: stats.reached_ever,
305            gossip: stats.gossip,
306            last_seed_round_error,
307            last_seed_round_error_at,
308            last_gossip_error,
309            last_gossip_error_at,
310        })
311    }
312
313    /// Peers the inventory scheduler caught misbehaving back off in the book
314    /// too, so discovery stops dialing them; the book is saved if it changed.
315    pub fn penalize(&self, peers: &[PeerId]) -> Result<(), Error> {
316        if peers.is_empty() {
317            return Ok(());
318        }
319        let at = now()?;
320        let mut book = self.lock()?;
321        let mut changed = false;
322        for peer in peers {
323            changed |= book.failed_peer(*peer, at);
324        }
325        if changed {
326            book.save(&self.path)?;
327        }
328        Ok(())
329    }
330
331    pub fn book(&self) -> Result<Vec<BookEntry>, Error> {
332        Ok(self.lock()?.listing())
333    }
334}