use offline_protocol_transport::TransportType;
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub(crate) const MAX_TRACKED_PEERS: usize = 1000;
pub(crate) const VERDICT_TTL: Duration = Duration::from_secs(600);
pub(crate) const PRESENCE_TTL: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Claim {
Unreachable,
Reachable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FactSource {
GatewayVerdict,
GatewayPresence,
}
impl FactSource {
fn ttl(self) -> Duration {
match self {
FactSource::GatewayVerdict => VERDICT_TTL,
FactSource::GatewayPresence => PRESENCE_TTL,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Fact {
claim: Claim,
source: FactSource,
recorded_at: Instant,
}
impl Fact {
fn is_fresh(&self, now: Instant) -> bool {
now.saturating_duration_since(self.recorded_at) < self.source.ttl()
}
}
#[derive(Debug, Default)]
struct PeerFacts {
by_carrier: Vec<(TransportType, Fact)>,
touched_at: Option<Instant>,
}
#[derive(Debug, Default)]
pub(crate) struct ReachabilityFacts {
peers: HashMap<String, PeerFacts>,
}
impl ReachabilityFacts {
pub(crate) fn record(
&mut self,
peer_id: &str,
carrier: TransportType,
claim: Claim,
source: FactSource,
now: Instant,
) {
if peer_id.is_empty() {
return;
}
if !self.peers.contains_key(peer_id) {
self.evict_if_full(now);
}
let entry = self.peers.entry(peer_id.to_string()).or_default();
let fact = Fact {
claim,
source,
recorded_at: now,
};
match entry.by_carrier.iter_mut().find(|(t, _)| *t == carrier) {
Some((_, existing)) => *existing = fact,
None => entry.by_carrier.push((carrier, fact)),
}
entry.touched_at = Some(now);
}
pub(crate) fn claim_for(
&self,
peer_id: &str,
carrier: TransportType,
now: Instant,
) -> Option<Claim> {
self.peers
.get(peer_id)
.and_then(|facts| facts.by_carrier.iter().find(|(t, _)| *t == carrier))
.filter(|(_, fact)| fact.is_fresh(now))
.map(|(_, fact)| fact.claim)
}
pub(crate) fn prune(&mut self, now: Instant) {
self.peers.retain(|_, facts| {
facts.by_carrier.retain(|(_, fact)| fact.is_fresh(now));
!facts.by_carrier.is_empty()
});
}
#[cfg(test)]
pub(crate) fn tracked_peers(&self) -> usize {
self.peers.len()
}
fn evict_if_full(&mut self, now: Instant) {
if self.peers.len() < MAX_TRACKED_PEERS {
return;
}
self.prune(now);
while self.peers.len() >= MAX_TRACKED_PEERS {
let oldest = self
.peers
.iter()
.min_by_key(|(_, facts)| facts.touched_at)
.map(|(peer, _)| peer.clone());
match oldest {
Some(peer) => {
self.peers.remove(&peer);
}
None => break,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(base: Instant, secs: u64) -> Instant {
base + Duration::from_secs(secs)
}
#[test]
fn an_unwritten_table_has_no_opinion_about_anyone() {
let facts = ReachabilityFacts::default();
assert_eq!(
facts.claim_for("bob", TransportType::Internet, Instant::now()),
None,
"absent facts must read as unknown, never as unreachable"
);
}
#[test]
fn a_fact_answers_only_for_the_carrier_that_made_it() {
let now = Instant::now();
let mut facts = ReachabilityFacts::default();
facts.record(
"bob",
TransportType::Internet,
Claim::Unreachable,
FactSource::GatewayVerdict,
now,
);
assert_eq!(
facts.claim_for("bob", TransportType::Internet, now),
Some(Claim::Unreachable)
);
assert_eq!(
facts.claim_for("bob", TransportType::Reticulum, now),
None,
"one carrier's verdict says nothing about another's reach"
);
assert_eq!(
facts.claim_for("carol", TransportType::Internet, now),
None,
"and nothing about another recipient"
);
}
#[test]
fn a_verdict_reverts_to_unknown_rather_than_standing_forever() {
let now = Instant::now();
let mut facts = ReachabilityFacts::default();
facts.record(
"bob",
TransportType::Internet,
Claim::Unreachable,
FactSource::GatewayVerdict,
now,
);
assert_eq!(
facts.claim_for(
"bob",
TransportType::Internet,
at(now, VERDICT_TTL.as_secs() - 1)
),
Some(Claim::Unreachable),
"inside its lifetime the verdict stands"
);
assert_eq!(
facts.claim_for(
"bob",
TransportType::Internet,
at(now, VERDICT_TTL.as_secs() + 1)
),
None,
"past it the answer is unknown, which means today's behaviour"
);
}
#[test]
fn a_presence_answer_decays_faster_than_a_verdict() {
let now = Instant::now();
let mut facts = ReachabilityFacts::default();
facts.record(
"bob",
TransportType::Internet,
Claim::Reachable,
FactSource::GatewayPresence,
now,
);
let after_presence_ttl = at(now, PRESENCE_TTL.as_secs() + 1);
assert_eq!(
facts.claim_for("bob", TransportType::Internet, after_presence_ttl),
None
);
assert!(
PRESENCE_TTL < VERDICT_TTL,
"the ordering is the point, not the specific numbers"
);
}
#[test]
fn the_newest_answer_for_a_carrier_replaces_the_last() {
let now = Instant::now();
let mut facts = ReachabilityFacts::default();
facts.record(
"bob",
TransportType::Internet,
Claim::Unreachable,
FactSource::GatewayVerdict,
now,
);
facts.record(
"bob",
TransportType::Internet,
Claim::Reachable,
FactSource::GatewayPresence,
at(now, 5),
);
assert_eq!(
facts.claim_for("bob", TransportType::Internet, at(now, 6)),
Some(Claim::Reachable),
"both answer the same question; the later one reflects the network now"
);
}
#[test]
fn the_table_stays_bounded_under_a_flood_of_recipients() {
let now = Instant::now();
let mut facts = ReachabilityFacts::default();
for n in 0..(MAX_TRACKED_PEERS + 50) {
facts.record(
&format!("peer{n}"),
TransportType::Internet,
Claim::Unreachable,
FactSource::GatewayVerdict,
at(now, n as u64),
);
}
assert!(
facts.tracked_peers() <= MAX_TRACKED_PEERS,
"the table must stay within its cap, found {}",
facts.tracked_peers()
);
assert_eq!(
facts.claim_for("peer0", TransportType::Internet, at(now, 1)),
None,
"the oldest write is the one evicted"
);
assert_eq!(
facts.claim_for(
&format!("peer{}", MAX_TRACKED_PEERS + 49),
TransportType::Internet,
at(now, (MAX_TRACKED_PEERS + 49) as u64)
),
Some(Claim::Unreachable),
"the newest write survives"
);
}
#[test]
fn pruning_drops_aged_facts_and_the_recipients_left_empty() {
let now = Instant::now();
let mut facts = ReachabilityFacts::default();
facts.record(
"bob",
TransportType::Internet,
Claim::Unreachable,
FactSource::GatewayVerdict,
now,
);
assert_eq!(facts.tracked_peers(), 1);
facts.prune(at(now, VERDICT_TTL.as_secs() + 1));
assert_eq!(
facts.tracked_peers(),
0,
"a recipient whose every fact expired holds no memory"
);
}
#[test]
fn an_empty_recipient_is_never_recorded() {
let mut facts = ReachabilityFacts::default();
facts.record(
"",
TransportType::Internet,
Claim::Unreachable,
FactSource::GatewayVerdict,
Instant::now(),
);
assert_eq!(facts.tracked_peers(), 0);
}
}