diff options
Diffstat (limited to 'src/swarm')
| -rw-r--r-- | src/swarm/auth.rs | 31 | ||||
| -rw-r--r-- | src/swarm/blob.rs | 508 | ||||
| -rw-r--r-- | src/swarm/bootstrap.rs | 13 | ||||
| -rw-r--r-- | src/swarm/members.rs | 262 | ||||
| -rw-r--r-- | src/swarm/mod.rs | 10 | ||||
| -rw-r--r-- | src/swarm/pair.rs | 78 | ||||
| -rw-r--r-- | src/swarm/peer.rs | 405 | ||||
| -rw-r--r-- | src/swarm/relay.rs | 179 | ||||
| -rw-r--r-- | src/swarm/rtt.rs | 179 | ||||
| -rw-r--r-- | src/swarm/source.rs | 210 | ||||
| -rw-r--r-- | src/swarm/stability.rs | 270 | ||||
| -rw-r--r-- | src/swarm/stats.rs | 284 | ||||
| -rw-r--r-- | src/swarm/sync.rs | 490 | ||||
| -rw-r--r-- | src/swarm/throughput.rs | 168 | ||||
| -rw-r--r-- | src/swarm/topology.rs | 521 |
15 files changed, 3176 insertions, 432 deletions
diff --git a/src/swarm/auth.rs b/src/swarm/auth.rs index ea95406..31230a5 100644 --- a/src/swarm/auth.rs +++ b/src/swarm/auth.rs @@ -8,8 +8,8 @@ //! The tag is `HMAC-SHA256(key, label ‖ swarm_id ‖ from_pk ‖ to_pk)`. It needs no //! challenge: it is bound to both keys, and only `from_pk` can send as `from_pk`, so a //! captured tag is useless to anyone else. Different labels for HELLO and HELLO_REPLY -//! keep one from being passed off as the other. A HELLO has no swarm ID yet (a new node -//! doesn't know it), so its tag binds an empty one. +//! keep one from being passed off as the other. A HELLO binds the swarm ID its sender +//! claims, or an empty one from a new node that doesn't know it yet. //! //! Limits of a shared key: every member holds the same secret, so a single member can't //! be revoked (forgetting it lasts until it says HELLO again), and changing the key @@ -82,9 +82,9 @@ impl SwarmKey { Some(std::mem::take(&mut rejections.suppressed)) } - /// Tag for a HELLO from `from` to `to`. - pub(crate) fn hello_tag(&self, from: &str, to: &str) -> Vec<u8> { - self.tag(HELLO_LABEL, "", from, to).to_vec() + /// Tag for a HELLO from `from` to `to`, claiming `swarm_id` ("" if unknown). + pub(crate) fn hello_tag(&self, swarm_id: &str, from: &str, to: &str) -> Vec<u8> { + self.tag(HELLO_LABEL, swarm_id, from, to).to_vec() } /// Tag for a HELLO_REPLY from `from` to `to`, naming the swarm. @@ -92,8 +92,8 @@ impl SwarmKey { self.tag(HELLO_REPLY_LABEL, swarm_id, from, to).to_vec() } - pub(crate) fn verify_hello(&self, tag: &[u8], from: &str, to: &str) -> bool { - constant_time_eq(tag, &self.tag(HELLO_LABEL, "", from, to)) + pub(crate) fn verify_hello(&self, tag: &[u8], swarm_id: &str, from: &str, to: &str) -> bool { + constant_time_eq(tag, &self.tag(HELLO_LABEL, swarm_id, from, to)) } pub(crate) fn verify_hello_reply( @@ -176,18 +176,19 @@ mod tests { let key = SwarmKey::new(vec![1; 32]); let other = SwarmKey::new(vec![2; 32]); - let hello = key.hello_tag("A", "B"); - assert!(key.verify_hello(&hello, "A", "B")); - assert!(!other.verify_hello(&hello, "A", "B")); - assert!(!key.verify_hello(&hello, "B", "A")); - assert!(!key.verify_hello(&hello, "A", "C")); - assert!(!key.verify_hello(&[], "A", "B")); + let hello = key.hello_tag("swarm", "A", "B"); + assert!(key.verify_hello(&hello, "swarm", "A", "B")); + assert!(!key.verify_hello(&hello, "", "A", "B")); + assert!(!other.verify_hello(&hello, "swarm", "A", "B")); + assert!(!key.verify_hello(&hello, "swarm", "B", "A")); + assert!(!key.verify_hello(&hello, "swarm", "A", "C")); + assert!(!key.verify_hello(&[], "swarm", "A", "B")); let reply = key.hello_reply_tag("swarm", "A", "B"); assert!(key.verify_hello_reply(&reply, "swarm", "A", "B")); assert!(!key.verify_hello_reply(&reply, "other-swarm", "A", "B")); // A HELLO tag is no HELLO_REPLY tag, and the other way round. - assert!(!key.verify_hello_reply(&hello, "", "A", "B")); - assert!(!key.verify_hello(&key.hello_reply_tag("", "A", "B"), "A", "B")); + assert!(!key.verify_hello_reply(&hello, "swarm", "A", "B")); + assert!(!key.verify_hello(&key.hello_reply_tag("swarm", "A", "B"), "swarm", "A", "B")); } } diff --git a/src/swarm/blob.rs b/src/swarm/blob.rs new file mode 100644 index 0000000..8c891ae --- /dev/null +++ b/src/swarm/blob.rs @@ -0,0 +1,508 @@ +//! Blobs: content-addressed bytes fetched in chunks from several peers at once. +//! +//! Large transfers can't go in one Mycelium message (it must arrive within Mycelium's +//! five-minute deadline), and a blob every node holds identically can come from many +//! of them. A blob is an Automerge change by its hash (e.g. an epoch's snapshot change, +//! which every node on that epoch holds byte for byte), or content by its BLAKE3 hash +//! (e.g. the new document of a `replace` or `import` remake). +//! +//! * Serving: a request without chunks gets the manifest (size, chunk size, a BLAKE3 +//! hash per chunk); one with chunks gets each chunk as a message of its own. A node +//! that doesn't hold the blob answers `Missing`. The bytes of the blob served last +//! are kept, so serving a download doesn't extract them for every chunk. Serving runs +//! in tasks of its own, at most [`MAX_SERVING`] chunk sends at a time. +//! * Fetching: the manifest from the first peer that has it (closest first), then the +//! chunks, a few in flight per peer from up to [`MAX_SOURCES`] peers. Every chunk is +//! checked against the manifest; a peer that sends a bad one is dropped, a chunk +//! that doesn't arrive in time is asked of another peer. The caller checks the whole +//! (for a change: its hash). +//! +//! The chunk size is the serving node's (`--blob-chunk-kib`); the manifest carries it. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use anyhow::{Result, anyhow, bail}; +use automerge::ChangeHash; +use tokio::sync::{Semaphore, mpsc}; +use tokio::time::Instant; +use tracing::{debug, info, warn}; + +use crate::state::AppState; +use crate::transport::{BlobBody, BlobId, BlobMessage, ParsedMessage}; + +/// Chunk sends in progress at once, over all downloads served. +const MAX_SERVING: usize = 8; +/// Peers a download fetches chunks from at once. +const MAX_SOURCES: usize = 4; +/// Chunks in flight per peer. +const WINDOW: usize = 4; +/// Largest blob a download accepts. +const MAX_SIZE: u64 = 4 << 30; +/// Largest chunk a download accepts. +const MAX_CHUNK: u32 = 4 << 20; +/// How long to wait for a manifest from one peer. +const MANIFEST_TIMEOUT: Duration = Duration::from_secs(15); +/// How long to wait for a chunk before asking another peer (raised by the peer's +/// round-trip time). +const CHUNK_TIMEOUT: Duration = Duration::from_secs(60); +/// A download gives up after this long without any chunk arriving. +const STALL_TIMEOUT: Duration = Duration::from_secs(180); +/// Bad chunks after which a peer is no longer asked. +const MAX_BAD: u32 = 2; + +/// Per node: what is being served and fetched. +pub(crate) struct Blobs { + chunk_size: u32, + serving: Arc<Semaphore>, + /// The blob served last. + cache: Mutex<Option<(BlobId, Arc<Prepared>)>>, + /// Answers for downloads in progress, by blob. + fetches: Mutex<HashMap<BlobId, mpsc::UnboundedSender<(String, BlobBody)>>>, +} + +struct Prepared { + bytes: Arc<Vec<u8>>, + hashes: Vec<[u8; 32]>, +} + +impl Blobs { + pub(crate) fn new(chunk_size: u32) -> Self { + Self { + chunk_size: chunk_size.max(1), + serving: Arc::new(Semaphore::new(MAX_SERVING)), + cache: Mutex::new(None), + fetches: Mutex::new(HashMap::new()), + } + } + + fn fetches( + &self, + ) -> MutexGuard<'_, HashMap<BlobId, mpsc::UnboundedSender<(String, BlobBody)>>> { + self.fetches + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn cached(&self, id: BlobId) -> Option<Arc<Prepared>> { + self.cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|(cached, _)| *cached == id) + .map(|(_, prepared)| Arc::clone(prepared)) + } + + fn remember(&self, id: BlobId, prepared: Arc<Prepared>) { + *self + .cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((id, prepared)); + } +} + +fn chunk_hashes(bytes: &[u8], chunk_size: u32) -> Vec<[u8; 32]> { + bytes + .chunks(chunk_size as usize) + .map(|chunk| *blake3::hash(chunk).as_bytes()) + .collect() +} + +fn chunk_count(size: u64, chunk_size: u32) -> u64 { + size.div_ceil(u64::from(chunk_size)) +} + +/// A blob message from `peer`. +pub(crate) async fn handle(state: &AppState, peer: &str, message: BlobMessage) { + match message.body { + BlobBody::Request { chunks } => { + let state = state.clone(); + let peer = peer.to_owned(); + // Off the inbound loop: answering may send many large messages. + tokio::spawn(async move { serve(&state, &peer, message.id, chunks).await }); + } + body => { + let sender = state.blobs.fetches().get(&message.id).cloned(); + match sender { + Some(sender) => { + let _ = sender.send((peer.to_owned(), body)); + } + None => { + debug!(peer = %peer, blob = %message.id, "Blob answer for no download in progress") + } + } + } + } +} + +async fn prepare(state: &AppState, id: BlobId) -> Option<Arc<Prepared>> { + if let Some(prepared) = state.blobs.cached(id) { + return Some(prepared); + } + let bytes = match id { + BlobId::Change(hash) => { + let doc = Arc::clone(&state.doc); + tokio::task::spawn_blocking(move || { + // get_change_by_hash needs the document mutably. + let mut replica = doc.blocking_write(); + replica + .doc + .get_change_by_hash(&hash) + .map(|change| change.raw_bytes().to_vec()) + }) + .await + .ok() + .flatten() + .map(Arc::new)? + } + BlobId::Content(hash) => state.remake.content(&hash)?, + }; + let chunk_size = state.blobs.chunk_size; + let prepared = tokio::task::spawn_blocking(move || { + let hashes = chunk_hashes(&bytes, chunk_size); + Prepared { bytes, hashes } + }) + .await + .ok()?; + let prepared = Arc::new(prepared); + state.blobs.remember(id, Arc::clone(&prepared)); + Some(prepared) +} + +async fn reply(state: &AppState, peer: &str, id: BlobId, body: BlobBody) { + let message = ParsedMessage::Blob(BlobMessage { + epoch: state.local_epoch(), + id, + body, + }); + if let Err(error) = state.transport.send_message(peer, &message).await { + debug!(peer = %peer, blob = %id, error = %format!("{error:#}"), "Blob answer failed"); + } +} + +async fn serve(state: &AppState, peer: &str, id: BlobId, chunks: Vec<u32>) { + let Some(prepared) = prepare(state, id).await else { + return reply(state, peer, id, BlobBody::Missing).await; + }; + let chunk_size = state.blobs.chunk_size; + if chunks.is_empty() { + let body = BlobBody::Manifest { + size: prepared.bytes.len() as u64, + chunk_size, + hashes: prepared.hashes.clone(), + }; + return reply(state, peer, id, body).await; + } + for index in chunks.into_iter().take(WINDOW * 2) { + let start = index as usize * chunk_size as usize; + if start >= prepared.bytes.len() { + continue; + } + let end = (start + chunk_size as usize).min(prepared.bytes.len()); + let Ok(_permit) = state.blobs.serving.acquire().await else { + return; + }; + let data = bytes::Bytes::copy_from_slice(&prepared.bytes[start..end]); + reply(state, peer, id, BlobBody::Chunk { index, data }).await; + } +} + +// --- fetching ---------------------------------------------------------------------- + +/// Removes the download's answer channel when it ends, however it ends. +struct Registration<'a> { + state: &'a AppState, + id: BlobId, +} + +impl Drop for Registration<'_> { + fn drop(&mut self) { + self.state.blobs.fetches().remove(&self.id); + } +} + +async fn request(state: &AppState, peer: &str, id: BlobId, chunks: Vec<u32>) { + let message = ParsedMessage::Blob(BlobMessage { + epoch: state.local_epoch(), + id, + body: BlobBody::Request { chunks }, + }); + if let Err(error) = state.transport.send_message(peer, &message).await { + debug!(peer = %peer, blob = %id, error = %format!("{error:#}"), "Blob request failed"); + } +} + +struct Manifest { + size: u64, + chunk_size: u32, + hashes: Vec<[u8; 32]>, +} + +/// One peer during a download. +struct Source { + peer: String, + in_flight: HashMap<u32, Instant>, + window: usize, + bad: u32, + timeout: Duration, +} + +/// Downloads blob `id` from `peers` (all thought to hold it). +pub(crate) async fn fetch(state: &AppState, id: BlobId, peers: Vec<String>) -> Result<Vec<u8>> { + let (sender, mut answers) = mpsc::unbounded_channel(); + if state.blobs.fetches().insert(id, sender).is_some() { + bail!("{id} is being downloaded already"); + } + let _registration = Registration { state, id }; + + // Closest first; peers without a round-trip time last. + let now = Instant::now(); + let mut peers: Vec<(String, Option<Duration>)> = { + let rtt = state.lock_rtt(); + peers + .into_iter() + .map(|peer| { + let estimate = rtt.effective(&peer, now, state.timing.rtt_fresh_for()); + (peer, estimate) + }) + .collect() + }; + peers.sort_by_key(|(_, rtt)| rtt.unwrap_or(Duration::MAX)); + let rtts: HashMap<String, Option<Duration>> = peers.iter().cloned().collect(); + let mut candidates: VecDeque<String> = peers.into_iter().map(|(peer, _)| peer).collect(); + if candidates.is_empty() { + bail!("no peer to download {id} from"); + } + + // The manifest, from the first peer that has the blob. + let mut manifest = None; + let mut missing: HashSet<String> = HashSet::new(); + for peer in &candidates { + request(state, peer, id, Vec::new()).await; + let deadline = Instant::now() + MANIFEST_TIMEOUT; + loop { + let answer = tokio::time::timeout_at(deadline, answers.recv()).await; + match answer { + Ok(Some(( + from, + BlobBody::Manifest { + size, + chunk_size, + hashes, + }, + ))) => { + if size > MAX_SIZE + || chunk_size == 0 + || chunk_size > MAX_CHUNK + || hashes.len() as u64 != chunk_count(size, chunk_size) + { + warn!(peer = %from, blob = %id, size, chunk_size, "Unusable blob manifest; asking another peer"); + missing.insert(from); + break; + } + manifest = Some(Manifest { + size, + chunk_size, + hashes, + }); + break; + } + Ok(Some((from, BlobBody::Missing))) => { + missing.insert(from.clone()); + if &from == peer { + break; + } + } + Ok(Some(_)) => {} // chunks of an earlier attempt + Ok(None) | Err(_) => break, + } + } + if manifest.is_some() { + break; + } + } + let Some(manifest) = manifest else { + bail!("no peer sent a manifest for {id}"); + }; + candidates.retain(|peer| !missing.contains(peer)); + + let total = manifest.hashes.len(); + info!(blob = %id, bytes = manifest.size, chunks = total, peers = candidates.len(), "Downloading blob"); + let started = Instant::now(); + let mut chunks: Vec<Option<bytes::Bytes>> = vec![None; total]; + let mut done = 0usize; + let mut sources: Vec<Source> = Vec::new(); + let mut last_progress = Instant::now(); + + while done < total { + // Fill up the sources, then their windows. + while sources.len() < MAX_SOURCES { + let Some(peer) = candidates.pop_front() else { + break; + }; + let timeout = + CHUNK_TIMEOUT + rtts.get(&peer).copied().flatten().unwrap_or_default() * 4; + sources.push(Source { + peer, + in_flight: HashMap::new(), + window: WINDOW, + bad: 0, + timeout, + }); + } + if sources.is_empty() { + bail!("no peer left to download {id} from ({done} of {total} chunks)"); + } + let now = Instant::now(); + let assigned: HashSet<u32> = sources + .iter() + .flat_map(|source| source.in_flight.keys().copied()) + .collect(); + let mut wanted = (0..total as u32) + .filter(|index| chunks[*index as usize].is_none() && !assigned.contains(index)); + for source in &mut sources { + let free = source.window.saturating_sub(source.in_flight.len()); + let batch: Vec<u32> = wanted.by_ref().take(free).collect(); + if batch.is_empty() { + continue; + } + for index in &batch { + source.in_flight.insert(*index, now); + } + request(state, &source.peer, id, batch).await; + } + + match tokio::time::timeout(Duration::from_millis(500), answers.recv()).await { + Ok(Some((from, BlobBody::Chunk { index, data }))) => { + let Some(source) = sources.iter_mut().find(|source| source.peer == from) else { + continue; + }; + let sent_at = source.in_flight.remove(&index); + let slot = index as usize; + if slot >= total || chunks[slot].is_some() { + continue; + } + let expected = expected_len(&manifest, slot); + if data.len() != expected + || *blake3::hash(&data).as_bytes() != manifest.hashes[slot] + { + source.bad += 1; + warn!(peer = %from, blob = %id, index, "Bad blob chunk"); + continue; + } + if let Some(sent_at) = sent_at { + debug!(peer = %from, blob = %id, index, ms = sent_at.elapsed().as_millis() as u64, "Blob chunk"); + } + chunks[slot] = Some(data); + done += 1; + last_progress = Instant::now(); + // A peer that delivers gets its full window back. + source.window = WINDOW; + } + Ok(Some((from, BlobBody::Missing))) => { + sources.retain(|source| source.peer != from); + } + Ok(Some(_)) => {} + Ok(None) => bail!("download of {id} cancelled"), + Err(_) => {} + } + + // Chunks that took too long go to another peer; the slow peer gets a smaller + // window. Peers that sent bad chunks are dropped. + let now = Instant::now(); + for source in &mut sources { + let timeout = source.timeout; + let before = source.in_flight.len(); + source + .in_flight + .retain(|_, sent| now.saturating_duration_since(*sent) < timeout); + if source.in_flight.len() < before { + source.window = 1; + debug!(peer = %source.peer, blob = %id, "Blob chunks timed out; asking others"); + } + } + sources.retain(|source| { + let keep = source.bad < MAX_BAD; + if !keep { + warn!(peer = %source.peer, blob = %id, "Dropping a peer that sent bad blob chunks"); + } + keep + }); + if last_progress.elapsed() > STALL_TIMEOUT { + bail!( + "download of {id} stalled ({done} of {total} chunks, nothing for {}s)", + STALL_TIMEOUT.as_secs() + ); + } + } + + let mut bytes = Vec::with_capacity(manifest.size as usize); + for chunk in chunks.into_iter().flatten() { + bytes.extend_from_slice(&chunk); + } + info!( + blob = %id, + bytes = bytes.len(), + secs = started.elapsed().as_secs_f32(), + "Downloaded blob" + ); + Ok(bytes) +} + +fn expected_len(manifest: &Manifest, slot: usize) -> usize { + let chunk_size = manifest.chunk_size as u64; + let start = slot as u64 * chunk_size; + (manifest.size - start).min(chunk_size) as usize +} + +/// Downloads change `hash` from `peers` and checks it. +/// Downloads the content with BLAKE3 hash `hash` from `peers` and checks it. +pub(crate) async fn fetch_content( + state: &AppState, + hash: [u8; 32], + peers: Vec<String>, +) -> Result<Vec<u8>> { + let id = BlobId::Content(hash); + let bytes = fetch(state, id, peers).await?; + if *blake3::hash(&bytes).as_bytes() != hash { + bail!("downloaded {id} doesn't match its hash"); + } + Ok(bytes) +} + +pub(crate) async fn fetch_change( + state: &AppState, + hash: ChangeHash, + peers: Vec<String>, +) -> Result<automerge::Change> { + let bytes = fetch(state, BlobId::Change(hash), peers).await?; + let change = automerge::Change::from_bytes(bytes) + .map_err(|error| anyhow!("downloaded change {hash} doesn't load: {error}"))?; + if change.hash() != hash { + bail!("downloaded change has hash {}, not {hash}", change.hash()); + } + Ok(change) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunking() { + let bytes: Vec<u8> = (0..1000u32).map(|n| n as u8).collect(); + let hashes = chunk_hashes(&bytes, 300); + assert_eq!(hashes.len(), 4); + assert_eq!(chunk_count(1000, 300), 4); + assert_eq!(chunk_count(900, 300), 3); + let manifest = Manifest { + size: 1000, + chunk_size: 300, + hashes, + }; + assert_eq!(expected_len(&manifest, 0), 300); + assert_eq!(expected_len(&manifest, 3), 100); + assert_eq!(manifest.hashes[3], *blake3::hash(&bytes[900..]).as_bytes()); + } +} diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs index 3b6e510..27ffdd8 100644 --- a/src/swarm/bootstrap.rs +++ b/src/swarm/bootstrap.rs @@ -7,7 +7,7 @@ use anyhow::{Result, bail}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::state::{AppState, PeerMembership}; +use crate::state::AppState; use crate::swarm::peer::{remember_peer, send_hello_once}; const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(300); @@ -68,15 +68,10 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String]) -> Result<()> for (peer, peer_state_mutex) in &peer_states { let peer_state = peer_state_mutex.lock().await; - let validated_swarm = match &peer_state.membership { - PeerMembership::Unknown => None, - PeerMembership::Member { swarm_id } => Some(swarm_id.as_str()), - }; + let validated_swarm = peer_state.standing.swarm_id(); peer_diagnostics.push(format!( - "{peer}: validated_swarm={:?}, epoch={:?}, pending_inbound_sync={}", - validated_swarm, - peer_state.epoch, - peer_state.pending_inbound_sync.len() + "{peer}: validated_swarm={:?}, epoch={:?}", + validated_swarm, peer_state.epoch, )) } let local_swarm = state.swarm_id().await; diff --git a/src/swarm/members.rs b/src/swarm/members.rs new file mode 100644 index 0000000..221437e --- /dev/null +++ b/src/swarm/members.rs @@ -0,0 +1,262 @@ +//! The member registry: every node that has joined the swarm, under +//! `_continuum/members/<pk>`, replicated with the document (and carried into new +//! epochs with the rest of `_continuum`). +//! +//! A pure registry: it decides nothing about admission (that's the swarm key). Each +//! node registers itself once it is ready, and re-registers if its entry disappears +//! while it is alive. An operator's forget (`DELETE /peers/{pk}`) removes the entry +//! together with writing the forget record; a forgotten node that turns out to be alive +//! cancels the record and registers again. +//! +//! Besides its tier, an entry holds the alias a node gives itself (`--alias`), a name +//! for people reading /peers and metrics (not unique and never an identity: the public +//! key stays that), and an `alive` stamp the node renews now and then: the members +//! renewed within `--electorate-window-days` are the electorate of a remake. + +use std::collections::HashMap; +use std::time::Duration; + +use automerge::AutoCommit; +use serde_json::{Value as JsonValue, json}; +use tracing::{debug, info, warn}; + +use crate::document::store::{self, StoreError}; +use crate::state::{AppState, unix_millis}; + +const META_ROOT: &str = "_continuum"; + +fn members_dir() -> Vec<String> { + vec![META_ROOT.to_owned(), "members".to_owned()] +} + +pub(crate) fn member_path(pk: &str) -> Vec<String> { + let mut path = members_dir(); + path.push(pk.to_owned()); + path +} + +/// Public keys of all registered members, sorted. +pub(crate) fn members(doc: &AutoCommit) -> Vec<String> { + let mut members = store::keys_at(doc, &members_dir()).unwrap_or_default(); + members.sort(); + members +} + +/// A member's stability tier, judged by itself (`swarm::stability`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Tier { + Stable, + #[default] + Transient, +} + +impl Tier { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Transient => "transient", + } + } +} + +fn tier_path(pk: &str) -> Vec<String> { + let mut path = member_path(pk); + path.push("tier".to_owned()); + path +} + +/// `pk`'s tier; transient unless its entry says stable. +pub(crate) fn tier(doc: &AutoCommit, pk: &str) -> Tier { + match store::read_path(doc, &tier_path(pk)) { + Ok(Some(JsonValue::String(tier))) if tier == "stable" => Tier::Stable, + _ => Tier::Transient, + } +} + +pub(crate) fn set_tier(doc: &mut AutoCommit, pk: &str, tier: Tier) -> Result<(), StoreError> { + store::write_path(doc, &tier_path(pk), &json!(tier.as_str())) +} + +fn alias_path(pk: &str) -> Vec<String> { + let mut path = member_path(pk); + path.push("alias".to_owned()); + path +} + +/// `pk`'s alias, if it gave itself one. +pub(crate) fn alias(doc: &AutoCommit, pk: &str) -> Option<String> { + match store::read_path(doc, &alias_path(pk)) { + Ok(Some(JsonValue::String(alias))) => Some(alias), + _ => None, + } +} + +/// The aliases of all members that have one. +pub(crate) fn aliases(doc: &AutoCommit) -> HashMap<String, String> { + members(doc) + .into_iter() + .filter_map(|pk| alias(doc, &pk).map(|alias| (pk, alias))) + .collect() +} + +fn set_alias(doc: &mut AutoCommit, pk: &str, alias: Option<&str>) -> Result<(), StoreError> { + match alias { + Some(alias) => store::write_path(doc, &alias_path(pk), &json!(alias)), + None => match store::delete_path(doc, &alias_path(pk), false) { + Err(StoreError::NotFound) => Ok(()), + result => result, + }, + } +} + +fn alive_path(pk: &str) -> Vec<String> { + let mut path = member_path(pk); + path.push("alive".to_owned()); + path +} + +fn millis_at(doc: &AutoCommit, path: &[String]) -> Option<u64> { + match store::read_path(doc, path) { + Ok(Some(JsonValue::Number(number))) => number.as_u64(), + _ => None, + } +} + +/// When `pk` last said it is alive (Unix ms): its last renewal, or its registration. +pub(crate) fn alive_at(doc: &AutoCommit, pk: &str) -> Option<u64> { + let mut since = member_path(pk); + since.push("since".to_owned()); + millis_at(doc, &alive_path(pk)).max(millis_at(doc, &since)) +} + +/// The members that said they are alive within `window` before `now_ms` (sorted): the +/// electorate of a remake (`remake::coordinator`). +pub(crate) fn electorate(doc: &AutoCommit, now_ms: u64, window: Duration) -> Vec<String> { + let oldest = now_ms.saturating_sub(window.as_millis() as u64); + members(doc) + .into_iter() + .filter(|pk| alive_at(doc, pk).is_some_and(|at| at >= oldest)) + .collect() +} + +/// How often a node renews its `alive` stamp: often enough that a live node never +/// drops out of the electorate (a seventh of the window), but at most hourly and at +/// least daily, so idle swarms stay quiet. +fn alive_renewal(window: Duration) -> Duration { + (window / 7).clamp(Duration::from_secs(3600), Duration::from_secs(24 * 3600)) +} + +/// Registers this node if it is ready and its entry is missing, keeps its alias as +/// configured (set, changed or removed after a restart with another `--alias`), and +/// renews its `alive` stamp now and then (see `electorate`). +pub(crate) async fn ensure_registered(state: &AppState) { + if !state.is_ready().await { + return; + } + let local = state.local_id().to_owned(); + let own = member_path(&local); + let (registered, recorded_alias, alive) = { + let replica = state.doc.read().await; + let registered = store::read_path(&replica.doc, &own) + .ok() + .flatten() + .is_some(); + ( + registered, + alias(&replica.doc, &local), + millis_at(&replica.doc, &alive_path(&local)), + ) + }; + let wanted = state.alias.clone(); + let now = unix_millis(); + + if !registered { + let mut entry = json!({ "since": now, "alive": now }); + if let Some(alias) = &wanted { + entry["alias"] = json!(alias); + } + match state + .mutate_doc(move |doc| store::write_path(doc, &own, &entry)) + .await + { + Ok(()) => info!( + alias = wanted.as_deref(), + "Registered in the swarm's member list" + ), + Err(error) => warn!(error = %error, "Failed to register in the member list"), + } + return; + } + + let renewal = alive_renewal(state.electorate_window).as_millis() as u64; + if alive.is_none_or(|at| now.saturating_sub(at) >= renewal) { + let path = alive_path(&local); + match state + .mutate_doc(move |doc| store::write_path(doc, &path, &json!(now))) + .await + { + Ok(()) => debug!("Renewed our alive stamp in the member list"), + Err(error) => warn!(error = %error, "Failed to renew our alive stamp"), + } + } + + if recorded_alias == wanted { + return; + } + let alias = wanted.clone(); + match state + .mutate_doc(move |doc| set_alias(doc, &local, alias.as_deref())) + .await + { + Ok(()) => info!( + alias = wanted.as_deref(), + previous = recorded_alias.as_deref(), + "Alias updated in the member list" + ), + Err(error) => warn!(error = %error, "Failed to record our alias"), + } +} + +/// Removes `pk`'s entry (an operator forgot it). Absent is fine. +pub(crate) fn unregister(doc: &mut AutoCommit, pk: &str) -> Result<(), StoreError> { + match store::delete_path(doc, &member_path(pk), false) { + Err(StoreError::NotFound) => Ok(()), + result => result, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn electorate_by_alive_stamp() { + let day = 24 * 3600 * 1000; + let now = 100 * day; + let mut doc = AutoCommit::new(); + let entries = [ + ("fresh", json!({ "since": 1, "alive": now - day })), + ("stale", json!({ "since": 1, "alive": now - 8 * day })), + ("new", json!({ "since": now - day })), + ("old", json!({ "since": 1 })), + ]; + for (pk, entry) in entries { + store::write_path(&mut doc, &member_path(pk), &entry).unwrap(); + } + assert_eq!( + electorate(&doc, now, Duration::from_secs(7 * 24 * 3600)), + ["fresh", "new"] + ); + } + + #[test] + fn renewal_interval() { + let day = Duration::from_secs(24 * 3600); + assert_eq!(alive_renewal(7 * day), day); + assert_eq!(alive_renewal(70 * day), day); + assert_eq!(alive_renewal(day), day / 7); + // The hourly floor only matters for windows shorter than the option allows. + let hour = Duration::from_secs(3600); + assert_eq!(alive_renewal(2 * hour), hour); + } +} diff --git a/src/swarm/mod.rs b/src/swarm/mod.rs index a4277c7..adb5d1c 100644 --- a/src/swarm/mod.rs +++ b/src/swarm/mod.rs @@ -2,6 +2,16 @@ //! Automerge synchronization with every member (`sync`). pub(crate) mod auth; +pub(crate) mod blob; pub(crate) mod bootstrap; +pub(crate) mod members; +pub(crate) mod pair; pub(crate) mod peer; +pub(crate) mod relay; +pub(crate) mod rtt; +pub(crate) mod source; +pub(crate) mod stability; +pub(crate) mod stats; pub(crate) mod sync; +pub(crate) mod throughput; +pub(crate) mod topology; diff --git a/src/swarm/pair.rs b/src/swarm/pair.rs new file mode 100644 index 0000000..c1a196b --- /dev/null +++ b/src/swarm/pair.rs @@ -0,0 +1,78 @@ +//! Which side of a pair of nodes leads, for duties only one side needs to do (sending +//! keepalive PINGs). +//! +//! Both sides decide on their own, from the two keys alone, and agree. The leader is +//! chosen per pair by one bit of a hash of both keys, not by comparing the keys: a +//! global order would make the node with the smallest key lead every one of its pairs +//! and the one with the largest lead none, so every such duty would pile up on the same +//! few nodes. With the hash, each node leads about half of its pairs. + +use sha2::{Digest, Sha256}; + +const LABEL: &[u8] = b"continuum/pair-leader/1"; + +/// Whether `local` leads its pair with `peer`. Exactly one of `leads(a, b)` and +/// `leads(b, a)` is true for any two different keys. +pub(crate) fn leads(local: &str, peer: &str) -> bool { + let (low, high) = if local <= peer { + (local, peer) + } else { + (peer, local) + }; + let mut hash = Sha256::new(); + // Length-prefixed fields, so no two different pairs hash the same input. + for field in [LABEL, low.as_bytes(), high.as_bytes()] { + hash.update((field.len() as u32).to_be_bytes()); + hash.update(field); + } + let leader = if hash.finalize()[0] & 1 == 0 { + low + } else { + high + }; + leader == local +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(n: u32) -> String { + format!("{:064x}", u64::from(n).wrapping_mul(0x9e37_79b9_7f4a_7c15)) + } + + #[test] + fn exactly_one_side_leads() { + for a in 0..50 { + for b in 0..50 { + if a != b { + assert_ne!(leads(&key(a), &key(b)), leads(&key(b), &key(a))); + } + } + } + } + + #[test] + fn leading_is_spread_across_nodes() { + let nodes: Vec<String> = (0..64).map(key).collect(); + let mut smallest_key_leads = 0; + for (i, node) in nodes.iter().enumerate() { + let led = nodes + .iter() + .filter(|peer| *peer != node && leads(node, peer)) + .count(); + // 63 pairs per node: about half each; far from all or none. + assert!((10..=53).contains(&led), "node {i} leads {led} of 63 pairs"); + for peer in &nodes { + if node < peer && leads(node, peer) { + smallest_key_leads += 1; + } + } + } + // Not simply the smaller key of each pair (64 * 63 / 2 = 2016 pairs). + assert!( + (800..=1216).contains(&smallest_key_leads), + "{smallest_key_leads}" + ); + } +} diff --git a/src/swarm/peer.rs b/src/swarm/peer.rs index b6c9700..3d7da32 100644 --- a/src/swarm/peer.rs +++ b/src/swarm/peer.rs @@ -2,24 +2,22 @@ //! swarm-wide forget records, and dispatch of inbound messages. use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use automerge::AutoCommit; -use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use tokio::time::{Instant, MissedTickBehavior}; use tracing::{debug, info, instrument, trace, warn}; use crate::document::replica::{Epoch, EpochRelation}; use crate::document::store::{self, StoreError}; -use crate::state::{AppState, HelloState, Liveness, PeerMembership, PeerState}; +use crate::state::{AppState, HelloState, Liveness, PeerState, Standing}; use crate::swarm::sync::{SyncEvent, handle_sync}; +use crate::swarm::topology; use crate::transport::{self, ParsedMessage}; -const HELLO_RETRY_INITIAL: Duration = Duration::from_secs(1); -const HELLO_RETRY_MAX: Duration = Duration::from_secs(30); - const PEER_TICK: Duration = Duration::from_millis(500); const META_ROOT: &str = "_continuum"; @@ -98,24 +96,63 @@ async fn apply_forget_records(state: &AppState) { // --- keepalive and self-healing ---------------------------------------------- -fn heads_digest(doc: &mut AutoCommit) -> transport::HeadsDigest { - let mut heads = doc.get_heads(); - heads.sort_unstable(); - let mut hasher = Sha256::new(); - for head in &heads { - hasher.update(head.0); - } - hasher.finalize().into() +/// Sends `peer` a PING with our keepalive, noting the time for its round trip. +pub(crate) async fn send_ping(state: &AppState, peer: &str) -> Result<()> { + let keepalive = local_keepalive(state, peer).await; + state.lock_rtt().ping_sent(peer, Instant::now()); + state + .transport + .send_message(peer, &ParsedMessage::Ping(keepalive)) + .await } -pub(crate) async fn local_keepalive(state: &AppState) -> transport::Keepalive { - // AutoCommit::get_heads takes &mut self (it closes the pending transaction). - // Digest and epoch come from the same locked replica, so they always match. - let mut replica = state.doc.write().await; +/// Our keepalive for `peer`. +pub(crate) async fn local_keepalive(state: &AppState, peer: &str) -> transport::Keepalive { + // Heads and epoch come from the same locked replica, so they always match. The heads + // are cached (`Replica`), so the read lock is enough. + let (heads, epoch) = { + let replica = state.doc.read().await; + ( + transport::HeadsSummary::of(replica.heads().to_vec()), + replica.epoch, + ) + }; + let syncs_with_you = match state.get_peer_state(peer).await { + Some(peer_state) => { + let peer_state = peer_state.lock().await; + peer_state.liveness == Liveness::Active + && matches!(peer_state.standing, Standing::Member { .. }) + && peer_state.epoch == Some(epoch) + } + None => false, + }; transport::Keepalive { - heads_digest: heads_digest(&mut replica.doc), - epoch: replica.epoch, + heads, + epoch, + ready: state.is_ready().await, + syncs_with_you, + } +} + +/// What a peer's keepalive says about it. +async fn note_keepalive(state: &AppState, peer: &str, remote: &transport::Keepalive) { + if let Some(peer_state) = state.get_peer_state(peer).await { + let mut peer_state = peer_state.lock().await; + let became_ready = remote.ready && peer_state.reports_ready != Some(true); + peer_state.reports_ready = Some(remote.ready); + peer_state.syncs_with_us = Some(remote.syncs_with_you); + peer_state.reported_heads = remote.heads.heads().map(<[_]>::len); + drop(peer_state); + crate::swarm::relay::pull_if_behind(state, peer, remote).await; + if became_ready { + // We may have left the opening to it while it wasn't ready, and nothing else + // would make us look again. + let _ = state + .sync_events + .send(SyncEvent::PeerNeedsSync(peer.to_owned())); + } } + crate::swarm::source::reported(state, peer, remote.ready, Some(remote.syncs_with_you)); } async fn heal_if_diverged( @@ -124,8 +161,9 @@ async fn heal_if_diverged( local: &transport::Keepalive, remote: &transport::Keepalive, ) { - // Digests of documents from different epochs are never equal and never converge. - if local.epoch != remote.epoch { + // Heads of documents from different epochs are never equal and never converge. + // And a peer that isn't our neighbour isn't ours to keep in step. + if local.epoch != remote.epoch || !topology::is_neighbour(state, peer) { return; } @@ -135,18 +173,17 @@ async fn heal_if_diverged( let heal = { let mut peer_state = peer_state_mutex.lock().await; - if local.heads_digest == remote.heads_digest { + if local.heads == remote.heads { // Same document: whatever answer we were waiting for, there is nothing left // to send. (Automerge answers only when it has something to say, so a sync // message to a peer that already has everything gets no reply at all.) - peer_state.sync_in_step(); + peer_state.sync.reply_settled(); } - peer_state.needs_heal( - &local.heads_digest, - &remote.heads_digest, - &state.timing, - Instant::now(), - ) + // While we catch up, we hold back sync with everyone but the source on purpose: + // differing heads and idle sync are expected then, not a reason to heal. + let catching_up = !state.initial_sync_done.load(Ordering::Acquire); + !catching_up + && peer_state.needs_heal(&local.heads, &remote.heads, &state.timing, Instant::now()) }; if heal { @@ -171,7 +208,7 @@ pub(crate) async fn remember_peer(state: &AppState, peer: &str) { let mut peer_states = state.peer_states.write().await; if !peer_states.contains_key(peer) { - peer_states.entry(peer.to_owned()).or_default(); + peer_states.insert(peer.to_owned(), state.new_peer_state(peer)); info!( peer = %peer, peer_count = peer_states.len(), @@ -191,9 +228,10 @@ pub(crate) async fn send_hello_once(state: &AppState, peer: &str) -> Result<()> let claimed = { let mut peer_state = peer_state_mutex.lock().await; if peer_state.hello_state == HelloState::NotSent { - peer_state.hello_state = HelloState::Sending { - next_delay: HELLO_RETRY_INITIAL, - }; + // A new handshake: retries start over. + peer_state.hello_state = HelloState::Sending; + peer_state.hello_backoff.reset(); + peer_state.hello_attempts = 0; true } else { trace!(peer = %peer, hello_state = ?peer_state.hello_state, "not sending"); @@ -206,7 +244,7 @@ pub(crate) async fn send_hello_once(state: &AppState, peer: &str) -> Result<()> } debug!(to = %peer, "Sending HELLO"); - transmit_hello(state, peer, HELLO_RETRY_INITIAL).await + transmit_hello(state, peer).await } async fn retry_hello_if_due(state: &AppState, peer: &str) -> Result<()> { @@ -214,43 +252,41 @@ async fn retry_hello_if_due(state: &AppState, peer: &str) -> Result<()> { return Ok(()); }; - let delay = { + { let mut peer_state = peer_state_mutex.lock().await; match peer_state.hello_state { - HelloState::AwaitingReply { - next_attempt, - next_delay, - } if Instant::now() >= next_attempt => { - peer_state.hello_state = HelloState::Sending { next_delay }; - next_delay + HelloState::AwaitingReply { next_attempt } if Instant::now() >= next_attempt => { + peer_state.hello_state = HelloState::Sending; } _ => return Ok(()), } - }; + } - debug!( - peer = %peer, - next_retry_ms = delay.as_millis(), - "No HELLO_REPLY yet; re-sending HELLO" - ); - transmit_hello(state, peer, delay).await + debug!(peer = %peer, "No HELLO_REPLY yet; re-sending HELLO"); + transmit_hello(state, peer).await } /// Sends a HELLO without holding any lock, then schedules the next retry. /// Success and failure both end in AwaitingReply: Mycelium accepting the message /// says nothing about whether the peer received it. -async fn transmit_hello(state: &AppState, peer: &str, delay: Duration) -> Result<()> { +async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> { + let swarm_id = state.swarm_id().await; + let auth = state + .swarm_key + .as_ref() + .map(|key| key.hello_tag(swarm_id.as_deref().unwrap_or(""), state.local_id(), peer)) + .unwrap_or_default(); + state.lock_rtt().hello_sent(peer, Instant::now()); let result = state .transport .send_message( peer, &ParsedMessage::Hello(transport::HelloRequest { + swarm_id, epoch: state.local_epoch(), - auth: state - .swarm_key - .as_ref() - .map(|key| key.hello_tag(state.local_id(), peer)) - .unwrap_or_default(), + auth, + ready: state.is_ready().await, + run_id: state.run_id, }), ) .await; @@ -258,16 +294,14 @@ async fn transmit_hello(state: &AppState, peer: &str, delay: Duration) -> Result if let Some(peer_state_mutex) = state.get_peer_state(peer).await { let mut peer_state = peer_state_mutex.lock().await; - if matches!(peer_state.hello_state, HelloState::Sending { .. }) { - let (wait, next_delay) = match peer_state.liveness { - Liveness::Dormant { .. } => { - (state.timing.dormant_probe, state.timing.dormant_probe) - } - Liveness::Active => (delay, delay.saturating_mul(2).min(HELLO_RETRY_MAX)), + if peer_state.hello_state == HelloState::Sending { + peer_state.hello_sent(Instant::now()); + let wait = match peer_state.liveness { + Liveness::Dormant { .. } => state.timing.dormant_probe, + Liveness::Active => peer_state.hello_backoff.next(), }; peer_state.hello_state = HelloState::AwaitingReply { next_attempt: Instant::now() + wait, - next_delay, }; } } @@ -281,36 +315,14 @@ async fn ensure_handshake(state: &AppState, peer: &str) { } } -/// Sends a HELLO_REPLY if one is owed and our swarm ID is known. -/// Otherwise it stays owed and the peer worker tries again. -async fn send_owed_hello_reply(state: &AppState, peer: &str) { - if state.swarm_id().await.is_none() { - return; - } - - let Some(peer_state_mutex) = state.get_peer_state(peer).await else { - return; - }; - - let owed = std::mem::take(&mut peer_state_mutex.lock().await.reply_owed); - if !owed { - return; - } - - if let Err(error) = send_hello_reply(state, peer).await { - warn!(peer = %peer, error = %format!("{error:#}"), "HELLO_REPLY failed; will retry"); - peer_state_mutex.lock().await.reply_owed = true; - } -} - -async fn flush_owed_hello_replies(state: &AppState) { - let peers: Vec<String> = state.peer_states.read().await.keys().cloned().collect(); - for peer in &peers { - send_owed_hello_reply(state, peer).await; - } -} - -async fn send_hello_reply(state: &AppState, peer: &str) -> Result<()> { +/// Answers a HELLO that carried `run_id`; `away` is how long before it we had last heard +/// from the peer. +async fn send_hello_reply( + state: &AppState, + peer: &str, + run_id: transport::RunId, + away: Option<Duration>, +) -> Result<()> { let swarm_id = state .swarm_id() .await @@ -330,6 +342,7 @@ async fn send_hello_reply(state: &AppState, peer: &str) -> Result<()> { let peer_state = peer_state_mutex.lock().await; if peer_state.liveness == Liveness::Active && peer_state.hello_state == HelloState::Established + && topology::is_neighbour(state, &known) { peers.push(known); } @@ -340,15 +353,17 @@ async fn send_hello_reply(state: &AppState, peer: &str) -> Result<()> { .as_ref() .map(|key| key.hello_reply_tag(&swarm_id, state.local_id(), peer)) .unwrap_or_default(); + let ready = state.is_ready().await; let reply = transport::HelloReply { swarm_id, peers, epoch: state.local_epoch(), auth, + ready, + run_id, + away_ms: away.map(|away| away.as_millis() as u64), }; - let ready = state.is_ready().await; - debug!(to = %peer, ready, "Sending HELLO_REPLY"); state @@ -370,6 +385,9 @@ async fn discover_peers(state: &AppState, from_peer: &str, advertised_peers: &[S remember_peer(state, discovered_peer).await; + if !topology::is_neighbour(state, discovered_peer) { + continue; + } if let Err(error) = send_hello_once(state, discovered_peer).await { warn!( peer = %discovered_peer, @@ -380,6 +398,26 @@ async fn discover_peers(state: &AppState, from_peer: &str, advertised_peers: &[S } } +/// New neighbours get our HELLO (we may not have known them before: the member list is +/// how a partial mesh discovers them). Former neighbours get no more HELLOs, keepalives +/// or sync openings from us; their session is reset, so we don't keep pushing changes +/// to them either. They are still answered if they contact us. +async fn apply_neighbour_change(state: &AppState, change: topology::NeighbourChange) { + for peer in &change.added { + remember_peer(state, peer).await; + if let Err(error) = send_hello_once(state, peer).await { + warn!(peer = %peer, error = %format!("{error:#}"), "HELLO to new neighbour failed; will retry"); + } + } + for peer in &change.removed { + if let Some(peer_state) = state.get_peer_state(peer).await { + let mut peer_state = peer_state.lock().await; + peer_state.stop_hello_retries(); + peer_state.reset_sync(); + } + } +} + // --- peer worker ------------------------------------------------------------- pub(crate) async fn run_peer_worker(state: AppState) { @@ -406,12 +444,17 @@ pub(crate) async fn run_peer_worker(state: AppState) { for (peer, peer_state_mutex) in &peers { let now = Instant::now(); + // Only neighbours are kept alive and judged; others we only answer. + let neighbour = topology::is_neighbour(&state, peer); let (became_dormant, ping_due, unanswered) = { let mut peer_state = peer_state_mutex.lock().await; - let became_dormant = ready && peer_state.check_dormancy(&state.timing, now); - let we_ping = state.local_id() < peer.as_str(); - let ping_due = peer_state.take_keepalive_due(&state.timing, we_ping, now); + let became_dormant = + ready && neighbour && peer_state.check_dormancy(&state.timing, now); + let ping_due = neighbour && peer_state.take_keepalive_due(&state.timing, now); + if !neighbour { + peer_state.stop_hello_retries(); + } let unanswered = peer_state.take_overdue_sync_reply(now); (became_dormant, ping_due, unanswered) }; @@ -432,13 +475,10 @@ pub(crate) async fn run_peer_worker(state: AppState) { ); } - if ping_due { - let local = local_keepalive(&state).await; - if let Err(error) = state - .transport - .send_message(peer, &ParsedMessage::Ping(local)) - .await - { + let probe_due = + !ping_due && crate::swarm::source::probe_due(&state, peer, Instant::now()); + if ping_due || probe_due { + if let Err(error) = send_ping(&state, peer).await { debug!(peer = %peer, error = %format!("{error:#}"), "Keepalive PING failed"); } } @@ -446,12 +486,25 @@ pub(crate) async fn run_peer_worker(state: AppState) { if let Err(error) = retry_hello_if_due(&state, peer).await { warn!(peer = %peer, error = %format!("{error:#}"), "HELLO retry failed"); } - - send_owed_hello_reply(&state, peer).await; } if last_forget_check.is_none_or(|t| t.elapsed() >= FORGET_CHECK_INTERVAL) { apply_forget_records(&state).await; + crate::swarm::members::ensure_registered(&state).await; + crate::swarm::stability::tick(&state).await; + crate::swarm::stats::measure_saved_size(&state); + { + let known = state.peer_states.read().await; + state.traffic.retain(|peer| known.contains_key(peer)); + state + .throughput + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|peer| known.contains_key(peer)); + } + if let Some(change) = topology::refresh(&state).await { + apply_neighbour_change(&state, change).await; + } last_forget_check = Some(Instant::now()); } } @@ -459,11 +512,17 @@ pub(crate) async fn run_peer_worker(state: AppState) { // --- peer membership --------------------------------------------------------- +/// Makes `peer` a member of `swarm_id`, after its valid HELLO (`from_hello`) or +/// HELLO_REPLY. Either completes the handshake: one round trip proves both sides to +/// each other (the HELLO the sender's key, the reply the swarm ID and the responder's +/// key), so neither side needs a HELLO of its own. `ready` is what the message said. async fn establish_peer( state: &AppState, peer: &str, swarm_id: String, -) -> Result<Vec<transport::SyncEnvelope>> { + from_hello: bool, + ready: bool, +) -> Result<()> { let expected_swarm = state.swarm_id().await; if let Some(expected) = &expected_swarm { @@ -479,7 +538,7 @@ async fn establish_peer( let peer_state_mutex = state.get_or_create_peer_state(peer).await; let mut peer_state = peer_state_mutex.lock().await; - if let PeerMembership::Member { swarm_id: existing } = &peer_state.membership { + if let Standing::Member { swarm_id: existing } = &peer_state.standing { if existing != &swarm_id { bail!( "Peer {peer} changed swarm identity from {} to {}", @@ -494,34 +553,30 @@ async fn establish_peer( state.set_expected_swarm_id(swarm_id.clone()).await?; } - if matches!(peer_state.membership, PeerMembership::Unknown) { - peer_state.membership = PeerMembership::Member { + if !matches!(peer_state.standing, Standing::Member { .. }) { + peer_state.standing = Standing::Member { swarm_id: swarm_id.clone(), }; } - // First reply completes the handshake: stop retries and sync from a clean slate. - // Duplicate replies (caused by our own retries) don't reset again. - if peer_state.hello_state != HelloState::Established { + // Stop our retries and sync from a clean slate. A HELLO always resets: the peer + // starts over with us. Duplicate replies (caused by our own retries) don't. + peer_state.reports_ready = Some(ready); + if from_hello || peer_state.hello_state != HelloState::Established { peer_state.hello_state = HelloState::Established; peer_state.reset_sync(); - info!(peer = %peer, "Handshake complete; sync state reset"); + info!(peer = %peer, from_hello, "Handshake complete; sync state reset"); } - let pending = std::mem::take(&mut peer_state.pending_inbound_sync); - - info!(peer = %peer, swarm_id = %swarm_id, pending_messages = pending.len(), "Peer membership established"); - Ok(pending) + info!(peer = %peer, swarm_id = %swarm_id, "Peer membership established"); + Ok(()) } #[instrument(skip(state), fields(peer = %peer))] pub(crate) async fn peer_swarm(state: &AppState, peer: &str) -> Option<String> { let peer_state_mutex = state.get_peer_state(peer).await?; let peer_state = peer_state_mutex.lock().await; - match &peer_state.membership { - PeerMembership::Unknown => None, - PeerMembership::Member { swarm_id } => Some(swarm_id.clone()), - } + peer_state.standing.swarm_id().map(str::to_owned) } // --- epochs ------------------------------------------------------------------ @@ -545,6 +600,8 @@ async fn observe_peer_epoch(state: &AppState, peer: &str, remote: Epoch) -> bool // (resetting both sides again) while this fresh sync is under way. peer_state.reset_sync(); peer_state.last_sync_rx = Some(Instant::now()); + // Whether it would sync with us was said about its old epoch. + peer_state.syncs_with_us = None; } previous }; @@ -597,7 +654,12 @@ async fn admit(state: &AppState, peer: &str, message: &ParsedMessage) -> bool { let local = state.local_id(); let proof = match message { - ParsedMessage::Hello(hello) => Some(key.verify_hello(&hello.auth, peer, local)), + ParsedMessage::Hello(hello) => Some(key.verify_hello( + &hello.auth, + hello.swarm_id.as_deref().unwrap_or(""), + peer, + local, + )), ParsedMessage::HelloReply(reply) => { Some(key.verify_hello_reply(&reply.auth, &reply.swarm_id, peer, local)) } @@ -609,8 +671,8 @@ async fn admit(state: &AppState, peer: &str, message: &ParsedMessage) -> bool { remember_peer(state, peer).await; if let Some(peer_state) = state.get_peer_state(peer).await { let mut peer_state = peer_state.lock().await; - if !peer_state.authenticated { - peer_state.authenticated = true; + if peer_state.standing == Standing::Stranger { + peer_state.standing = Standing::Admitted; debug!(peer = %peer, "Peer proved the swarm key"); } } @@ -622,11 +684,10 @@ async fn admit(state: &AppState, peer: &str, message: &ParsedMessage) -> bool { // we stop syncing our document to it, not just listening to it. if let Some(peer_state) = state.get_peer_state(peer).await { let mut peer_state = peer_state.lock().await; - if peer_state.authenticated { + if peer_state.standing.is_admitted() { warn!(peer = %peer, "Peer no longer proves the swarm key; dropping its membership"); } - peer_state.authenticated = false; - peer_state.membership = PeerMembership::Unknown; + peer_state.standing = Standing::Stranger; peer_state.hello_state = HelloState::NotSent; peer_state.reset_sync(); } @@ -643,7 +704,7 @@ async fn admit(state: &AppState, peer: &str, message: &ParsedMessage) -> bool { } None => { let admitted = match state.get_peer_state(peer).await { - Some(peer_state) => peer_state.lock().await.authenticated, + Some(peer_state) => peer_state.lock().await.standing.is_admitted(), None => false, }; if !admitted { @@ -666,19 +727,31 @@ pub(crate) async fn handle_message( return Ok(()); } + // A reply to a HELLO from an earlier run of ours (late, or queued in Mycelium while + // we were down) says nothing about now; the peer answers our current HELLO. + if let transport::ParsedMessage::HelloReply(reply) = &message { + if reply.run_id != state.run_id { + state.stats.stale_hello_reply(); + debug!(peer = %peer, "Ignoring a HELLO_REPLY to a HELLO from an earlier run"); + return Ok(()); + } + } + if !admit(state, &peer, &message).await { return Ok(()); } remember_peer(state, &peer).await; - // Any inbound message proves liveness. - let reactivated = state - .get_or_create_peer_state(&peer) - .await - .lock() - .await - .mark_seen(Instant::now()); + // Any inbound message proves liveness. When we heard from it before tells a + // restarted peer how long it was away (in our HELLO_REPLY). + let (reactivated, previously_seen) = { + let peer_state_mutex = state.get_or_create_peer_state(&peer).await; + let mut peer_state = peer_state_mutex.lock().await; + let previously_seen = peer_state.last_seen; + (peer_state.mark_seen(Instant::now()), previously_seen) + }; + crate::swarm::source::heard_from(state, &peer); if reactivated { info!(peer = %peer, "Dormant peer is active again; re-handshaking"); @@ -689,50 +762,38 @@ pub(crate) async fn handle_message( //debug!(from = %peer, message = ?message, "Received message"); match message { - transport::ParsedMessage::Hello(_) => { + transport::ParsedMessage::Hello(hello) => { // A HELLO means the peer is (re)starting its handshake with us, e.g. after a - // restart. Whatever our sync state believed about it is stale. - { - let peer_state_mutex = state.get_or_create_peer_state(&peer).await; - let mut peer_state = peer_state_mutex.lock().await; - peer_state.reset_sync(); - peer_state.reply_owed = true; + // restart. It becomes a member right away; our reply makes us one for it. + let Some(swarm_id) = state.swarm_id().await else { + // We are joining ourselves; the peer retries until we know our swarm. + debug!(peer = %peer, "HELLO before we know our swarm; not answering yet"); + return Ok(()); + }; + if let Some(theirs) = &hello.swarm_id { + if theirs != &swarm_id { + bail!("Incompatible swarm at {peer}: ours is {swarm_id}, it claims {theirs}"); + } } + establish_peer(state, &peer, swarm_id, true, hello.ready).await?; - // Sends now if we know our swarm ID; otherwise stays owed. - send_owed_hello_reply(state, &peer).await; - - // Our own HELLO goes out regardless of whether the reply could be sent. - if let Err(error) = send_hello_once(state, &peer).await { - warn!(peer = %peer, error = %format!("{error:#}"), "HELLO failed; will retry"); + let away = previously_seen.map(|seen| seen.elapsed()); + if let Err(error) = send_hello_reply(state, &peer, hello.run_id, away).await { + // The peer retries its HELLO. + warn!(peer = %peer, error = %format!("{error:#}"), "HELLO_REPLY failed"); } - // Restart sync with the fresh state (no-op until the peer is a validated member). let _ = state .sync_events - .send(SyncEvent::PeerNeedsSync(peer.clone())); + .send(SyncEvent::PeerValidated(peer.clone())); } transport::ParsedMessage::HelloReply(reply) => { - let pending = establish_peer(state, &peer, reply.swarm_id.clone()).await?; + state.lock_rtt().hello_reply_received(&peer, Instant::now()); + crate::swarm::stability::note_away(state, reply.away_ms.map(Duration::from_millis)); + establish_peer(state, &peer, reply.swarm_id.clone(), false, reply.ready).await?; discover_peers(state, &peer, &reply.peers).await; - // Our swarm ID may have just become known: pay any replies we owe. - flush_owed_hello_replies(state).await; - - if !pending.is_empty() { - debug!( - to = %peer, - pending_messages = pending.len(), - "Replaying pending sync messages" - ); - for envelope in pending { - if let Err(error) = handle_sync(state, &peer, envelope).await { - warn!(peer = %peer, error = %format!("{error:#}"), "Discarding invalid pending sync envelope"); - } - } - } - state .sync_events .send(SyncEvent::PeerValidated(peer.clone())) @@ -752,7 +813,8 @@ pub(crate) async fn handle_message( if reactivated || peer_swarm(state, &peer).await.is_none() { ensure_handshake(state, &peer).await; } - let local = local_keepalive(state).await; + note_keepalive(state, &peer, &remote).await; + let local = local_keepalive(state, &peer).await; heal_if_diverged(state, &peer, &local, &remote).await; if let Err(error) = state .transport @@ -762,11 +824,16 @@ pub(crate) async fn handle_message( debug!(peer = %peer, error = %format!("{error:#}"), "PONG failed"); } } + transport::ParsedMessage::Blob(blob) => { + crate::swarm::blob::handle(state, &peer, blob).await; + } transport::ParsedMessage::Pong(remote) => { + state.lock_rtt().pong_received(&peer, Instant::now()); if reactivated || peer_swarm(state, &peer).await.is_none() { ensure_handshake(state, &peer).await; } - let local = local_keepalive(state).await; + note_keepalive(state, &peer, &remote).await; + let local = local_keepalive(state, &peer).await; heal_if_diverged(state, &peer, &local, &remote).await; } } diff --git a/src/swarm/relay.rs b/src/swarm/relay.rs new file mode 100644 index 0000000..703bc3f --- /dev/null +++ b/src/swarm/relay.rs @@ -0,0 +1,179 @@ +//! Passing changes on in a partial mesh (`swarm::topology`). +//! +//! A node pushes its own changes to its neighbours, but nothing it receives goes any +//! further by itself. So after applying changes from one neighbour, a node *announces* +//! its new heads to its other neighbours with a PING (a keepalive: heads, about 100 +//! bytes, not the changes). A neighbour that sees heads it lacks *pulls*: it enters the +//! announced heads as the peer's heads in its sync state, Automerge asks for what's +//! missing (`need`), and the announcer answers with exactly those changes. Each node +//! receives a change about once; the rest is announcements. Regular keepalives work the +//! same way, so any gap is noticed within a keepalive interval. +//! +//! Announcements are coalesced: the first applied change schedules one announcement +//! round `ANNOUNCE_DELAY` later, and every change applied until then rides along. A +//! round costs one PING and one PONG per neighbour (plus the pulls), whatever the +//! write rate; the delay is the price per hop. Without coalescing, every applied sync +//! message would announce to every neighbour, and announcements would outnumber the +//! data they announce (about 15 to 1 at 250 nodes). +//! +//! In a full mesh the writer reaches everyone itself, so nothing is announced. + +use std::collections::HashSet; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use tokio::time::Instant; +use tracing::debug; + +use crate::state::{AppState, Standing}; +use crate::swarm::peer::send_ping; +use crate::swarm::sync::SyncEvent; +use crate::swarm::topology; +use crate::transport::{self, HeadsSummary}; + +/// A pull that brought nothing for this long no longer blocks pulling from another peer. +const PULL_TIMEOUT: Duration = Duration::from_secs(15); + +/// How long an announcement waits for further changes to ride along. Added once per +/// hop to propagation; well below a round trip over Mycelium. +const ANNOUNCE_DELAY: Duration = Duration::from_millis(200); + +/// After changes from `from` were applied and our heads changed: tell our other +/// neighbours, in the next announcement round. +pub(crate) async fn announce(state: &AppState, from: &str) { + if !state.initial_sync_done.load(Ordering::Acquire) || topology::is_full_mesh(state) { + return; + } + let schedule = { + let mut pending = state + .announce_pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match pending.as_mut() { + Some(senders) => { + senders.insert(from.to_owned()); + false + } + None => { + *pending = Some(HashSet::from([from.to_owned()])); + true + } + } + }; + if !schedule { + return; + } + let state = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(ANNOUNCE_DELAY).await; + let senders = state + .announce_pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + .unwrap_or_default(); + announce_now(&state, &senders).await; + }); +} + +/// One announcement round. The peer whose changes we announce has them already, so a +/// round with a single sender skips it; with several, each lacks the others' changes, +/// so every neighbour is told. +async fn announce_now(state: &AppState, senders: &HashSet<String>) { + let skip = match senders.len() { + 1 => senders.iter().next().cloned(), + _ => None, + }; + let neighbours: Vec<String> = state + .peer_states + .read() + .await + .keys() + .filter(|peer| { + skip.as_deref() != Some(peer.as_str()) && topology::is_neighbour(state, peer) + }) + .cloned() + .collect(); + for peer in neighbours { + if let Err(error) = send_ping(state, &peer).await { + debug!(peer = %peer, error = %format!("{error:#}"), "Announcing our heads failed"); + } + } +} + +/// A keepalive from `peer`: if it has heads we lack, pull them (one peer at a time). +pub(crate) async fn pull_if_behind(state: &AppState, peer: &str, remote: &transport::Keepalive) { + if remote.epoch != state.local_epoch() || !topology::is_neighbour(state, peer) { + return; + } + // The common case, the same heads, needs only the cached heads (read lock). + if HeadsSummary::of(state.doc.read().await.heads().to_vec()) == remote.heads { + return; + } + let missing = match &remote.heads { + HeadsSummary::Heads(heads) => { + // AutoCommit's lookups take &mut self. + let mut replica = state.doc.write().await; + let missing = heads + .iter() + .any(|head| replica.doc.get_change_by_hash(head).is_none()); + if !missing { + return; + } + Some(heads.clone()) + } + // Can't tell which heads are missing; the digest differs from ours, though. + HeadsSummary::Digest(_) => None, + }; + + let now = Instant::now(); + { + let mut pulling = state + .pulling + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let busy = matches!( + &*pulling, + Some((other, since)) if other != peer && now < *since + PULL_TIMEOUT + ); + if busy { + return; + } + *pulling = Some((peer.to_owned(), now)); + } + + let Some(peer_state) = state.get_peer_state(peer).await else { + return; + }; + { + let mut peer_state = peer_state.lock().await; + if !matches!(peer_state.standing, Standing::Member { .. }) { + return; + } + match missing { + Some(heads) => { + // Automerge asks for whatever these heads need that we don't have. + peer_state.sync.state.their_heads = Some(heads); + peer_state.sync.state.in_flight = false; + } + // A fresh session's opening carries our heads and bloom filter; the answer + // brings what we lack. + None => peer_state.reset_sync(), + } + } + debug!(peer = %peer, "Neighbour has changes we lack; pulling"); + let _ = state + .sync_events + .send(SyncEvent::PeerNeedsSync(peer.to_owned())); +} + +/// A sync message from `peer` was applied: a pull from it is answered. +pub(crate) fn pulled(state: &AppState, peer: &str) { + let mut pulling = state + .pulling + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if pulling.as_ref().is_some_and(|(p, _)| p == peer) { + *pulling = None; + } +} diff --git a/src/swarm/rtt.rs b/src/swarm/rtt.rs new file mode 100644 index 0000000..ed6cc73 --- /dev/null +++ b/src/swarm/rtt.rs @@ -0,0 +1,179 @@ +//! Round-trip times to peers, measured from traffic we send anyway: PING → PONG and +//! HELLO → HELLO_REPLY. No probes of our own. +//! +//! A sample only counts when exactly one request was outstanding: with two, we can't tell +//! which one the answer belongs to (an answer to the older one would look too fast). +//! Samples are smoothed (exponential moving average, weight 1/4) to ride out jitter. +//! +//! What we use is the *effective* round-trip time: the smoothed value, raised to the age +//! of the oldest request still unanswered (the round trip takes at least that long), so +//! a peer that stops answering drifts away by itself. Without a recent sample (nodes +//! move; an old value says little) it is unknown. Silence alone doesn't count: we don't +//! ask non-neighbours anything, so their quiet is ours. + +use std::collections::HashMap; +use std::time::Duration; + +use tokio::time::Instant; + +#[derive(Debug, Default)] +pub(crate) struct RttTable { + pings: HashMap<String, Outstanding>, + hellos: HashMap<String, Outstanding>, + smoothed: HashMap<String, Sample>, +} + +#[derive(Debug, Clone, Copy)] +struct Sample { + rtt: Duration, + at: Instant, +} + +#[derive(Debug, Clone, Copy)] +struct Outstanding { + sent: Instant, + count: u32, +} + +impl RttTable { + pub(crate) fn ping_sent(&mut self, peer: &str, now: Instant) { + Self::sent(&mut self.pings, peer, now); + } + + pub(crate) fn pong_received(&mut self, peer: &str, now: Instant) { + if let Some(sample) = Self::answered(&mut self.pings, peer, now) { + self.record(peer, sample, now); + } + } + + pub(crate) fn hello_sent(&mut self, peer: &str, now: Instant) { + Self::sent(&mut self.hellos, peer, now); + } + + pub(crate) fn hello_reply_received(&mut self, peer: &str, now: Instant) { + if let Some(sample) = Self::answered(&mut self.hellos, peer, now) { + self.record(peer, sample, now); + } + } + + /// The effective round-trip time: the smoothed value if sampled within `fresh_for`, + /// raised to the age of the oldest unanswered request; None without a recent sample. + pub(crate) fn effective( + &self, + peer: &str, + now: Instant, + fresh_for: Duration, + ) -> Option<Duration> { + let sample = self + .smoothed + .get(peer) + .filter(|sample| now.saturating_duration_since(sample.at) < fresh_for)?; + let waiting = [self.pings.get(peer), self.hellos.get(peer)] + .into_iter() + .flatten() + .map(|outstanding| now.saturating_duration_since(outstanding.sent)) + .max() + .unwrap_or_default(); + Some(sample.rtt.max(waiting)) + } + + /// The effective round-trip time of every peer that has one. + pub(crate) fn all_effective( + &self, + now: Instant, + fresh_for: Duration, + ) -> impl Iterator<Item = (&str, Duration)> { + self.smoothed.keys().filter_map(move |peer| { + self.effective(peer, now, fresh_for) + .map(|rtt| (peer.as_str(), rtt)) + }) + } + + fn sent(map: &mut HashMap<String, Outstanding>, peer: &str, now: Instant) { + map.entry(peer.to_owned()) + .and_modify(|outstanding| outstanding.count += 1) + .or_insert(Outstanding { + sent: now, + count: 1, + }); + } + + fn answered( + map: &mut HashMap<String, Outstanding>, + peer: &str, + now: Instant, + ) -> Option<Duration> { + let outstanding = map.remove(peer)?; + (outstanding.count == 1).then(|| now.saturating_duration_since(outstanding.sent)) + } + + fn record(&mut self, peer: &str, sample: Duration, now: Instant) { + let rtt = match self.smoothed.get(peer) { + Some(old) => (old.rtt * 3 + sample) / 4, + None => sample, + }; + self.smoothed + .insert(peer.to_owned(), Sample { rtt, at: now }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FRESH: Duration = Duration::from_secs(300); + + #[test] + fn samples_only_unambiguous_answers_and_smooths() { + let mut table = RttTable::default(); + let start = Instant::now(); + let at = |ms| start + Duration::from_millis(ms); + table.ping_sent("a", start); + table.pong_received("a", at(100)); + assert_eq!( + table.effective("a", at(100), FRESH), + Some(Duration::from_millis(100)) + ); + + table.ping_sent("a", at(100)); + table.pong_received("a", at(300)); + assert_eq!( + table.effective("a", at(300), FRESH), + Some(Duration::from_millis(125)) + ); + + // Two PINGs out: the PONG can't be attributed. + table.ping_sent("a", at(300)); + table.ping_sent("a", at(400)); + table.pong_received("a", at(450)); + assert_eq!( + table.effective("a", at(450), FRESH), + Some(Duration::from_millis(125)) + ); + + // An unsolicited PONG changes nothing. + table.pong_received("b", start); + assert_eq!(table.effective("b", start, FRESH), None); + } + + #[test] + fn unanswered_requests_raise_it_and_old_samples_expire() { + let mut table = RttTable::default(); + let start = Instant::now(); + table.ping_sent("a", start); + table.pong_received("a", start + Duration::from_millis(50)); + + // A PING unanswered for 40 s: at least 40 s. + let sent = start + Duration::from_secs(60); + table.ping_sent("a", sent); + let later = sent + Duration::from_secs(40); + assert_eq!( + table.effective("a", later, FRESH), + Some(Duration::from_secs(40)) + ); + + // No sample within the freshness window: unknown. + let stale = start + FRESH + Duration::from_secs(1); + assert_eq!(table.effective("a", stale, FRESH), None); + } +} diff --git a/src/swarm/source.rs b/src/swarm/source.rs new file mode 100644 index 0000000..378c40a --- /dev/null +++ b/src/swarm/source.rs @@ -0,0 +1,210 @@ +//! The catch-up source: while a node catches up (joining, or rejoining a newer epoch, +//! until its initial sync is done), it syncs with one peer only. +//! +//! Every peer would otherwise send us what we lack, all at once: a node with an empty +//! document gets the whole document from each of its peers, and a node that is merely +//! behind gets the missing changes from each. Automerge can't deduplicate across peers. +//! So one suitable peer becomes the source, preferably the closest by round-trip time +//! (a clearly closer one gets a 1 s head start to claim it, else the first to ask); +//! the others wait until the initial sync is done, and then only exchange heads. Messages from waiting +//! peers are still applied; we just don't answer them yet. +//! +//! A source must be ready and willing to sync with us, as far as we know (its sync +//! messages and keepalives say so). It counts as alive while any message from it +//! arrives, so a slow transfer of a large document never looks like a stall. When it +//! is quiet for `Timing::catch_up_probe`, we PING it; after three unanswered probes, or +//! when it says it isn't ready, or twice in a row that it wouldn't sync with us (the +//! first time may be a handshake still under way), the next peer takes over. + +use std::sync::atomic::Ordering; +use std::time::Duration; + +use tokio::time::Instant; +use tracing::{debug, info}; + +use crate::state::{AppState, CatchUpSource, PeerState}; +use crate::swarm::sync::SyncEvent; + +/// How long a clearly closer peer (by round-trip time) gets to claim the source role +/// before whoever asks next takes it. +const CLOSER_PEER_HEAD_START: Duration = Duration::from_secs(1); + +/// Probes without an answer before the source is given up. +const UNANSWERED_PROBES: u32 = 3; + +/// Keepalives in a row saying "I wouldn't sync with you" before the source is given up. +const REFUSALS: u32 = 2; + +fn lock_preference(state: &AppState) -> std::sync::MutexGuard<'_, Option<Instant>> { + state + .catch_up_preference + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn catching_up(state: &AppState) -> bool { + !state.initial_sync_done.load(Ordering::Acquire) +} + +fn lock(state: &AppState) -> std::sync::MutexGuard<'_, Option<CatchUpSource>> { + state + .catch_up_source + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Returns how long to hold back sync with `peer`, if at all: while catching up, with +/// everyone but the source. Picks the source if there is none (or it went quiet). +pub(crate) fn defer(state: &AppState, peer: &str, peer_state: &PeerState) -> Option<Duration> { + if !catching_up(state) { + return None; + } + let stall = state.timing.catch_up_probe * UNANSWERED_PROBES; + if peer_state.reports_ready == Some(false) || peer_state.syncs_with_us == Some(false) { + // Not a candidate; its next message re-evaluates this, the timer is a fallback. + return Some(stall); + } + + let now = Instant::now(); + let mut source = lock(state); + let quiet = match &*source { + Some(current) if current.peer == peer => return None, + Some(current) if now < current.last_heard + stall => { + return Some(current.last_heard + stall - now); + } + Some(current) => Some(current.peer.clone()), + None => None, + }; + + // Prefer the closest peer: the source sends us everything we lack, so its distance + // matters most. If another peer we measured is clearly closer (by a fifth), it gets + // a short head start to claim the role; it is asked in the same sync pass. After + // that, whoever asks first takes it (the closer one may not be suitable after all). + let rtt = { + let rtts = state.lock_rtt(); + let fresh_for = state.timing.rtt_fresh_for(); + let mine = rtts.effective(peer, now, fresh_for); + let closer = rtts.all_effective(now, fresh_for).any(|(other, rtt)| { + other != peer + && quiet.as_deref() != Some(other) + && mine.is_none_or(|mine| rtt * 5 < mine * 4) + }); + if closer { + let mut preference = lock_preference(state); + let since = *preference.get_or_insert(now); + if now < since + CLOSER_PEER_HEAD_START { + return Some(since + CLOSER_PEER_HEAD_START - now); + } + } + mine + }; + *lock_preference(state) = None; + let rtt_ms = rtt.map(|rtt| rtt.as_millis() as u64); + + match quiet { + Some(previous) => info!( + peer = %peer, + previous = %previous, + rtt_ms, + quiet_secs = stall.as_secs_f32(), + "Catch-up source stopped answering; catching up from another peer" + ), + None => debug!(peer = %peer, rtt_ms, "Catching up from this peer first"), + } + state.stats.catch_up_source_chosen(); + *source = Some(CatchUpSource { + peer: peer.to_owned(), + last_heard: now, + last_probe: None, + refusals: 0, + }); + None +} + +/// Any message from `peer` arrived. +pub(crate) fn heard_from(state: &AppState, peer: &str) { + if let Some(current) = lock(state).as_mut().filter(|current| current.peer == peer) { + current.last_heard = Instant::now(); + } +} + +/// What `peer` said about itself, in a sync message (`syncs_with_us: None`) or a +/// keepalive. Gives up the source if that rules it out. +pub(crate) fn reported(state: &AppState, peer: &str, ready: bool, syncs_with_us: Option<bool>) { + if !catching_up(state) { + return; + } + let reason = { + let mut source = lock(state); + let Some(current) = source.as_mut().filter(|current| current.peer == peer) else { + return; + }; + let reason = if !ready { + Some("it isn't ready itself") + } else { + match syncs_with_us { + Some(false) => { + current.refusals += 1; + (current.refusals >= REFUSALS).then_some("it wouldn't sync with us") + } + Some(true) => { + current.refusals = 0; + None + } + None => None, + } + }; + if reason.is_some() { + *source = None; + } + reason + }; + if let Some(reason) = reason { + debug!(peer = %peer, reason, "Giving up the catch-up source; trying another peer"); + resync_everyone(state); + } +} + +/// Whether to PING `peer` now: it is the source and has been quiet for a probe interval. +pub(crate) fn probe_due(state: &AppState, peer: &str, now: Instant) -> bool { + if !catching_up(state) { + return false; + } + let probe = state.timing.catch_up_probe; + let mut source = lock(state); + let Some(current) = source.as_mut().filter(|current| current.peer == peer) else { + return false; + }; + let due = now >= current.last_heard + probe + && current.last_probe.is_none_or(|last| now >= last + probe); + if due { + current.last_probe = Some(now); + } + due +} + +/// The initial sync is done: no source any more, and the peers we held back only need +/// to learn our heads now. +pub(crate) fn finished(state: &AppState, from: &str) { + *lock(state) = None; + *lock_preference(state) = None; + let state = state.clone(); + let from = from.to_owned(); + tokio::spawn(async move { + let peers: Vec<String> = state.peer_states.read().await.keys().cloned().collect(); + for other in peers.into_iter().filter(|other| *other != from) { + let _ = state.sync_events.send(SyncEvent::PeerNeedsSync(other)); + } + }); +} + +/// After giving up a source: let every peer be considered again. +pub(crate) fn resync_everyone(state: &AppState) { + let state = state.clone(); + tokio::spawn(async move { + let peers: Vec<String> = state.peer_states.read().await.keys().cloned().collect(); + for other in peers { + let _ = state.sync_events.send(SyncEvent::PeerNeedsSync(other)); + } + }); +} diff --git a/src/swarm/stability.rs b/src/swarm/stability.rs new file mode 100644 index 0000000..9d79b17 --- /dev/null +++ b/src/swarm/stability.rs @@ -0,0 +1,270 @@ +//! Whether this node is a stable member of the swarm, judged by itself. +//! +//! Long-running, well-connected nodes make a good backbone; laptops that sleep and +//! phones that lose signal don't. Each node measures what fraction of the last 24 hours +//! it had at least one live peer, and publishes only a coarse tier in its registry entry +//! (`_continuum/members/<pk>/tier`): `stable` or `transient`, written only when it +//! changes, with hysteresis. So the swarm learns about stability from the replicated +//! document alone, without anyone measuring anyone else, and the tier changes rarely. +//! +//! * transient → stable: at least 24 h observed, connected at least 95% of it; +//! * stable → transient: connected less than 80% (over at least an hour observed). +//! +//! After a restart a node has no history. If its entry says `stable` and a peer reports +//! (in its HELLO_REPLY) that it last heard from us less than an hour ago, the node was +//! only briefly away and keeps its tier, starting with a full day's credit. Otherwise it +//! starts over as transient. Time the process didn't run (a laptop asleep) counts as +//! disconnected. + +use std::collections::VecDeque; +use std::time::Duration; + +use tokio::time::Instant; +use tracing::{info, warn}; + +use crate::state::{AppState, HelloState}; +use crate::swarm::members::{self, Tier}; + +const WINDOW: Duration = Duration::from_secs(24 * 3600); +const BUCKET: Duration = Duration::from_secs(5 * 60); +const BECOME_STABLE: f64 = 0.95; +const BECOME_TRANSIENT: f64 = 0.80; +/// A day observed (the oldest bucket may be partly outside the window). +const FULL_DAY: Duration = WINDOW.saturating_sub(BUCKET); +/// Less than this observed is too little to demote a stable node. +const MIN_TO_DEMOTE: Duration = Duration::from_secs(3600); +/// An absence shorter than this keeps a stable node stable across a restart. +const BRIEF_ABSENCE: Duration = Duration::from_secs(3600); +/// How long after becoming ready we wait for a peer to tell us how long we were away. +const AWAY_REPORT_GRACE: Duration = Duration::from_secs(30); +/// A gap between two measurements longer than this means we didn't run (asleep). +const MAX_TICK_GAP: Duration = Duration::from_secs(30); + +#[derive(Debug, Default)] +pub(crate) struct Stability { + buckets: VecDeque<Bucket>, + last_tick: Option<Instant>, + /// The shortest absence any peer reported to us in this run. + away: Option<Duration>, + ready_since: Option<Instant>, + /// The decision about the tier we found after a restart is made. + settled: bool, +} + +#[derive(Debug, Clone, Copy)] +struct Bucket { + start: Instant, + observed: Duration, + connected: Duration, +} + +impl Stability { + fn record(&mut self, now: Instant, connected: bool) { + let Some(last) = self.last_tick.replace(now) else { + return; + }; + let elapsed = now.saturating_duration_since(last).min(WINDOW); + let connected = connected && elapsed <= MAX_TICK_GAP; + if self + .buckets + .back() + .is_none_or(|bucket| now.saturating_duration_since(bucket.start) >= BUCKET) + { + self.buckets.push_back(Bucket { + start: now, + observed: Duration::ZERO, + connected: Duration::ZERO, + }); + } + let bucket = self.buckets.back_mut().expect("just ensured"); + bucket.observed += elapsed; + if connected { + bucket.connected += elapsed; + } + while self + .buckets + .front() + .is_some_and(|bucket| now.saturating_duration_since(bucket.start) > WINDOW) + { + self.buckets.pop_front(); + } + } + + /// A full day of connectivity, for a stable node that was only briefly away. + fn credit(&mut self, now: Instant) { + self.buckets.push_front(Bucket { + start: now, + observed: WINDOW, + connected: WINDOW, + }); + } + + fn observed(&self) -> Duration { + self.buckets.iter().map(|bucket| bucket.observed).sum() + } + + fn fraction(&self) -> f64 { + let observed = self.observed().as_secs_f64(); + if observed == 0.0 { + return 0.0; + } + let connected: f64 = self.buckets.iter().map(|b| b.connected.as_secs_f64()).sum(); + connected / observed + } + + /// The tier we should have, given the one we have now. + fn assess(&self, current: Tier) -> Tier { + match current { + Tier::Transient if self.observed() >= FULL_DAY && self.fraction() >= BECOME_STABLE => { + Tier::Stable + } + Tier::Stable + if self.observed() >= MIN_TO_DEMOTE && self.fraction() < BECOME_TRANSIENT => + { + Tier::Transient + } + tier => tier, + } + } +} + +fn lock(state: &AppState) -> std::sync::MutexGuard<'_, Stability> { + state + .stability + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The fraction of the observed time (up to a day) we had a live peer, and how much +/// time was observed. +pub(crate) fn measured(state: &AppState) -> (f64, Duration) { + let stability = lock(state); + (stability.fraction(), stability.observed()) +} + +/// A HELLO_REPLY told us how long ago that peer last heard from us before this run. +pub(crate) fn note_away(state: &AppState, away: Option<Duration>) { + if let Some(away) = away { + let mut stability = lock(state); + stability.away = Some(stability.away.map_or(away, |known| known.min(away))); + } +} + +/// Measures, and updates our tier in the registry when it should change. Called every +/// few seconds by the peer worker. +pub(crate) async fn tick(state: &AppState) { + let now = Instant::now(); + let mut connected = false; + let peers: Vec<_> = state.peer_states.read().await.values().cloned().collect(); + for peer_state in peers { + let peer_state = peer_state.lock().await; + if peer_state.hello_state == HelloState::Established + && peer_state.is_alive(&state.timing, now) + { + connected = true; + break; + } + } + lock(state).record(now, connected); + + if !state.is_ready().await { + return; + } + let local = state.local_id().to_owned(); + let current = members::tier(&state.doc.read().await.doc, &local); + + let desired = { + let mut stability = lock(state); + let ready_since = *stability.ready_since.get_or_insert(now); + if stability.settled { + stability.assess(current) + } else { + let brief = stability.away.is_some_and(|away| away < BRIEF_ABSENCE); + if current == Tier::Stable && !brief && now < ready_since + AWAY_REPORT_GRACE { + return; // a peer may still tell us we were only briefly away + } + stability.settled = true; + match current { + Tier::Stable if brief => { + stability.credit(now); + info!("Back after a brief absence; staying a stable member"); + Tier::Stable + } + // Restarted without a report that we were only briefly away. + Tier::Stable => Tier::Transient, + Tier::Transient => Tier::Transient, + } + } + }; + if desired == current { + return; + } + + let fraction = lock(state).fraction(); + match state + .mutate_doc(move |doc| members::set_tier(doc, &local, desired)) + .await + { + Ok(()) => info!( + tier = desired.as_str(), + connected_percent = (fraction * 100.0).round(), + "Stability tier changed" + ), + Err(error) => warn!(error = %error, "Failed to record our stability tier"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(stability: &mut Stability, start: Instant, hours: u64, connected: impl Fn(u64) -> bool) { + let step = Duration::from_secs(5); + let steps = hours * 3600 / 5; + for i in 0..=steps { + stability.record(start + step * i as u32, connected(i)); + } + } + + #[test] + fn becomes_stable_after_a_well_connected_day() { + let mut stability = Stability::default(); + let start = Instant::now(); + run(&mut stability, start, 12, |_| true); + assert_eq!(stability.assess(Tier::Transient), Tier::Transient); + run( + &mut stability, + start + Duration::from_secs(12 * 3600), + 13, + |_| true, + ); + assert_eq!(stability.assess(Tier::Transient), Tier::Stable); + } + + #[test] + fn hysteresis_between_the_thresholds() { + let mut stability = Stability::default(); + // Connected 9 of every 10 measurements: 90%. + run(&mut stability, Instant::now(), 25, |i| i % 10 != 0); + assert_eq!(stability.assess(Tier::Transient), Tier::Transient); + assert_eq!(stability.assess(Tier::Stable), Tier::Stable); + } + + #[test] + fn falls_back_to_transient_when_often_disconnected() { + let mut stability = Stability::default(); + // Connected half the time. + run(&mut stability, Instant::now(), 2, |i| i % 2 == 0); + assert_eq!(stability.assess(Tier::Stable), Tier::Transient); + } + + #[test] + fn time_asleep_counts_as_disconnected() { + let mut stability = Stability::default(); + let start = Instant::now(); + run(&mut stability, start, 1, |_| true); + // The process didn't run for two hours, then one more measurement. + stability.record(start + Duration::from_secs(3 * 3600), true); + assert!(stability.fraction() < 0.5); + } +} diff --git a/src/swarm/stats.rs b/src/swarm/stats.rs new file mode 100644 index 0000000..eb34276 --- /dev/null +++ b/src/swarm/stats.rs @@ -0,0 +1,284 @@ +//! Node-wide figures for /admin/stats that no other part keeps. Recorded where things +//! happen anyway; the one exception is the document's saved size (see +//! `measure_saved_size`). + +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use tokio::time::Instant; +use tracing::{debug, warn}; + +use crate::document::replica::Epoch; +use crate::state::AppState; + +/// Saving takes time proportional to the document; we spend at most about 1% of it on +/// measuring, and measure at least this long apart, and only after a change. +const SAVED_SIZE_MIN_INTERVAL: Duration = Duration::from_secs(60); +const SAVED_SIZE_DUTY: u32 = 100; + +#[derive(Debug)] +pub(crate) struct NodeStats { + started: Instant, + inner: Mutex<Inner>, +} + +#[derive(Debug, Default)] +struct Inner { + caught_up: Option<CaughtUp>, + catch_up_sources: u32, + max_heads: usize, + stale_hello_replies: u64, + saved_size: Option<SavedSize>, + measuring: bool, + /// The document version (`AppState::change_tx`) and epoch the saved size is of. + measured_version: Option<(u64, Epoch)>, + propagation: Propagation, +} + +/// Upper bounds (inclusive, milliseconds) of the propagation delay buckets; a last +/// bucket takes the rest. +const PROPAGATION_BUCKETS_MS: [u64; 11] = [ + 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000, 60_000, 300_000, 3_600_000, +]; + +/// How long changes written elsewhere took to reach us: from the writer's commit (its +/// clock) to applying them here (ours), so clock skew between the two is part of it. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct Propagation { + /// Per bucket of `PROPAGATION_BUCKETS_MS`, then the rest; not cumulative. + pub(crate) buckets: [u64; PROPAGATION_BUCKETS_MS.len() + 1], + pub(crate) count: u64, + pub(crate) sum_ms: u64, + /// Changes stamped later than our clock said on arrival (counted in the first + /// bucket): the writer's clock is ahead of ours. + pub(crate) ahead: u64, +} + +impl Propagation { + fn record(&mut self, delay_ms: i64) { + if delay_ms < 0 { + self.ahead += 1; + } + let delay_ms = delay_ms.max(0) as u64; + let bucket = PROPAGATION_BUCKETS_MS + .iter() + .position(|&bound| delay_ms <= bound) + .unwrap_or(PROPAGATION_BUCKETS_MS.len()); + self.buckets[bucket] += 1; + self.count += 1; + self.sum_ms += delay_ms; + } + + /// Each bucket's upper bound (None for the last) and count. + pub(crate) fn buckets(&self) -> impl Iterator<Item = (Option<u64>, u64)> + '_ { + PROPAGATION_BUCKETS_MS + .iter() + .copied() + .map(Some) + .chain([None]) + .zip(self.buckets.iter().copied()) + } +} + +/// The document's size as saved (compressed), when it was measured and how long that +/// took (copy and save). +#[derive(Debug, Clone, Copy)] +pub(crate) struct SavedSize { + pub(crate) bytes: u64, + pub(crate) at: Instant, + pub(crate) took: Duration, +} + +/// When the initial sync finished, and what it took. +#[derive(Debug, Clone, Copy)] +pub(crate) struct CaughtUp { + /// Since the process started (handshakes included). + pub(crate) after: Duration, + /// Sync payload received until then, from all peers. + pub(crate) sync_bytes: u64, +} + +/// A copy of the figures. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Snapshot { + pub(crate) uptime: Duration, + pub(crate) caught_up: Option<CaughtUp>, + pub(crate) catch_up_sources: u32, + pub(crate) max_heads: usize, + pub(crate) stale_hello_replies: u64, + pub(crate) propagation: Propagation, +} + +impl Default for NodeStats { + fn default() -> Self { + Self { + started: Instant::now(), + inner: Mutex::default(), + } + } +} + +impl NodeStats { + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub(crate) fn caught_up(&self, sync_bytes: u64) { + let after = self.started.elapsed(); + self.lock() + .caught_up + .get_or_insert(CaughtUp { after, sync_bytes }); + } + + /// A catch-up source was chosen (the first, or a replacement). + pub(crate) fn catch_up_source_chosen(&self) { + self.lock().catch_up_sources += 1; + } + + /// Our document has `count` heads (after applying a sync message). + pub(crate) fn heads_seen(&self, count: usize) { + let mut inner = self.lock(); + inner.max_heads = inner.max_heads.max(count); + } + + /// Changes from elsewhere were applied, `delays_ms` after they were committed. + pub(crate) fn propagated(&self, delays_ms: impl IntoIterator<Item = i64>) { + let mut inner = self.lock(); + for delay_ms in delays_ms { + inner.propagation.record(delay_ms); + } + } + + pub(crate) fn stale_hello_reply(&self) { + self.lock().stale_hello_replies += 1; + } + + /// The last measurement of the saved size, if any. + pub(crate) fn saved_size(&self) -> Option<SavedSize> { + self.lock().saved_size + } + + /// Whether a measurement of the document at `version` is due; if so, it counts as + /// started. + fn start_measuring(&self, now: Instant, version: (u64, Epoch)) -> bool { + let mut inner = self.lock(); + let due = !inner.measuring + && inner.measured_version != Some(version) + && inner.saved_size.is_none_or(|last| { + now >= last.at + SAVED_SIZE_MIN_INTERVAL.max(last.took * SAVED_SIZE_DUTY) + }); + if due { + inner.measuring = true; + } + due + } + + /// The document at `version` was saved for another reason (an export): a + /// measurement for free. + pub(crate) fn saved_size_measured(&self, bytes: u64, took: Duration, version: (u64, Epoch)) { + let mut inner = self.lock(); + inner.saved_size = Some(SavedSize { + bytes, + at: Instant::now(), + took, + }); + inner.measured_version = Some(version); + } + + fn finish_measuring(&self, measured: Option<(SavedSize, (u64, Epoch))>) { + let mut inner = self.lock(); + inner.measuring = false; + if let Some((measured, version)) = measured { + inner.saved_size = Some(measured); + inner.measured_version = Some(version); + } + } + + pub(crate) fn snapshot(&self) -> Snapshot { + let inner = self.lock(); + Snapshot { + uptime: self.started.elapsed(), + caught_up: inner.caught_up, + catch_up_sources: inner.catch_up_sources, + max_heads: inner.max_heads, + stale_hello_replies: inner.stale_hello_replies, + propagation: inner.propagation, + } + } +} + +/// The document's version for measuring: bumped by every write and applied sync +/// message (`AppState::notify_store_changed`), together with the epoch (a switch +/// replaces the document). +pub(crate) fn document_version(state: &AppState) -> (u64, Epoch) { + (*state.change_tx.borrow(), state.local_epoch()) +} + +/// Measures the document's saved size in the background when due: the first time, then +/// only after the document changed, at least a minute apart and spending no more than +/// about 1% of the time on it (a document that takes 2 s to copy and save is measured +/// at most every 200 s). An idle swarm measures once. Called by the peer worker every +/// few seconds. +/// +/// The copy holds the read lock (writes wait for it); the save runs without any lock. +pub(crate) fn measure_saved_size(state: &AppState) { + // Taken before the copy: a change during it makes another measurement due. + let version = document_version(state); + if !state.stats.start_measuring(Instant::now(), version) { + return; + } + let state = state.clone(); + tokio::spawn(async move { + let start = Instant::now(); + let doc = std::sync::Arc::clone(&state.doc); + // Both steps take time proportional to the document: off the async threads. + let measured = match tokio::task::spawn_blocking(move || { + let mut copy = doc.blocking_read().doc.clone(); + copy.save().len() as u64 + }) + .await + { + Ok(bytes) => { + let took = start.elapsed(); + debug!( + bytes, + took_ms = took.as_millis() as u64, + "Measured the saved document size" + ); + Some(( + SavedSize { + bytes, + at: Instant::now(), + took, + }, + version, + )) + } + Err(error) => { + warn!(error = %error, "Measuring the saved document size failed"); + None + } + }; + state.stats.finish_measuring(measured); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn propagation_buckets() { + let mut propagation = Propagation::default(); + for delay_ms in [-20, 100, 101, 7_200_000] { + propagation.record(delay_ms); + } + let buckets: Vec<_> = propagation.buckets().filter(|(_, n)| *n > 0).collect(); + assert_eq!(buckets, [(Some(100), 2), (Some(250), 1), (None, 1)]); + assert_eq!(propagation.ahead, 1); + assert_eq!(propagation.count, 4); + assert_eq!(propagation.sum_ms, 7_200_201); + } +} diff --git a/src/swarm/sync.rs b/src/swarm/sync.rs index d82238e..80d6d56 100644 --- a/src/swarm/sync.rs +++ b/src/swarm/sync.rs @@ -4,7 +4,6 @@ use std::collections::HashSet; use std::sync::Arc; use std::sync::atomic::Ordering; -use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use automerge::sync::SyncDoc; @@ -12,20 +11,18 @@ use tokio::sync::mpsc; use tokio::time::Instant; use tracing::{debug, info, trace, warn}; -use crate::state::{AppState, Liveness, PeerMembership, PeerState, PendingOutbound}; +use crate::state::{AppState, Liveness, Sending, Standing}; use crate::swarm::peer::{peer_swarm, send_hello_once}; +use crate::swarm::source; +use crate::swarm::throughput::SyncBytes; use crate::transport::{self, ParsedMessage}; -const INITIAL_SYNC_RETRY: Duration = Duration::from_millis(500); -const MAX_SYNC_RETRY: Duration = Duration::from_secs(30); - #[derive(Debug)] pub(crate) enum SyncEvent { PeerValidated(String), DocumentChanged, MessageReceived(String), PeerNeedsSync(String), - RetryDelivery(String), } /// Store paths touched by one batch of document changes. @@ -34,12 +31,20 @@ pub(crate) struct ChangeSet { pub(crate) paths: Vec<Vec<String>>, } -async fn deliver_sync_to_peer(state: &AppState, peer: &str, retry_delivery: bool) -> Result<()> { +async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> { + if state.remake.fetching_snapshot() { + // Rejoining: the snapshot comes as a download, not in a sync message. Sync + // starts once it is here (`remake::catchup`). + trace!("Waiting for the snapshot download; not syncing yet"); + return Ok(()); + } let Some(peer_state_mutex) = state.get_peer_state(peer).await else { trace!("Peer was forgotten; skipping synchronization"); return Ok(()); }; + let we_ready = state.is_ready().await; + let validated_swarm = { let peer_state = peer_state_mutex.lock().await; @@ -58,25 +63,41 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str, retry_delivery: bool return Ok(()); } - let PeerMembership::Member { swarm_id } = &peer_state.membership else { + let Standing::Member { swarm_id } = &peer_state.standing else { return Ok(()); }; - if !retry_delivery { - let we_open = state.local_id() < peer; - if let Some(wait) = peer_state.opening_wait(we_open, Instant::now()) { - trace!( - wait_ms = wait.as_millis(), - "Leaving the opening sync message to the peer" - ); - let tx = state.sync_events.clone(); - let peer = peer.to_owned(); - tokio::spawn(async move { - tokio::time::sleep(wait).await; - let _ = tx.send(SyncEvent::PeerNeedsSync(peer)); - }); - return Ok(()); - } + if let Some(wait) = source::defer(state, peer, &peer_state) { + trace!( + wait_ms = wait.as_millis(), + "Catching up from another peer first; not syncing with this one yet" + ); + // Done earlier when the initial sync completes (every peer is enqueued + // then); this only matters if the source goes quiet. + let tx = state.sync_events.clone(); + let peer = peer.to_owned(); + tokio::spawn(async move { + tokio::time::sleep(wait).await; + let _ = tx.send(SyncEvent::PeerNeedsSync(peer)); + }); + return Ok(()); + } + + if peer_state + .sync + .leave_opening(we_ready, peer_state.reports_ready) + { + // Its own opening (or anything else from it) brings us back here. + trace!("Leaving the opening sync message to the peer, which isn't ready"); + return Ok(()); + } + + if peer_state.sync.state.their_heads.is_none() + && !crate::swarm::topology::is_neighbour(state, peer) + { + // We only answer peers that aren't our neighbours. + trace!("Not opening sync with a peer that isn't our neighbour"); + return Ok(()); } swarm_id.clone() @@ -91,104 +112,113 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str, retry_delivery: bool bail!("Peer {peer} belongs to a different swarm"); } - let existing = { - let mut guard = peer_state_mutex.lock().await; - let peer_state = &mut *guard; - match &peer_state.pending_outbound_sync { - Some(entry) if retry_delivery => Some((entry.message.clone(), entry.next_attempt)), - Some(_) => { - peer_state.needs_generate = true; - return Ok(()); - } - None => None, + { + let mut peer_state = peer_state_mutex.lock().await; + if let Some(sending) = &mut peer_state.sync.sending { + sending.regenerate = true; + return Ok(()); } - }; - - let encoded = match existing { - Some((message, next_attempt)) => { - if Instant::now() < next_attempt { - return Ok(()); - } - message + if peer_state + .sync + .retry_at + .is_some_and(|at| Instant::now() < at) + { + // The retry timer enqueues the peer once it's due. + return Ok(()); } - None if retry_delivery => return Ok(()), - None => { - let doc = Arc::clone(&state.doc); - let psm = Arc::clone(&peer_state_mutex); - let transport = Arc::clone(&state.transport); - let peer_owned = peer.to_owned(); + } - let generated = tokio::task::spawn_blocking( - move || -> Result<Option<transport::EncodedMessage>> { - // Label the message with the epoch of the document it was generated - // from, read under the same lock. - let (epoch, message) = { - let mut replica = doc.blocking_write(); - let mut peer_state = psm.blocking_lock(); - peer_state.needs_generate = false; - let epoch = replica.epoch; - let sent_before = peer_state.sync_state.sent_hashes.clone(); - let message = { - let sync_doc = replica.doc.sync(); - sync_doc.generate_sync_message(&mut peer_state.sync_state) - }; - // A peer whose heads are ours already has everything; Automerge - // answers only when it has something to say, so it will stay - // silent. Waiting for an answer would only end in a pointless - // resend. - let in_step = peer_state.sync_state.their_heads.as_ref() - == Some(&replica.doc.get_heads()); - if in_step { - peer_state.sync_in_step(); - } else if message.is_some() { - let sent = peer_state - .sync_state - .sent_hashes - .difference(&sent_before) - .copied() - .collect(); - peer_state.expect_sync_reply(sent, Instant::now()); - } - (epoch, message) - }; - let Some(message) = message else { - return Ok(None); - }; - let envelope = transport::SyncEnvelope { - swarm_id, - epoch, - message, + let encoded = { + let doc = Arc::clone(&state.doc); + let psm = Arc::clone(&peer_state_mutex); + let transport = Arc::clone(&state.transport); + let peer_owned = peer.to_owned(); + let ready = state.is_ready().await; + // Before this message is counted. + let bytes_before = SyncBytes::of(&state.traffic, peer); + + let generated = + tokio::task::spawn_blocking(move || -> Result<Option<transport::EncodedMessage>> { + // Label the message with the epoch of the document it was generated + // from, read under the same lock. + let (epoch, message) = { + let mut replica = doc.blocking_write(); + let mut peer_state = psm.blocking_lock(); + let epoch = replica.epoch; + let sent_before = peer_state.sync.state.sent_hashes.clone(); + let message = { + let sync_doc = replica.doc.sync(); + sync_doc.generate_sync_message(&mut peer_state.sync.state) }; - transport - .encode(&peer_owned, &ParsedMessage::Sync(envelope)) - .map(Some) - }, - ) + // A peer whose heads are ours already has everything; Automerge + // answers only when it has something to say, so it will stay + // silent. Waiting for an answer would only end in a pointless + // resend. + let in_step = peer_state.sync.state.their_heads.as_ref() + == Some(&replica.doc.get_heads()); + let sent: Vec<_> = peer_state + .sync + .state + .sent_hashes + .difference(&sent_before) + .copied() + .collect(); + if in_step { + peer_state.sync.reply_settled(); + } else if message.is_some() { + peer_state + .sync + .expect_reply(sent.clone(), Instant::now(), bytes_before); + } + if message.is_some() { + // Marked under the same lock as the generation, so no second + // message is generated while this one is handed over. + peer_state.sync.sending = Some(Sending { + sent, + regenerate: false, + }); + } + (epoch, message) + }; + let Some(message) = message else { + return Ok(None); + }; + let envelope = transport::SyncEnvelope { + swarm_id, + epoch, + message, + ready, + }; + transport + .encode(&peer_owned, &ParsedMessage::Sync(envelope)) + .map(Some) + }) .await - .context("Sync generation task panicked")??; - - let Some(encoded) = generated else { - return Ok(()); - }; + .context("Sync generation task panicked")?; + let generated = match generated { + Ok(generated) => generated, + Err(error) => { + // Not encodable, so nothing went out: take back what it would have carried. + let _ = peer_state_mutex + .lock() + .await + .sync + .send_failed(Instant::now()); + return Err(error); + } + }; - peer_state_mutex.lock().await.pending_outbound_sync = Some(PendingOutbound { - message: encoded.clone(), - retry_delay: INITIAL_SYNC_RETRY, - next_attempt: Instant::now(), - }); + let Some(encoded) = generated else { + return Ok(()); + }; - encoded - } + encoded }; // No state locks are held across the send. match state.transport.send_encoded(&encoded).await { Ok(()) => { - let regenerate = { - let mut peer_state = peer_state_mutex.lock().await; - peer_state.pending_outbound_sync = None; - std::mem::take(&mut peer_state.needs_generate) - }; + let regenerate = peer_state_mutex.lock().await.sync.send_succeeded(); trace!(regenerate, "Outbound sync message accepted by Mycelium"); @@ -202,30 +232,27 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str, retry_delivery: bool Ok(()) } Err(error) => { - let delay = { - let mut peer_state = peer_state_mutex.lock().await; - - let Some(ref mut entry) = peer_state.pending_outbound_sync else { - debug!("Pending envelope discarded by a sync reset during delivery"); - return Ok(()); - }; - - entry.retry_delay = entry.retry_delay.saturating_mul(2).min(MAX_SYNC_RETRY); - entry.next_attempt = Instant::now() + entry.retry_delay; - entry.retry_delay + let Some(delay) = peer_state_mutex + .lock() + .await + .sync + .send_failed(Instant::now()) + else { + debug!("Sync message refused, but a reset already discarded it"); + return Ok(()); }; warn!( retry_ms = delay.as_millis(), error = %format!("{error:#}"), - "Sync delivery failed; retaining the same outbound envelope" + "Sync delivery failed; generating a fresh message later" ); let tx = state.sync_events.clone(); let peer = peer.to_owned(); tokio::spawn(async move { tokio::time::sleep(delay).await; - let _ = tx.send(SyncEvent::RetryDelivery(peer)); + let _ = tx.send(SyncEvent::PeerNeedsSync(peer)); }); Ok(()) @@ -239,38 +266,28 @@ pub(crate) async fn handle_sync( envelope: transport::SyncEnvelope, ) -> Result<()> { debug!(peer = %peer, "Processing sync envelope"); + // This message is counted already (`transport::traffic`). + let arrived = (Instant::now(), SyncBytes::of(&state.traffic, peer)); let peer_state_mutex = state.get_or_create_peer_state(peer).await; let needs_hello = { - let mut peer_state = peer_state_mutex.lock().await; + let peer_state = peer_state_mutex.lock().await; - match &peer_state.membership { - PeerMembership::Unknown => { - let duplicate = peer_state.pending_inbound_sync.iter().any(|pending| { - pending.message == envelope.message - && pending.swarm_id == envelope.swarm_id - && pending.epoch == envelope.epoch - }); - if !duplicate { - peer_state.pending_inbound_sync.push(envelope.clone()); - info!( - peer = %peer, - pending_count = peer_state.pending_inbound_sync.len(), - "Queued sync envelope pending peer membership" - ); - } else { - trace!(peer = %peer, "Ignoring duplicate pending sync envelope"); - } + match &peer_state.standing { + Standing::Stranger | Standing::Admitted => { + // Not processed: our handshake reply resets the session anyway, and the + // sender's reply timeout resends what still matters. + debug!(peer = %peer, "Dropping sync message from a peer that isn't a member yet"); true } - PeerMembership::Member { swarm_id } if swarm_id != &envelope.swarm_id => { + Standing::Member { swarm_id } if swarm_id != &envelope.swarm_id => { bail!( "Sync swarm mismatch for peer {peer}: validated_swarm={swarm_id}, envelope_swarm={}", envelope.swarm_id ); } - PeerMembership::Member { .. } => false, + Standing::Member { .. } => false, } }; @@ -296,17 +313,26 @@ pub(crate) async fn handle_sync( } let epoch_label = envelope.epoch; + let from_ready = envelope.ready; let applied = { let doc = Arc::clone(&state.doc); let peer_state_mutex = Arc::clone(&peer_state_mutex); + let stats = Arc::clone(&state.stats); + let throughput = Arc::clone(&state.throughput); + let rtt = state + .lock_rtt() + .effective(peer, Instant::now(), state.timing.rtt_fresh_for()); let check_caught_up = !state.initial_sync_done.load(Ordering::Acquire); + // While catching up, everything arrives late; that says nothing about propagation. + let measure_propagation = !check_caught_up; let epoch = envelope.epoch; let message = envelope.message; let peer = peer.to_owned(); - // Some(caught_up) when applied, None when the message belongs to another epoch. - tokio::task::spawn_blocking(move || -> Result<Option<bool>> { + // Some((caught_up, heads changed)) when applied, None when the message belongs + // to another epoch. + tokio::task::spawn_blocking(move || -> Result<Option<(bool, bool)>> { let mut replica = doc.blocking_write(); // Authoritative check, under the same lock as the apply: the document may @@ -316,52 +342,90 @@ pub(crate) async fn handle_sync( } let mut peer_state = peer_state_mutex.blocking_lock(); - - let heads_before = replica.doc.get_heads(); - let their_heads_before = peer_state.sync_state.their_heads.clone(); + peer_state.reports_ready = Some(from_ready); + let heads_before = replica.heads().to_vec(); replica .doc .sync() - .receive_sync_message(&mut peer_state.sync_state, message) + .receive_sync_message(&mut peer_state.sync.state, message) .context("Automerge synchronization failed")?; peer_state.last_sync_rx = Some(Instant::now()); - peer_state.sync_reply_received(); + if let Some(wait) = &peer_state.sync.reply { + throughput + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .exchange(&peer, wait.started, arrived, rtt); + } + peer_state.sync.reply_settled(); - let heads_after = replica.doc.get_heads(); - let unproductive = heads_before == heads_after - && peer_state.sync_state.their_heads == their_heads_before - && peer_state.sync_state.their_heads.as_ref() != Some(&heads_after); - break_sync_stall(&peer, &mut peer_state, unproductive); + resend_if_needed(&peer, &mut peer_state.sync.state); + replica.refresh_heads(); + let heads_after = replica.heads().to_vec(); + stats.heads_seen(heads_after.len()); + let changed = heads_after != heads_before; + if changed && measure_propagation { + let now_ms = crate::state::unix_millis() as i64; + // Time 0: not stamped (snapshots, older nodes). + let delays: Vec<i64> = replica + .doc + .get_changes(&heads_before) + .iter() + .map(|change| change.timestamp()) + .filter(|&time| time > 0) + .map(|time| now_ms - time) + .collect(); + stats.propagated(delays); + } - Ok(Some( + Ok(Some(( + // Only from a ready peer, and only if it has heads at all: empty heads are + // "contained" in every document, so they prove nothing. A peer still + // catching up has them, and so does a ready peer whose document is empty + // (a misconfigured second swarm, or a genesis node in its first seconds, + // before it registered itself). check_caught_up + && from_ready && peer_state - .sync_state + .sync + .state .their_heads .as_ref() .is_some_and(|heads| { - heads - .iter() - .all(|h| replica.doc.get_change_by_hash(h).is_some()) + !heads.is_empty() + && heads + .iter() + .all(|h| replica.doc.get_change_by_hash(h).is_some()) }), - )) + changed, + ))) }) .await .context("Sync task panicked")?? }; - let Some(caught_up) = applied else { + let Some((caught_up, changed)) = applied else { debug!(peer = %peer, epoch = %epoch_label, "Dropped sync message from another epoch"); return Ok(()); }; + source::reported(state, peer, from_ready, None); + if caught_up && !state.initial_sync_done.swap(true, Ordering::AcqRel) { info!(peer = %peer, "Initial sync complete"); + state + .stats + .caught_up(state.traffic.total().received.sync.bytes); + source::finished(state, peer); } state.notify_store_changed(); + crate::swarm::relay::pulled(state, peer); + if changed { + crate::swarm::relay::announce(state, peer).await; + } + state .sync_events .send(SyncEvent::MessageReceived(peer.to_owned())) @@ -370,75 +434,38 @@ pub(crate) async fn handle_sync( Ok(()) } -/// Exchanges in a row that changed nothing on either side before `sent_hashes` is -/// cleared, and before a full two-sided reset (re-handshake). -const STALL_RESEND_NEEDED: u32 = 2; -const STALL_CLEAR_SENT: u32 = 8; -const STALL_RESET: u32 = 32; - -/// Breaks sync livelocks. -/// /// Automerge never resends a change it believes the peer has received (`sent_hashes`). -/// If that change was lost on the way (a dropped or undeliverable message, an epoch -/// check, a reset of only one side's state), the two sides keep exchanging messages -/// with differing heads and no changes, as fast as the transport allows. The keepalive -/// heal never fires then, because sync traffic keeps flowing. -/// -/// * A change the peer keeps asking for is sent again. -/// * After a few unproductive exchanges, forget what we sent: the next message carries -/// everything the peer's bloom filter lacks. -/// * If that doesn't help either, reset both sides through a fresh handshake. -fn break_sync_stall(peer: &str, peer_state: &mut PeerState, unproductive: bool) { - if !unproductive { - peer_state.stalled_exchanges = 0; +/// If the message carrying it was lost while later ones arrive, the peer keeps asking for +/// it (`need`, from the heads we advertise) and we keep not sending it: a livelock the +/// reply timeout can't see (answers keep coming) and the keepalive heal can't either +/// (sync never goes idle). So when the peer needs a change we think it has, what we +/// think it has is wrong: forget all of it, and the next message carries everything its +/// bloom filter lacks. (A reordered message can make that send a change twice; cheap.) +fn resend_if_needed(peer: &str, sync_state: &mut automerge::sync::State) { + let Some(need) = &sync_state.their_need else { return; - } - peer_state.stalled_exchanges += 1; - - let sync_state = &mut peer_state.sync_state; - - // Not on the first unproductive exchange: when messages cross, the peer asks for a - // change that is merely still in flight, and resending it would only duplicate it. - if peer_state.stalled_exchanges >= STALL_RESEND_NEEDED { - if let Some(need) = &sync_state.their_need { - let before = sync_state.sent_hashes.len(); - sync_state.sent_hashes.retain(|hash| !need.contains(hash)); - let resend = before - sync_state.sent_hashes.len(); - if resend > 0 { - debug!(peer = %peer, resend, "Peer still needs changes we had sent; sending them again"); - } - } - } - - match peer_state.stalled_exchanges { - STALL_CLEAR_SENT => { - warn!( - peer = %peer, - sent = sync_state.sent_hashes.len(), - their_heads = ?sync_state.their_heads, - shared_heads = ?sync_state.shared_heads, - "Sync is stalled; forgetting which changes were sent" - ); - sync_state.sent_hashes.clear(); - } - STALL_RESET => { - warn!(peer = %peer, "Sync is still stalled; resetting it on both sides"); - peer_state.stalled_exchanges = 0; - peer_state.request_resync(); - } - _ => {} + }; + if need + .iter() + .any(|hash| sync_state.sent_hashes.contains(hash)) + { + debug!( + peer = %peer, + sent = sync_state.sent_hashes.len(), + "Peer needs changes we had sent; forgetting what was sent" + ); + sync_state.sent_hashes.clear(); } } pub(crate) async fn run_sync_worker(state: AppState, mut rx: mpsc::UnboundedReceiver<SyncEvent>) { let mut dirty_peers = HashSet::<String>::new(); - let mut retry_peers = HashSet::<String>::new(); let mut document_dirty = false; info!("Starting synchronization worker"); loop { - if dirty_peers.is_empty() && retry_peers.is_empty() && !document_dirty { + if dirty_peers.is_empty() && !document_dirty { match rx.recv().await { Some(event) => match event { SyncEvent::PeerValidated(peer) @@ -446,9 +473,6 @@ pub(crate) async fn run_sync_worker(state: AppState, mut rx: mpsc::UnboundedRece | SyncEvent::PeerNeedsSync(peer) => { dirty_peers.insert(peer); } - SyncEvent::RetryDelivery(peer) => { - retry_peers.insert(peer); - } SyncEvent::DocumentChanged => { document_dirty = true; } @@ -468,9 +492,6 @@ pub(crate) async fn run_sync_worker(state: AppState, mut rx: mpsc::UnboundedRece | SyncEvent::PeerNeedsSync(peer) => { dirty_peers.insert(peer); } - SyncEvent::RetryDelivery(peer) => { - retry_peers.insert(peer); - } SyncEvent::DocumentChanged => { document_dirty = true; } @@ -489,30 +510,21 @@ pub(crate) async fn run_sync_worker(state: AppState, mut rx: mpsc::UnboundedRece peers_guard.keys().cloned().collect() }; + // Our own changes go to our neighbours; they pass them on. Others only get + // answers to what they send us. for peer in peer_keys { - if peer_swarm(&state, &peer).await.is_some() { + if peer_swarm(&state, &peer).await.is_some() + && crate::swarm::topology::is_neighbour(&state, &peer) + { dirty_peers.insert(peer); } } } - // Retry existing envelopes separately from generating new ones. - let retries: Vec<String> = retry_peers.drain().collect(); - - for peer in retries { - if let Err(error) = deliver_sync_to_peer(&state, &peer, true).await { - warn!( - peer = %peer, - error = %format!("{error:#}"), - "Sync delivery retry failed" - ); - } - } - let peers: Vec<String> = dirty_peers.drain().collect(); for peer in peers { - if let Err(error) = deliver_sync_to_peer(&state, &peer, false).await { + if let Err(error) = deliver_sync_to_peer(&state, &peer).await { warn!( peer = %peer, error = %format!("{error:#}"), diff --git a/src/swarm/throughput.rs b/src/swarm/throughput.rs new file mode 100644 index 0000000..f1b9b14 --- /dev/null +++ b/src/swarm/throughput.rs @@ -0,0 +1,168 @@ +//! Sync throughput with peers, measured from sync exchanges we have anyway: from sending +//! a sync message until the peer's answer arrives, the sync bytes that went either way +//! with that peer. No transfers of our own. +//! +//! Only exchanges of at least `MIN_BYTES` count; smaller ones measure the round trip, +//! not the throughput. The round trip (`swarm::rtt`) is taken off the elapsed time, but +//! never more than half of it, so a stale round trip can't inflate a sample. What +//! remains also includes the time the peer took to apply and answer: this is the rate a +//! peer delivers at in practice, not the raw capacity of the path. +//! +//! Samples are smoothed (exponential moving average, weight 1/4). Large exchanges are +//! rare (catching up, big writes), so estimates are kept however old; they come with +//! their age. + +use std::collections::HashMap; +use std::time::Duration; + +use tokio::time::Instant; + +use crate::transport::traffic::Traffic; + +/// Smallest exchange that counts: some dozens of Mycelium chunks. +const MIN_BYTES: u64 = 32 * 1024; +const WEIGHT: f64 = 0.25; + +/// Sync bytes exchanged with a peer so far (from the traffic counters). +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct SyncBytes { + sent: u64, + received: u64, +} + +impl SyncBytes { + pub(crate) fn of(traffic: &Traffic, peer: &str) -> Self { + traffic + .peer(peer) + .map(|t| Self { + sent: t.sent.sync.bytes, + received: t.received.sync.bytes, + }) + .unwrap_or_default() + } + + /// Bytes either way since `earlier` (0 if the counters were dropped meanwhile). + fn since(&self, earlier: &SyncBytes) -> u64 { + self.sent.saturating_sub(earlier.sent) + self.received.saturating_sub(earlier.received) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Estimate { + pub(crate) bytes_per_sec: f64, + pub(crate) samples: u32, + /// When the last sample was taken. + pub(crate) at: Instant, +} + +#[derive(Debug, Default)] +pub(crate) struct ThroughputTable { + peers: HashMap<String, Estimate>, +} + +impl ThroughputTable { + /// A sync exchange with `peer` finished: it started at `started` with the counters + /// at `before`, and its answer arrived at `arrived` with the counters at `after`. + pub(crate) fn exchange( + &mut self, + peer: &str, + (started, before): (Instant, SyncBytes), + (arrived, after): (Instant, SyncBytes), + rtt: Option<Duration>, + ) { + let bytes = after.since(&before); + if bytes < MIN_BYTES { + return; + } + let elapsed = arrived.saturating_duration_since(started); + let transfer = elapsed + .saturating_sub(rtt.unwrap_or_default()) + .max(elapsed / 2); + if transfer.is_zero() { + return; + } + let sample = bytes as f64 / transfer.as_secs_f64(); + let estimate = self.peers.entry(peer.to_owned()).or_insert(Estimate { + bytes_per_sec: sample, + samples: 0, + at: arrived, + }); + if estimate.samples > 0 { + estimate.bytes_per_sec += WEIGHT * (sample - estimate.bytes_per_sec); + } + estimate.samples += 1; + estimate.at = arrived; + } + + pub(crate) fn get(&self, peer: &str) -> Option<Estimate> { + self.peers.get(peer).copied() + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) { + self.peers.retain(|peer, _| keep(peer)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bytes(sent: u64, received: u64) -> SyncBytes { + SyncBytes { sent, received } + } + + #[test] + fn small_exchanges_dont_count() { + let mut table = ThroughputTable::default(); + let t0 = Instant::now(); + table.exchange( + "a", + (t0, bytes(0, 0)), + (t0 + Duration::from_millis(200), bytes(500, 10_000)), + None, + ); + assert!(table.get("a").is_none()); + } + + #[test] + fn round_trip_is_taken_off_but_at_most_half() { + let mut table = ThroughputTable::default(); + let t0 = Instant::now(); + // 1 MB in 2 s, of which 1 s round trip: 1 MB/s. + table.exchange( + "a", + (t0, bytes(100, 0)), + (t0 + Duration::from_secs(2), bytes(200, 999_900)), + Some(Duration::from_secs(1)), + ); + let a = table.get("a").unwrap(); + assert!((a.bytes_per_sec - 1_000_000.0).abs() < 1.0); + + // A round trip longer than the exchange takes off only half. + table.exchange( + "b", + (t0, bytes(0, 0)), + (t0 + Duration::from_secs(2), bytes(0, 1_000_000)), + Some(Duration::from_secs(5)), + ); + assert!((table.get("b").unwrap().bytes_per_sec - 1_000_000.0).abs() < 1.0); + } + + #[test] + fn smoothed() { + let mut table = ThroughputTable::default(); + let t0 = Instant::now(); + for received in [500_000u64, 1_000_000] { + table.exchange( + "a", + (t0, bytes(0, 0)), + (t0 + Duration::from_secs(1), bytes(0, received)), + None, + ); + } + // Samples of 500 kB/s, then 1 MB/s: 500 kB/s + (1 MB/s - 500 kB/s) / 4. + let a = table.get("a").unwrap(); + assert_eq!(a.samples, 2); + assert!((a.bytes_per_sec - 625_000.0).abs() < 1.0); + } +} diff --git a/src/swarm/topology.rs b/src/swarm/topology.rs new file mode 100644 index 0000000..57856b6 --- /dev/null +++ b/src/swarm/topology.rs @@ -0,0 +1,521 @@ +//! Which members a node syncs with: its neighbours. +//! +//! In a full mesh every change reaches every node from every other node (Automerge +//! decides per pair, so nobody knows that a third peer already passed it on), so the +//! copies of one write grow with N². Above `k` members, each node keeps only a few +//! neighbours, and changes travel over several hops. +//! +//! Every node computes the graph from the member list and which members it can reach, +//! with no coordination: +//! * a ring ordered by key hash: each member's nearest reachable member in both +//! directions, which keeps the reachable members connected; +//! * plus, for each member, the `k - 2` reachable others with the lowest pair score (a +//! hash of both keys, so both ends agree on it), which keeps paths short. +//! +//! A link exists if either end chose it, so links are symmetric and a node has at least +//! `k` neighbours. With at most `k + 1` reachable members the graph is the full mesh: +//! small swarms behave exactly as before. A member joining, leaving or going offline +//! changes only links near it. + +use std::collections::{BTreeMap, BTreeSet}; + +use sha2::{Digest, Sha256}; +use tokio::time::Instant; +use tracing::info; + +use crate::state::AppState; +use crate::swarm::members; + +/// This node's current neighbours, and the member list they were computed from. +#[derive(Debug, Default)] +pub(crate) struct NeighbourView { + members: Vec<String>, + k: usize, + unreachable: BTreeSet<String>, + stable: BTreeSet<String>, + neighbours: BTreeSet<String>, + /// Every reachable member is a neighbour. + full_mesh: bool, +} + +/// Whether every reachable member is our neighbour (also while the view is unknown). +pub(crate) fn is_full_mesh(state: &AppState) -> bool { + state + .neighbours + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .is_none_or(|view| view.full_mesh) +} + +/// The current view in numbers, once known. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Summary { + /// Registered members, us included. + pub(crate) members: usize, + /// Members other than us not currently judged unreachable. + pub(crate) reachable: usize, + pub(crate) stable: usize, + pub(crate) neighbours: usize, + pub(crate) stable_neighbours: usize, + pub(crate) full_mesh: bool, +} + +pub(crate) fn summary(state: &AppState) -> Option<Summary> { + let local = state.local_id(); + state + .neighbours + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map(|view| Summary { + members: view.members.len(), + reachable: view + .members + .iter() + .filter(|m| m.as_str() != local && !view.unreachable.contains(*m)) + .count(), + stable: view.stable.len(), + neighbours: view.neighbours.len(), + stable_neighbours: view + .neighbours + .iter() + .filter(|m| view.stable.contains(*m)) + .count(), + full_mesh: view.full_mesh, + }) +} + +/// Whether we initiate contact with `peer`: HELLOs, keepalives, dormancy checks, sync +/// openings. Everyone is a neighbour until the view is known (while joining, or before +/// any member registered), so joining works as in a full mesh. We always *answer* +/// anyone who contacts us. +pub(crate) fn is_neighbour(state: &AppState, peer: &str) -> bool { + match &*state + .neighbours + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + { + Some(view) => view.neighbours.contains(peer), + None => true, + } +} + +/// How the neighbours changed. +pub(crate) struct NeighbourChange { + pub(crate) added: Vec<String>, + pub(crate) removed: Vec<String>, +} + +/// Recomputes the neighbours once ready, if the member list, the reachable members or +/// `k` changed. +pub(crate) async fn refresh(state: &AppState) -> Option<NeighbourChange> { + if !state.is_ready().await { + return None; + } + let (current, stable) = { + let replica = state.doc.read().await; + let current = members::members(&replica.doc); + let stable: BTreeSet<String> = current + .iter() + .filter(|m| members::tier(&replica.doc, m) == members::Tier::Stable) + .cloned() + .collect(); + (current, stable) + }; + let k = state.neighbours_k; + let now = Instant::now(); + let mut unreachable = BTreeSet::new(); + for member in ¤t { + if let Some(peer_state) = state.get_peer_state(member).await { + if peer_state.lock().await.is_unreachable(&state.timing, now) { + unreachable.insert(member.clone()); + } + } + } + let previous = { + let view = state + .neighbours + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if view.as_ref().is_some_and(|view| { + view.members == current + && view.k == k + && view.unreachable == unreachable + && view.stable == stable + }) { + return None; + } + view.as_ref().map(|view| view.neighbours.clone()) + }; + + // Before the first view everyone known counted as a neighbour. + let previous = match previous { + Some(previous) => previous, + None => state.peer_states.read().await.keys().cloned().collect(), + }; + let computed = neighbours( + ¤t, + state.local_id(), + k, + &|m| unreachable.contains(m), + &|m| stable.contains(m), + ); + let change = NeighbourChange { + added: computed + .iter() + .filter(|m| !previous.contains(*m)) + .cloned() + .collect(), + removed: previous + .iter() + .filter(|m| !computed.contains(*m)) + .cloned() + .collect(), + }; + info!( + members = current.len(), + unreachable = unreachable.len(), + stable = stable.len(), + k, + neighbours = computed.len(), + added = change.added.len(), + removed = change.removed.len(), + "Neighbours computed from the member list" + ); + let reachable_others = current + .iter() + .filter(|m| m.as_str() != state.local_id() && !unreachable.contains(*m)) + .count(); + *state + .neighbours + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(NeighbourView { + full_mesh: computed.len() >= reachable_others, + members: current, + k, + unreachable, + stable, + neighbours: computed, + }); + Some(change) +} + +const RING_LABEL: &[u8] = b"continuum/topology-ring/1"; +const PAIR_LABEL: &[u8] = b"continuum/topology-pair/1"; + +/// How many members may prefer the same stable member, as a multiple of `k`. Without a +/// bound, 20 stable members among 2000 get about 500 neighbours each: every +/// announcement in the swarm lands on them, and the backbone saturates first. Members +/// that find a stable member full prefer the next one, then go by pair score, so the +/// graph stays connected and symmetric. +const STABLE_FANIN_FACTOR: usize = 4; + +/// The neighbours of `local` among `members` (which may or may not contain `local`), +/// skipping members for which `unreachable` holds. +pub(crate) fn neighbours( + members: &[String], + local: &str, + k: usize, + unreachable: &dyn Fn(&str) -> bool, + stable: &dyn Fn(&str) -> bool, +) -> BTreeSet<String> { + let mut all: Vec<String> = members.to_vec(); + all.push(local.to_owned()); + graph(&all, k, &|m: &str| m != local && unreachable(m), stable) + .remove(local) + .unwrap_or_default() +} + +/// Every reachable member's neighbours. Costs about N² hashes (a few milliseconds for +/// hundreds of members). `stable` members (`swarm::stability`) are preferred for half of +/// each member's scored slots, each by at most `STABLE_FANIN_FACTOR * k` members. +/// +/// Each reachable member walks the ring (by key hash) both ways to the first reachable +/// member, which gives a ring over the reachable members, and adds the `k - 2` +/// reachable members with the lowest pair score. A link exists if either end chose it. +/// With at most `k + 1` reachable members, the reachable members form a full mesh. +/// +/// Each node judges reachability by itself, so nodes may briefly disagree; that only +/// adds links (whoever chose a link initiates, the other answers), it never cuts one. +/// The preference budget is spent in key order, which every node shares, so nodes that +/// agree on reachability compute the same links. +pub(crate) fn graph( + members: &[String], + k: usize, + unreachable: &dyn Fn(&str) -> bool, + stable: &dyn Fn(&str) -> bool, +) -> BTreeMap<String, BTreeSet<String>> { + let k = k.max(2); + let all: BTreeSet<&str> = members.iter().map(String::as_str).collect(); + let live: BTreeSet<&str> = all.iter().copied().filter(|m| !unreachable(m)).collect(); + let mut links: BTreeMap<String, BTreeSet<String>> = live + .iter() + .map(|m| ((*m).to_owned(), BTreeSet::new())) + .collect(); + + let mut link = |a: &str, b: &str| { + if a != b { + links.get_mut(a).expect("live member").insert(b.to_owned()); + links.get_mut(b).expect("live member").insert(a.to_owned()); + } + }; + + if live.len() <= k + 1 { + for a in &live { + for b in &live { + link(a, b); + } + } + return links; + } + + // The ring over all members, so its order doesn't change when one goes offline. + let mut ring: Vec<([u8; 32], &str)> = + all.iter().map(|m| (hash(RING_LABEL, &[*m]), *m)).collect(); + ring.sort_unstable(); + let n = ring.len(); + let position: BTreeMap<&str, usize> = + ring.iter().enumerate().map(|(i, (_, m))| (*m, i)).collect(); + let first_live = |from: usize, step: usize| { + (1..n) + .map(|d| ring[(from + d * step) % n].1) + .find(|m| live.contains(m)) + .expect("more than k + 1 live members") + }; + + // How many members have preferred each stable member so far (`live` iterates in key + // order, the same on every node). + let budget = STABLE_FANIN_FACTOR * k; + let mut preferred_by: BTreeMap<&str, usize> = BTreeMap::new(); + + for member in &live { + let at = position[member]; + let next = first_live(at, 1); + let prev = first_live(at, n - 1); + link(member, next); + link(member, prev); + let mut candidates: Vec<([u8; 32], &str)> = live + .iter() + .copied() + .filter(|other| other != member && *other != next && *other != prev) + .map(|other| (pair_score(member, other), other)) + .collect(); + candidates.sort_unstable(); + // Half the slots (rounded up) prefer stable members, so long-running nodes form + // a backbone; the rest go by pair score alone, keeping paths short. A stable + // member enough others prefer already is skipped, so no hub grows with the swarm. + let stable_slots = (k - 2).div_ceil(2); + let preferred: Vec<&str> = candidates + .iter() + .map(|(_, other)| *other) + .filter(|other| stable(other) && preferred_by.get(other).copied().unwrap_or(0) < budget) + .take(stable_slots) + .collect(); + for other in preferred.iter().copied() { + *preferred_by.entry(other).or_default() += 1; + } + let rest = candidates + .iter() + .map(|(_, other)| *other) + .filter(|other| !preferred.contains(other)) + .take(k - 2 - preferred.len()); + for partner in preferred.iter().copied().chain(rest) { + link(member, partner); + } + } + links +} + +/// Symmetric: the same for (a, b) and (b, a). +fn pair_score(a: &str, b: &str) -> [u8; 32] { + let (low, high) = if a <= b { (a, b) } else { (b, a) }; + hash(PAIR_LABEL, &[low, high]) +} + +fn hash(label: &[u8], fields: &[&str]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for field in [label] + .into_iter() + .chain(fields.iter().map(|f| f.as_bytes())) + { + hasher.update((field.len() as u32).to_be_bytes()); + hasher.update(field); + } + hasher.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + fn members(n: usize) -> Vec<String> { + (0..n).map(|i| format!("{:064x}", i * 7919 + 13)).collect() + } + + fn all_reachable(_: &str) -> bool { + false + } + + fn none_stable(_: &str) -> bool { + false + } + + fn graph(members: &[String], k: usize) -> BTreeMap<String, BTreeSet<String>> { + super::graph(members, k, &all_reachable, &none_stable) + } + + fn connected(g: &BTreeMap<String, BTreeSet<String>>) -> bool { + let Some(start) = g.keys().next() else { + return true; + }; + let mut seen = BTreeSet::from([start.clone()]); + let mut queue = VecDeque::from([start.clone()]); + while let Some(m) = queue.pop_front() { + for next in &g[&m] { + if seen.insert(next.clone()) { + queue.push_back(next.clone()); + } + } + } + seen.len() == g.len() + } + + fn edges(graph: &BTreeMap<String, BTreeSet<String>>) -> BTreeSet<(String, String)> { + graph + .iter() + .flat_map(|(a, ns)| { + ns.iter().map(move |b| { + if a < b { + (a.clone(), b.clone()) + } else { + (b.clone(), a.clone()) + } + }) + }) + .collect() + } + + #[test] + fn small_swarms_are_a_full_mesh() { + let ms = members(13); + for m in &ms { + assert_eq!( + neighbours(&ms, m, 12, &all_reachable, &none_stable).len(), + 12 + ); + } + } + + #[test] + fn links_are_symmetric_and_degree_at_least_k() { + let ms = members(250); + let g = graph(&ms, 8); + for (a, ns) in &g { + assert!(ns.len() >= 8, "{a} has {} neighbours", ns.len()); + assert!(!ns.contains(a)); + for b in ns { + assert!(g[b].contains(a), "{a} -> {b} not symmetric"); + } + } + } + + #[test] + fn graph_is_connected() { + for (n, k) in [(250, 8), (250, 2), (60, 3)] { + assert!(connected(&graph(&members(n), k)), "n={n} k={k}"); + } + } + + #[test] + fn one_member_joining_changes_few_links() { + let ms = members(200); + let before = edges(&graph(&ms, 8)); + let mut more = ms.clone(); + more.push(format!("{:064x}", 999_999)); + let after = edges(&graph(&more, 8)); + let changed = before.symmetric_difference(&after).count(); + assert!(changed <= 4 * 8, "{changed} links changed"); + } + + #[test] + fn reachable_members_stay_connected_when_most_are_offline() { + let ms = members(100); + // Every tenth member is online. + let online: BTreeSet<String> = ms.iter().step_by(10).cloned().collect(); + let offline = |m: &str| !online.contains(m); + let g = super::graph(&ms, 12, &offline, &none_stable); + assert_eq!(g.len(), 10); + // Ten reachable members and k = 12: a full mesh among them. + assert!(g.values().all(|ns| ns.len() == 9)); + + let online: BTreeSet<String> = ms.iter().step_by(4).cloned().collect(); + let offline = |m: &str| !online.contains(m); + let g = super::graph(&ms, 8, &offline, &none_stable); + assert_eq!(g.len(), 25); + assert!(connected(&g)); + assert!( + g.values() + .all(|ns| ns.len() >= 8 && ns.iter().all(|n| online.contains(n))) + ); + } + + #[test] + fn stable_members_become_hubs_with_bounded_degree() { + let ms = members(250); + let stable: BTreeSet<String> = ms.iter().step_by(12).take(20).cloned().collect(); + let is_stable = |m: &str| stable.contains(m); + let g = super::graph(&ms, 12, &all_reachable, &is_stable); + assert!(connected(&g)); + + let degree = |pred: &dyn Fn(&String) -> bool| -> (usize, f64) { + let degrees: Vec<usize> = g + .iter() + .filter(|(m, _)| pred(m)) + .map(|(_, ns)| ns.len()) + .collect(); + let max = *degrees.iter().max().unwrap(); + ( + max, + degrees.iter().sum::<usize>() as f64 / degrees.len() as f64, + ) + }; + let (stable_max, stable_mean) = degree(&|m| stable.contains(m)); + let (_, transient_mean) = degree(&|m| !stable.contains(m)); + // 230 transient members put about 5 of their 10 scored slots on 20 stable ones. + assert!( + stable_mean > 3.0 * transient_mean, + "{stable_mean} vs {transient_mean}" + ); + assert!( + stable_max <= 100, + "a stable member has {stable_max} neighbours" + ); + } + + #[test] + fn hub_degree_is_bounded_however_few_stable_members() { + // Few stable members among many: without the fan-in budget each would be chosen + // by most of the swarm (about 130 neighbours with 10 of 250). With it, a hub has + // at most the budget plus the links others choose by pair score and the ring. + for (n, stable_count) in [(250, 10), (1000, 10), (1000, 20)] { + let k = 12; + let ms = members(n); + let stable: BTreeSet<String> = ms.iter().step_by(n / stable_count).cloned().collect(); + let is_stable = |m: &str| stable.contains(m); + let g = super::graph(&ms, k, &all_reachable, &is_stable); + assert!(connected(&g), "n={n} stable={stable_count}"); + let hub_max = g + .iter() + .filter(|(m, _)| stable.contains(*m)) + .map(|(_, ns)| ns.len()) + .max() + .unwrap(); + assert!( + hub_max <= 8 * k, + "n={n} stable={stable_count}: a stable member has {hub_max} neighbours" + ); + } + } +} |
