use std::collections::HashSet;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use rand::seq::IndexedRandom;
use rand::{RngExt, SeedableRng};
use rand_chacha::ChaCha20Rng;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PeerCategory {
Bootstrap,
Trusted,
Recent,
Random,
}
impl PeerCategory {
pub const ALL: [PeerCategory; 4] = [
PeerCategory::Bootstrap,
PeerCategory::Trusted,
PeerCategory::Recent,
PeerCategory::Random,
];
}
#[derive(Clone, Debug)]
pub(crate) struct PeerEntry {
pub addr: SocketAddr,
pub first_seen: Instant,
pub last_seen: Instant,
pub seen_count: u32,
pub category: PeerCategory,
pub is_self: bool,
}
impl PeerEntry {
fn new_self(addr: SocketAddr, now: Instant) -> Self {
Self {
addr,
first_seen: now,
last_seen: now,
seen_count: 1,
category: PeerCategory::Trusted,
is_self: true,
}
}
fn observed(addr: SocketAddr, last_seen: Instant, is_bootstrap: bool) -> Self {
Self {
addr,
first_seen: last_seen,
last_seen,
seen_count: 1,
category: if is_bootstrap {
PeerCategory::Bootstrap
} else {
PeerCategory::Recent
},
is_self: false,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ViewConfig {
pub max_non_bootstrap: usize,
pub trust_threshold: u32,
pub recent_max_age: Duration,
pub trusted_max_age: Duration,
pub random_max_age: Duration,
pub random_sample_bias: f64,
}
impl Default for ViewConfig {
fn default() -> Self {
Self {
max_non_bootstrap: 24,
trust_threshold: 3,
recent_max_age: Duration::from_secs(60),
trusted_max_age: Duration::from_secs(600),
random_max_age: Duration::from_secs(120),
random_sample_bias: 0.15,
}
}
}
pub(crate) struct View {
config: ViewConfig,
self_addr: SocketAddr,
entries: Vec<PeerEntry>,
addrs: HashSet<SocketAddr>,
rng: Mutex<ChaCha20Rng>,
}
impl View {
pub fn new(config: ViewConfig, self_addr: SocketAddr, seed: u64) -> Self {
let mut v = Self {
config,
self_addr,
entries: Vec::with_capacity(32),
addrs: HashSet::with_capacity(32),
rng: Mutex::new(ChaCha20Rng::seed_from_u64(seed)),
};
let me = PeerEntry::new_self(self_addr, Instant::now());
v.entries.push(me);
v.addrs.insert(self_addr);
v
}
pub fn set_self_addr(&mut self, addr: SocketAddr, now: Instant) {
if self.self_addr == addr {
return;
}
self.addrs.remove(&self.self_addr);
self.self_addr = addr;
self.addrs.insert(addr);
if let Some(e) = self.entries.iter_mut().find(|e| e.is_self) {
e.addr = addr;
e.first_seen = now;
e.last_seen = now;
}
}
pub fn add_bootstrap(&mut self, addrs: impl IntoIterator<Item = SocketAddr>) {
let now = Instant::now();
for addr in addrs {
if self.addrs.insert(addr) {
self.entries.push(PeerEntry::observed(addr, now, true));
}
}
}
pub fn add_recent(&mut self, addrs: impl IntoIterator<Item = SocketAddr>) {
let now = Instant::now();
for addr in addrs {
if addr == self.self_addr {
continue;
}
if self.addrs.insert(addr) {
let mut entry = PeerEntry::observed(addr, now, false);
entry.category = PeerCategory::Recent;
self.entries.push(entry);
}
}
}
pub fn contains(&self, addr: &SocketAddr) -> bool {
self.addrs.contains(addr)
}
pub(crate) fn category(&self, addr: &SocketAddr) -> Option<PeerCategory> {
self.entries
.iter()
.find(|e| !e.is_self && e.addr == *addr)
.map(|e| e.category)
}
pub(crate) fn snapshot_address_set(&self) -> HashSet<SocketAddr> {
let mut out = HashSet::with_capacity(self.entries.len());
for e in &self.entries {
if !e.is_self {
out.insert(e.addr);
}
}
out
}
#[allow(dead_code)]
pub fn bootstrap_addrs(&self) -> Vec<SocketAddr> {
let mut out: Vec<SocketAddr> = self
.entries
.iter()
.filter(|e| e.category == PeerCategory::Bootstrap)
.map(|e| e.addr)
.collect();
out.sort();
out
}
pub fn merge_sample(
&mut self,
sample: &[crate::sample::PeerEntry],
now: Instant,
) -> Vec<SocketAddr> {
for entry in sample {
if entry.addr == self.self_addr {
continue;
}
let heard = now
.checked_sub(Duration::from_secs(entry.age_secs as u64))
.unwrap_or(now);
if let Some(existing) = self.entries.iter_mut().find(|e| e.addr == entry.addr) {
existing.seen_count = existing.seen_count.saturating_add(1);
if heard > existing.last_seen {
existing.last_seen = heard;
}
Self::maybe_promote(existing, &self.config);
} else {
let is_random = {
let mut rng = self.rng.lock();
rng.random::<f64>() < self.config.random_sample_bias
};
let mut new = PeerEntry::observed(entry.addr, heard, false);
if is_random {
new.category = PeerCategory::Random;
}
self.addrs.insert(new.addr);
self.entries.push(new);
}
}
self.reclassify_and_evict(now)
}
pub fn reclassify_and_evict(&mut self, now: Instant) -> Vec<SocketAddr> {
let mut evicted: Vec<SocketAddr> = Vec::new();
let cfg = &self.config;
self.entries.retain_mut(|e| {
if e.is_self {
return true;
}
if e.category == PeerCategory::Bootstrap {
return true;
}
let age = now.saturating_duration_since(e.last_seen);
let keep = match e.category {
PeerCategory::Trusted => {
if age > cfg.trusted_max_age {
e.category = PeerCategory::Recent;
}
true
}
PeerCategory::Recent => age <= cfg.recent_max_age,
PeerCategory::Random => age <= cfg.random_max_age,
PeerCategory::Bootstrap => true,
};
if !keep {
evicted.push(e.addr);
}
keep
});
for addr in &evicted {
self.addrs.remove(addr);
}
let non_bootstrap: usize = self
.entries
.iter()
.filter(|e| !e.is_self && e.category != PeerCategory::Bootstrap)
.count();
if non_bootstrap <= cfg.max_non_bootstrap {
return evicted;
}
let mut candidates: Vec<&PeerEntry> = self
.entries
.iter()
.filter(|e| !e.is_self && e.category != PeerCategory::Bootstrap)
.collect();
candidates.sort_unstable_by_key(|e| e.last_seen);
let to_remove = non_bootstrap - cfg.max_non_bootstrap;
let removed: HashSet<SocketAddr> = candidates
.into_iter()
.take(to_remove)
.map(|e| e.addr)
.collect();
for addr in &removed {
self.addrs.remove(addr);
}
self.entries.retain(|e| !removed.contains(&e.addr));
evicted.extend(removed);
evicted
}
pub fn sample(
&self,
k: usize,
exclude: &HashSet<SocketAddr>,
rng: &mut ChaCha20Rng,
) -> Vec<crate::sample::PeerEntry> {
let mut random_pool: Vec<usize> = Vec::new();
let mut rest_pool: Vec<usize> = Vec::new();
for (i, e) in self.entries.iter().enumerate() {
if exclude.contains(&e.addr) {
continue;
}
if e.category == PeerCategory::Random {
random_pool.push(i);
} else {
rest_pool.push(i);
}
}
let now = Instant::now();
let mut chosen: Vec<crate::sample::PeerEntry> = Vec::with_capacity(k);
let mut used: HashSet<SocketAddr> = HashSet::with_capacity(k);
for _ in 0..k {
let prefer_random = !random_pool.is_empty()
&& (rest_pool.is_empty() || rng.random::<f64>() < self.config.random_sample_bias);
let (primary, fallback) = if prefer_random {
(&random_pool[..], &rest_pool[..])
} else {
(&rest_pool[..], &random_pool[..])
};
let mut picked = pick_unused(primary, &mut used, &self.entries, rng);
if picked.is_none() {
picked = pick_unused(fallback, &mut used, &self.entries, rng);
}
let Some(e) = picked else { break };
let age = if e.is_self {
0
} else {
now.saturating_duration_since(e.last_seen).as_secs() as u32
};
chosen.push(crate::sample::PeerEntry {
addr: e.addr,
age_secs: age,
});
}
chosen
}
pub fn snapshot(&self) -> ViewSnapshot {
let now = Instant::now();
let mut trusted = Vec::new();
let mut recent = Vec::new();
let mut random = Vec::new();
let mut bootstrap = Vec::new();
for e in &self.entries {
if e.is_self {
continue;
}
let info = PeerInfo {
addr: e.addr,
last_seen: now.saturating_duration_since(e.last_seen),
first_seen: now.saturating_duration_since(e.first_seen),
seen_count: e.seen_count,
category: e.category,
};
match e.category {
PeerCategory::Bootstrap => bootstrap.push(info),
PeerCategory::Trusted => trusted.push(info),
PeerCategory::Recent => recent.push(info),
PeerCategory::Random => random.push(info),
}
}
let sort = |v: &mut Vec<PeerInfo>| v.sort_by_key(|p| p.addr);
sort(&mut trusted);
sort(&mut recent);
sort(&mut random);
sort(&mut bootstrap);
ViewSnapshot {
trusted,
recent,
random,
bootstrap,
}
}
pub fn drop_entry(&mut self, addr: &SocketAddr) {
if let Some(e) = self.entries.iter().find(|e| e.addr == *addr)
&& (e.category == PeerCategory::Bootstrap || e.is_self)
{
return;
}
self.entries.retain(|e| e.addr != *addr);
self.addrs.remove(addr);
}
fn maybe_promote(e: &mut PeerEntry, cfg: &ViewConfig) {
if e.category == PeerCategory::Recent && e.seen_count >= cfg.trust_threshold {
e.category = PeerCategory::Trusted;
}
}
}
fn pick_unused<'a>(
pool: &[usize],
used: &mut HashSet<SocketAddr>,
entries: &'a [PeerEntry],
rng: &mut ChaCha20Rng,
) -> Option<&'a PeerEntry> {
let candidates: Vec<usize> = pool
.iter()
.copied()
.filter(|&i| entries.get(i).is_some_and(|e| !used.contains(&e.addr)))
.collect();
let &i = candidates.choose(rng)?;
let entry = entries.get(i)?;
used.insert(entry.addr);
Some(entry)
}
#[derive(Clone, Debug, Default)]
pub struct ViewSnapshot {
pub trusted: Vec<PeerInfo>,
pub recent: Vec<PeerInfo>,
pub random: Vec<PeerInfo>,
pub bootstrap: Vec<PeerInfo>,
}
impl ViewSnapshot {
pub fn total(&self) -> usize {
self.trusted.len() + self.recent.len() + self.random.len() + self.bootstrap.len()
}
pub fn is_disconnected(&self) -> bool {
self.trusted.is_empty() && self.recent.is_empty() && self.random.is_empty()
}
}
#[derive(Clone, Debug)]
pub struct PeerInfo {
pub addr: SocketAddr,
pub last_seen: Duration,
pub first_seen: Duration,
pub seen_count: u32,
pub category: PeerCategory,
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
fn make_view() -> View {
let cfg = ViewConfig {
random_sample_bias: 0.0,
..ViewConfig::default()
};
View::new(cfg, "127.0.0.1:1".parse().unwrap(), 0)
}
#[test]
fn self_is_always_present() {
let v = make_view();
assert!(v.contains(&"127.0.0.1:1".parse().unwrap()));
assert_eq!(v.snapshot().total(), 0);
}
#[test]
fn set_self_addr_updates_addrs_and_self_entry() {
let mut v = make_view();
let placeholder = v.self_addr;
let new_addr: SocketAddr = "10.0.0.1:9000".parse().unwrap();
v.set_self_addr(new_addr, Instant::now());
assert!(v.contains(&new_addr));
assert!(!v.contains(&placeholder));
assert_eq!(v.self_addr, new_addr);
}
#[test]
fn set_self_addr_idempotent() {
let mut v = make_view();
let addr: SocketAddr = "10.0.0.1:9000".parse().unwrap();
v.set_self_addr(addr, Instant::now());
v.set_self_addr(addr, Instant::now());
}
#[test]
fn self_never_appears_in_snapshot() {
let v = make_view();
let self_addr = v.self_addr;
let snap = v.snapshot();
for p in snap
.trusted
.iter()
.chain(snap.recent.iter())
.chain(snap.random.iter())
{
assert_ne!(p.addr, self_addr);
}
}
#[test]
fn fresh_entry_goes_to_recent_not_random() {
let mut v = make_view();
let target: SocketAddr = "10.0.0.5:5".parse().unwrap();
v.merge_sample(
&[crate::sample::PeerEntry {
addr: target,
age_secs: 0,
}],
Instant::now(),
);
let snap = v.snapshot();
assert!(
snap.recent.iter().any(|p| p.addr == target),
"expected target in recent pool: {:?}",
snap
);
assert!(
snap.random.iter().all(|p| p.addr != target),
"expected target NOT in random pool: {:?}",
snap
);
}
#[test]
fn random_pool_is_populated() {
let cfg = ViewConfig {
random_sample_bias: 1.0,
..ViewConfig::default()
};
let mut v = View::new(cfg, "127.0.0.1:1".parse().unwrap(), 0);
let target: SocketAddr = "10.0.0.7:7".parse().unwrap();
v.merge_sample(
&[crate::sample::PeerEntry {
addr: target,
age_secs: 0,
}],
Instant::now(),
);
let snap = v.snapshot();
assert!(
snap.random.iter().any(|p| p.addr == target),
"expected target in random pool with bias=1.0: {:?}",
snap,
);
assert!(
snap.recent.iter().all(|p| p.addr != target),
"expected target NOT in recent pool with bias=1.0: {:?}",
snap,
);
}
#[test]
fn merge_sample_uses_age_secs_for_last_seen() {
let cfg = ViewConfig {
random_sample_bias: 0.0,
..ViewConfig::default()
};
let mut v = View::new(cfg, "127.0.0.1:1".parse().unwrap(), 0);
let target: SocketAddr = "10.0.0.9:9".parse().unwrap();
let now = Instant::now();
v.merge_sample(
&[crate::sample::PeerEntry {
addr: target,
age_secs: 30,
}],
now,
);
let snap = v.snapshot();
let info = snap
.recent
.iter()
.find(|p| p.addr == target)
.expect("target in recent pool");
assert!(
info.last_seen.as_secs() >= 29 && info.last_seen.as_secs() <= 31,
"expected last_seen ~30s, got {:?}",
info.last_seen,
);
}
#[test]
fn merge_sample_keeps_freshest_last_seen() {
let cfg = ViewConfig {
random_sample_bias: 0.0,
..ViewConfig::default()
};
let mut v = View::new(cfg, "127.0.0.1:1".parse().unwrap(), 0);
let target: SocketAddr = "10.0.0.9:9".parse().unwrap();
let t0 = Instant::now();
v.merge_sample(
&[crate::sample::PeerEntry {
addr: target,
age_secs: 0,
}],
t0,
);
v.merge_sample(
&[crate::sample::PeerEntry {
addr: target,
age_secs: 60,
}],
t0,
);
let snap = v.snapshot();
let info = snap
.recent
.iter()
.find(|p| p.addr == target)
.expect("target in recent pool");
assert!(
info.last_seen.as_secs() <= 1,
"expected last_seen ~0s (freshest wins), got {:?}",
info.last_seen,
);
}
#[test]
fn add_bootstrap_inserts_entries() {
let mut v = make_view();
v.add_bootstrap(["10.0.0.1:1".parse().unwrap(), "10.0.0.2:2".parse().unwrap()]);
assert_eq!(v.bootstrap_addrs().len(), 2);
let snap = v.snapshot();
assert_eq!(snap.bootstrap.len(), 2);
assert_eq!(
snap.trusted.len() + snap.recent.len() + snap.random.len(),
0
);
}
#[test]
fn merge_promotes_after_threshold() {
let mut v = make_view();
let target: SocketAddr = "10.0.0.5:5".parse().unwrap();
let entry = crate::sample::PeerEntry {
addr: target,
age_secs: 1,
};
for _ in 0..3 {
v.merge_sample(&[entry], Instant::now());
}
let snap = v.snapshot();
assert!(snap.trusted.iter().any(|p| p.addr == target));
}
#[test]
fn capacity_is_enforced() {
let cfg = ViewConfig {
max_non_bootstrap: 4,
random_sample_bias: 0.0,
..ViewConfig::default()
};
let mut v = View::new(cfg, "127.0.0.1:1".parse().unwrap(), 0);
let mut entries = Vec::new();
for i in 0..10 {
entries.push(crate::sample::PeerEntry {
addr: format!("10.0.0.{i}:80").parse().unwrap(),
age_secs: 1,
});
}
v.merge_sample(&entries, Instant::now());
let snap = v.snapshot();
let non_bootstrap = snap.trusted.len() + snap.recent.len() + snap.random.len();
assert!(
non_bootstrap <= 4,
"got {non_bootstrap} non-bootstrap entries"
);
}
#[test]
fn sample_excludes_target() {
let mut v = make_view();
let entries: Vec<_> = (0..20)
.map(|i| crate::sample::PeerEntry {
addr: format!("10.0.0.{i}:80").parse().unwrap(),
age_secs: 1,
})
.collect();
v.merge_sample(&entries, Instant::now());
let mut rng = ChaCha20Rng::seed_from_u64(42);
let exclude: HashSet<SocketAddr> = ["10.0.0.3:80".parse().unwrap()].into_iter().collect();
let sample = v.sample(8, &exclude, &mut rng);
for entry in &sample {
assert!(!exclude.contains(&entry.addr));
}
let unique: HashSet<_> = sample.iter().map(|e| e.addr).collect();
assert_eq!(unique.len(), sample.len());
let self_addr = v.self_addr;
let _ = self_addr;
}
#[test]
fn drop_entry_skips_bootstrap_and_self() {
let mut v = make_view();
let bs: SocketAddr = "10.0.0.1:1".parse().unwrap();
v.add_bootstrap([bs]);
v.drop_entry(&bs);
assert!(v.contains(&bs));
let other: SocketAddr = "10.0.0.2:2".parse().unwrap();
v.merge_sample(
&[crate::sample::PeerEntry {
addr: other,
age_secs: 1,
}],
Instant::now(),
);
v.drop_entry(&other);
assert!(!v.contains(&other));
}
}