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}