fix federation recovery after network suspension
CI / check (push) Successful in 1m13s

This commit is contained in:
Ultradesu
2026-09-02 15:09:16 +01:00
parent 02ef0095f5
commit 95bcc9bac4
12 changed files with 342 additions and 63 deletions
+6
View File
@@ -0,0 +1,6 @@
# Changelog
## v0.4.1 — 2026-09-02
- `federation-net 0.3.1`: recover timed-out or failed Mainline-DHT rendezvous clients and expose network health to applications.
- `music-dht 0.4.1`: retain temporarily unreachable contacts and serialize duplicate dial attempts with bounded backoff.
Generated
+2 -2
View File
@@ -837,7 +837,7 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "federation-net" name = "federation-net"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3", "blake3",
@@ -2073,7 +2073,7 @@ dependencies = [
[[package]] [[package]]
name = "music-dht" name = "music-dht"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+2 -2
View File
@@ -13,8 +13,8 @@ rust-version = "1.97"
repository = "https://gt.hexor.cy/ab/frid" repository = "https://gt.hexor.cy/ab/frid"
[workspace.dependencies] [workspace.dependencies]
federation-net = { path = "crates/federation-net", version = "0.3.0" } federation-net = { path = "crates/federation-net", version = "0.3.1" }
music-dht = { path = "crates/music-dht", version = "0.4.0" } music-dht = { path = "crates/music-dht", version = "0.4.1" }
iroh = "1" iroh = "1"
iroh-base = "1" iroh-base = "1"
iroh-tickets = "1" iroh-tickets = "1"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "federation-net" name = "federation-net"
version = "0.3.0" version = "0.3.1"
description = "Generic peer-to-peer networking engine built on Iroh" description = "Generic peer-to-peer networking engine built on Iroh"
readme = "README.md" readme = "README.md"
documentation = "https://docs.rs/federation-net" documentation = "https://docs.rs/federation-net"
+199 -25
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::fmt; use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
use std::time::Duration; use std::time::{Duration, Instant};
use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets}; use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
use iroh::protocol::{AcceptError, ProtocolHandler, Router}; use iroh::protocol::{AcceptError, ProtocolHandler, Router};
@@ -19,6 +19,7 @@ use tracing::{debug, info, warn};
use crate::config::NetworkConfig; use crate::config::NetworkConfig;
use crate::error::{NetworkError, Result}; use crate::error::{NetworkError, Result};
use crate::event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver}; use crate::event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
use crate::health::{NetworkHealthSnapshot, NetworkHealthState};
use crate::identity; use crate::identity;
use crate::protocol::{ use crate::protocol::{
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE, ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
@@ -50,6 +51,12 @@ const REJECT_LINGER: Duration = Duration::from_secs(3);
const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5); const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5);
/// Upper bound of the rendezvous round delay while no peer is connected yet. /// Upper bound of the rendezvous round delay while no peer is connected yet.
const RENDEZVOUS_LONELY_INTERVAL: Duration = Duration::from_secs(15); const RENDEZVOUS_LONELY_INTERVAL: Duration = Duration::from_secs(15);
/// A stuck Mainline-DHT operation must not stall discovery forever.
const RENDEZVOUS_ROUND_TIMEOUT: Duration = Duration::from_secs(30);
/// Recreate the Mainline-DHT client after this many consecutive failures.
const RENDEZVOUS_REBUILD_AFTER_FAILURES: u32 = 2;
/// Recommend an application-owned full restart after sustained discovery failure.
const RENDEZVOUS_RESTART_AFTER_FAILURES: u32 = 4;
/// Capacity of the queue of accepted-but-not-yet-consumed incoming byte /// Capacity of the queue of accepted-but-not-yet-consumed incoming byte
/// streams, per stream protocol. A full queue delays the handshake ack of /// streams, per stream protocol. A full queue delays the handshake ack of
/// further incoming streams (natural backpressure). /// further incoming streams (natural backpressure).
@@ -260,6 +267,27 @@ struct StreamAcceptorSlot {
receiver: Option<mpsc::Receiver<ByteStream>>, receiver: Option<mpsc::Receiver<ByteStream>>,
} }
#[derive(Debug)]
struct RendezvousHealth {
last_success: Option<Instant>,
consecutive_failures: u32,
consecutive_peer_dial_failures: u32,
client_restarts: u64,
last_error: Option<String>,
}
impl RendezvousHealth {
fn new() -> Self {
Self {
last_success: None,
consecutive_failures: 0,
consecutive_peer_dial_failures: 0,
client_restarts: 0,
last_error: None,
}
}
}
fn alpn_display(alpn: &[u8]) -> String { fn alpn_display(alpn: &[u8]) -> String {
String::from_utf8_lossy(alpn).into_owned() String::from_utf8_lossy(alpn).into_owned()
} }
@@ -276,6 +304,7 @@ struct Shared<M> {
tasks: Mutex<JoinSet<()>>, tasks: Mutex<JoinSet<()>>,
next_generation: AtomicU64, next_generation: AtomicU64,
shutting_down: AtomicBool, shutting_down: AtomicBool,
rendezvous_health: Mutex<Option<RendezvousHealth>>,
/// Broadcasts the start of the shutdown to long-running background /// Broadcasts the start of the shutdown to long-running background
/// loops so they can stop promptly instead of being aborted. /// loops so they can stop promptly instead of being aborted.
shutdown_signal: watch::Sender<bool>, shutdown_signal: watch::Sender<bool>,
@@ -294,6 +323,97 @@ impl<M: Message> Shared<M> {
} }
} }
fn record_rendezvous_success(&self) {
if let Some(health) = lock(&self.rendezvous_health).as_mut() {
health.last_success = Some(Instant::now());
health.consecutive_failures = 0;
health.last_error = None;
}
}
fn record_rendezvous_failure(&self, error: String) -> u32 {
let mut guard = lock(&self.rendezvous_health);
let Some(health) = guard.as_mut() else {
return 0;
};
health.consecutive_failures = health.consecutive_failures.saturating_add(1);
health.last_error = Some(error);
health.consecutive_failures
}
fn record_rendezvous_restart(&self) {
if let Some(health) = lock(&self.rendezvous_health).as_mut() {
health.client_restarts = health.client_restarts.saturating_add(1);
}
}
fn record_peer_connection_success(&self) {
if let Some(health) = lock(&self.rendezvous_health).as_mut() {
health.consecutive_peer_dial_failures = 0;
}
}
fn record_rendezvous_peer_dials(&self, candidates: usize, attempts: usize, successes: usize) {
let mut guard = lock(&self.rendezvous_health);
let Some(health) = guard.as_mut() else {
return;
};
if candidates == 0 || successes > 0 {
health.consecutive_peer_dial_failures = 0;
} else if attempts > 0 {
health.consecutive_peer_dial_failures =
health.consecutive_peer_dial_failures.saturating_add(1);
health.last_error = Some(format!(
"failed to connect to all {attempts} peer(s) discovered by rendezvous"
));
}
}
fn health(&self) -> NetworkHealthSnapshot {
let connected_peers = lock(&self.peers).len();
let guard = lock(&self.rendezvous_health);
let Some(health) = guard.as_ref() else {
return NetworkHealthSnapshot {
state: if connected_peers == 0 {
NetworkHealthState::Discovering
} else {
NetworkHealthState::Healthy
},
connected_peers,
rendezvous_enabled: false,
consecutive_rendezvous_failures: 0,
consecutive_peer_dial_failures: 0,
rendezvous_restarts: 0,
last_rendezvous_success_ago: None,
last_rendezvous_error: None,
restart_recommended: false,
};
};
let restart_recommended = connected_peers == 0
&& (health.consecutive_failures >= RENDEZVOUS_RESTART_AFTER_FAILURES
|| health.consecutive_peer_dial_failures >= RENDEZVOUS_RESTART_AFTER_FAILURES);
let state = if restart_recommended {
NetworkHealthState::Degraded
} else if health.consecutive_failures > 0 || health.consecutive_peer_dial_failures > 0 {
NetworkHealthState::Recovering
} else if health.last_success.is_some() {
NetworkHealthState::Healthy
} else {
NetworkHealthState::Discovering
};
NetworkHealthSnapshot {
state,
connected_peers,
rendezvous_enabled: true,
consecutive_rendezvous_failures: health.consecutive_failures,
consecutive_peer_dial_failures: health.consecutive_peer_dial_failures,
rendezvous_restarts: health.client_restarts,
last_rendezvous_success_ago: health.last_success.map(|at| at.elapsed()),
last_rendezvous_error: health.last_error.clone(),
restart_recommended,
}
}
/// Delivers an event to the application. /// Delivers an event to the application.
/// ///
/// The channel is bounded; if it is full this awaits until the /// The channel is bounded; if it is full this awaits until the
@@ -335,6 +455,7 @@ impl<M: Message> Shared<M> {
generation, generation,
}; };
let replaced = lock(&self.peers).insert(peer_id, state); let replaced = lock(&self.peers).insert(peer_id, state);
self.record_peer_connection_success();
if let Some(old) = replaced { if let Some(old) = replaced {
debug!(peer = %peer_id, "replacing existing connection"); debug!(peer = %peer_id, "replacing existing connection");
old.connection old.connection
@@ -786,39 +907,92 @@ impl<M: Message> Shared<M> {
/// Failures of a single round or dial are logged and retried on the next /// Failures of a single round or dial are logged and retried on the next
/// round; the loop only ends when the engine shuts down (the task is /// round; the loop only ends when the engine shuts down (the task is
/// aborted). /// aborted).
async fn rendezvous_loop<M: Message>( async fn rendezvous_loop<M: Message>(shared: Arc<Shared<M>>, config: RendezvousConfig) {
shared: Arc<Shared<M>>,
client: RendezvousClient,
config: RendezvousConfig,
) {
let mut shutdown = shared.shutdown_signal.subscribe(); let mut shutdown = shared.shutdown_signal.subscribe();
// Give the endpoint a moment to learn its relay and direct addresses so // Give the endpoint a moment to learn its relay and direct addresses so
// the very first published record is already dialable. // the very first published record is already dialable.
let _ = timeout(shared.config.request_timeout, shared.endpoint.online()).await; let _ = timeout(shared.config.request_timeout, shared.endpoint.online()).await;
let self_id = shared.endpoint.id(); let self_id = shared.endpoint.id();
let mut client = None;
loop { loop {
if shared.is_shutting_down() { if shared.is_shutting_down() {
return; return;
} }
if client.is_none() {
match RendezvousClient::new(shared.config.network_id, &config) {
Ok(new_client) => client = Some(new_client),
Err(err) => {
let failures = shared.record_rendezvous_failure(err.to_string());
warn!(error = %err, failures, "failed to start peer rendezvous; retrying");
}
}
}
let round = async { let round = async {
let Some(active_client) = client.as_ref() else {
return;
};
let addr = shared.endpoint.addr(); let addr = shared.endpoint.addr();
let self_addr = (!addr.is_empty()).then_some(addr); let self_addr = (!addr.is_empty()).then_some(addr);
match client.round(self_addr, now_ms()).await { if self_addr.is_none() {
Ok(peers) => { let failures = shared.record_rendezvous_failure(
for peer_addr in peers { "local endpoint has no dialable address".to_string(),
);
debug!(failures, "rendezvous waiting for a dialable local address");
return;
}
match timeout(
RENDEZVOUS_ROUND_TIMEOUT,
active_client.round(self_addr, now_ms()),
)
.await
{
Ok(Ok(round)) => {
let candidates = round.peers.len();
let mut attempts = 0;
let mut successes = 0;
for peer_addr in round.peers {
let peer = peer_addr.id; let peer = peer_addr.id;
if peer == self_id || lock(&shared.peers).contains_key(&peer) { if peer == self_id || lock(&shared.peers).contains_key(&peer) {
continue; continue;
} }
debug!(peer = %peer, "rendezvous discovered a peer; connecting"); debug!(peer = %peer, "rendezvous discovered a peer; connecting");
if let Err(err) = shared.connect_to_addr(peer_addr).await { attempts += 1;
// Stale entries (peers that left) fail here; they match shared.connect_to_addr(peer_addr).await {
// age out of the record by TTL. Ok(_) => successes += 1,
debug!(peer = %peer, error = %err, "rendezvous connect attempt failed"); Err(err) => {
// Stale entries (peers that left) fail here;
// they age out of the record by TTL.
debug!(peer = %peer, error = %err, "rendezvous connect attempt failed");
}
} }
} }
if let Some(error) = round.publish_error {
let failures = shared.record_rendezvous_failure(error.clone());
debug!(%error, failures, "rendezvous publish failed");
} else {
shared.record_rendezvous_success();
}
shared.record_rendezvous_peer_dials(candidates, attempts, successes);
} }
Err(err) => debug!(error = %err, "rendezvous round failed"), Ok(Err(err)) => {
let failures = shared.record_rendezvous_failure(err.to_string());
debug!(error = %err, failures, "rendezvous round failed");
}
Err(_) => {
let failures = shared.record_rendezvous_failure(format!(
"rendezvous round timed out after {}s",
RENDEZVOUS_ROUND_TIMEOUT.as_secs()
));
warn!(failures, "rendezvous round timed out");
}
}
let failures = lock(&shared.rendezvous_health)
.as_ref()
.map_or(0, |health| health.consecutive_failures);
if failures >= RENDEZVOUS_REBUILD_AFTER_FAILURES {
client = None;
shared.record_rendezvous_restart();
info!(failures, "rebuilding the rendezvous client");
} }
}; };
tokio::select! { tokio::select! {
@@ -960,6 +1134,7 @@ impl<M: Message> NetworkEngine<M> {
config: NetworkConfig, config: NetworkConfig,
secret_key: SecretKey, secret_key: SecretKey,
) -> Result<(Self, NetworkEventReceiver<M>)> { ) -> Result<(Self, NetworkEventReceiver<M>)> {
let rendezvous_enabled = config.rendezvous.is_some();
let endpoint = Endpoint::builder(presets::N0) let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key) .secret_key(secret_key)
.bind() .bind()
@@ -992,6 +1167,7 @@ impl<M: Message> NetworkEngine<M> {
tasks: Mutex::new(JoinSet::new()), tasks: Mutex::new(JoinSet::new()),
next_generation: AtomicU64::new(0), next_generation: AtomicU64::new(0),
shutting_down: AtomicBool::new(false), shutting_down: AtomicBool::new(false),
rendezvous_health: Mutex::new(rendezvous_enabled.then(RendezvousHealth::new)),
shutdown_signal: watch::Sender::new(false), shutdown_signal: watch::Sender::new(false),
}); });
let handler = FederationProtocol { let handler = FederationProtocol {
@@ -1015,17 +1191,10 @@ impl<M: Message> NetworkEngine<M> {
let router = router_builder.spawn(); let router = router_builder.spawn();
*lock(&shared.router) = Some(router); *lock(&shared.router) = Some(router);
if let Some(rendezvous) = shared.config.rendezvous.clone() { if let Some(rendezvous) = shared.config.rendezvous.clone() {
match RendezvousClient::new(shared.config.network_id, &rendezvous) { let loop_shared = shared.clone();
Ok(client) => { shared.spawn_task(async move {
let loop_shared = shared.clone(); rendezvous_loop(loop_shared, rendezvous).await;
shared.spawn_task(async move { });
rendezvous_loop(loop_shared, client, rendezvous).await;
});
}
// Rendezvous is a convenience; the engine stays usable via
// tickets even when the DHT client cannot start.
Err(err) => warn!(error = %err, "peer rendezvous disabled"),
}
} }
info!( info!(
endpoint_id = %endpoint_id, endpoint_id = %endpoint_id,
@@ -1257,6 +1426,11 @@ impl<M: Message> NetworkEngine<M> {
lock(&self.shared.peers).keys().copied().collect() lock(&self.shared.peers).keys().copied().collect()
} }
/// Returns a point-in-time snapshot of transport and rendezvous health.
pub fn health(&self) -> NetworkHealthSnapshot {
self.shared.health()
}
/// Returns `true` if there is an active connection to `peer`. /// Returns `true` if there is an active connection to `peer`.
pub fn is_connected(&self, peer: EndpointId) -> bool { pub fn is_connected(&self, peer: EndpointId) -> bool {
lock(&self.shared.peers).contains_key(&peer) lock(&self.shared.peers).contains_key(&peer)
+62
View File
@@ -0,0 +1,62 @@
//! Point-in-time health information for the transport and rendezvous loop.
use std::fmt;
use std::time::Duration;
/// Coarse health state of a running network engine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkHealthState {
/// The engine is starting or has no discovery mechanism configured.
Discovering,
/// Transport and rendezvous maintenance are operating normally.
Healthy,
/// A recent rendezvous operation failed and is being retried.
Recovering,
/// Repeated rendezvous failures indicate that the engine should be restarted.
Degraded,
}
impl NetworkHealthState {
/// Returns a compact stable label for logs and user interfaces.
pub fn as_str(self) -> &'static str {
match self {
Self::Discovering => "discovering",
Self::Healthy => "healthy",
Self::Recovering => "recovering",
Self::Degraded => "degraded",
}
}
}
impl fmt::Display for NetworkHealthState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Point-in-time health snapshot of a running network engine.
///
/// Zero connected peers is not itself an error: a network may legitimately
/// contain only one online node. [`Self::restart_recommended`] is therefore
/// based on repeated discovery failures as well as the absence of peers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkHealthSnapshot {
/// Coarse state suitable for logs and user interfaces.
pub state: NetworkHealthState,
/// Number of currently connected peers.
pub connected_peers: usize,
/// Whether Mainline-DHT rendezvous discovery is enabled.
pub rendezvous_enabled: bool,
/// Consecutive failed or timed-out rendezvous rounds.
pub consecutive_rendezvous_failures: u32,
/// Consecutive rendezvous rounds in which every discovered peer dial failed.
pub consecutive_peer_dial_failures: u32,
/// Number of times the rendezvous client was rebuilt after failures.
pub rendezvous_restarts: u64,
/// Time elapsed since the most recent successful rendezvous round.
pub last_rendezvous_success_ago: Option<Duration>,
/// Most recent rendezvous error, if a later successful round has not cleared it.
pub last_rendezvous_error: Option<String>,
/// Whether an application-owned full service restart is advisable.
pub restart_recommended: bool,
}
+2
View File
@@ -55,6 +55,7 @@ mod config;
mod engine; mod engine;
mod error; mod error;
mod event; mod event;
mod health;
mod identity; mod identity;
mod protocol; mod protocol;
mod rendezvous; mod rendezvous;
@@ -71,6 +72,7 @@ pub use engine::{
}; };
pub use error::{NetworkError, Result}; pub use error::{NetworkError, Result};
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver}; pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
pub use health::{NetworkHealthSnapshot, NetworkHealthState};
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId}; pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
pub use rendezvous::RENDEZVOUS_RECORD_VERSION; pub use rendezvous::RENDEZVOUS_RECORD_VERSION;
pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig}; pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig};
+25 -12
View File
@@ -27,7 +27,7 @@ use iroh::{EndpointAddr, EndpointId};
use mainline::async_dht::AsyncDht; use mainline::async_dht::AsyncDht;
use mainline::{Dht, MutableItem, SigningKey}; use mainline::{Dht, MutableItem, SigningKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, warn}; use tracing::debug;
use crate::error::{NetworkError, Result}; use crate::error::{NetworkError, Result};
use crate::protocol::NetworkId; use crate::protocol::NetworkId;
@@ -171,6 +171,12 @@ pub(crate) struct RendezvousClient {
entry_ttl: Duration, entry_ttl: Duration,
} }
/// Result of one read/merge/publish cycle.
pub(crate) struct RendezvousRound {
pub(crate) peers: Vec<EndpointAddr>,
pub(crate) publish_error: Option<String>,
}
impl RendezvousClient { impl RendezvousClient {
/// Binds a mainline DHT client for the rendezvous record of `network_id`. /// Binds a mainline DHT client for the rendezvous record of `network_id`.
pub(crate) fn new(network_id: NetworkId, config: &RendezvousConfig) -> Result<Self> { pub(crate) fn new(network_id: NetworkId, config: &RendezvousConfig) -> Result<Self> {
@@ -201,7 +207,7 @@ impl RendezvousClient {
&self, &self,
self_addr: Option<EndpointAddr>, self_addr: Option<EndpointAddr>,
now_ms: u64, now_ms: u64,
) -> Result<Vec<EndpointAddr>> { ) -> Result<RendezvousRound> {
let public_key = self.key.verifying_key().to_bytes(); let public_key = self.key.verifying_key().to_bytes();
let mut items = self.dht.get_mutable(&public_key, None, None); let mut items = self.dht.get_mutable(&public_key, None, None);
let mut seen = Vec::new(); let mut seen = Vec::new();
@@ -222,21 +228,28 @@ impl RendezvousClient {
let publish = self_entry.is_some(); let publish = self_entry.is_some();
let merged = merge_entries(seen, self_entry, now_ms, self.entry_ttl); let merged = merge_entries(seen, self_entry, now_ms, self.entry_ttl);
if publish { let publish_error = if publish {
let encoded = encode_record_capped(merged.clone())?; let encoded = encode_record_capped(merged.clone())?;
// Strictly newer than every instance seen this round; concurrent // Strictly newer than every instance seen this round; concurrent
// writers race, but merging on read makes lost updates benign. // writers race, but merging on read makes lost updates benign.
let item = MutableItem::new(self.key.clone(), &encoded, max_seq + 1, None); let item = MutableItem::new(self.key.clone(), &encoded, max_seq + 1, None);
if let Err(err) = self.dht.put_mutable(item, None).await { self.dht
warn!(error = %err, "failed to publish the rendezvous record"); .put_mutable(item, None)
} .await
} .err()
.map(|err| format!("failed to publish the rendezvous record: {err}"))
} else {
None
};
Ok(merged Ok(RendezvousRound {
.into_iter() peers: merged
.map(|entry| entry.addr) .into_iter()
.filter(|addr| Some(addr.id) != self_id) .map(|entry| entry.addr)
.collect()) .filter(|addr| Some(addr.id) != self_id)
.collect(),
publish_error,
})
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "music-dht" name = "music-dht"
version = "0.4.0" version = "0.4.1"
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net" description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
readme = "README.md" readme = "README.md"
documentation = "https://docs.rs/music-dht" documentation = "https://docs.rs/music-dht"
+3 -2
View File
@@ -133,6 +133,7 @@ pub use service::{
// Re-exported types from the transport layer that appear in this API. // Re-exported types from the transport layer that appear in this API.
pub use federation_net::{ pub use federation_net::{
ByteStream, ByteStreamConnectionStats, ConnectionPathKind, EndpointAddr, EndpointId, NetworkId, ByteStream, ByteStreamConnectionStats, ConnectionPathKind, EndpointAddr, EndpointId,
PeerTicket, RecvStream, RendezvousConfig, SecretKey, SendStream, StreamAcceptor, NetworkHealthSnapshot, NetworkHealthState, NetworkId, PeerTicket, RecvStream, RendezvousConfig,
SecretKey, SendStream, StreamAcceptor,
}; };
+32 -16
View File
@@ -36,8 +36,8 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
const DIAL_BACKOFF_BASE: Duration = Duration::from_secs(30); const DIAL_BACKOFF_BASE: Duration = Duration::from_secs(30);
/// Upper bound of the dial backoff. /// Upper bound of the dial backoff.
const DIAL_BACKOFF_MAX: Duration = Duration::from_secs(10 * 60); const DIAL_BACKOFF_MAX: Duration = Duration::from_secs(10 * 60);
/// Consecutive failed dials after which a contact is evicted entirely. /// Consecutive failed dials after which a contact is considered cold.
const DIAL_FAILURES_BEFORE_EVICT: u32 = 5; const DIAL_FAILURES_BEFORE_COLD: u32 = 5;
/// Dial-failure state of one currently unreachable contact. /// Dial-failure state of one currently unreachable contact.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -97,6 +97,8 @@ pub(crate) struct Node {
exchange_sent: Mutex<HashSet<EndpointId>>, exchange_sent: Mutex<HashSet<EndpointId>>,
/// Contacts that recently failed to dial, with their backoff state. /// Contacts that recently failed to dial, with their backoff state.
dial_failures: Mutex<HashMap<EndpointId, DialFailure>>, dial_failures: Mutex<HashMap<EndpointId, DialFailure>>,
/// Serializes on-demand connection attempts to the same contact.
dial_locks: Mutex<HashMap<EndpointId, Arc<tokio::sync::Mutex<()>>>>,
events: Mutex<Option<mpsc::Sender<MusicDhtEvent>>>, events: Mutex<Option<mpsc::Sender<MusicDhtEvent>>>,
/// Set once the post-startup republish has been triggered. /// Set once the post-startup republish has been triggered.
initial_republish_done: AtomicBool, initial_republish_done: AtomicBool,
@@ -123,6 +125,7 @@ impl Node {
hello_sent: Mutex::new(HashSet::new()), hello_sent: Mutex::new(HashSet::new()),
exchange_sent: Mutex::new(HashSet::new()), exchange_sent: Mutex::new(HashSet::new()),
dial_failures: Mutex::new(HashMap::new()), dial_failures: Mutex::new(HashMap::new()),
dial_locks: Mutex::new(HashMap::new()),
events: Mutex::new(Some(events)), events: Mutex::new(Some(events)),
initial_republish_done: AtomicBool::new(false), initial_republish_done: AtomicBool::new(false),
shutting_down: AtomicBool::new(false), shutting_down: AtomicBool::new(false),
@@ -478,6 +481,18 @@ impl Node {
if self.engine.is_connected(contact.peer_id) { if self.engine.is_connected(contact.peer_id) {
return Ok(contact.peer_id); return Ok(contact.peer_id);
} }
let dial_lock = lock(&self.dial_locks)
.entry(contact.peer_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
let _dial_guard = dial_lock.lock().await;
self.ensure_running()?;
if self.engine.is_connected(contact.peer_id) {
return Ok(contact.peer_id);
}
if self.dial_backoff_active(&contact.peer_id, now_ms()) {
return Err(MusicDhtError::Timeout);
}
let ticket: PeerTicket = contact let ticket: PeerTicket = contact
.ticket .ticket
.parse() .parse()
@@ -493,7 +508,7 @@ impl Node {
Ok(peer) Ok(peer)
} }
Err(err) => { Err(err) => {
self.note_dial_failure(contact).await; self.note_dial_failure(contact);
Err(err) Err(err)
} }
} }
@@ -519,35 +534,36 @@ impl Node {
} }
} }
/// Records a failed dial; after [`DIAL_FAILURES_BEFORE_EVICT`] failures /// Records a failed dial and increases its bounded retry backoff.
/// in a row the contact is dropped from the routing table and the ///
/// database (gossip re-adds it with a clean slate if it comes back). /// The contact remains in memory and durable storage: temporary network
async fn note_dial_failure(&self, contact: &NodeContact) { /// loss or system sleep must not erase the only route back to a peer.
fn note_dial_failure(&self, contact: &NodeContact) {
let consecutive = { let consecutive = {
let mut failures = lock(&self.dial_failures); let mut failures = lock(&self.dial_failures);
let failure = failures.entry(contact.peer_id).or_insert(DialFailure { let failure = failures.entry(contact.peer_id).or_insert(DialFailure {
consecutive: 0, consecutive: 0,
last_attempt_ms: 0, last_attempt_ms: 0,
}); });
failure.consecutive += 1; failure.consecutive = failure.consecutive.saturating_add(1);
failure.last_attempt_ms = now_ms(); failure.last_attempt_ms = now_ms();
failure.consecutive failure.consecutive
}; };
if consecutive < DIAL_FAILURES_BEFORE_EVICT { if consecutive == DIAL_FAILURES_BEFORE_COLD {
info!(
peer = %contact.peer_id,
consecutive,
backoff_s = dial_backoff(consecutive).as_secs(),
"DHT contact remains unavailable; retaining it for later recovery"
);
} else {
debug!( debug!(
peer = %contact.peer_id, peer = %contact.peer_id,
consecutive, consecutive,
backoff_s = dial_backoff(consecutive).as_secs(), backoff_s = dial_backoff(consecutive).as_secs(),
"dial failed; backing off" "dial failed; backing off"
); );
return;
} }
lock(&self.dial_failures).remove(&contact.peer_id);
lock(&self.routing).remove(&contact.peer_id);
if let Err(err) = self.db.delete_known_peer(contact.peer_id).await {
warn!(error = %err, "failed to delete evicted peer from the database");
}
info!(peer = %contact.peer_id, "evicted unreachable DHT contact");
} }
/// Sends one request and awaits its response, cleaning up the pending /// Sends one request and awaits its response, cleaning up the pending
+7 -2
View File
@@ -5,8 +5,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use federation_net::{ use federation_net::{
ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkId, PeerTicket, ByteStream, EndpointAddr, EndpointId, NetworkConfig, NetworkEngine, NetworkHealthSnapshot,
SchemaId, SecretKey, Signature, StreamAcceptor, NetworkId, PeerTicket, SchemaId, SecretKey, Signature, StreamAcceptor,
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -375,6 +375,11 @@ impl MusicDhtService {
self.node.engine.connected_peers() self.node.engine.connected_peers()
} }
/// Returns a point-in-time snapshot of transport and rendezvous health.
pub fn network_health(&self) -> NetworkHealthSnapshot {
self.node.engine.health()
}
/// Returns `true` if a transport connection to `peer` is open. /// Returns `true` if a transport connection to `peer` is open.
pub fn is_connected(&self, peer: EndpointId) -> bool { pub fn is_connected(&self, peer: EndpointId) -> bool {
self.node.engine.is_connected(peer) self.node.engine.is_connected(peer)