diff options
Diffstat (limited to 'src/compaction/catchup.rs')
| -rw-r--r-- | src/compaction/catchup.rs | 492 |
1 files changed, 0 insertions, 492 deletions
diff --git a/src/compaction/catchup.rs b/src/compaction/catchup.rs deleted file mode 100644 index c71bb57..0000000 --- a/src/compaction/catchup.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! 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 serving requests and keeps its document aside; -//! 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. -//! -//! 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::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.compaction().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.compaction().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.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; - - start(host, local, target, attempts).await; -} - -async fn start<H: Host>(host: &H, local: Epoch, target: Epoch, attempts: u32) { - host.compaction().set_catch_up(Some(CatchUp { - target, - attempts, - last_attempt: Instant::now(), - error: None, - })); - - 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.compaction().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.compaction().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.compaction().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; - } - 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.compaction().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.compaction().archive( - previous, - document, - metadata.policy.archive_retention.clone(), - target.generation, - ); - } - - host.compaction().set_status( - &metadata.compaction_id, - LocalStatus { - switched_at: Some(unix_millis()), - replayed_paths: Some(stats.replayed), - failed_paths: Some(stats.failed), - quarantine: quarantine_id.clone(), - error: None, - }, - ); - host.compaction().set_catch_up(None); - if was_ready { - host.resume().await; - } - - 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 {} - } - if was_ready { - host.resume().await; - } - - let attempts = host - .compaction() - .catch_up() - .filter(|catch_up| catch_up.target == target) - .map_or(1, |catch_up| catch_up.attempts); - host.compaction().set_catch_up(Some(CatchUp { - target, - attempts, - last_attempt: Instant::now(), - error: Some(reason), - })); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::compaction::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 { - compaction_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(_) - )); - } -} |
