summaryrefslogtreecommitdiffstats
path: root/src/swarm
diff options
context:
space:
mode:
Diffstat (limited to 'src/swarm')
-rw-r--r--src/swarm/blob.rs8
-rw-r--r--src/swarm/bootstrap.rs252
-rw-r--r--src/swarm/members.rs6
-rw-r--r--src/swarm/mod.rs1
-rw-r--r--src/swarm/peer.rs96
-rw-r--r--src/swarm/source.rs111
-rw-r--r--src/swarm/state_file.rs189
-rw-r--r--src/swarm/sync.rs17
-rw-r--r--src/swarm/topology.rs82
9 files changed, 725 insertions, 37 deletions
diff --git a/src/swarm/blob.rs b/src/swarm/blob.rs
index 8c891ae..42cfbf3 100644
--- a/src/swarm/blob.rs
+++ b/src/swarm/blob.rs
@@ -171,8 +171,10 @@ async fn prepare(state: &AppState, id: BlobId) -> Option<Arc<Prepared>> {
}
async fn reply(state: &AppState, peer: &str, id: BlobId, body: BlobBody) {
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
let message = ParsedMessage::Blob(BlobMessage {
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
id,
body,
});
@@ -223,8 +225,10 @@ impl Drop for Registration<'_> {
}
async fn request(state: &AppState, peer: &str, id: BlobId, chunks: Vec<u32>) {
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
let message = ParsedMessage::Blob(BlobMessage {
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
id,
body: BlobBody::Request { chunks },
});
diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs
index 1ae5173..5e9ac06 100644
--- a/src/swarm/bootstrap.rs
+++ b/src/swarm/bootstrap.rs
@@ -7,18 +7,29 @@
//!
//! The genesis node records the swarm ID in the document (`_continuum/swarm`), so
//! snapshots carry it forward and every backup names its swarm.
+//!
+//! A node recovering from a backup (`--recover-from`) starts on the backup's document,
+//! swarm and epoch instead, and is ready at once, like a genesis node with content.
use std::sync::atomic::Ordering;
use std::time::Duration;
-use anyhow::{Context, Result};
+use std::collections::HashSet;
+use std::path::Path;
+
+use anyhow::{Context, Result, bail};
+use automerge::{ActorId, AutoCommit};
use serde_json::Value as JsonValue;
use tokio::time::Instant;
use tracing::{debug, info, warn};
use uuid::Uuid;
+use crate::document;
+use crate::document::replica::Epoch;
use crate::document::store;
-use crate::remake::snapshot::{META_ROOT, SWARM};
+use crate::files::{FileKind, Files};
+use crate::force::Force;
+use crate::remake::snapshot::{self, META_ROOT, SWARM};
use crate::state::AppState;
use crate::swarm::peer::{remember_peer, send_hello_once};
@@ -29,10 +40,52 @@ const BOOTSTRAP_REPORT: Duration = Duration::from_secs(300);
const RESOLVE_RETRY: Duration = Duration::from_secs(5);
const RESOLVE_RETRY_MAX: Duration = Duration::from_secs(300);
-pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], genesis: bool) -> Result<()> {
- if genesis {
- let swarm_id = Uuid::new_v4().to_string();
- info!(swarm_id = %swarm_id, "Starting a new swarm as its genesis node (--genesis)");
+/// How this node starts.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Start {
+ /// Join a swarm through seeds (or remembered members).
+ Join,
+ /// Start a swarm, empty (`--genesis`).
+ Genesis,
+ /// Start on a document already loaded as the replica (`StartingDocument`).
+ FromDocument(Origin),
+}
+
+pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) -> Result<()> {
+ if let Start::FromDocument(origin) = start {
+ let Some(swarm_id) = state.swarm_id().await else {
+ bail!("starting on a document without a swarm ID");
+ };
+ info!(
+ swarm_id = %swarm_id,
+ epoch = %state.local_epoch(),
+ "Starting on {}, ready at once",
+ origin.describe()
+ );
+ state.initial_sync_done.store(true, Ordering::Release);
+ state.mark_ready(swarm_id).await?;
+ // Members of the swarm still running, if any: they merge with this node, or it
+ // rejoins them if they moved on to a newer epoch.
+ resolve_seeds(state, &mut seeds.to_vec(), &mut HashSet::new()).await;
+ return Ok(());
+ }
+ if start == Start::Genesis {
+ // A remembered swarm starts again under its ID: nodes of it still running then
+ // merge with this one instead of ending up in another swarm.
+ let swarm_id = match state.swarm_id().await {
+ Some(remembered) => {
+ warn!(
+ swarm_id = %remembered,
+ "--genesis with a remembered swarm: starting it again, empty; nodes of it still running merge with this one"
+ );
+ remembered
+ }
+ None => {
+ let swarm_id = Uuid::new_v4().to_string();
+ info!(swarm_id = %swarm_id, "Starting a new swarm as its genesis node (--genesis)");
+ swarm_id
+ }
+ };
let recorded = JsonValue::String(swarm_id.clone());
state
.mutate_doc(move |doc| store::write_path(doc, &swarm_id_path(), &recorded))
@@ -40,6 +93,8 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], genesis: bool)
.context("Recording the swarm ID")?;
state.initial_sync_done.store(true, Ordering::Release);
state.mark_ready(swarm_id).await?;
+ // Remembered members, if any, to find those still running.
+ resolve_seeds(state, &mut seeds.to_vec(), &mut HashSet::new()).await;
return Ok(());
}
@@ -52,6 +107,7 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], genesis: bool)
}
let mut unresolved: Vec<String> = seeds.to_vec();
+ let mut failing = HashSet::new();
let mut resolve_retry = RESOLVE_RETRY;
let mut next_resolve = Instant::now();
let mut next_report = Instant::now() + BOOTSTRAP_REPORT;
@@ -68,7 +124,7 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], genesis: bool)
let now = Instant::now();
if !unresolved.is_empty() && now >= next_resolve {
- resolve_seeds(state, &mut unresolved).await;
+ resolve_seeds(state, &mut unresolved, &mut failing).await;
if !unresolved.is_empty() {
next_resolve = now + resolve_retry;
resolve_retry = (resolve_retry * 2).min(RESOLVE_RETRY_MAX);
@@ -86,21 +142,190 @@ fn swarm_id_path() -> Vec<String> {
vec![META_ROOT.to_owned(), SWARM.to_owned()]
}
+/// The swarm ID the document names, if any.
+fn read_swarm_id(doc: &AutoCommit) -> Option<String> {
+ match store::read_path(doc, &swarm_id_path()) {
+ Ok(Some(JsonValue::String(id))) if !id.is_empty() => Some(id),
+ _ => None,
+ }
+}
+
+/// Where a document to start from came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Origin {
+ /// A backup to recover from (`--recover-from`).
+ Backup,
+ /// This node's document file (`--document-file`).
+ DocumentFile,
+}
+
+impl Origin {
+ fn describe(self) -> &'static str {
+ match self {
+ Origin::Backup => "the backup",
+ Origin::DocumentFile => "the document file",
+ }
+ }
+}
+
+/// A document the node starts from, ready at once: on the swarm and epoch it names.
+pub(crate) struct StartingDocument {
+ /// Under a fresh actor.
+ pub(crate) doc: AutoCommit,
+ pub(crate) epoch: Epoch,
+ pub(crate) swarm_id: String,
+ pub(crate) origin: Origin,
+ /// The actors this node wrote with before (from the document file).
+ pub(crate) earlier_actors: Vec<ActorId>,
+}
+
+/// What `--recover-from` and `--document-file` say about where to start.
+pub(crate) struct StartOptions<'a> {
+ pub(crate) recover_from: Option<&'a Path>,
+ pub(crate) document_file: Option<&'a Path>,
+ pub(crate) genesis: bool,
+ /// The swarm the state file remembers.
+ pub(crate) remembered: Option<&'a str>,
+}
+
+/// The document to start from, if any, checked against the other options. Conflicts
+/// are errors unless forced (`force`):
+/// * an unreadable document file (`badDocumentFile`: moved aside, start without it);
+/// * a backup to recover from while the document file holds a document
+/// (`replaceDocumentFile`: the backup wins, and the file is rewritten from it);
+/// * a document of another swarm than the state file remembers (`swarmId`).
+///
+/// A document file holding a document with `--genesis` is an error: move it away.
+pub(crate) fn starting_document(
+ options: &StartOptions<'_>,
+ files: &Files,
+ force: &Force,
+) -> Result<Option<StartingDocument>> {
+ let from_file = match options.document_file {
+ None => None,
+ Some(path) => match document::file::load(path, files) {
+ Ok(loaded) => loaded,
+ Err(error) if force.allows("badDocumentFile") => {
+ warn!(error = %format!("{error:#}"), "Unusable document file; moving it aside and starting without it (--force badDocumentFile)");
+ let aside = files.move_aside(path, ".bad").with_context(|| {
+ format!("Moving the document file {} aside", path.display())
+ })?;
+ warn!(moved_to = %aside.display(), "Moved the document file aside");
+ None
+ }
+ Err(error) => {
+ bail!(
+ "{error:#}; add --force badDocumentFile to move it aside and start without it"
+ )
+ }
+ },
+ };
+ let backup = match options.recover_from {
+ None => None,
+ Some(path) => Some(load_backup(path, files)?),
+ };
+
+ let (doc, origin, earlier_actors) = match (backup, from_file) {
+ (Some(_), Some(_)) if !force.allows("replaceDocumentFile") => {
+ bail!(
+ "recover-from would replace the document in the document file; add --force replaceDocumentFile to recover the backup anyway"
+ );
+ }
+ (Some(backup), from_file) => {
+ if from_file.is_some() {
+ warn!(
+ "Recovering the backup instead of the document file's document (--force replaceDocumentFile)"
+ );
+ }
+ (backup, Origin::Backup, Vec::new())
+ }
+ (None, Some(loaded)) => (loaded.doc, Origin::DocumentFile, loaded.actors),
+ (None, None) => return Ok(None),
+ };
+ if options.genesis && origin == Origin::DocumentFile {
+ bail!(
+ "The document file holds a document of a swarm, and genesis would start a new one; move the file away to start a new swarm"
+ );
+ }
+ let starting = identify(doc, origin, earlier_actors)?;
+
+ if let Some(remembered) = options.remembered.filter(|id| *id != starting.swarm_id) {
+ let what = origin.describe();
+ if !force.allows("swarmId") {
+ bail!(
+ "{what} is of swarm {}, but the state file remembers swarm {remembered}; add --force swarmId to start on {what}'s swarm anyway",
+ starting.swarm_id
+ );
+ }
+ warn!(
+ document = %starting.swarm_id,
+ remembered = %remembered,
+ "Starting on {what}'s swarm, not the remembered one (--force swarmId)"
+ );
+ }
+ Ok(Some(starting))
+}
+
+/// The swarm and epoch `doc` is on.
+fn identify(
+ mut doc: AutoCommit,
+ origin: Origin,
+ earlier_actors: Vec<ActorId>,
+) -> Result<StartingDocument> {
+ let what = origin.describe();
+ let Some(swarm_id) = read_swarm_id(&doc) else {
+ bail!("{what} names no swarm (no _continuum/swarm); it predates swarm IDs in documents");
+ };
+ let epoch =
+ snapshot::epoch_of(&mut doc).map_err(|reason| anyhow::anyhow!("{what}: {reason}"))?;
+ Ok(StartingDocument {
+ doc,
+ epoch,
+ swarm_id,
+ origin,
+ earlier_actors,
+ })
+}
+
+/// A backup to recover from, as `GET /admin/document` saves it, under a fresh actor.
+fn load_backup(path: &Path, files: &Files) -> Result<AutoCommit> {
+ let bytes = files
+ .read(FileKind::Backup, path)
+ .with_context(|| format!("Reading the backup {}", path.display()))?
+ .with_context(|| format!("The backup {} doesn't exist", path.display()))?;
+ Ok(AutoCommit::load(&bytes)
+ .with_context(|| format!("Loading the backup {}", path.display()))?
+ .with_actor(ActorId::random()))
+}
+
/// Resolves the seeds in `unresolved`, says HELLO to each that resolves, and keeps the
-/// rest for later.
-async fn resolve_seeds(state: &AppState, unresolved: &mut Vec<String>) {
+/// rest for later. Warns once per seed that fails (`failing` remembers which), and says
+/// when such a seed resolves after all.
+async fn resolve_seeds(
+ state: &AppState,
+ unresolved: &mut Vec<String>,
+ failing: &mut HashSet<String>,
+) {
let mut still = Vec::new();
for seed in unresolved.drain(..) {
match state.transport.resolve_seed(&seed).await {
Ok(peer) => {
- debug!(seed = %seed, peer = %peer, "Resolved seed");
+ if failing.remove(&seed) {
+ info!(seed = %seed, peer = %peer, "Seed resolves now");
+ } else {
+ debug!(seed = %seed, peer = %peer, "Resolved seed");
+ }
remember_peer(state, &peer).await;
if let Err(error) = send_hello_once(state, &peer).await {
warn!(peer = %peer, error = %format!("{error:#}"), "Initial HELLO failed; will retry");
}
}
Err(error) => {
- warn!(seed = %seed, error = %error, "Seed doesn't resolve; will retry");
+ if failing.insert(seed.clone()) {
+ warn!(seed = %seed, error = %error, "Seed doesn't resolve; trying again until it does");
+ } else {
+ debug!(seed = %seed, error = %error, "Seed still doesn't resolve");
+ }
still.push(seed);
}
}
@@ -126,8 +351,11 @@ async fn report_joining(state: &AppState, seeds: &[String], unresolved: &[String
peer_state.epoch,
));
}
+ // Awaited outside the macro: its arguments aren't Send, and a spawned bootstrap
+ // (the simulator's) must be.
+ let local_swarm = state.swarm_id().await;
warn!(
- local_swarm = ?state.swarm_id().await,
+ local_swarm = ?local_swarm,
local_epoch = %state.local_epoch(),
initial_sync_done = state.initial_sync_done.load(Ordering::Acquire),
unresolved_seeds = ?unresolved,
diff --git a/src/swarm/members.rs b/src/swarm/members.rs
index 221437e..9b0de18 100644
--- a/src/swarm/members.rs
+++ b/src/swarm/members.rs
@@ -21,11 +21,11 @@ use serde_json::{Value as JsonValue, json};
use tracing::{debug, info, warn};
use crate::document::store::{self, StoreError};
-use crate::state::{AppState, unix_millis};
+use crate::state::AppState;
const META_ROOT: &str = "_continuum";
-fn members_dir() -> Vec<String> {
+pub(crate) fn members_dir() -> Vec<String> {
vec![META_ROOT.to_owned(), "members".to_owned()]
}
@@ -168,7 +168,7 @@ pub(crate) async fn ensure_registered(state: &AppState) {
)
};
let wanted = state.alias.clone();
- let now = unix_millis();
+ let now = state.clock.now_millis();
if !registered {
let mut entry = json!({ "since": now, "alive": now });
diff --git a/src/swarm/mod.rs b/src/swarm/mod.rs
index adb5d1c..d424529 100644
--- a/src/swarm/mod.rs
+++ b/src/swarm/mod.rs
@@ -11,6 +11,7 @@ pub(crate) mod relay;
pub(crate) mod rtt;
pub(crate) mod source;
pub(crate) mod stability;
+pub(crate) mod state_file;
pub(crate) mod stats;
pub(crate) mod sync;
pub(crate) mod throughput;
diff --git a/src/swarm/peer.rs b/src/swarm/peer.rs
index 3d7da32..e8248bf 100644
--- a/src/swarm/peer.rs
+++ b/src/swarm/peer.rs
@@ -110,11 +110,12 @@ pub(crate) async fn send_ping(state: &AppState, peer: &str) -> Result<()> {
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 (heads, epoch, epoch_seq) = {
let replica = state.doc.read().await;
(
transport::HeadsSummary::of(replica.heads().to_vec()),
replica.epoch,
+ replica.epoch_seq,
)
};
let syncs_with_you = match state.get_peer_state(peer).await {
@@ -129,6 +130,7 @@ pub(crate) async fn local_keepalive(state: &AppState, peer: &str) -> transport::
transport::Keepalive {
heads,
epoch,
+ epoch_seq,
ready: state.is_ready().await,
syncs_with_you,
}
@@ -276,6 +278,7 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> {
.as_ref()
.map(|key| key.hello_tag(swarm_id.as_deref().unwrap_or(""), state.local_id(), peer))
.unwrap_or_default();
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
state.lock_rtt().hello_sent(peer, Instant::now());
let result = state
.transport
@@ -283,7 +286,8 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> {
peer,
&ParsedMessage::Hello(transport::HelloRequest {
swarm_id,
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
auth,
ready: state.is_ready().await,
run_id: state.run_id,
@@ -295,7 +299,11 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> {
let mut peer_state = peer_state_mutex.lock().await;
if peer_state.hello_state == HelloState::Sending {
+ let was_unreachable = peer_state.unreachable_since.is_some();
peer_state.hello_sent(Instant::now());
+ if !was_unreachable && peer_state.unreachable_since.is_some() {
+ state.topology_changed.notify_one();
+ }
let wait = match peer_state.liveness {
Liveness::Dormant { .. } => state.timing.dormant_probe,
Liveness::Active => peer_state.hello_backoff.next(),
@@ -354,14 +362,17 @@ async fn send_hello_reply(
.map(|key| key.hello_reply_tag(&swarm_id, state.local_id(), peer))
.unwrap_or_default();
let ready = state.is_ready().await;
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
let reply = transport::HelloReply {
swarm_id,
peers,
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
auth,
ready,
run_id,
away_ms: away.map(|away| away.as_millis() as u64),
+ sender_run_id: state.run_id,
};
debug!(to = %peer, ready, "Sending HELLO_REPLY");
@@ -399,15 +410,24 @@ 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) {
+/// how a partial mesh discovers them) and a sync opening. The opening matters for a
+/// neighbour whose handshake is already complete, e.g. one that returned from dormancy
+/// while it wasn't our neighbour: it was only answered then, and with no HELLO to send,
+/// nothing else would open sync until the keepalive heal (`resync_after`). 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.
+pub(crate) 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");
}
+ // Skipped there unless the handshake is complete; otherwise its reply restarts
+ // sync anyway.
+ let _ = state
+ .sync_events
+ .send(SyncEvent::PeerNeedsSync(peer.clone()));
}
for peer in &change.removed {
if let Some(peer_state) = state.get_peer_state(peer).await {
@@ -427,12 +447,20 @@ pub(crate) async fn run_peer_worker(state: AppState) {
info!("Starting peer maintenance worker");
let mut last_forget_check: Option<Instant> = None;
+ let mut was_ready = false;
loop {
tick.tick().await;
// While joining, keep handshakes fast; dormancy would slow probes to minutes.
let ready = state.is_ready().await;
+ if ready && !was_ready {
+ // Just became ready: register in the member list (and do the other periodic
+ // checks) now rather than at the next check. Until it is registered, other
+ // members don't count this node as a neighbour.
+ last_forget_check = None;
+ }
+ was_ready = ready;
let peers: Vec<(String, Arc<Mutex<PeerState>>)> = state
.peer_states
@@ -461,12 +489,14 @@ pub(crate) async fn run_peer_worker(state: AppState) {
if let Some(changes) = unanswered {
debug!(peer = %peer, changes, "No answer to our sync message; sending again");
+ crate::swarm::source::reply_timed_out(&state, peer);
let _ = state
.sync_events
.send(SyncEvent::PeerNeedsSync(peer.clone()));
}
if became_dormant {
+ state.topology_changed.notify_one();
warn!(
peer = %peer,
silent_secs = state.timing.dormant_after.as_secs(),
@@ -493,6 +523,8 @@ pub(crate) async fn run_peer_worker(state: AppState) {
crate::swarm::members::ensure_registered(&state).await;
crate::swarm::stability::tick(&state).await;
crate::swarm::stats::measure_saved_size(&state);
+ crate::swarm::source::warn_if_stalled(&state);
+ crate::swarm::state_file::update(&state, false).await;
{
let known = state.peer_states.read().await;
state.traffic.retain(|peer| known.contains_key(peer));
@@ -502,9 +534,8 @@ pub(crate) async fn run_peer_worker(state: AppState) {
.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;
- }
+ // Fallback for what doesn't notify (`topology::run_worker`).
+ state.topology_changed.notify_one();
last_forget_check = Some(Instant::now());
}
}
@@ -581,9 +612,20 @@ pub(crate) async fn peer_swarm(state: &AppState, peer: &str) -> Option<String> {
// --- epochs ------------------------------------------------------------------
-/// Records the epoch a peer advertised and compares it with ours. Returns true when both
-/// are on the same epoch, the only case in which the documents may be synced.
-async fn observe_peer_epoch(state: &AppState, peer: &str, remote: Epoch) -> bool {
+/// Records the epoch a peer advertised and compares it with ours. Returns true when the
+/// message's epoch is ours, the only case in which its documents may be synced.
+///
+/// `seq` is the peer's `epoch_seq` with it. A message with a lower one than we have was
+/// sent before the peer's last epoch change and delivered late: its epoch isn't
+/// recorded, so it can't flip our view of the peer back. The count starts over when the
+/// peer restarts, which its HELLO or HELLO_REPLY tells by a new run (`run`).
+async fn observe_peer_epoch(
+ state: &AppState,
+ peer: &str,
+ remote: Epoch,
+ seq: u32,
+ run: Option<transport::RunId>,
+) -> bool {
let local = state.local_epoch();
let relation = local.relation_to(&remote);
@@ -593,6 +635,21 @@ async fn observe_peer_epoch(state: &AppState, peer: &str, remote: Epoch) -> bool
let previous = {
let mut peer_state = peer_state_mutex.lock().await;
+ if let Some(run) = run.filter(|run| peer_state.run_id != Some(*run)) {
+ peer_state.run_id = Some(run);
+ peer_state.epoch_seq = None;
+ }
+ if peer_state.epoch_seq.is_some_and(|newest| seq < newest) {
+ debug!(
+ peer = %peer,
+ epoch = %remote,
+ seq,
+ newest = ?peer_state.epoch_seq,
+ "Ignoring a late message's epoch"
+ );
+ return relation == EpochRelation::Same;
+ }
+ peer_state.epoch_seq = Some(seq);
let previous = peer_state.epoch.replace(remote);
if previous.is_some_and(|previous| previous != remote) {
// The peer switched documents; our sync state describes its old one. Count
@@ -749,6 +806,10 @@ pub(crate) async fn handle_message(
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;
+ if peer_state.unreachable_since.is_some() {
+ // Reachable again (`mark_seen` clears it).
+ state.topology_changed.notify_one();
+ }
(peer_state.mark_seen(Instant::now()), previously_seen)
};
crate::swarm::source::heard_from(state, &peer);
@@ -757,7 +818,13 @@ pub(crate) async fn handle_message(
info!(peer = %peer, "Dormant peer is active again; re-handshaking");
}
- let same_epoch = observe_peer_epoch(state, &peer, message.epoch()).await;
+ let run = match &message {
+ transport::ParsedMessage::Hello(hello) => Some(hello.run_id),
+ transport::ParsedMessage::HelloReply(reply) => Some(reply.sender_run_id),
+ _ => None,
+ };
+ let same_epoch =
+ observe_peer_epoch(state, &peer, message.epoch(), message.epoch_seq(), run).await;
//debug!(from = %peer, message = ?message, "Received message");
@@ -807,6 +874,7 @@ pub(crate) async fn handle_message(
debug!(peer = %peer, epoch = %envelope.epoch, "Dropping sync message from another epoch");
return Ok(());
}
+ crate::swarm::source::synced_from(state, &peer);
handle_sync(state, &peer, envelope).await?;
}
transport::ParsedMessage::Ping(remote) => {
diff --git a/src/swarm/source.rs b/src/swarm/source.rs
index 378c40a..9eac445 100644
--- a/src/swarm/source.rs
+++ b/src/swarm/source.rs
@@ -15,12 +15,22 @@
//! 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.
+//!
+//! Alive isn't enough: a source that answers keepalives but not our sync messages (a
+//! big answer lost on the way, a stuck sync state) would keep us catching up forever.
+//! So it is also given up after three of our sync messages in a row went unanswered,
+//! once nothing from it can still be on the way: no sync message from it for
+//! `Timing::delivery_limit`, after which Mycelium has delivered or dropped anything it
+//! sent before. It isn't chosen again for another `delivery_limit`, so another peer
+//! takes over rather than the same one stalling again; if it is the only candidate,
+//! catching up waits that long before trying it again. Meanwhile, every minute without a sync message from it is logged as a
+//! warning.
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::time::Instant;
-use tracing::{debug, info};
+use tracing::{debug, info, warn};
use crate::state::{AppState, CatchUpSource, PeerState};
use crate::swarm::sync::SyncEvent;
@@ -35,6 +45,12 @@ 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;
+/// Unanswered sync messages in a row before the source may be given up.
+const REPLY_TIMEOUTS: u32 = 3;
+
+/// How often a stalled catch-up is logged.
+const STALL_WARNING: Duration = Duration::from_secs(60);
+
fn lock_preference(state: &AppState) -> std::sync::MutexGuard<'_, Option<Instant>> {
state
.catch_up_preference
@@ -42,6 +58,13 @@ fn lock_preference(state: &AppState) -> std::sync::MutexGuard<'_, Option<Instant
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
+fn lock_avoided(state: &AppState) -> std::sync::MutexGuard<'_, Option<(String, Instant)>> {
+ state
+ .catch_up_avoided
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
fn catching_up(state: &AppState) -> bool {
!state.initial_sync_done.load(Ordering::Acquire)
}
@@ -60,12 +83,20 @@ pub(crate) fn defer(state: &AppState, peer: &str, peer_state: &PeerState) -> Opt
return None;
}
let stall = state.timing.catch_up_probe * UNANSWERED_PROBES;
+ let now = Instant::now();
+ let avoided_until = lock_avoided(state)
+ .as_ref()
+ .filter(|(avoided, until)| avoided == peer && now < *until)
+ .map(|(_, until)| *until);
+ if let Some(until) = avoided_until {
+ // Given up for not answering; the timer brings it back once it may be chosen.
+ return Some(until - now);
+ }
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,
@@ -115,6 +146,9 @@ pub(crate) fn defer(state: &AppState, peer: &str, peer_state: &PeerState) -> Opt
*source = Some(CatchUpSource {
peer: peer.to_owned(),
last_heard: now,
+ last_sync: now,
+ reply_timeouts: 0,
+ last_warned: None,
last_probe: None,
refusals: 0,
});
@@ -128,6 +162,78 @@ pub(crate) fn heard_from(state: &AppState, peer: &str) {
}
}
+/// A sync message from `peer` arrived: progress, if it is the source.
+pub(crate) fn synced_from(state: &AppState, peer: &str) {
+ if let Some(current) = lock(state).as_mut().filter(|current| current.peer == peer) {
+ current.last_sync = Instant::now();
+ current.reply_timeouts = 0;
+ }
+}
+
+/// Our sync message to `peer` went unanswered (`SyncSession` reply deadline). Gives up
+/// the source after `REPLY_TIMEOUTS` in a row, once nothing from it can be on the way.
+pub(crate) fn reply_timed_out(state: &AppState, peer: &str) {
+ if !catching_up(state) {
+ return;
+ }
+ let given_up = {
+ let mut source = lock(state);
+ let Some(current) = source.as_mut().filter(|current| current.peer == peer) else {
+ return;
+ };
+ current.reply_timeouts += 1;
+ let silent = current.last_sync.elapsed();
+ let give_up =
+ current.reply_timeouts >= REPLY_TIMEOUTS && silent >= state.timing.delivery_limit;
+ let timeouts = current.reply_timeouts;
+ if give_up {
+ *source = None;
+ *lock_avoided(state) = Some((
+ peer.to_owned(),
+ Instant::now() + state.timing.delivery_limit,
+ ));
+ }
+ give_up.then_some((timeouts, silent))
+ };
+ if let Some((timeouts, silent)) = given_up {
+ warn!(
+ peer = %peer,
+ reply_timeouts = timeouts,
+ silent_secs = silent.as_secs(),
+ "Catch-up source doesn't answer our sync messages; catching up from another peer"
+ );
+ resync_everyone(state);
+ }
+}
+
+/// Warns, at most every `STALL_WARNING`, while the source sends no sync messages. Called
+/// by the peer worker on its maintenance tick.
+pub(crate) fn warn_if_stalled(state: &AppState) {
+ if !catching_up(state) {
+ return;
+ }
+ let mut source = lock(state);
+ let Some(current) = source.as_mut() else {
+ return;
+ };
+ let silent = current.last_sync.elapsed();
+ if silent < STALL_WARNING
+ || current
+ .last_warned
+ .is_some_and(|at| at.elapsed() < STALL_WARNING)
+ {
+ return;
+ }
+ current.last_warned = Some(Instant::now());
+ warn!(
+ peer = %current.peer,
+ silent_secs = silent.as_secs(),
+ reply_timeouts = current.reply_timeouts,
+ heard_secs_ago = current.last_heard.elapsed().as_secs(),
+ "Catching up stalls: no sync message from the catch-up source"
+ );
+}
+
/// 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>) {
@@ -188,6 +294,7 @@ pub(crate) fn probe_due(state: &AppState, peer: &str, now: Instant) -> bool {
pub(crate) fn finished(state: &AppState, from: &str) {
*lock(state) = None;
*lock_preference(state) = None;
+ *lock_avoided(state) = None;
let state = state.clone();
let from = from.to_owned();
tokio::spawn(async move {
diff --git a/src/swarm/state_file.rs b/src/swarm/state_file.rs
new file mode 100644
index 0000000..8b883fc
--- /dev/null
+++ b/src/swarm/state_file.rs
@@ -0,0 +1,189 @@
+//! The state file (`--state-file`): what a node remembers across restarts, so it
+//! rejoins its swarm on its own: the swarm ID and some members to reach it through.
+//! Not the document: a restarted node still starts empty and syncs.
+//!
+//! Written rarely: once the node is ready, then when the remembered members change
+//! (checked every few minutes, written at most once an hour), and at shutdown if they
+//! changed since, atomically (`files`).
+
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use tokio::time::Instant;
+use tracing::{error, info, warn};
+
+use crate::files::{FileKind, Files};
+use crate::state::AppState;
+use crate::swarm::members;
+
+/// Members remembered as seeds.
+const MAX_SEEDS: usize = 16;
+/// How often the remembered members are compared with the registry.
+const CHECK_INTERVAL: Duration = Duration::from_secs(300);
+/// Writes at least this far apart, but for the first one and at shutdown.
+const MIN_WRITE_INTERVAL: Duration = Duration::from_secs(3600);
+
+/// The file's content.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct Remembered {
+ pub(crate) swarm_id: String,
+ /// Public keys of members to join through (sorted).
+ #[serde(default)]
+ pub(crate) seeds: Vec<String>,
+}
+
+pub(crate) struct StateFile {
+ path: PathBuf,
+ files: Arc<Files>,
+ // A std mutex: never held across an await.
+ inner: Mutex<Inner>,
+}
+
+#[derive(Default)]
+struct Inner {
+ written: Option<Remembered>,
+ written_at: Option<Instant>,
+ checked_at: Option<Instant>,
+}
+
+impl StateFile {
+ /// Opens `path`, returning what it remembers. A missing file remembers nothing; an
+ /// unreadable one is moved aside (`.bad`) and logged, and remembers nothing either.
+ pub(crate) fn open(path: PathBuf, files: Arc<Files>) -> (Self, Option<Remembered>) {
+ let remembered = match files.read(FileKind::State, &path) {
+ Ok(Some(bytes)) => match serde_json::from_slice::<Remembered>(&bytes) {
+ Ok(remembered) if !remembered.swarm_id.is_empty() => Some(remembered),
+ Ok(_) => None,
+ Err(error) => {
+ error!(
+ path = %path.display(),
+ error = %error,
+ "Unreadable state file; moving it aside and remembering nothing"
+ );
+ match files.move_aside(&path, ".bad") {
+ Ok(aside) => {
+ warn!(moved_to = %aside.display(), "Moved the state file aside")
+ }
+ Err(error) => warn!(error = %error, "Couldn't move the state file aside"),
+ }
+ None
+ }
+ },
+ Ok(None) => None,
+ Err(error) => {
+ error!(path = %path.display(), error = %error, "Couldn't read the state file; remembering nothing");
+ None
+ }
+ };
+ let file = Self {
+ path,
+ files,
+ inner: Mutex::new(Inner {
+ written: remembered.clone(),
+ ..Inner::default()
+ }),
+ };
+ (file, remembered)
+ }
+
+ fn lock(&self) -> MutexGuard<'_, Inner> {
+ self.inner
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ fn write(&self, remembered: &Remembered) -> std::io::Result<()> {
+ let mut bytes = serde_json::to_vec_pretty(remembered)?;
+ bytes.push(b'\n');
+ self.files.write_atomic(FileKind::State, &self.path, &bytes)
+ }
+}
+
+/// Brings the state file up to date if it is due: `now` for the first write and at
+/// shutdown, otherwise only every few minutes and at most once an hour. Called by the
+/// peer worker on its maintenance tick.
+pub(crate) async fn update(state: &AppState, now: bool) {
+ let Some(file) = state.state_file.clone() else {
+ return;
+ };
+ if !state.is_ready().await {
+ return;
+ }
+ let Some(swarm_id) = state.swarm_id().await else {
+ return;
+ };
+ {
+ let mut inner = file.lock();
+ if !now
+ && inner
+ .checked_at
+ .is_some_and(|at| at.elapsed() < CHECK_INTERVAL)
+ {
+ return;
+ }
+ inner.checked_at = Some(Instant::now());
+ }
+
+ let seeds = {
+ let replica = state.doc.read().await;
+ seeds(
+ &replica.doc,
+ state.local_id(),
+ state.electorate_window,
+ state.clock.now_millis(),
+ )
+ };
+ let remembered = Remembered { swarm_id, seeds };
+
+ {
+ let inner = file.lock();
+ if inner.written.as_ref() == Some(&remembered) {
+ return;
+ }
+ let first = inner.written_at.is_none();
+ if !now
+ && !first
+ && inner
+ .written_at
+ .is_some_and(|at| at.elapsed() < MIN_WRITE_INTERVAL)
+ {
+ return;
+ }
+ }
+
+ let writer = Arc::clone(&file);
+ let content = remembered.clone();
+ match tokio::task::spawn_blocking(move || writer.write(&content)).await {
+ Ok(Ok(())) => {
+ info!(
+ path = %file.path.display(),
+ seeds = remembered.seeds.len(),
+ "Wrote the state file"
+ );
+ let mut inner = file.lock();
+ inner.written = Some(remembered);
+ inner.written_at = Some(Instant::now());
+ }
+ Ok(Err(error)) => {
+ error!(path = %file.path.display(), error = %error, "Couldn't write the state file");
+ }
+ Err(error) => error!(error = %error, "State file write task failed"),
+ }
+}
+
+/// Members to rejoin through: those alive within the electorate window, stable ones
+/// first, at most `MAX_SEEDS`, sorted. Changes only when members join, leave or change
+/// tier, so the file rarely does.
+fn seeds(doc: &automerge::AutoCommit, me: &str, window: Duration, now: u64) -> Vec<String> {
+ let (mut stable, transient): (Vec<String>, Vec<String>) = members::electorate(doc, now, window)
+ .into_iter()
+ .filter(|pk| pk != me)
+ .partition(|pk| members::tier(doc, pk) == members::Tier::Stable);
+ stable.extend(transient);
+ stable.truncate(MAX_SEEDS);
+ stable.sort();
+ stable
+}
diff --git a/src/swarm/sync.rs b/src/swarm/sync.rs
index 80d6d56..a05e346 100644
--- a/src/swarm/sync.rs
+++ b/src/swarm/sync.rs
@@ -141,10 +141,10 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> {
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 (epoch, epoch_seq, message) = {
let mut replica = doc.blocking_write();
let mut peer_state = psm.blocking_lock();
- let epoch = replica.epoch;
+ let (epoch, epoch_seq) = (replica.epoch, replica.epoch_seq);
let sent_before = peer_state.sync.state.sent_hashes.clone();
let message = {
let sync_doc = replica.doc.sync();
@@ -178,7 +178,7 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> {
regenerate: false,
});
}
- (epoch, message)
+ (epoch, epoch_seq, message)
};
let Some(message) = message else {
return Ok(None);
@@ -186,6 +186,7 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> {
let envelope = transport::SyncEnvelope {
swarm_id,
epoch,
+ epoch_seq,
message,
ready,
};
@@ -329,6 +330,7 @@ pub(crate) async fn handle_sync(
let epoch = envelope.epoch;
let message = envelope.message;
let peer = peer.to_owned();
+ let clock = Arc::clone(&state.clock);
// Some((caught_up, heads changed)) when applied, None when the message belongs
// to another epoch.
@@ -365,7 +367,7 @@ pub(crate) async fn handle_sync(
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;
+ let now_ms = clock.now_millis() as i64;
// Time 0: not stamped (snapshots, older nodes).
let delays: Vec<i64> = replica
.doc
@@ -607,6 +609,13 @@ pub(crate) async fn run_change_publisher(state: AppState) {
warn!(path = ?path, "Concurrent writes produced a conflict");
}
+ if paths
+ .iter()
+ .any(|path| crate::swarm::topology::affects_neighbours(path))
+ {
+ state.topology_changed.notify_one();
+ }
+
// Fails only when nobody is subscribed.
let _ = state.change_events.send(Arc::new(ChangeSet { paths }));
}
diff --git a/src/swarm/topology.rs b/src/swarm/topology.rs
index 57856b6..0dae7e9 100644
--- a/src/swarm/topology.rs
+++ b/src/swarm/topology.rs
@@ -107,6 +107,26 @@ pub(crate) struct NeighbourChange {
pub(crate) removed: Vec<String>,
}
+/// Recomputes the neighbours whenever something they depend on may have changed
+/// (`AppState::topology_changed`): the member list (`swarm::sync::run_change_publisher`),
+/// a peer becoming unreachable or reachable again (`swarm::peer`), and every 5 s as a
+/// fallback, which also covers unreachability expiring after `dormant_probe`. A burst of
+/// notifications coalesces into one recomputation.
+pub(crate) async fn run_worker(state: AppState) {
+ loop {
+ state.topology_changed.notified().await;
+ if let Some(change) = refresh(&state).await {
+ crate::swarm::peer::apply_neighbour_change(&state, change).await;
+ }
+ }
+}
+
+/// Whether a change at `path` may change the neighbours: one in the member list, or the
+/// root (the whole document replaced, as after an epoch switch).
+pub(crate) fn affects_neighbours(path: &[String]) -> bool {
+ path.is_empty() || path.starts_with(&members::members_dir())
+}
+
/// Recomputes the neighbours once ready, if the member list, the reachable members or
/// `k` changed.
pub(crate) async fn refresh(state: &AppState) -> Option<NeighbourChange> {
@@ -397,6 +417,68 @@ mod tests {
.collect()
}
+ fn hex(bytes: [u8; 32]) -> String {
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+ }
+
+ /// Over random swarms (size, `k`, who is reachable, who is stable): links only
+ /// between reachable members, never to oneself, symmetric; a full mesh while small,
+ /// otherwise at least `k` links each and connected; independent of the order members
+ /// are listed in; and each member's own view (`neighbours`) agrees with the graph.
+ #[test]
+ fn random_swarms() {
+ crate::testrand::for_seeds(300, |rng| {
+ let n = rng.between(1, 60);
+ let k = rng.between(2, 14);
+ let mut all: Vec<String> = (0..n).map(|_| hex(rng.bytes32())).collect();
+ let unreachable: BTreeSet<String> =
+ all.iter().filter(|_| rng.chance(0.3)).cloned().collect();
+ let stable: BTreeSet<String> =
+ all.iter().filter(|_| rng.chance(0.3)).cloned().collect();
+ let is_unreachable = |m: &str| unreachable.contains(m);
+ let is_stable = |m: &str| stable.contains(m);
+
+ let g = super::graph(&all, k, &is_unreachable, &is_stable);
+ // Owned: `all` is shuffled below.
+ let live: BTreeSet<String> = all
+ .iter()
+ .filter(|m| !unreachable.contains(*m))
+ .cloned()
+ .collect();
+ assert_eq!(g.keys().cloned().collect::<BTreeSet<_>>(), live);
+ for (a, ns) in &g {
+ assert!(!ns.contains(a), "{a} links to itself");
+ for b in ns {
+ assert!(live.contains(b), "{a} links to unreachable {b}");
+ assert!(g[b].contains(a), "{a} -> {b} but not back");
+ }
+ }
+ if live.len() <= k + 1 {
+ assert!(
+ g.values().all(|ns| ns.len() + 1 == live.len()),
+ "not a full mesh"
+ );
+ } else {
+ assert!(
+ g.values().all(|ns| ns.len() >= k),
+ "a member with fewer than k links"
+ );
+ assert!(connected(&g), "not connected");
+ }
+
+ rng.shuffle(&mut all);
+ assert_eq!(super::graph(&all, k, &is_unreachable, &is_stable), g);
+
+ if let Some(local) = live.iter().nth(rng.below(live.len())) {
+ let others: Vec<String> = all.iter().filter(|m| *m != local).cloned().collect();
+ assert_eq!(
+ neighbours(&others, local, k, &is_unreachable, &is_stable),
+ g[local]
+ );
+ }
+ });
+ }
+
#[test]
fn small_swarms_are_a_full_mesh() {
let ms = members(13);