aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-05 05:38:20 +0100
committertv <tv@krebsco.de>2026-11-05 05:38:27 +0100
commit4e53e152f83ecb88fa162e0a18e6832df5a38fef (patch)
tree12e92bedfd0f081b8827f956d0d15c9e8837fb12 /src
parentc1cb16be7894885ec5066dafc25b92f348ced11a (diff)
round trips in trees
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/mod.rs30
-rw-r--r--src/simtest/identity.rs1
-rw-r--r--src/simtest/timing.rs1
-rw-r--r--src/state/peer.rs5
-rw-r--r--src/swarm/peer/keepalive.rs9
-rw-r--r--src/swarm/reach.rs61
-rw-r--r--src/swarm/rtt.rs63
-rw-r--r--src/swarm/tree.rs183
-rw-r--r--src/transport/libp2p/tests.rs1
-rw-r--r--src/transport/mod.rs5
-rw-r--r--src/transport/mycelium/mod.rs1
-rw-r--r--src/transport/paths.rs1
-rw-r--r--src/transport/sim.rs1
-rw-r--r--src/transport/wire.rs8
14 files changed, 293 insertions, 77 deletions
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 831df6f..aae514a 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -574,30 +574,40 @@ struct TreeQuery {
}
/// `GET /admin/dashboard/tree?writer=…`: the writer's tree (`swarm::tree`), as this node
-/// computes it from the member list and the links members report (whom each reaches;
-/// one that reports none counts as reaching its neighbours by the member list): every
-/// member's parent, `null` for one without. `404` if the writer isn't a member.
+/// computes it from the member list and the links members report (whom each reaches,
+/// and how far: its round trip, as a bucket, `rtt::bucket`; one that reports none counts
+/// as reaching its neighbours by the member list): every member's parent, `null` for
+/// one without. `404` if the writer isn't a member.
async fn writer_tree(
Extension(hub): Extension<Arc<Hub>>,
Query(query): Query<TreeQuery>,
) -> Response {
let state = &hub.state;
- let mut reaches: BTreeMap<String, BTreeSet<String>> = state
+ let mut reports: BTreeMap<String, Vec<Link>> = state
.links
.replies()
.into_iter()
- .map(|(member, _, links)| (member, reached_by(&links)))
+ .map(|(member, _, links)| (member, links))
+ .collect();
+ reports.insert(state.local_id().to_owned(), links::own_links(state).await);
+ let reaches: BTreeMap<&str, BTreeSet<String>> = reports
+ .iter()
+ .map(|(member, links)| (member.as_str(), reached_by(links)))
.collect();
- reaches.insert(
- state.local_id().to_owned(),
- reached_by(&links::own_links(state).await),
- );
let reached = |by: &str, member: &str| {
reaches
.get(by)
.is_none_or(|reached| reached.contains(member))
};
- match crate::swarm::tree::whole(state, &query.writer, &reached) {
+ let round_trip = |by: &str, member: &str| {
+ let rtt = reports
+ .get(by)
+ .and_then(|links| links.iter().find(|link| link.peer == member))
+ .and_then(|link| link.rtt_us)
+ .map(|us| std::time::Duration::from_micros(u64::from(us)));
+ crate::swarm::rtt::bucket(rtt, None)
+ };
+ match crate::swarm::tree::whole(state, &query.writer, &reached, &round_trip) {
Some(parents) => {
axum::Json(json!({ "writer": query.writer, "parents": parents })).into_response()
}
diff --git a/src/simtest/identity.rs b/src/simtest/identity.rs
index 9f77836..f1a1b05 100644
--- a/src/simtest/identity.rs
+++ b/src/simtest/identity.rs
@@ -112,6 +112,7 @@ fn status(node: &AppState) -> ParsedMessage {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
})
}
diff --git a/src/simtest/timing.rs b/src/simtest/timing.rs
index 602a7bf..25dee02 100644
--- a/src/simtest/timing.rs
+++ b/src/simtest/timing.rs
@@ -337,6 +337,7 @@ async fn late_epoch_does_not_flip_a_peers_epoch() {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
})
};
let a = &a;
diff --git a/src/state/peer.rs b/src/state/peer.rs
index 231f320..4923f0f 100644
--- a/src/state/peer.rs
+++ b/src/state/peer.rs
@@ -159,6 +159,9 @@ pub(crate) struct PeerState {
pub(crate) slow_announced_at: Option<Instant>,
/// Whom it said it reaches (`swarm::reach`), by pair key, and when.
pub(crate) reaches: Option<(Instant, Vec<u64>)>,
+ /// Its round trip to each of them, in their order, as a bucket
+ /// (`Status::round_trips`); None if it said none, or not one each.
+ pub(crate) round_trips: Option<Vec<u8>>,
/// Whose change the next sync message to it passes on along the writer's tree
/// (`swarm::tree`); taken when it is generated.
pub(crate) tree_writer: Option<u64>,
@@ -297,6 +300,7 @@ impl PeerState {
hop_delays: Default::default(),
slow_announced_at: None,
reaches: None,
+ round_trips: None,
tree_writer: None,
left: false,
hello_attempts: 0,
@@ -316,6 +320,7 @@ impl PeerState {
self.sync = SyncSession::new(reply_backoff);
// Said over the session that ended.
self.reaches = None;
+ self.round_trips = None;
}
/// An overdue sync answer from an established, active peer (see
diff --git a/src/swarm/peer/keepalive.rs b/src/swarm/peer/keepalive.rs
index aa34e70..44f2879 100644
--- a/src/swarm/peer/keepalive.rs
+++ b/src/swarm/peer/keepalive.rs
@@ -41,6 +41,7 @@ pub(crate) async fn announce_heads(state: &AppState, peer: &str, with_reaches: b
let status = transport::Status {
announcing: true,
reaches: local.reaches.filter(|_| with_reaches),
+ round_trips: local.round_trips.filter(|_| with_reaches),
..local
};
state
@@ -189,6 +190,7 @@ pub(crate) async fn local_status(state: &AppState, peer: &str) -> transport::Sta
Some(peer_state) => peer_state.lock().await.syncs_on(epoch),
None => false,
};
+ let (reaches, round_trips) = crate::swarm::reach::published_with_round_trips(state);
transport::Status {
heads,
epoch,
@@ -197,7 +199,8 @@ pub(crate) async fn local_status(state: &AppState, peer: &str) -> transport::Sta
syncs_with_you,
leaving: state.is_closing(),
announcing: false,
- reaches: Some(crate::swarm::reach::published(state)),
+ reaches: Some(reaches),
+ round_trips: Some(round_trips),
}
}
@@ -236,6 +239,10 @@ pub(super) async fn note_status(state: &AppState, peer: &str, remote: &transport
peer_state.last_heads = Some((remote.epoch, remote.heads.clone()));
if let Some(reaches) = &remote.reaches {
peer_state.reaches = Some((Instant::now(), reaches.clone()));
+ peer_state.round_trips = remote
+ .round_trips
+ .clone()
+ .filter(|round_trips| round_trips.len() == reaches.len());
}
drop(peer_state);
if left {
diff --git a/src/swarm/reach.rs b/src/swarm/reach.rs
index 8c22ef6..e6cee2f 100644
--- a/src/swarm/reach.rs
+++ b/src/swarm/reach.rs
@@ -7,7 +7,8 @@
//! `Timing::unreached_after`, asked again halfway, with nothing else heard from it; a
//! member gone shows within seconds, not after `dormant_after`). It tells its
//! neighbours so in its STATUS (`Status::reaches`, each member by its pair key,
-//! `topology::pair_key`, 8 bytes instead of a node ID): in every keepalive STATUS and
+//! `topology::pair_key`, 8 bytes instead of a node ID, with its round trip to each as a
+//! bucket: `Status::round_trips`, `rtt::bucket`): in every keepalive STATUS and
//! STATUS_REPLY, and soon to every neighbour when it changed (the peer worker looks
//! every 5 s, and at once when a member we reach stops answering), but not in
//! announcements of heads, which go often. So every node knows whom its neighbours
@@ -32,17 +33,20 @@ use tracing::debug;
use crate::locks::MutexExt;
use crate::state::{AppState, HelloState, Standing};
use crate::swarm::peer::announce_heads;
-use crate::swarm::{members, topology};
+use crate::swarm::{members, rtt, topology};
-/// Whom we reach, as last told our neighbours.
+/// Whom we reach, as last told our neighbours, and our round trip to each.
#[derive(Default)]
pub(crate) struct Reach {
published: Option<Vec<u64>>,
+ /// One per member in `published`, in its order (`rtt::bucket`).
+ round_trips: Vec<u8>,
}
/// The pair keys of the members we reach now, sorted: those with an active session
-/// that answer.
-async fn measure(state: &AppState) -> Vec<u64> {
+/// that answer; and our round trip to each, as a bucket, held near a bound to the one
+/// told before (`before`, by pair key: `rtt::bucket`).
+async fn measure(state: &AppState, before: &HashMap<u64, u8>) -> (Vec<u64>, Vec<u8>) {
let now = Instant::now();
let mut reached = Vec::new();
for (peer, peer_state) in state.peers_snapshot().await {
@@ -53,21 +57,37 @@ async fn measure(state: &AppState) -> Vec<u64> {
&& !peer_state.answer_overdue(now)
&& matches!(peer_state.standing, Standing::Member { .. })
{
- reached.push(topology::pair_key(&peer));
+ reached.push((topology::pair_key(&peer), peer));
}
}
reached.sort_unstable();
- reached
+ let rtts = state.lock_rtt();
+ let round_trips = reached
+ .iter()
+ .map(|(key, peer)| {
+ let rtt = rtts.effective(peer, now, state.timing.rtt_fresh_for());
+ rtt::bucket(rtt, before.get(key).copied())
+ })
+ .collect();
+ (
+ reached.into_iter().map(|(key, _)| key).collect(),
+ round_trips,
+ )
}
/// Whom we reach, as we tell it (`Status::reaches`); empty until first measured.
pub(crate) fn published(state: &AppState) -> Vec<u64> {
- state
- .reach
- .lock_or_recover()
- .published
- .clone()
- .unwrap_or_default()
+ published_with_round_trips(state).0
+}
+
+/// Whom we reach and our round trip to each, as we tell it (`Status::reaches`,
+/// `Status::round_trips`); empty until first measured.
+pub(crate) fn published_with_round_trips(state: &AppState) -> (Vec<u64>, Vec<u8>) {
+ let reach = state.reach.lock_or_recover();
+ match &reach.published {
+ Some(keys) => (keys.clone(), reach.round_trips.clone()),
+ None => (Vec::new(), Vec::new()),
+ }
}
/// With the peer worker's periodic pass (every 5 s), and at once when a member we said
@@ -75,13 +95,24 @@ pub(crate) fn published(state: &AppState) -> Vec<u64> {
/// our neighbours are told at once (a STATUS each, unanswered unless they lack some of
/// our heads). A change that is undone within a pass isn't told.
pub(crate) async fn check(state: &AppState) {
- let now = measure(state).await;
+ let before: HashMap<u64, u8> = {
+ let reach = state.reach.lock_or_recover();
+ reach
+ .published
+ .iter()
+ .flatten()
+ .copied()
+ .zip(reach.round_trips.iter().copied())
+ .collect()
+ };
+ let (now, round_trips) = measure(state, &before).await;
{
let mut reach = state.reach.lock_or_recover();
- if reach.published.as_ref() == Some(&now) {
+ if reach.published.as_ref() == Some(&now) && reach.round_trips == round_trips {
return;
}
reach.published = Some(now.clone());
+ reach.round_trips = round_trips;
}
debug!(
reached = now.len(),
diff --git a/src/swarm/rtt.rs b/src/swarm/rtt.rs
index cf91ac2..370754c 100644
--- a/src/swarm/rtt.rs
+++ b/src/swarm/rtt.rs
@@ -108,6 +108,51 @@ impl RttTable {
}
}
+/// Upper bounds of the round-trip buckets in which members tell how far those they
+/// reach are (`swarm::reach`), in ms; the last bucket is above them all. Doubling: a
+/// writer's tree only needs to tell which of a member's possible parents is clearly the
+/// faster (`swarm::tree`), and a coarse value changes seldom, and with it what is told.
+const BUCKET_BOUNDS_MS: [u64; 6] = [25, 50, 100, 200, 400, 800];
+
+/// The bucket of a link without a round trip measured: in the middle, neither fast
+/// nor slow.
+pub(crate) const UNKNOWN_BUCKET: u8 = 3;
+
+/// How far past a bound a round trip goes before its bucket changes: a round trip close
+/// to a bound would otherwise move between two buckets, and every move is told to every
+/// neighbour and may change who passes changes to whom.
+const BUCKET_HYSTERESIS: f64 = 0.2;
+
+/// The round-trip bucket of a link (`BUCKET_BOUNDS_MS`): `rtt`'s, unless it is still
+/// within `BUCKET_HYSTERESIS` of the bucket `before` (as last told). Without a round
+/// trip, as before; never measured, `UNKNOWN_BUCKET`.
+pub(crate) fn bucket(rtt: Option<Duration>, before: Option<u8>) -> u8 {
+ let Some(rtt) = rtt else {
+ return before.unwrap_or(UNKNOWN_BUCKET);
+ };
+ let ms = rtt.as_secs_f64() * 1000.0;
+ let plain = BUCKET_BOUNDS_MS
+ .iter()
+ .take_while(|bound| ms >= **bound as f64)
+ .count() as u8;
+ let Some(before) = before.filter(|before| *before != plain) else {
+ return plain;
+ };
+ let index = usize::from(before);
+ let lower = index
+ .checked_sub(1)
+ .and_then(|below| BUCKET_BOUNDS_MS.get(below))
+ .map_or(0.0, |bound| *bound as f64 * (1.0 - BUCKET_HYSTERESIS));
+ let upper = BUCKET_BOUNDS_MS.get(index).map_or(f64::INFINITY, |bound| {
+ *bound as f64 * (1.0 + BUCKET_HYSTERESIS)
+ });
+ if (lower..upper).contains(&ms) {
+ before
+ } else {
+ plain
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -169,4 +214,22 @@ mod tests {
let stale = start + FRESH + Duration::from_secs(1);
assert_eq!(table.effective("a", stale, FRESH), None);
}
+
+ #[test]
+ fn buckets_double_and_hold_near_their_bounds() {
+ let ms = |ms| Some(Duration::from_millis(ms));
+ assert_eq!(bucket(ms(10), None), 0);
+ assert_eq!(bucket(ms(25), None), 1);
+ assert_eq!(bucket(ms(150), None), 3);
+ assert_eq!(bucket(ms(5000), None), 6);
+ assert_eq!(bucket(None, None), UNKNOWN_BUCKET);
+ assert_eq!(bucket(None, Some(1)), 1, "unmeasured now: as before");
+ // Just past a bound: still the bucket told before.
+ assert_eq!(bucket(ms(110), Some(2)), 2);
+ assert_eq!(bucket(ms(45), Some(2)), 2);
+ // Well past it: the new one.
+ assert_eq!(bucket(ms(130), Some(2)), 3);
+ assert_eq!(bucket(ms(35), Some(2)), 1);
+ assert_eq!(bucket(ms(2000), Some(6)), 6);
+ }
}
diff --git a/src/swarm/tree.rs b/src/swarm/tree.rs
index c84056c..f04e442 100644
--- a/src/swarm/tree.rs
+++ b/src/swarm/tree.rs
@@ -7,7 +7,8 @@
//! (`swarm::reach`). Every member's distance from the writer over that graph orders the
//! members: by distance, then whether it reaches a member a hop closer, then pair key;
//! a member's parent is the neighbour it reaches that comes before it, the closest to
-//! the writer first, then one that reaches closer, then by pair score
+//! the writer first, then one that reaches closer, then the one it has the shortest
+//! round trip to (as it said, a bucket: `rtt::bucket`), then by pair score
//! (`topology::pair_score`). Coming before rules out cycles, and taking only a neighbour
//! it reaches routes around links that don't work: where a writer behind NAT reaches a
//! single member, that one comes first and the others take it.
@@ -32,7 +33,7 @@ use tracing::trace;
use crate::locks::MutexExt;
use crate::state::AppState;
-use crate::swarm::{reach, topology};
+use crate::swarm::{reach, rtt, topology};
/// The writers whose distances are kept at most.
const MAX_WRITERS: usize = 256;
@@ -94,7 +95,7 @@ fn distances(state: &AppState, writer: u64) -> Option<(Arc<Graph>, Arc<HashMap<S
/// Members are ordered by distance from the writer, then whether they reach a member a
/// hop closer (as they say: else, while it hasn't the change from its own side, it is
/// as good as farther), then pair key; a member's parent is the neighbour it reaches
-/// that comes before it, by the same, then pair score. So where the writer reaches few
+/// that comes before it, by the same, then round trip, then pair score. So where the writer reaches few
/// (behind NAT), those it reaches come first among the members as far from it, and the
/// others take them as parents. What a member reaches we know of ourselves and our
/// neighbours, as they said; of others, the graph is taken as right (if we're wrong,
@@ -127,12 +128,15 @@ fn rank(
}
/// `member`'s parent in the writer's tree (`rank`): the neighbour it reaches that comes
-/// before it, the closest to the writer first, then one that reaches closer, then by
-/// pair score; None if none does (or it is the writer).
+/// before it, the closest to the writer first, then one that reaches closer, then the
+/// one it has the shortest round trip to, as it said (`round_trip(member, candidate)`,
+/// a bucket: `rtt::bucket`), then by pair score; None if none does (or it is the
+/// writer).
fn parent<'g>(
graph: &'g Graph,
distance: &HashMap<String, u32>,
reached: &dyn Fn(&str, &str) -> bool,
+ round_trip: &dyn Fn(&str, &str) -> u8,
member: &str,
) -> Option<&'g str> {
let its_rank = rank(graph, distance, reached, member)?;
@@ -147,19 +151,25 @@ fn parent<'g>(
})
.min_by_key(|candidate| {
let (d, far, _) = rank(graph, distance, reached, candidate).expect("ranked");
- (d, far, topology::pair_score(candidate, member))
+ (
+ d,
+ far,
+ round_trip(member, candidate),
+ topology::pair_score(candidate, member),
+ )
})
}
-/// `writer`'s whole tree, as far as `reached` knows whom each member reaches (the
-/// dashboard's map, from the links members report): every member's parent, None for
-/// those without one (they get the writer's changes by announcement). None while the
-/// member list isn't known, or the writer isn't in it.
+/// `writer`'s whole tree, as far as `reached` knows whom each member reaches and
+/// `round_trip` how far (the dashboard's map, from the links members report): every
+/// member's parent, None for those without one (they get the writer's changes by
+/// announcement). None while the member list isn't known, or the writer isn't in it.
#[cfg_attr(not(feature = "dashboard"), allow(dead_code))]
pub(crate) fn whole(
state: &AppState,
writer: &str,
reached: &dyn Fn(&str, &str) -> bool,
+ round_trip: &dyn Fn(&str, &str) -> u8,
) -> Option<BTreeMap<String, Option<String>>> {
let (graph, distance) = distances(state, topology::pair_key(writer))?;
Some(
@@ -167,29 +177,70 @@ pub(crate) fn whole(
.keys()
.filter(|member| distance.get(member.as_str()).is_some_and(|d| *d > 0))
.map(|member| {
- let parent = parent(&graph, &distance, reached, member).map(str::to_owned);
+ let parent =
+ parent(&graph, &distance, reached, round_trip, member).map(str::to_owned);
(member.clone(), parent)
})
.collect(),
)
}
-/// Whom we and our neighbours in `graph` reach, by pair key, as far as we know (our
-/// own, and what our neighbours said).
-async fn known_reaches<'g>(state: &AppState, graph: &'g Graph) -> HashMap<&'g str, Vec<u64>> {
+/// Whom a member reaches, by pair key, and its round trip to each (`rtt::bucket`), as
+/// it said.
+struct Said {
+ keys: Vec<u64>,
+ round_trips: Option<Vec<u8>>,
+}
+
+/// Whom we and our neighbours in `graph` reach, and how far, as far as we know (our
+/// own, and what our neighbours said). Of other members, nothing: the graph is taken as
+/// right, and their round trips as alike.
+struct Known<'g>(HashMap<&'g str, Said>);
+
+impl Known<'_> {
+ /// Whether `by` reaches `member`: as it said, or by the graph.
+ fn reached(&self, by: &str, member: &str) -> bool {
+ self.0
+ .get(by)
+ .is_none_or(|said| said.keys.contains(&topology::pair_key(member)))
+ }
+
+ /// `by`'s round trip to `member`, as a bucket; `rtt::UNKNOWN_BUCKET` if not said.
+ fn round_trip(&self, by: &str, member: &str) -> u8 {
+ let key = topology::pair_key(member);
+ self.0
+ .get(by)
+ .and_then(|said| {
+ let index = said.keys.iter().position(|said| *said == key)?;
+ said.round_trips.as_ref()?.get(index).copied()
+ })
+ .unwrap_or(rtt::UNKNOWN_BUCKET)
+ }
+}
+
+async fn known_reaches<'g>(state: &AppState, graph: &'g Graph) -> Known<'g> {
let me = state.local_id();
- let mut reaches: HashMap<&str, Vec<u64>> = HashMap::new();
+ let mut reaches: HashMap<&str, Said> = HashMap::new();
if let Some((me, _)) = graph.get_key_value(me) {
- reaches.insert(me.as_str(), reach::published(state));
+ let (keys, round_trips) = reach::published_with_round_trips(state);
+ reaches.insert(
+ me.as_str(),
+ Said {
+ keys,
+ round_trips: Some(round_trips),
+ },
+ );
}
for member in graph.get(me).into_iter().flatten() {
- if let Some(peer_state) = state.get_peer_state(member).await
- && let Some(keys) = reach::of(peer_state.lock().await.reaches.as_ref())
- {
- reaches.insert(member.as_str(), keys);
+ if let Some(peer_state) = state.get_peer_state(member).await {
+ let peer_state = peer_state.lock().await;
+ if let Some(keys) = reach::of(peer_state.reaches.as_ref()) {
+ let round_trips = peer_state.round_trips.clone();
+ reaches.insert(member.as_str(), Said { keys, round_trips });
+ }
}
}
- reaches
+ Known(reaches)
}
/// Our own tree, as our writes go along it (`Plan`, as every member computes its part).
@@ -213,13 +264,10 @@ pub(crate) struct Shape {
pub(crate) async fn own_shape(state: &AppState) -> Option<Shape> {
let me = state.local_id();
let (graph, distance) = distances(state, topology::pair_key(me))?;
- let reaches = known_reaches(state, &graph).await;
- let reached = |by: &str, member: &str| {
- reaches
- .get(by)
- .is_none_or(|keys| keys.contains(&topology::pair_key(member)))
- };
- Some(shape(&graph, &distance, &reached, me))
+ let known = known_reaches(state, &graph).await;
+ let reached = |by: &str, member: &str| known.reached(by, member);
+ let round_trip = |by: &str, member: &str| known.round_trip(by, member);
+ Some(shape(&graph, &distance, &reached, &round_trip, me))
}
/// `own_shape` over `graph`, from `me` at distance 0.
@@ -227,12 +275,16 @@ fn shape(
graph: &Graph,
distance: &HashMap<String, u32>,
reached: &dyn Fn(&str, &str) -> bool,
+ round_trip: &dyn Fn(&str, &str) -> u8,
me: &str,
) -> Shape {
let parents: HashMap<&str, Option<&str>> = graph
.keys()
.filter(|member| distance.get(member.as_str()).is_some_and(|d| *d > 0))
- .map(|member| (member.as_str(), parent(graph, distance, reached, member)))
+ .map(|member| {
+ let parent = parent(graph, distance, reached, round_trip, member);
+ (member.as_str(), parent)
+ })
.collect();
// Hops from us along parents; None where the chain ends without us. Parents come
// before their children, so chains end.
@@ -275,9 +327,9 @@ fn shape(
/// How long our writes take to reach the members along our own tree, by link latency
/// alone (the bench's, which knows each link's): the mean over the members it reaches
-/// (and the others do), against two other trees of ours. In one, each member's parent is chosen as now, but
-/// among those as close to us and as well placed (`rank`) by latency before pair
-/// score; in the other, every member takes the fastest path over the links members
+/// (and the others do), against two other trees of ours. In one, each member's parent is
+/// chosen as now, but among those as close to us and as well placed (`rank`) by the
+/// exact latency, not the round-trip bucket it said; in the other, every member takes the fastest path over the links members
/// reach (no tree computed alike everywhere gets below it). In milliseconds, one way.
#[cfg(test)]
#[derive(Debug, Clone, Copy)]
@@ -297,12 +349,9 @@ pub(crate) async fn own_latencies(
) -> Option<Latencies> {
let me = state.local_id();
let (graph, distance) = distances(state, topology::pair_key(me))?;
- let reaches = known_reaches(state, &graph).await;
- let reached = |by: &str, member: &str| {
- reaches
- .get(by)
- .is_none_or(|keys| keys.contains(&topology::pair_key(member)))
- };
+ let known = known_reaches(state, &graph).await;
+ let reached = |by: &str, member: &str| known.reached(by, member);
+ let round_trip = |by: &str, member: &str| known.round_trip(by, member);
let members: Vec<&str> = graph
.keys()
.map(String::as_str)
@@ -310,7 +359,12 @@ pub(crate) async fn own_latencies(
.collect();
let now: HashMap<&str, Option<&str>> = members
.iter()
- .map(|member| (*member, parent(&graph, &distance, &reached, member)))
+ .map(|member| {
+ (
+ *member,
+ parent(&graph, &distance, &reached, &round_trip, member),
+ )
+ })
.collect();
let faster: HashMap<&str, Option<&str>> = members
.iter()
@@ -428,12 +482,9 @@ pub(crate) async fn plan(state: &AppState, writer: u64) -> Option<Plan> {
if !distance.contains_key(me) {
return None;
}
- let reaches = known_reaches(state, &graph).await;
- let reached = |by: &str, member: &str| {
- reaches
- .get(by)
- .is_none_or(|keys| keys.contains(&topology::pair_key(member)))
- };
+ let known = known_reaches(state, &graph).await;
+ let reached = |by: &str, member: &str| known.reached(by, member);
+ let round_trip = |by: &str, member: &str| known.round_trip(by, member);
let my_rank = rank(&graph, &distance, &reached, me)?;
let my_key = topology::pair_key(me);
let mut children = Vec::new();
@@ -448,14 +499,15 @@ pub(crate) async fn plan(state: &AppState, writer: u64) -> Option<Plan> {
covered.push(member.clone());
continue;
}
- match parent(&graph, &distance, &reached, member) {
+ match parent(&graph, &distance, &reached, &round_trip, member) {
None => uncovered.push(member.clone()),
Some(parent)
if parent == me && its_rank > my_rank && topology::reaches(state, member).await =>
{
- if reaches
+ if known
+ .0
.get(member.as_str())
- .is_some_and(|keys| keys.iter().all(|key| *key == my_key))
+ .is_some_and(|said| said.keys.iter().all(|key| *key == my_key))
{
alone.push(member.clone());
}
@@ -549,6 +601,33 @@ pub(crate) async fn forward(
mod tests {
use super::*;
+ /// Of the members as close to the writer and as well placed, the one a member has
+ /// the shorter round trip to is its parent, whatever the pair scores.
+ #[test]
+ fn the_nearer_parent_wins() {
+ let mut graph = Graph::new();
+ for (a, b) in [("w", "a"), ("w", "b"), ("a", "c"), ("b", "c")] {
+ graph.entry(a.into()).or_default().insert(b.into());
+ graph.entry(b.into()).or_default().insert(a.into());
+ }
+ let distance: HashMap<String, u32> = [("w", 0), ("a", 1), ("b", 1), ("c", 2)]
+ .into_iter()
+ .map(|(member, d)| (member.to_owned(), d))
+ .collect();
+ let reached = |_: &str, _: &str| true;
+ for (near, far) in [("a", "b"), ("b", "a")] {
+ let round_trip = |by: &str, member: &str| match (by, member) {
+ ("c", m) if m == near => 1,
+ ("c", m) if m == far => 5,
+ _ => rtt::UNKNOWN_BUCKET,
+ };
+ assert_eq!(
+ parent(&graph, &distance, &reached, &round_trip, "c"),
+ Some(near)
+ );
+ }
+ }
+
/// Hops along parents from us; members without a parent, below one without, or
/// not connected count as off the tree.
#[test]
@@ -570,7 +649,13 @@ mod tests {
// c doesn't reach us: no parent, nor e below it.
let reached = |by: &str, member: &str| !(by == "c" && member == "me");
assert_eq!(
- shape(&graph, &distance, &reached, "me"),
+ shape(
+ &graph,
+ &distance,
+ &reached,
+ &|_, _| rtt::UNKNOWN_BUCKET,
+ "me"
+ ),
Shape {
members: 2,
depth: 2,
diff --git a/src/transport/libp2p/tests.rs b/src/transport/libp2p/tests.rs
index 9f013c2..5d08190 100644
--- a/src/transport/libp2p/tests.rs
+++ b/src/transport/libp2p/tests.rs
@@ -61,6 +61,7 @@ fn status() -> ParsedMessage {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
})
}
diff --git a/src/transport/mod.rs b/src/transport/mod.rs
index 85f69f5..e81adfb 100644
--- a/src/transport/mod.rs
+++ b/src/transport/mod.rs
@@ -210,6 +210,11 @@ pub(crate) struct Status {
/// of the members it has an active session with that answer, sorted. In keepalive STATUSes and
/// their replies, and when it changed; not in announcements of heads (None).
pub(crate) reaches: Option<Vec<u64>>,
+ /// The round trip to each member in `reaches`, in its order, as a bucket
+ /// (`swarm::rtt::bucket`): which of a member's possible parents in a writer's tree
+ /// is the faster (`swarm::tree`). With `reaches`; None otherwise.
+ #[serde(with = "serde_bytes")]
+ pub(crate) round_trips: Option<Vec<u8>>,
}
/// Cheap to clone: large parts are shared (`bytes::Bytes`).
diff --git a/src/transport/mycelium/mod.rs b/src/transport/mycelium/mod.rs
index 3552edf..7bae0a7 100644
--- a/src/transport/mycelium/mod.rs
+++ b/src/transport/mycelium/mod.rs
@@ -719,6 +719,7 @@ mod tests {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
};
let payload = wire::encode(&ParsedMessage::StatusReply(status.clone())).unwrap();
// Mycelium's message data: a length byte, the topic, then our payload.
diff --git a/src/transport/paths.rs b/src/transport/paths.rs
index 8c03b1f..aca0684 100644
--- a/src/transport/paths.rs
+++ b/src/transport/paths.rs
@@ -1619,6 +1619,7 @@ mod tests {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
})
}
diff --git a/src/transport/sim.rs b/src/transport/sim.rs
index 12f3885..06f15f6 100644
--- a/src/transport/sim.rs
+++ b/src/transport/sim.rs
@@ -434,6 +434,7 @@ mod tests {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
})
}
diff --git a/src/transport/wire.rs b/src/transport/wire.rs
index 580712e..769545c 100644
--- a/src/transport/wire.rs
+++ b/src/transport/wire.rs
@@ -14,7 +14,7 @@ use super::{EncodedMessage, MessageKind, ParsedMessage};
/// The wire format's version, the first byte of every payload. Bump it with every change
/// to a message type or a codec (`golden_bytes` fails then).
-pub(crate) const VERSION: u8 = 34;
+pub(crate) const VERSION: u8 = 35;
/// What the second byte of a payload says.
fn tag(kind: MessageKind) -> u8 {
@@ -384,6 +384,7 @@ pub(crate) fn samples() -> Vec<ParsedMessage> {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
};
let proof = IdentityProof {
key: [8; 32],
@@ -570,6 +571,7 @@ mod tests {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
};
let decoded =
decode(&encode(&ParsedMessage::StatusReply(status.clone())).unwrap()).unwrap();
@@ -595,6 +597,7 @@ mod tests {
leaving: false,
announcing: false,
reaches: None,
+ round_trips: None,
};
assert!(decode(&encode(&ParsedMessage::Status(status)).unwrap()).is_err());
}
@@ -695,10 +698,11 @@ mod tests {
leaving: false,
announcing: true,
reaches: None,
+ round_trips: None,
});
assert_eq!(
hex(&encode(&status).unwrap()),
- format!("{v}03989290c400{epoch_hex}01c3c2c2c3c0")
+ format!("{v}03999290c400{epoch_hex}01c3c2c2c3c0c0")
);
let who = ParsedMessage::Who(IdentityProof {