Skip to main content

nodedb_cluster/install_snapshot/
finalize.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Final snapshot commit: CRC validation → atomic rename → Raft log boundary advance.
4//!
5//! Called only when the last chunk (`done == true`) has been written to the
6//! `.partial` file. Performs three operations in sequence:
7//!
8//! 1. **CRC validation** — re-reads the assembled file and recomputes the
9//!    CRC32C. If it disagrees with the running CRC accumulated during chunk
10//!    writes, the partial file is left in place and `SnapshotCrcMismatch` is
11//!    returned. The partial file is intentionally *not* deleted on CRC failure
12//!    so the operator can inspect it.
13//!
14//! 2. **Atomic rename** — the `.partial` file is renamed to `<group_id>.snap`.
15//!    The rename is atomic on POSIX filesystems (same directory, same inode
16//!    table). If the process crashes between steps 1 and 2, the partial file
17//!    survives; the GC sweeper will remove it after `orphan_partial_max_age_secs`.
18//!
19//! 3. **Raft log boundary advance** — calls
20//!    `MultiRaft::handle_install_snapshot` to advance the Raft log pointer to
21//!    `last_included_index` / `last_included_term`. This is the same call the
22//!    existing stub in `handle_rpc.rs` made; we now call it only here, after
23//!    CRC validation, to prevent advancing Raft state on corrupt data.
24
25use std::path::PathBuf;
26use std::sync::{Arc, Mutex};
27
28use nodedb_raft::{InstallSnapshotRequest, InstallSnapshotResponse};
29
30use crate::error::ClusterError;
31use crate::install_snapshot::state::PartialSnapshotState;
32use crate::multi_raft::MultiRaft;
33use crate::raft_loop::SnapshotApplier;
34
35/// Validate, rename, and advance Raft state after the last chunk.
36///
37/// Returns the `InstallSnapshotResponse` produced by
38/// `MultiRaft::handle_install_snapshot` so callers can propagate the
39/// Raft term back to the leader.
40pub async fn commit(
41    state: PartialSnapshotState,
42    multi_raft: &Arc<Mutex<MultiRaft>>,
43    snapshot_applier: Option<&Arc<dyn SnapshotApplier>>,
44) -> Result<InstallSnapshotResponse, ClusterError> {
45    let group_id = state.group_id;
46    let partial_path = state.partial_path.clone();
47    let expected_crc = state.running_crc;
48
49    // Flush and close the partial file before reading it back.
50    // `state.partial_file` may be `None` if the snapshot had zero bytes
51    // (bootstrap stub). In that case skip the I/O validation.
52    if let Some(file) = state.partial_file {
53        tokio::task::spawn_blocking(move || -> std::io::Result<()> { file.sync_all() })
54            .await
55            .map_err(|e| ClusterError::PartialSnapshotCorrupt {
56                group_id,
57                detail: format!("spawn_blocking join error on sync: {e}"),
58            })?
59            .map_err(|e| ClusterError::Storage {
60                detail: format!("sync partial file for group {group_id}: {e}"),
61            })?;
62    }
63
64    // CRC validation: re-read the file and compare against running CRC.
65    // If the file is empty (bootstrap stub), skip.
66    let file_bytes = tokio::task::spawn_blocking({
67        let path = partial_path.clone();
68        move || std::fs::read(&path)
69    })
70    .await
71    .map_err(|e| ClusterError::PartialSnapshotCorrupt {
72        group_id,
73        detail: format!("spawn_blocking join error on read: {e}"),
74    })?
75    .map_err(|e| ClusterError::Storage {
76        detail: format!("read partial file for group {group_id}: {e}"),
77    })?;
78
79    if !file_bytes.is_empty() {
80        let computed = crc32c::crc32c(&file_bytes);
81        if computed != expected_crc {
82            return Err(ClusterError::SnapshotCrcMismatch {
83                group_id,
84                stored: expected_crc,
85                computed,
86            });
87        }
88    }
89
90    // Atomic rename: .partial → .snap
91    let snap_path = snap_path_for(&partial_path);
92    tokio::task::spawn_blocking({
93        let from = partial_path.clone();
94        let to = snap_path.clone();
95        move || std::fs::rename(&from, &to)
96    })
97    .await
98    .map_err(|e| ClusterError::PartialSnapshotCorrupt {
99        group_id,
100        detail: format!("spawn_blocking join error on rename: {e}"),
101    })?
102    .map_err(|e| ClusterError::Storage {
103        detail: format!("rename partial to snap for group {group_id}: {e}"),
104    })?;
105
106    // Apply the snapshot to the local Data-Plane state machine BEFORE advancing
107    // Raft, so the data is visible on this node before the Raft log boundary
108    // moves. An apply failure is fatal — we return WITHOUT advancing Raft so the
109    // follower retries the install (no silent partial success). The empty
110    // bootstrap stub (no engine data) is skipped: there is nothing to apply, and
111    // group 0 (metadata) is a no-op the applier handles internally.
112    if !file_bytes.is_empty()
113        && let Some(applier) = snapshot_applier
114    {
115        applier
116            .apply_snapshot(group_id, &file_bytes)
117            .await
118            .map_err(|e| ClusterError::SnapshotApplyFailed {
119                group_id,
120                detail: e.to_string(),
121            })?;
122    }
123
124    // Advance Raft log boundary. Build a minimal InstallSnapshotRequest
125    // that satisfies `handle_install_snapshot` — `data` is the assembled
126    // bytes (may be empty for the bootstrap stub), `done` is always `true`.
127    let req = InstallSnapshotRequest {
128        term: state.term,
129        leader_id: state.leader_id,
130        last_included_index: state.last_included_index,
131        last_included_term: state.last_included_term,
132        offset: 0,
133        data: file_bytes,
134        done: true,
135        group_id,
136        total_size: 0,
137    };
138
139    let mut mr = multi_raft.lock().unwrap_or_else(|p| p.into_inner());
140    let resp = mr.handle_install_snapshot(&req)?;
141    // Persist any term bump (become_follower) durably before replying.
142    mr.persist_group_hard_state(group_id)?;
143    Ok(resp)
144}
145
146/// Derive the `.snap` path from the `.partial` path (same directory, stem only).
147fn snap_path_for(partial: &std::path::Path) -> PathBuf {
148    let parent = partial
149        .parent()
150        .unwrap_or_else(|| std::path::Path::new("."));
151    let stem = partial
152        .file_stem()
153        .unwrap_or_else(|| std::ffi::OsStr::new("unknown"));
154    parent.join(format!("{}.snap", stem.to_string_lossy()))
155}