diff options
Diffstat (limited to 'src/remake/catchup.rs')
| -rw-r--r-- | src/remake/catchup.rs | 506 |
1 files changed, 506 insertions, 0 deletions
diff --git a/src/remake/catchup.rs b/src/remake/catchup.rs new file mode 100644 index 0000000..22aa0f8 --- /dev/null +++ b/src/remake/catchup.rs @@ -0,0 +1,506 @@ +//! Catching up with a newer epoch: the node rejoins. +//! +//! Peers on another epoch don't sync, so a node that missed a switch (new, late, +//! returning, or one whose own build failed) can't follow through ordinary sync. Once +//! an active peer advertises a newer generation, the node +//! +//! 1. stops being ready and keeps its document aside; reads are answered from a copy +//! of it, marked stale (`X-Continuum-Stale`), writes get `503`; +//! 2. switches to an empty document labelled with the newer epoch: an empty history +//! conflicts with nothing, so ordinary sync fills it from peers on that epoch, +//! exactly like a new node joining; +//! 3. once caught up with a peer, checks that the history starts with the epoch's +//! snapshot change; +//! 4. decides about its own changes the cluster never saw (none, for a new node): +//! * the metadata of the epoch that followed the node's own records which changes +//! went into it (per-actor clock); the rest are this node's missing changes; +//! * within that epoch's replay window they are replayed (state per path, as in a +//! switch), otherwise they are quarantined for an operator +//! (`/admin/quarantine`), as are changes that can't be told apart; +//! 5. archives the old document and serves requests again. A node that was never +//! ready (a new node) is made ready by bootstrap, which waits for the rejoin. +//! +//! If no peer completes a sync in time, or the check fails, the node restores its old +//! document and epoch and tries again later. + +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::time::Duration; + +use automerge::{AutoCommit, ChangeHash}; +use tokio::time::Instant; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use super::delta::{self, ReplayStats}; +use super::local::{CatchUp, LocalStatus, Quarantined, Rejoin}; +use super::records::unix_millis; +use super::snapshot::{self, EpochMetadata}; +use super::Host; +use crate::document::replica::Epoch; + +/// Pause before trying the same target again after a failed rejoin. +const RETRY: Duration = Duration::from_secs(30); +/// Lower bound for how long a rejoin may take to complete a sync. +const MIN_REJOIN_TIMEOUT: Duration = Duration::from_secs(60); + +/// Called by the switch worker when there is nothing to switch to locally, and on every +/// tick while a rejoin is in progress. +pub(super) async fn check<H: Host>(host: &H) { + if let Some((target, started)) = host.remake().rejoin_progress() { + let timeout = host.dormant_after().max(MIN_REJOIN_TIMEOUT); + if host.initial_sync_done() { + finish(host).await; + } else if started.elapsed() > timeout { + if let Some(rejoin) = host.remake().take_rejoin() { + let reason = format!( + "no peer on {target} completed a sync within {}s", + timeout.as_secs() + ); + abandon(host, rejoin, reason).await; + } + } + return; + } + + let local = host.current_epoch(); + let mut ahead: Vec<(String, Epoch)> = host + .peer_summaries() + .await + .into_iter() + .filter(|peer| peer.active) + .filter_map(|peer| peer.epoch.map(|epoch| (peer.id, epoch))) + .filter(|(_, epoch)| epoch.generation > local.generation) + .collect(); + ahead.sort_by(|a, b| a.0.cmp(&b.0)); + + let Some(generation) = ahead.iter().map(|(_, epoch)| epoch.generation).max() else { + if host.remake().catch_up().is_some() { + host.remake().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 + .remake() + .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; + + start(host, local, target, attempts).await; +} + +async fn start<H: Host>(host: &H, local: Epoch, target: Epoch, attempts: u32) { + host.remake().set_catch_up(Some(CatchUp { + target, + attempts, + last_attempt: Instant::now(), + error: None, + })); + + // Keep answering reads, from a copy of the current document marked stale, before + // the node stops being ready: no window in which reads get 503. + if host.is_node_ready().await { + let copy = host.read_doc(|doc| doc.clone()).await; + host.set_stale_view(Some((local, copy))); + } + let was_ready = host.suspend().await; + let old = match host + .switch_epoch(target, |_live| Ok::<_, Infallible>(AutoCommit::new())) + .await + { + Ok(old) => old, + Err(never) => match never {}, + }; + // After the swap: messages of the old epoch are dropped from now on, so nothing + // can mark the new, empty document as caught up by mistake. A stale "done" from + // just before is caught in `finish` (the snapshot change is missing then). + host.reset_initial_sync(); + + info!(from = %local, to = %target, attempt = attempts, "Rejoining newer epoch"); + host.remake().start_rejoin(Rejoin { + previous: local, + target, + old, + started: Instant::now(), + was_ready, + }); +} + +/// What to do with this node's own changes the new epoch lacks. +enum Plan { + Nothing, + Replay(Vec<Vec<String>>), + Quarantine { + paths: Vec<Vec<String>>, + reason: String, + }, +} + +enum Prepared { + /// Caught up with someone, but not with the target epoch yet: keep waiting. + NotYet(AutoCommit), + Failed(AutoCommit, String), + Ready { + old: AutoCommit, + metadata: EpochMetadata, + plan: Plan, + own_missing: usize, + had_history: bool, + }, +} + +async fn finish<H: Host>(host: &H) { + let Some(rejoin) = host.remake().take_rejoin() else { + return; + }; + let Rejoin { + previous, + target, + old, + started, + was_ready, + } = rejoin; + + // Everything heavy runs on a copy, without a lock. + let copy = host.read_doc(|doc| doc.clone()).await; + let prepared = tokio::task::spawn_blocking(move || prepare(copy, old, previous, target)).await; + + let (old, metadata, plan, own_missing, had_history) = match prepared { + Ok(Prepared::Ready { + old, + metadata, + plan, + own_missing, + had_history, + }) => (old, metadata, plan, own_missing, had_history), + Ok(Prepared::NotYet(old)) => { + host.reset_initial_sync(); + host.remake().start_rejoin(Rejoin { + previous, + target, + old, + started, + was_ready, + }); + return; + } + Ok(Prepared::Failed(old, reason)) => { + let rejoin = Rejoin { + previous, + target, + old, + started, + was_ready, + }; + return abandon(host, rejoin, reason).await; + } + Err(join_error) => { + // The old document is gone with the panicked task; nothing to restore. + error!(error = %join_error, "Rejoin task failed; staying on the new epoch"); + if was_ready { + host.resume().await; + } + host.set_stale_view(None); + return; + } + }; + + let mut stats = ReplayStats::default(); + let mut quarantine_id = None; + let mut keep_archive = true; + let mut old = Some(old); + + match plan { + Plan::Nothing => {} + Plan::Replay(paths) => { + let document = old.take().expect("old document"); + let (replayed, document) = host + .commit_doc(move |doc| { + let stats = delta::replay_paths(&document, doc, &paths); + Ok::<_, Infallible>((stats, document)) + }) + .await + .unwrap_or_else(|never| match never {}); + stats = replayed; + old = Some(document); + } + Plan::Quarantine { paths, reason } => { + let id = Uuid::new_v4().to_string(); + warn!(quarantine = %id, epoch = %previous, paths = paths.len(), reason = %reason, "Quarantined local changes"); + host.remake().add_quarantine(Quarantined { + id: id.clone(), + epoch: previous, + target, + created_at: unix_millis(), + reason, + paths, + document: old.take().expect("old document"), + }); + quarantine_id = Some(id); + keep_archive = false; // the quarantine holds the document + } + } + + if let (true, true, Some(document)) = (had_history, keep_archive, old) { + host.remake().archive( + previous, + document, + metadata.policy.archive_retention.clone(), + target.generation, + ); + } + + host.remake().set_status( + &metadata.remake_id, + LocalStatus { + switched_at: Some(unix_millis()), + replayed_paths: Some(stats.replayed), + failed_paths: Some(stats.failed), + quarantine: quarantine_id.clone(), + error: None, + }, + ); + host.remake().set_catch_up(None); + if was_ready { + host.resume().await; + } + // After resuming, so reads move from the stale view to the live document directly. + host.set_stale_view(None); + + info!( + from = %previous, + to = %target, + own_changes = own_missing, + replayed = stats.replayed, + failed = stats.failed, + quarantine = ?quarantine_id, + secs = started.elapsed().as_secs(), + "Rejoined newer epoch" + ); +} + +/// Verifies the synced document and decides about the old document's own changes. +fn prepare(mut copy: AutoCommit, mut old: AutoCommit, previous: Epoch, target: Epoch) -> Prepared { + if copy.get_change_by_hash(&ChangeHash(target.id)).is_none() { + return Prepared::NotYet(old); + } + let metadata = match snapshot::verify_document(&mut copy, target) { + Ok(metadata) => metadata, + Err(reason) => return Prepared::Failed(old, reason), + }; + + let old_heads = old.get_heads(); + if old_heads.is_empty() { + return Prepared::Ready { + old, + metadata, + plan: Plan::Nothing, + own_missing: 0, + had_history: false, + }; + } + + // The epoch that followed ours records (per actor) which of our changes it took. + // Snapshots carry earlier epochs' metadata forward, so this works across any gap. + let next = snapshot::read_epoch_metadata(©, previous.generation + 1) + .ok() + .filter(|next| next.previous == previous); + + let own = old.get_actor().clone(); + let clock = next + .as_ref() + .map_or_else(BTreeMap::new, |next| next.clock.clone()); + let missing = delta::missing_from_snapshot(&mut old, &clock, &own); + if missing.own == 0 { + return Prepared::Ready { + old, + metadata, + plan: Plan::Nothing, + own_missing: 0, + had_history: true, + }; + } + + let paths = delta::changed_paths(&mut old, &missing.before, &old_heads); + let gap = target.generation - previous.generation; + + let plan = match &next { + None => Plan::Quarantine { + paths, + reason: format!( + "the metadata of the epoch after {previous} is missing, so it is unknown which of {} local changes the cluster has", + missing.own + ), + }, + Some(next) => { + let away_ms = unix_millis().saturating_sub(next.proposed_at); + if next.policy.replay_window.covers(gap, away_ms) { + Plan::Replay(paths) + } else { + Plan::Quarantine { + paths, + reason: format!( + "{} local changes are outside the replay window ({gap} epochs behind, cluster moved on {}s ago)", + missing.own, + away_ms / 1000 + ), + } + } + } + }; + + Prepared::Ready { + old, + metadata, + plan, + own_missing: missing.own, + had_history: true, + } +} + +/// Restores the old document and epoch, and retries later. +async fn abandon<H: Host>(host: &H, rejoin: Rejoin, reason: String) { + let Rejoin { + previous, + target, + old, + was_ready, + .. + } = rejoin; + warn!(target = %target, reason = %reason, "Rejoin failed; restoring the previous epoch"); + + if let Err(never) = host + .switch_epoch(previous, move |_live| Ok::<_, Infallible>(old)) + .await + { + match never {} + } + // A node that was never ready must sync again before bootstrap may finish. + host.reset_initial_sync(); + if was_ready { + host.resume().await; + } + host.set_stale_view(None); + + let attempts = host + .remake() + .catch_up() + .filter(|catch_up| catch_up.target == target) + .map_or(1, |catch_up| catch_up.attempts); + host.remake().set_catch_up(Some(CatchUp { + target, + attempts, + last_attempt: Instant::now(), + error: Some(reason), + })); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::remake::snapshot::{build, SnapshotParams}; + use automerge::{transaction::Transactable, ActorId, ROOT}; + use serde_json::{json, Value as JsonValue}; + + /// An old-epoch document with one own change after the base, and the cluster's + /// document of the next epoch (snapshot plus one foreign change). + fn scenario(policy: &JsonValue) -> (AutoCommit, AutoCommit, Epoch) { + let mut old = AutoCommit::new().with_actor(ActorId::from(&b"late-node"[..])); + old.put(ROOT, "base", 1_i64).unwrap(); + old.commit(); + let base = old.get_heads(); + + let snapshot = build( + &mut old, + &SnapshotParams { + remake_id: "c-1", + previous: Epoch::GENESIS, + base_heads: &base, + policy, + proposed_at: 0, + }, + ) + .unwrap(); + let target = snapshot.epoch; + + let mut cluster = snapshot.doc.with_actor(ActorId::from(&b"cluster-node"[..])); + cluster.put(ROOT, "theirs", 1_i64).unwrap(); + cluster.commit(); + + old.put(ROOT, "mine", 1_i64).unwrap(); + old.commit(); + (old, cluster, target) + } + + #[test] + fn replays_within_the_window() { + let (old, cluster, target) = scenario(&json!({})); + match prepare(cluster, old, Epoch::GENESIS, target) { + Prepared::Ready { + plan: Plan::Replay(paths), + own_missing, + .. + } => { + assert_eq!(own_missing, 1); + assert_eq!(paths, vec![vec!["mine".to_owned()]]); + } + _ => panic!("expected a replay"), + } + } + + #[test] + fn quarantines_outside_the_window() { + let (old, cluster, target) = scenario(&json!({ "replayWindow": { "epochs": 0 } })); + match prepare(cluster, old, Epoch::GENESIS, target) { + Prepared::Ready { + plan: Plan::Quarantine { paths, .. }, + .. + } => assert_eq!(paths, vec![vec!["mine".to_owned()]]), + _ => panic!("expected a quarantine"), + } + } + + #[test] + fn new_node_has_nothing_to_replay() { + let (_, cluster, target) = scenario(&json!({})); + assert!(matches!( + prepare(cluster, AutoCommit::new(), Epoch::GENESIS, target), + Prepared::Ready { + plan: Plan::Nothing, + had_history: false, + .. + } + )); + } + + #[test] + fn waits_for_the_target_epoch() { + let (old, _, target) = scenario(&json!({})); + assert!(matches!( + prepare(AutoCommit::new(), old, Epoch::GENESIS, target), + Prepared::NotYet(_) + )); + } +} |
