diff options
Diffstat (limited to 'src/compaction/catchup.rs')
| -rw-r--r-- | src/compaction/catchup.rs | 283 |
1 files changed, 212 insertions, 71 deletions
diff --git a/src/compaction/catchup.rs b/src/compaction/catchup.rs index f166f56..4694fbc 100644 --- a/src/compaction/catchup.rs +++ b/src/compaction/catchup.rs @@ -2,9 +2,9 @@ //! //! 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: +//! once an active peer advertises a newer generation, the node downloads that peer's +//! document (`SnapshotRequest`/`SnapshotReply`), checks that it 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; @@ -13,17 +13,24 @@ //! * 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. +//! The document travels in chunks of `CHUNK_BYTES`, one request per chunk, so a large +//! document neither needs one huge transport message nor one long timeout. The serving +//! node saves its document once per download and serves every chunk from that copy; a +//! SHA-256 digest of the whole copy is in every reply and checked at the end. +//! +//! Peers refuse explicitly (non-members, busy, other epoch), so the requester moves on +//! to the next peer right away instead of timing out. +use std::sync::Arc; use std::time::Duration; use automerge::{ActorId, AutoCommit}; +use sha2::{Digest, Sha256}; use tokio::time::Instant; use tracing::{debug, error, info, warn}; use super::delta::{self, ReplayStats}; -use super::local::{CatchUp, LocalStatus}; +use super::local::{CatchUp, LocalStatus, Served}; use super::records::unix_millis; use super::snapshot; use super::switch::swap_in; @@ -31,21 +38,31 @@ use super::Host; use crate::replica::Epoch; use crate::transport::{ParsedMessage, SnapshotReply, SnapshotRequest}; +/// Pause before the next attempt (next peer) after a failed download. const RETRY: Duration = Duration::from_secs(10); -const FETCH_TIMEOUT: Duration = Duration::from_secs(60); +/// Payload per reply. +pub(crate) const CHUNK_BYTES: usize = 1024 * 1024; +/// How long to wait for one chunk, and how often to ask for it. +const CHUNK_TIMEOUT: Duration = Duration::from_secs(60); +const CHUNK_ATTEMPTS: u32 = 3; +/// A served copy older than this is replaced when a new download starts. +const SERVED_FRESH_FOR: 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 + // Any active peer will do: it proved liveness recently, and whether it accepts us + // as a member is its decision (it refuses explicitly otherwise). + let mut ahead: Vec<(String, Epoch)> = host .peer_summaries() .await .into_iter() - .filter(|peer| peer.active && peer.established) + .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() { @@ -81,7 +98,7 @@ pub(super) async fn check<H: Host>(host: &H) { } let attempts = previous.map_or(0, |catch_up| catch_up.attempts) + 1; - // Rotate through the peers on the target epoch. + // Rotate through the peers on the target epoch (sorted, so the rotation is stable). let sources: Vec<&String> = ahead .iter() .filter(|(_, epoch)| *epoch == target) @@ -98,7 +115,7 @@ pub(super) async fn check<H: Host>(host: &H) { info!(local = %local, target = %target, peer = %source, attempt = attempts, "Fetching newer epoch from peer"); - let result = match fetch(host, &source, local, target).await { + let result = match download(host, &source, local, target).await { Ok(document) => adopt(host, local, target, document).await, Err(error) => Err(error), }; @@ -117,38 +134,95 @@ pub(super) async fn check<H: Host>(host: &H) { } } -async fn fetch<H: Host>( +/// Downloads `peer`'s document of `target`, chunk by chunk. +async fn download<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 mut document: Vec<u8> = Vec::new(); + let mut expected: Option<([u8; 32], u64)> = None; + let mut last_progress = 0; + + loop { + let offset = document.len() as u64; + let reply = request_chunk(host, peer, local, target, offset).await?; + + if !reply.refused.is_empty() { + return Err(format!("peer refused: {}", reply.refused)); + } + if reply.epoch != target { + return Err(format!("peer is on {} instead of {target}", reply.epoch)); + } + match expected { + None => { + info!(peer = %peer, document_bytes = reply.total, "Downloading document"); + expected = Some((reply.digest, reply.total)); + } + Some((digest, total)) if digest != reply.digest || total != reply.total => { + return Err("the peer's document changed during the download".to_owned()); + } + Some(_) => {} + } + let total = reply.total; + if reply.data.is_empty() && offset < total { + return Err(format!("empty chunk at offset {offset} of {total}")); + } + document.extend_from_slice(&reply.data); + + let percent = (document.len() as u64 * 100) + .checked_div(total) + .unwrap_or(100); + if percent >= last_progress + 10 { + last_progress = percent; + debug!(peer = %peer, percent, "Download progress"); + } + + if document.len() as u64 >= total { + break; + } + } + + let (digest, total) = expected.expect("set by the first reply"); + if document.len() as u64 != total || sha256(&document) != digest { + return Err("downloaded document does not match its digest".to_owned()); } + Ok(document) +} - let reply = match tokio::time::timeout(FETCH_TIMEOUT, reply).await { - Ok(Ok(reply)) => reply, - Ok(Err(_)) => return Err("request superseded".to_owned()), - Err(_) => { +async fn request_chunk<H: Host>( + host: &H, + peer: &str, + local: Epoch, + target: Epoch, + offset: u64, +) -> Result<SnapshotReply, String> { + for attempt in 1..=CHUNK_ATTEMPTS { + let reply = host.compaction().await_reply(peer, target, offset); + let request = ParsedMessage::SnapshotRequest(SnapshotRequest { + epoch: local, + wanted: target, + offset, + }); + if let Err(error) = host.send_message(peer, request).await { host.compaction().stop_awaiting(); - return Err(format!("no reply within {}s", FETCH_TIMEOUT.as_secs())); + return Err(format!("sending the request failed: {error:#}")); } - }; - if reply.epoch != target || reply.document.is_empty() { - return Err(format!( - "peer is on {} and did not send {target}", - reply.epoch - )); + match tokio::time::timeout(CHUNK_TIMEOUT, reply).await { + Ok(Ok(reply)) => return Ok(reply), + Ok(Err(_)) => return Err("request superseded".to_owned()), + Err(_) => { + host.compaction().stop_awaiting(); + debug!(peer = %peer, offset, attempt, "No reply for chunk; asking again"); + } + } } - Ok(reply.document) + Err(format!( + "no reply for the chunk at offset {offset} after {CHUNK_ATTEMPTS} attempts of {}s", + CHUNK_TIMEOUT.as_secs() + )) } /// Verifies the fetched document, carries over this node's own missing changes, and @@ -250,58 +324,125 @@ struct Prepared { 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. +/// Answers a peer's chunk request from a saved copy of this node's document, if this +/// node is on the wanted epoch and the peer is a member of our cluster. Refuses +/// explicitly otherwise. 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() + let reply = match served_copy(&host, &peer, current, wanted, request.offset).await { + Ok(served) => { + let start = (request.offset as usize).min(served.bytes.len()); + let end = (start + CHUNK_BYTES).min(served.bytes.len()); + if request.offset == 0 { + info!(peer = %peer, wanted = %wanted, document_bytes = served.bytes.len(), "Serving document"); } - }) + SnapshotReply { + epoch: served.epoch, + wanted, + digest: served.digest, + total: served.bytes.len() as u64, + offset: request.offset, + data: served.bytes[start..end].to_vec(), + refused: String::new(), + } + } + Err(reason) => { + info!(peer = %peer, wanted = %wanted, reason = %reason, "Refusing document request"); + SnapshotReply { + epoch: current, + wanted, + digest: [0; 32], + total: 0, + offset: request.offset, + data: Vec::new(), + refused: reason, + } + } + }; + + if let Err(error) = host + .send_message(&peer, ParsedMessage::SnapshotReply(reply)) .await - .unwrap_or_else(|error| { + { + warn!(peer = %peer, error = %format!("{error:#}"), "Sending document chunk failed"); + } +} + +/// The saved document to serve chunks from: reused while a download is in progress, +/// saved anew when a download starts and the copy is stale. +async fn served_copy<H: Host>( + host: &H, + peer: &str, + current: Epoch, + wanted: Epoch, + offset: u64, +) -> Result<Arc<Served>, String> { + if !host.is_cluster_member(peer).await { + return Err("not a member of this cluster (no completed handshake)".to_owned()); + } + if current != wanted { + return Err(format!("this node is on {current}")); + } + + if let Some(served) = host.compaction().served(wanted) { + if offset > 0 || served.saved_at.elapsed() < SERVED_FRESH_FOR { + return Ok(served); + } + } else if offset > 0 { + return Err("download expired; start again".to_owned()); + } + + if !host.compaction().try_start_serving() { + return Err("busy saving the document; try again".to_owned()); + } + + // 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; + let saved = tokio::task::spawn_blocking(move || { + if doc + .get_change_by_hash(&automerge::ChangeHash(wanted.id)) + .is_some() + { + Some(doc.save()) + } else { + None + } + }) + .await; + host.compaction().done_serving(); + + let bytes = match saved { + Ok(Some(bytes)) => bytes, + Ok(None) => return Err("this node switched epochs".to_owned()), + Err(error) => { error!(error = %error, "Saving the document for a peer failed"); - Vec::new() - }) - } else { - Vec::new() + return Err("saving the document failed".to_owned()); + } }; - 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, + let served = Arc::new(Served { + epoch: wanted, + digest: sha256(&bytes), + bytes, + saved_at: Instant::now(), }); - if let Err(error) = host.send_message(&peer, reply).await { - warn!(peer = %peer, error = %format!("{error:#}"), "Sending snapshot failed"); - } + host.compaction().set_served(Arc::clone(&served)); + Ok(served) +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut digest = [0; 32]; + digest.copy_from_slice(&Sha256::digest(bytes)); + digest } -/// Hands a peer's snapshot reply to the waiting fetch. +/// Hands a peer's chunk to the waiting download. pub(crate) fn receive_snapshot_reply<H: Host>(host: &H, peer: &str, reply: SnapshotReply) { + let offset = reply.offset; if !host.compaction().deliver_reply(peer, reply) { - debug!(peer = %peer, "Unexpected snapshot reply; dropped"); + debug!(peer = %peer, offset, "Unexpected document chunk; dropped"); } } |
