diff options
| -rw-r--r-- | src/compaction/api.rs | 7 | ||||
| -rw-r--r-- | src/compaction/catchup.rs | 307 | ||||
| -rw-r--r-- | src/compaction/delta.rs | 122 | ||||
| -rw-r--r-- | src/compaction/local.rs | 78 | ||||
| -rw-r--r-- | src/compaction/mod.rs | 18 | ||||
| -rw-r--r-- | src/compaction/records.rs | 2 | ||||
| -rw-r--r-- | src/compaction/snapshot.rs | 118 | ||||
| -rw-r--r-- | src/compaction/switch.rs | 72 | ||||
| -rw-r--r-- | src/main.rs | 9 | ||||
| -rw-r--r-- | src/mycelium.rs | 60 | ||||
| -rw-r--r-- | src/peer.rs | 11 | ||||
| -rw-r--r-- | src/state.rs | 30 | ||||
| -rw-r--r-- | src/transport.rs | 31 |
13 files changed, 831 insertions, 34 deletions
diff --git a/src/compaction/api.rs b/src/compaction/api.rs index 9a77e4d..f5c01f2 100644 --- a/src/compaction/api.rs +++ b/src/compaction/api.rs @@ -7,7 +7,8 @@ //! * `GET /admin/compactions` lists all compactions. //! * `GET /admin/compactions/{id}` reports one; `?wait=true` long-polls until decided //! (bounded by the proposal's own expiry). -//! * `GET /admin/epochs` shows the local epoch and the archived previous ones. +//! * `GET /admin/epochs` shows the local epoch, the archived previous ones and whether +//! the node is catching up with a newer epoch from a peer. //! * `DELETE /admin/compactions/{id}` asks the coordinator to abort. Only possible while //! proposed; `409` once committed (a switch can't be undone) or already over. //! `DELETE /admin/compaction` does the same for the compaction of the local epoch that @@ -442,12 +443,14 @@ async fn abort_by_id<H: Host>(host: H, id: String, wait: bool) -> Response { } } -/// `GET /admin/epochs`: the local epoch and the previous epochs this node still keeps. +/// `GET /admin/epochs`: the local epoch, the previous epochs this node still keeps, and +/// an ongoing catch-up with a peer's newer epoch (`null` if none). pub(crate) async fn epochs<H: Host>(State(host): State<H>) -> Response { let current = EpochRecord::from(host.current_epoch()); Json(json!({ "current": current, "archived": host.compaction().archived_epochs(), + "catchUp": host.compaction().catch_up_report(), })) .into_response() } diff --git a/src/compaction/catchup.rs b/src/compaction/catchup.rs new file mode 100644 index 0000000..f166f56 --- /dev/null +++ b/src/compaction/catchup.rs @@ -0,0 +1,307 @@ +//! Catching up with a newer epoch from a peer. +//! +//! Peers on another epoch don't sync, so a node that missed a switch (new, late, or +//! one whose own build failed) can't learn the new epoch through ordinary sync. Instead, +//! once an established peer advertises a newer generation, the node asks it for its +//! whole document (`SnapshotRequest`), checks that the document starts with the +//! advertised snapshot change, and switches to it: +//! +//! * a node that wrote nothing the snapshot lacks (e.g. a brand-new node) adopts the +//! document as is, whatever the gap; +//! * a node exactly one epoch behind replays its own changes the snapshot lacks, found +//! with the snapshot's per-actor clock; +//! * a node further behind with such changes stays where it is and reports it; that is +//! the returning-node case (replay window or quarantine, step 7). +//! +//! Only one fetch runs at a time, and the worker retries after `RETRY`. The reply is the +//! peer's full document in one message; chunking comes later. + +use std::time::Duration; + +use automerge::{ActorId, AutoCommit}; +use tokio::time::Instant; +use tracing::{debug, error, info, warn}; + +use super::delta::{self, ReplayStats}; +use super::local::{CatchUp, LocalStatus}; +use super::records::unix_millis; +use super::snapshot; +use super::switch::swap_in; +use super::Host; +use crate::replica::Epoch; +use crate::transport::{ParsedMessage, SnapshotReply, SnapshotRequest}; + +const RETRY: Duration = Duration::from_secs(10); +const FETCH_TIMEOUT: Duration = Duration::from_secs(60); + +/// Called by the switch worker when there is nothing to switch to locally. +pub(super) async fn check<H: Host>(host: &H) { + let local = host.current_epoch(); + + let ahead: Vec<(String, Epoch)> = host + .peer_summaries() + .await + .into_iter() + .filter(|peer| peer.active && peer.established) + .filter_map(|peer| peer.epoch.map(|epoch| (peer.id, epoch))) + .filter(|(_, epoch)| epoch.generation > local.generation) + .collect(); + + let Some(generation) = ahead.iter().map(|(_, epoch)| epoch.generation).max() else { + if host.compaction().catch_up().is_some() { + host.compaction().set_catch_up(None); + } + return; + }; + + let mut targets: Vec<Epoch> = ahead + .iter() + .filter(|(_, epoch)| epoch.generation == generation) + .map(|(_, epoch)| *epoch) + .collect(); + targets.sort_by_key(|epoch| epoch.id); + targets.dedup(); + let [target] = targets.as_slice() else { + let listed: Vec<String> = targets.iter().map(ToString::to_string).collect(); + warn!(epochs = ?listed, "Peers disagree about the newest epoch; not catching up"); + return; + }; + let target = *target; + + // Back off between attempts for the same target. + let previous = host + .compaction() + .catch_up() + .filter(|catch_up| catch_up.target == target); + if previous + .as_ref() + .is_some_and(|catch_up| catch_up.last_attempt.elapsed() < RETRY) + { + return; + } + let attempts = previous.map_or(0, |catch_up| catch_up.attempts) + 1; + + // Rotate through the peers on the target epoch. + let sources: Vec<&String> = ahead + .iter() + .filter(|(_, epoch)| *epoch == target) + .map(|(peer, _)| peer) + .collect(); + let source = sources[(attempts as usize - 1) % sources.len()].clone(); + + host.compaction().set_catch_up(Some(CatchUp { + target, + attempts, + last_attempt: Instant::now(), + error: None, + })); + + info!(local = %local, target = %target, peer = %source, attempt = attempts, "Fetching newer epoch from peer"); + + let result = match fetch(host, &source, local, target).await { + Ok(document) => adopt(host, local, target, document).await, + Err(error) => Err(error), + }; + + match result { + Ok(()) => host.compaction().set_catch_up(None), + Err(error) => { + warn!(target = %target, peer = %source, error = %error, "Catching up failed; will retry"); + host.compaction().set_catch_up(Some(CatchUp { + target, + attempts, + last_attempt: Instant::now(), + error: Some(error), + })); + } + } +} + +async fn fetch<H: Host>( + host: &H, + peer: &str, + local: Epoch, + target: Epoch, +) -> Result<Vec<u8>, String> { + let reply = host.compaction().await_reply(peer, target); + let request = ParsedMessage::SnapshotRequest(SnapshotRequest { + epoch: local, + wanted: target, + }); + if let Err(error) = host.send_message(peer, request).await { + host.compaction().stop_awaiting(); + return Err(format!("sending the request failed: {error:#}")); + } + + let reply = match tokio::time::timeout(FETCH_TIMEOUT, reply).await { + Ok(Ok(reply)) => reply, + Ok(Err(_)) => return Err("request superseded".to_owned()), + Err(_) => { + host.compaction().stop_awaiting(); + return Err(format!("no reply within {}s", FETCH_TIMEOUT.as_secs())); + } + }; + + if reply.epoch != target || reply.document.is_empty() { + return Err(format!( + "peer is on {} and did not send {target}", + reply.epoch + )); + } + Ok(reply.document) +} + +/// Verifies the fetched document, carries over this node's own missing changes, and +/// switches to it. +async fn adopt<H: Host>( + host: &H, + local: Epoch, + target: Epoch, + document: Vec<u8>, +) -> Result<(), String> { + // Copy under the read lock only; everything else happens without a lock. + let copy = host.read_doc(|doc| doc.clone()).await; + let bytes = document.len(); + + let prepared = tokio::task::spawn_blocking(move || -> Result<Prepared, String> { + let mut fetched = + AutoCommit::load(&document).map_err(|error| format!("invalid document: {error}"))?; + let metadata = snapshot::verify_document(&mut fetched, target)?; + + let mut copy = copy; + let own = copy.get_actor().clone(); + let copy_heads = copy.get_heads(); + let had_history = !copy_heads.is_empty(); + let missing = delta::missing_from_snapshot(&mut copy, &metadata.clock, &own); + + let mut doc = fetched.with_actor(ActorId::random()); + let stats = if missing.own == 0 { + ReplayStats::default() + } else if metadata.previous == local { + delta::replay(&mut copy, &missing.before, ©_heads, &mut doc) + } else { + return Err(format!( + "{} local changes are not in {target}, and this node is more than one epoch behind (on {local}, {target} follows {}); not replaying them automatically", + missing.own, metadata.previous + )); + }; + + Ok(Prepared { + doc, + copy_heads, + stats, + own_missing: missing.own, + had_history, + metadata, + }) + }) + .await + .map_err(|error| format!("catch-up task failed: {error}"))??; + + let Prepared { + doc, + copy_heads, + stats, + own_missing, + had_history, + metadata, + } = prepared; + + let old = swap_in(host, target, doc, copy_heads).await?; + + if had_history { + host.compaction().archive( + local, + old, + metadata.policy.archive_retention.clone(), + target.generation, + ); + } + + // Visible in the compaction's report, if this node has its record. + host.compaction().set_status( + &metadata.compaction_id, + LocalStatus { + switched_at: Some(unix_millis()), + replayed_paths: Some(stats.replayed), + failed_paths: Some(stats.failed), + error: None, + }, + ); + + info!( + from = %local, + to = %target, + document_bytes = bytes, + own_changes = own_missing, + replayed = stats.replayed, + failed = stats.failed, + "Caught up with newer epoch" + ); + Ok(()) +} + +struct Prepared { + doc: AutoCommit, + copy_heads: Vec<automerge::ChangeHash>, + stats: ReplayStats, + own_missing: usize, + had_history: bool, + metadata: snapshot::EpochMetadata, +} + +/// Answers a peer's snapshot request with this node's document, if it is on the wanted +/// epoch. Only for members of our cluster; one at a time. +pub(crate) async fn serve_snapshot<H: Host>(host: H, peer: String, request: SnapshotRequest) { + if !host.is_cluster_member(&peer).await { + debug!(peer = %peer, "Ignoring snapshot request from a non-member"); + return; + } + if !host.compaction().try_start_serving() { + debug!(peer = %peer, "Already serving a snapshot; the peer will retry"); + return; + } + + let current = host.current_epoch(); + let wanted = request.wanted; + + let document = if current == wanted { + // Clone under the read lock, save without one. The history check guards against + // a switch between reading the epoch and cloning. + let mut doc = host.read_doc(|doc| doc.clone()).await; + tokio::task::spawn_blocking(move || { + if doc + .get_change_by_hash(&automerge::ChangeHash(wanted.id)) + .is_some() + { + doc.save() + } else { + Vec::new() + } + }) + .await + .unwrap_or_else(|error| { + error!(error = %error, "Saving the document for a peer failed"); + Vec::new() + }) + } else { + Vec::new() + }; + host.compaction().done_serving(); + + info!(peer = %peer, wanted = %wanted, document_bytes = document.len(), "Serving snapshot"); + let reply = ParsedMessage::SnapshotReply(SnapshotReply { + epoch: if document.is_empty() { current } else { wanted }, + document, + }); + if let Err(error) = host.send_message(&peer, reply).await { + warn!(peer = %peer, error = %format!("{error:#}"), "Sending snapshot failed"); + } +} + +/// Hands a peer's snapshot reply to the waiting fetch. +pub(crate) fn receive_snapshot_reply<H: Host>(host: &H, peer: &str, reply: SnapshotReply) { + if !host.compaction().deliver_reply(peer, reply) { + debug!(peer = %peer, "Unexpected snapshot reply; dropped"); + } +} diff --git a/src/compaction/delta.rs b/src/compaction/delta.rs index 3bc9456..533a7be 100644 --- a/src/compaction/delta.rs +++ b/src/compaction/delta.rs @@ -17,7 +17,9 @@ //! The protocol's own metadata (`_continuum/compactions`, `_continuum/epochs`) is never //! replayed: the snapshot owns it. -use automerge::{AutoCommit, ChangeHash, PatchAction, Prop}; +use std::collections::{BTreeMap, HashSet}; + +use automerge::{ActorId, AutoCommit, ChangeHash, PatchAction, Prop}; use tracing::warn; use super::snapshot::{COMPACTIONS, EPOCHS, META_ROOT}; @@ -92,6 +94,81 @@ fn contains_protocol_path(path: &[String]) -> bool { matches!(path, [] | [_]) && path.first().is_none_or(|root| root == META_ROOT) } +/// What a late node has that a snapshot lacks. +#[derive(Debug)] +pub(crate) struct Missing { + /// Heads of everything except `own` changes the snapshot lacks (and changes built on + /// them). Replaying from these heads to the document's heads replays exactly those. + pub(crate) before: Vec<ChangeHash>, + /// Changes by `own` that the snapshot lacks. + pub(crate) own: usize, +} + +/// Splits `doc`'s history against a snapshot `clock`: a change (actor, seq) is in the +/// snapshot iff seq <= clock[actor]. +/// +/// Only changes this node wrote itself (actor `own`) are worth replaying: other nodes' +/// changes after the base were replayed by those nodes when they switched, and replaying +/// them again would overwrite newer writes in the new epoch with stale ones. +pub(crate) fn missing_from_snapshot( + doc: &mut AutoCommit, + clock: &BTreeMap<String, u64>, + own: &ActorId, +) -> Missing { + let own_hex = own.to_string(); + let changes: Vec<(ChangeHash, Vec<ChangeHash>, bool)> = doc + .get_changes(&[]) + .iter() + .map(|change| { + let actor = change.actor_id().to_string(); + let in_snapshot = clock.get(&actor).is_some_and(|&seq| change.seq() <= seq); + let own_missing = actor == own_hex && !in_snapshot; + (change.hash(), change.deps().to_vec(), own_missing) + }) + .collect(); + + // Excluded: own missing changes and everything that (transitively) depends on them. + // A fixpoint, so it doesn't rely on the order `get_changes` returns. + let mut excluded: HashSet<ChangeHash> = changes + .iter() + .filter(|(_, _, own_missing)| *own_missing) + .map(|(hash, _, _)| *hash) + .collect(); + let own_count = excluded.len(); + loop { + let before = excluded.len(); + for (hash, deps, _) in &changes { + if !excluded.contains(hash) && deps.iter().any(|dep| excluded.contains(dep)) { + excluded.insert(*hash); + } + } + if excluded.len() == before { + break; + } + } + + // Heads of the kept part: kept changes no kept change depends on. + let kept: Vec<&(ChangeHash, Vec<ChangeHash>, bool)> = changes + .iter() + .filter(|(hash, _, _)| !excluded.contains(hash)) + .collect(); + let depended_on: HashSet<ChangeHash> = kept + .iter() + .flat_map(|(_, deps, _)| deps.iter().copied()) + .collect(); + let mut before: Vec<ChangeHash> = kept + .iter() + .map(|(hash, _, _)| *hash) + .filter(|hash| !depended_on.contains(hash)) + .collect(); + before.sort(); + + Missing { + before, + own: own_count, + } +} + /// The minimal set of store paths covering every change between `before` and `after`: /// sorted, and no path is a prefix of another. pub(crate) fn changed_paths( @@ -217,6 +294,49 @@ mod tests { } #[test] + fn missing_counts_only_own_changes() { + let own = ActorId::from(&b"own-actor"[..]); + let other = ActorId::from(&b"other-actor"[..]); + + let mut doc = AutoCommit::new().with_actor(own.clone()); + doc.put(ROOT, "base", 1_i64).unwrap(); + doc.commit(); + let base = doc.get_heads(); + + // The snapshot contains the base only. + let clock: BTreeMap<String, u64> = [(own.to_string(), 1)].into_iter().collect(); + + // A foreign change after the base (replayed by its own node) ... + let mut foreign = doc.fork().with_actor(other); + foreign.put(ROOT, "theirs", 1_i64).unwrap(); + foreign.commit(); + doc.merge(&mut foreign).unwrap(); + let with_foreign = doc.get_heads(); + + // ... and one of ours. + doc.put(ROOT, "mine", 1_i64).unwrap(); + doc.commit(); + let after = doc.get_heads(); + + let missing = missing_from_snapshot(&mut doc, &clock, &own); + assert_eq!(missing.own, 1); + assert_eq!(missing.before, with_foreign); + assert_ne!(missing.before, base); + assert_eq!( + changed_paths(&mut doc, &missing.before, &after), + vec![vec!["mine".to_owned()]] + ); + + // Nothing of ours missing: nothing to replay. + let full: BTreeMap<String, u64> = [(own.to_string(), 2)].into_iter().collect(); + let none = missing_from_snapshot(&mut doc, &full, &own); + assert_eq!(none.own, 0); + let mut heads = after.clone(); + heads.sort(); + assert_eq!(none.before, heads); + } + + #[test] fn protocol_metadata_is_never_replayed() { let mut old = AutoCommit::new(); old.put(ROOT, "x", 1_i64).unwrap(); diff --git a/src/compaction/local.rs b/src/compaction/local.rs index 6290a5e..869c94e 100644 --- a/src/compaction/local.rs +++ b/src/compaction/local.rs @@ -5,20 +5,25 @@ //! archive and rebuilds snapshots when needed. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, MutexGuard}; use automerge::AutoCommit; use serde::Serialize; +use tokio::sync::oneshot; use tokio::time::Instant; use super::records::{unix_millis, Retention}; use super::snapshot::Snapshot; use crate::replica::Epoch; +use crate::transport::SnapshotReply; #[derive(Default)] pub(crate) struct Compaction { // A std mutex: every access is short and never spans an await. inner: Mutex<Inner>, + /// Set while this node saves its document for a peer; one at a time. + serving: AtomicBool, } #[derive(Default)] @@ -29,6 +34,27 @@ struct Inner { first_seen: HashMap<String, Instant>, status: HashMap<String, LocalStatus>, archive: Vec<Archived>, + /// The snapshot request this node is waiting for (peer, wanted epoch). + awaiting: Option<(String, Epoch, oneshot::Sender<SnapshotReply>)>, + catch_up: Option<CatchUp>, +} + +/// Fetching a newer epoch from a peer. +#[derive(Clone, Debug)] +pub(crate) struct CatchUp { + pub(crate) target: Epoch, + pub(crate) attempts: u32, + pub(crate) last_attempt: Instant, + pub(crate) error: Option<String>, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CatchUpReport { + pub(crate) target: String, + pub(crate) attempts: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option<String>, } /// What happened to a compaction on this node. @@ -118,6 +144,58 @@ impl Compaction { prune(&mut self.lock().archive, current_generation); } + /// Registers interest in a snapshot reply from `peer`, replacing any earlier one. + pub(crate) fn await_reply( + &self, + peer: &str, + wanted: Epoch, + ) -> oneshot::Receiver<SnapshotReply> { + let (sender, receiver) = oneshot::channel(); + self.lock().awaiting = Some((peer.to_owned(), wanted, sender)); + receiver + } + + /// Hands a reply to the waiting fetch. False if nobody asked that peer. + pub(crate) fn deliver_reply(&self, peer: &str, reply: SnapshotReply) -> bool { + let mut inner = self.lock(); + match inner.awaiting.take() { + Some((expected, _, sender)) if expected == peer => sender.send(reply).is_ok(), + other => { + inner.awaiting = other; + false + } + } + } + + pub(crate) fn stop_awaiting(&self) { + self.lock().awaiting = None; + } + + /// Claims the right to serve one snapshot; release with [`Compaction::done_serving`]. + pub(crate) fn try_start_serving(&self) -> bool { + !self.serving.swap(true, Ordering::AcqRel) + } + + pub(crate) fn done_serving(&self) { + self.serving.store(false, Ordering::Release); + } + + pub(crate) fn catch_up(&self) -> Option<CatchUp> { + self.lock().catch_up.clone() + } + + pub(crate) fn set_catch_up(&self, catch_up: Option<CatchUp>) { + self.lock().catch_up = catch_up; + } + + pub(crate) fn catch_up_report(&self) -> Option<CatchUpReport> { + self.lock().catch_up.as_ref().map(|catch_up| CatchUpReport { + target: catch_up.target.to_string(), + attempts: catch_up.attempts, + error: catch_up.error.clone(), + }) + } + pub(crate) fn archived_epochs(&self) -> Vec<ArchivedEpoch> { self.lock() .archive diff --git a/src/compaction/mod.rs b/src/compaction/mod.rs index 69c633e..e97d9c8 100644 --- a/src/compaction/mod.rs +++ b/src/compaction/mod.rs @@ -4,6 +4,7 @@ //! * `records`: the replicated compaction records under `_continuum/compactions/<id>`. //! * `coordinator`: proposal, settle period and decision on the node that was asked. //! * `switch`: switching this node to a committed epoch (worker). +//! * `catchup`: fetching a newer epoch from a peer (late, new or failed nodes). //! * `delta`: replaying this node's changes since the base into the new epoch. //! * `local`: per-node in-memory state (prebuilt snapshots, switch status, archive). //! * `api`: `/admin/compaction(s)` handlers. @@ -12,6 +13,7 @@ //! depends on `AppState`, peers or sync internals. mod api; +mod catchup; mod coordinator; mod delta; mod local; @@ -19,6 +21,7 @@ mod records; pub(crate) mod snapshot; mod switch; +pub(crate) use catchup::{receive_snapshot_reply, serve_snapshot}; pub(crate) use local::Compaction; pub(crate) use switch::run_worker; @@ -35,6 +38,7 @@ use tokio::sync::broadcast; use crate::replica::Epoch; use crate::sync::ChangeSet; +use crate::transport::ParsedMessage; /// What compaction needs from the node. Implemented by `AppState`. /// @@ -57,6 +61,16 @@ pub(crate) trait Host: Clone + Send + Sync + 'static { /// Known (not forgotten) peers. fn peer_summaries(&self) -> impl Future<Output = Vec<PeerSummary>> + Send; + /// True if `peer` completed the handshake as a member of our cluster. + fn is_cluster_member(&self, peer: &str) -> impl Future<Output = bool> + Send; + + /// Sends a message to a peer over the transport. + fn send_message( + &self, + peer: &str, + message: ParsedMessage, + ) -> impl Future<Output = anyhow::Result<()>> + Send; + /// Store paths touched by each published document change. fn subscribe_changes(&self) -> broadcast::Receiver<Arc<ChangeSet>>; @@ -97,6 +111,10 @@ pub(crate) struct PeerSummary { pub(crate) id: String, /// Not dormant. pub(crate) active: bool, + /// Handshake completed. + pub(crate) established: bool, + /// The epoch the peer last advertised. + pub(crate) epoch: Option<Epoch>, } /// Admin routes. Merged into the authenticated part of the node's router. diff --git a/src/compaction/records.rs b/src/compaction/records.rs index ce2b2a5..fbb9fb9 100644 --- a/src/compaction/records.rs +++ b/src/compaction/records.rs @@ -76,8 +76,10 @@ pub(crate) struct Retention { pub(crate) struct Policy { /// How stale a returning node's changes may be and still be replayed automatically; /// beyond it they are quarantined. + #[serde(default = "Policy::default_replay_window")] pub(crate) replay_window: Retention, /// How long nodes keep the old epoch's document. + #[serde(default = "Policy::default_archive_retention")] pub(crate) archive_retention: Retention, } diff --git a/src/compaction/snapshot.rs b/src/compaction/snapshot.rs index 1f57352..86caba9 100644 --- a/src/compaction/snapshot.rs +++ b/src/compaction/snapshot.rs @@ -18,7 +18,8 @@ //! at the snapshot change itself, so edits made afterwards through the store API can't //! change what the protocol sees. -// `Snapshot::clock` is used by returning nodes (step 7). +// `Snapshot::clock` is kept for returning nodes (step 7); late nodes read the clock from +// the metadata instead. #![cfg_attr(not(test), allow(dead_code))] use std::collections::BTreeMap; @@ -27,6 +28,7 @@ use automerge::{transaction::CommitOptions, ActorId, AutoCommit, ChangeHash}; use serde_json::{json, Map as JsonMap, Value as JsonValue}; use sha2::{Digest, Sha256}; +use super::records::{EpochRecord, Policy}; use crate::replica::Epoch; use crate::store::{self, StoreError}; @@ -105,6 +107,67 @@ pub(crate) async fn build_detached( .map_err(|error| SnapshotError::Task(error.to_string()))? } +/// The protocol's view of an epoch, read at its snapshot change. +#[derive(Clone, Debug)] +pub(crate) struct EpochMetadata { + pub(crate) compaction_id: String, + pub(crate) previous: Epoch, + pub(crate) clock: BTreeMap<String, u64>, + pub(crate) policy: Policy, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct MetadataRecord { + generation: u64, + compaction_id: String, + previous: EpochRecord, + clock: BTreeMap<String, u64>, + policy: Policy, +} + +/// Checks that `doc` is a document of `epoch`: its history starts with exactly the +/// snapshot change `epoch.id` (no dependencies), and the metadata at that change names +/// the same generation. Returns the metadata. +/// +/// This does not prove the snapshot's content matches the old epoch; the hash commits +/// to it, and the peer is a cluster member trusted as much as for ordinary sync. +pub(crate) fn verify_document(doc: &mut AutoCommit, epoch: Epoch) -> Result<EpochMetadata, String> { + let root = ChangeHash(epoch.id); + let Some(change) = doc.get_change_by_hash(&root) else { + return Err(format!( + "document does not contain the snapshot change of {epoch}" + )); + }; + if !change.deps().is_empty() { + return Err(format!("snapshot change of {epoch} has dependencies")); + } + + let at_snapshot = doc.fork_at(&[root]).map_err(|error| error.to_string())?; + let value = store::read_path(&at_snapshot, &epoch_metadata_path(epoch.generation)) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("no epoch metadata for generation {}", epoch.generation))?; + let record: MetadataRecord = serde_json::from_value(value) + .map_err(|error| format!("invalid epoch metadata: {error}"))?; + if record.generation != epoch.generation { + return Err(format!( + "epoch metadata names generation {} instead of {}", + record.generation, epoch.generation + )); + } + let previous = record + .previous + .to_epoch() + .ok_or_else(|| "invalid previous epoch in metadata".to_owned())?; + + Ok(EpochMetadata { + compaction_id: record.compaction_id, + previous, + clock: record.clock, + policy: record.policy, + }) +} + /// Path of the metadata record for an epoch generation. pub(crate) fn epoch_metadata_path(generation: u64) -> Vec<String> { vec![ @@ -493,4 +556,57 @@ mod tests { assert!(snap.clock.is_empty()); assert_eq!(user_view(&snap.doc), json!({})); } + + #[test] + fn fetched_document_is_verified_and_late_changes_replayed() { + use crate::compaction::delta; + + // Old epoch: the base, then a late write by this node. + let mut late = doc(b"late-node"); + late.put(ROOT, "shared", "base").unwrap(); + late.commit(); + let base = late.get_heads(); + let policy = json!({}); + let mut snap = build(&mut late, ¶ms(&base, &policy)).unwrap(); + + // Meanwhile the cluster moved on in the new epoch. + let mut cluster = snap + .doc + .fork() + .with_actor(ActorId::from(&b"cluster-node"[..])); + cluster.put(ROOT, "new", 1_i64).unwrap(); + cluster.commit(); + let bytes = cluster.save(); + + late.put(ROOT, "mine", "late").unwrap(); + late.commit(); + + let mut fetched = AutoCommit::load(&bytes).unwrap(); + let metadata = verify_document(&mut fetched, snap.epoch).unwrap(); + assert_eq!(metadata.previous, Epoch::GENESIS); + assert_eq!(metadata.compaction_id, "c-1"); + assert_eq!( + metadata.policy.replay_window, + Policy::default_replay_window() + ); + + // Documents of other epochs are refused. + let mut wrong = snap.epoch; + wrong.generation = 2; + assert!(verify_document(&mut fetched, wrong).is_err()); + let mut wrong = snap.epoch; + wrong.id = [0; 32]; + assert!(verify_document(&mut fetched, wrong).is_err()); + + let own = late.get_actor().clone(); + let missing = delta::missing_from_snapshot(&mut late, &metadata.clock, &own); + assert_eq!(missing.own, 1); + let heads = late.get_heads(); + let mut adopted = fetched.with_actor(ActorId::from(&b"late-node-next"[..])); + delta::replay(&mut late, &missing.before, &heads, &mut adopted); + assert_eq!( + user_view(&adopted), + json!({ "shared": "base", "new": 1, "mine": "late" }) + ); + } } diff --git a/src/compaction/switch.rs b/src/compaction/switch.rs index 5eb1962..9f17df2 100644 --- a/src/compaction/switch.rs +++ b/src/compaction/switch.rs @@ -15,20 +15,22 @@ //! The write lock is held only for step 3, which is proportional to the changes made //! during steps 1-2. //! -//! A node whose build doesn't match the committed epoch stays on the old epoch and -//! reports the mismatch; fetching the snapshot from peers comes with step 6. +//! A node whose build doesn't match the committed epoch, or that never saw the commit, +//! fetches the new epoch from a peer instead (`catchup`). use std::time::Duration; -use automerge::{ActorId, AutoCommit}; +use automerge::{ActorId, AutoCommit, ChangeHash}; use tokio::sync::broadcast; use tracing::{error, info, warn}; +use super::catchup; use super::delta::{self, ReplayStats}; use super::local::LocalStatus; use super::records::{self, unix_millis, EpochRecord, Record}; use super::snapshot::{self, COMPACTIONS, META_ROOT}; use super::Host; +use crate::replica::Epoch; use crate::store; use crate::sync::paths_related; @@ -79,13 +81,15 @@ async fn check<H: Host>(host: &H) { .collect(); let (id, record) = match committed.as_slice() { - [] => return, + // Nothing committed here; maybe peers moved on without us. + [] => return catchup::check(host).await, [(id, record)] => (id.clone(), record.clone()), several => { - // The settle protocol prevents this; never guess which one is right. + // The settle protocol prevents this; never guess which one is right. Peers + // that did switch can still be followed. let ids: Vec<&str> = several.iter().map(|(id, _)| id.as_str()).collect(); - error!(compactions = ?ids, "Several compactions committed for this epoch; not switching"); - return; + error!(compactions = ?ids, "Several compactions committed for this epoch; not switching on my own"); + return catchup::check(host).await; } }; @@ -94,7 +98,9 @@ async fn check<H: Host>(host: &H) { .status(&id) .is_some_and(|status| status.error.is_some()) { - return; // already failed; reported in the compaction's status + // Our own switch failed (reported in the compaction's status): fetch the epoch + // from a peer that made it. + return catchup::check(host).await; } let switch_delay = host.dormant_after() / 4; // one keepalive interval @@ -148,7 +154,7 @@ async fn switch<H: Host>(host: &H, id: &str, record: &Record) -> Result<ReplaySt // 2. Build (or reuse), verify and replay without holding any lock. let (new_doc, copy_heads, stats) = tokio::task::spawn_blocking( - move || -> Result<(AutoCommit, Vec<automerge::ChangeHash>, ReplayStats), String> { + move || -> Result<(AutoCommit, Vec<ChangeHash>, ReplayStats), String> { let mut copy = copy; let snapshot = match prebuilt { Some(snapshot) => snapshot, @@ -179,21 +185,7 @@ async fn switch<H: Host>(host: &H, id: &str, record: &Record) -> Result<ReplaySt .map_err(|error| format!("switch task failed: {error}"))??; // 3. Swap, replaying what arrived since the copy. - let old = host - .switch_epoch(expected, move |live| { - let mut new_doc = new_doc; - let live_heads = live.get_heads(); - let late = delta::replay(live, ©_heads, &live_heads, &mut new_doc); - if late.replayed + late.failed > 0 { - info!( - replayed = late.replayed, - failed = late.failed, - "Replayed late changes" - ); - } - Ok::<_, String>(new_doc) - }) - .await?; + let old = swap_in(host, expected, new_doc, copy_heads).await?; // 4. Keep the old document as configured. host.compaction() @@ -208,3 +200,35 @@ async fn switch<H: Host>(host: &H, id: &str, record: &Record) -> Result<ReplaySt ); Ok(stats) } + +/// Under the write lock: replays into `new_doc` whatever the live document received +/// since `copy_heads` (the heads of the copy `new_doc` was prepared from), then swaps it +/// in as `target`. Returns the old document. +pub(super) async fn swap_in<H: Host>( + host: &H, + target: Epoch, + new_doc: AutoCommit, + copy_heads: Vec<ChangeHash>, +) -> Result<AutoCommit, String> { + host.switch_epoch(target, move |live| { + // Only one worker switches, but be sure the copy still describes the live document. + if !copy_heads + .iter() + .all(|head| live.get_change_by_hash(head).is_some()) + { + return Err("the document was replaced while preparing the switch".to_owned()); + } + let mut new_doc = new_doc; + let live_heads = live.get_heads(); + let late = delta::replay(live, ©_heads, &live_heads, &mut new_doc); + if late.replayed + late.failed > 0 { + info!( + replayed = late.replayed, + failed = late.failed, + "Replayed late changes" + ); + } + Ok(new_doc) + }) + .await +} diff --git a/src/main.rs b/src/main.rs index 1127cd8..f5cec55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -187,6 +187,12 @@ async fn main() -> Result<()> { }); info!("Peer worker task spawned"); + // Switches to committed epochs and catches up with newer ones. Starts before + // bootstrapping: a node joining a compacted cluster can only finish its initial sync + // after fetching the current epoch from a peer. + tokio::spawn(compaction::run_worker(state.clone())); + info!("Compaction worker task spawned"); + bootstrap::bootstrap(&state, &seeds) .await .context("Joining cluster")?; @@ -196,9 +202,6 @@ async fn main() -> Result<()> { let publisher_state = state.clone(); tokio::spawn(async move { sync::run_change_publisher(publisher_state).await }); - // Switches to committed epochs. Runs only once the node is Ready. - tokio::spawn(compaction::run_worker(state.clone())); - info!( cluster_id = ?state.cluster_id().await, ready = state.is_ready().await, diff --git a/src/mycelium.rs b/src/mycelium.rs index 8d0821b..22d49c1 100644 --- a/src/mycelium.rs +++ b/src/mycelium.rs @@ -19,7 +19,9 @@ use url::Url; use crate::transport::EncodedMessage; use crate::transport::Keepalive; -use crate::transport::{Epoch, HelloReply, HelloRequest, SyncEnvelope}; +use crate::transport::{ + Epoch, HelloReply, HelloRequest, SnapshotReply, SnapshotRequest, SyncEnvelope, +}; use crate::transport::{MessageHandler, ParsedMessage, Transport}; const MYCELIUM_POLL_TIMEOUT_SECS: u64 = 300; @@ -30,6 +32,8 @@ const TOPIC_HELLO_REPLY: &str = "continuum.v1.hello-reply"; const TOPIC_SYNC: &str = "continuum.v1.sync"; const TOPIC_PING: &str = "continuum.v1.ping"; const TOPIC_PONG: &str = "continuum.v1.pong"; +const TOPIC_SNAPSHOT_REQUEST: &str = "continuum.v1.snapshot-request"; +const TOPIC_SNAPSHOT_REPLY: &str = "continuum.v1.snapshot-reply"; const EMPTY_QUEUE_DELAY: Duration = Duration::from_millis(25); @@ -696,6 +700,12 @@ fn decode_payload(topic: &str, payload: &str) -> Result<ParsedMessage, Transport } TOPIC_PING => Ok(ParsedMessage::Ping(bytes_to_keepalive(&bytes)?)), TOPIC_PONG => Ok(ParsedMessage::Pong(bytes_to_keepalive(&bytes)?)), + TOPIC_SNAPSHOT_REQUEST => Ok(ParsedMessage::SnapshotRequest(bytes_to_snapshot_request( + &bytes, + )?)), + TOPIC_SNAPSHOT_REPLY => Ok(ParsedMessage::SnapshotReply(bytes_to_snapshot_reply( + &bytes, + )?)), t => Err(TransportError::UnknownTopic(t.to_string())), } } @@ -723,6 +733,10 @@ fn format_message( ParsedMessage::Sync(env) => (TOPIC_SYNC, sync_envelope_to_bytes(env)?), ParsedMessage::Ping(k) => (TOPIC_PING, keepalive_to_bytes(k)?), ParsedMessage::Pong(k) => (TOPIC_PONG, keepalive_to_bytes(k)?), + ParsedMessage::SnapshotRequest(req) => { + (TOPIC_SNAPSHOT_REQUEST, snapshot_request_to_bytes(req)?) + } + ParsedMessage::SnapshotReply(res) => (TOPIC_SNAPSHOT_REPLY, snapshot_reply_to_bytes(res)?), }; Ok(OutboundMessage { dst: Destination { @@ -900,6 +914,50 @@ pub fn bytes_to_sync_envelope(bytes: &[u8]) -> Result<SyncEnvelope, TransportErr }) } +// --- Snapshot transfer --- +#[derive(Serialize, Deserialize)] +struct SnapshotRequestShadow { + epoch: EpochShadow, + wanted: EpochShadow, +} + +#[derive(Serialize, Deserialize)] +struct SnapshotReplyShadow { + epoch: EpochShadow, + document: ByteBuf, +} + +fn snapshot_request_to_bytes(msg: &SnapshotRequest) -> Result<Vec<u8>, rmp_serde::encode::Error> { + rmp_serde::to_vec(&SnapshotRequestShadow { + epoch: epoch_to_shadow(&msg.epoch), + wanted: epoch_to_shadow(&msg.wanted), + }) +} + +fn bytes_to_snapshot_request(bytes: &[u8]) -> Result<SnapshotRequest, TransportError> { + let shadow: SnapshotRequestShadow = rmp_serde::from_slice(bytes)?; + Ok(SnapshotRequest { + epoch: epoch_from_shadow(shadow.epoch)?, + wanted: epoch_from_shadow(shadow.wanted)?, + }) +} + +fn snapshot_reply_to_bytes(msg: &SnapshotReply) -> Result<Vec<u8>, rmp_serde::encode::Error> { + rmp_serde::to_vec(&SnapshotReplyShadow { + epoch: epoch_to_shadow(&msg.epoch), + // One copy of the document; replies are rare. + document: ByteBuf::from(msg.document.clone()), + }) +} + +fn bytes_to_snapshot_reply(bytes: &[u8]) -> Result<SnapshotReply, TransportError> { + let shadow: SnapshotReplyShadow = rmp_serde::from_slice(bytes)?; + Ok(SnapshotReply { + epoch: epoch_from_shadow(shadow.epoch)?, + document: shadow.document.into_vec(), + }) +} + #[derive(Error, Debug)] pub enum TransportError { #[error("Mycelium delivered a message without a sender public key")] diff --git a/src/peer.rs b/src/peer.rs index 238c341..015b5ff 100644 --- a/src/peer.rs +++ b/src/peer.rs @@ -660,6 +660,17 @@ pub(crate) async fn handle_message( let local = local_keepalive(state).await; heal_if_diverged(state, &peer, &local, &remote).await; } + transport::ParsedMessage::SnapshotRequest(request) => { + // Saving a large document takes a while; don't stall the inbox. + tokio::spawn(crate::compaction::serve_snapshot( + state.clone(), + peer.clone(), + request, + )); + } + transport::ParsedMessage::SnapshotReply(reply) => { + crate::compaction::receive_snapshot_reply(state, &peer, reply); + } } Ok(()) diff --git a/src/state.rs b/src/state.rs index a8b1917..b3a496c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -566,13 +566,39 @@ impl Host for AppState { let mut summaries = Vec::with_capacity(peers.len()); for (id, peer) in peers { - let active = peer.lock().await.liveness == Liveness::Active; - summaries.push(PeerSummary { id, active }); + let peer = peer.lock().await; + summaries.push(PeerSummary { + id, + active: peer.liveness == Liveness::Active, + established: peer.hello_state == HelloState::Established, + epoch: peer.epoch, + }); } summaries } } + fn is_cluster_member(&self, peer: &str) -> impl Future<Output = bool> + Send { + let state = self.clone(); + let peer = peer.to_owned(); + async move { + let Some(ours) = state.cluster_id().await else { + return false; + }; + crate::peer::peer_cluster(&state, &peer).await.as_deref() == Some(ours.as_str()) + } + } + + fn send_message( + &self, + peer: &str, + message: transport::ParsedMessage, + ) -> impl Future<Output = anyhow::Result<()>> + Send { + let state = self.clone(); + let peer = peer.to_owned(); + async move { state.transport.send_message(&peer, &message).await } + } + fn subscribe_changes(&self) -> broadcast::Receiver<Arc<ChangeSet>> { self.change_events.subscribe() } diff --git a/src/transport.rs b/src/transport.rs index 9cfb026..b7a4094 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -23,6 +23,9 @@ pub enum ParsedMessage { Sync(SyncEnvelope), Ping(Keepalive), Pong(Keepalive), + /// Asks a peer for its document of a newer epoch (late or new nodes). + SnapshotRequest(SnapshotRequest), + SnapshotReply(SnapshotReply), } impl ParsedMessage { @@ -33,6 +36,8 @@ impl ParsedMessage { ParsedMessage::HelloReply(reply) => reply.epoch, ParsedMessage::Sync(envelope) => envelope.epoch, ParsedMessage::Ping(keepalive) | ParsedMessage::Pong(keepalive) => keepalive.epoch, + ParsedMessage::SnapshotRequest(request) => request.epoch, + ParsedMessage::SnapshotReply(reply) => reply.epoch, } } } @@ -57,6 +62,32 @@ pub struct SyncEnvelope { pub message: Message, } +#[derive(Debug, Clone)] +pub struct SnapshotRequest { + /// The requester's epoch. + pub epoch: Epoch, + /// The epoch whose document is wanted. + pub wanted: Epoch, +} + +#[derive(Clone)] +pub struct SnapshotReply { + /// The sender's epoch, which is the epoch of `document`. + pub epoch: Epoch, + /// `AutoCommit::save()` of the sender's document; empty if it isn't on the wanted + /// epoch (any more). + pub document: Vec<u8>, +} + +impl std::fmt::Debug for SnapshotReply { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SnapshotReply") + .field("epoch", &self.epoch) + .field("document_bytes", &self.document.len()) + .finish() + } +} + /// A message serialized for the wire. Cloning is cheap (shared buffer). #[derive(Clone, Debug)] pub struct EncodedMessage { |
