1use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35use mongreldb_log::commit_log::LogPosition;
36use mongreldb_types::hlc::HlcTimestamp;
37use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId, RaftGroupId};
38
39use crate::storage_mode::StorageMode;
40use crate::{MongrelError, Result};
41
42pub const COMMAND_TYPE_CATALOG_COMMAND: u32 = 2;
47
48pub const COMMAND_TYPE_MAINTENANCE: u32 = 3;
52
53pub const REPLICATED_TXN_FORMAT_VERSION: u16 = 1;
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ReplicatedTxnPayload {
67 pub version: u16,
69 pub records: Vec<crate::wal::Record>,
71}
72
73impl ReplicatedTxnPayload {
74 pub fn new(records: Vec<crate::wal::Record>) -> Self {
76 Self {
77 version: REPLICATED_TXN_FORMAT_VERSION,
78 records,
79 }
80 }
81
82 pub fn encode(&self) -> Result<Vec<u8>> {
84 Ok(bincode::serialize(self)?)
85 }
86
87 pub fn decode(bytes: &[u8]) -> Result<Self> {
89 let payload: Self = bincode::deserialize(bytes)?;
90 if payload.version != REPLICATED_TXN_FORMAT_VERSION {
91 return Err(MongrelError::UnsupportedStorageVersion {
92 component: "replicated transaction payload",
93 found: payload.version,
94 supported: REPLICATED_TXN_FORMAT_VERSION,
95 });
96 }
97 Ok(payload)
98 }
99}
100
101pub const ENGINE_SNAPSHOT_FORMAT_VERSION: u16 = 1;
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct EngineSnapshotFile {
112 pub path: PathBuf,
114 pub sha256: [u8; 32],
116 pub data: Vec<u8>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct EngineSnapshotTable {
123 pub table_id: u64,
125 pub name: String,
127 pub visible_rows: u64,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct EngineSnapshot {
136 pub version: u16,
138 pub group_id: RaftGroupId,
140 pub last_included: LogPosition,
142 pub commit_ts: Option<HlcTimestamp>,
145 pub epoch: u64,
147 pub cluster_id: ClusterId,
149 pub database_id: DatabaseId,
151 pub catalog_version: u64,
153 pub tables: Vec<EngineSnapshotTable>,
155 pub files: Vec<EngineSnapshotFile>,
158 pub wal_format: u16,
160 pub storage_mode_format: u16,
162}
163
164impl EngineSnapshot {
165 pub fn capture(
170 db: &crate::Database,
171 group_id: RaftGroupId,
172 last_included: LogPosition,
173 commit_ts: Option<HlcTimestamp>,
174 ) -> Result<Self> {
175 let mode = db.storage_mode()?.ok_or_else(|| {
176 MongrelError::Other("engine snapshot capture before marker write".into())
177 })?;
178 let (cluster_id, _node_id, database_id) = mode.cluster_identity().ok_or_else(|| {
179 MongrelError::InvalidArgument(format!(
180 "engine snapshots capture cluster replicas, got mode {mode:?}"
181 ))
182 })?;
183 let epoch = db.visible_epoch();
184 let snapshot = crate::epoch::Snapshot::at(epoch);
185 let mut tables = Vec::new();
186 for name in db.table_names() {
187 let table_id = db.table_id(&name)?;
188 let handle = db.table(&name)?;
189 let visible_rows = handle.lock().visible_rows(snapshot)?.len() as u64;
190 tables.push(EngineSnapshotTable {
191 table_id,
192 name,
193 visible_rows,
194 });
195 }
196 tables.sort_by_key(|table| table.table_id);
197 let files = crate::replication::capture_files(db.root())?
198 .into_iter()
199 .map(|file| {
200 let sha256: [u8; 32] = Sha256::digest(&file.data).into();
201 Ok(EngineSnapshotFile {
202 path: file.path,
203 sha256,
204 data: file.data,
205 })
206 })
207 .collect::<Result<Vec<_>>>()?;
208 Ok(Self {
209 version: ENGINE_SNAPSHOT_FORMAT_VERSION,
210 group_id,
211 last_included,
212 commit_ts,
213 epoch: epoch.0,
214 cluster_id,
215 database_id,
216 catalog_version: db.catalog_version(),
217 tables,
218 files,
219 wal_format: crate::wal::WAL_VERSION,
220 storage_mode_format: crate::storage_mode::STORAGE_MODE_FORMAT_VERSION,
221 })
222 }
223
224 pub fn encode(&self) -> Result<Vec<u8>> {
226 Ok(bincode::serialize(self)?)
227 }
228
229 pub fn decode(bytes: &[u8]) -> Result<Self> {
231 let snapshot: Self = bincode::deserialize(bytes)?;
232 if snapshot.version != ENGINE_SNAPSHOT_FORMAT_VERSION {
233 return Err(MongrelError::UnsupportedStorageVersion {
234 component: "engine snapshot",
235 found: snapshot.version,
236 supported: ENGINE_SNAPSHOT_FORMAT_VERSION,
237 });
238 }
239 Ok(snapshot)
240 }
241
242 pub fn validate(
246 &self,
247 group_id: &RaftGroupId,
248 cluster_id: &ClusterId,
249 database_id: &DatabaseId,
250 ) -> Result<()> {
251 if &self.group_id != group_id {
252 return Err(MongrelError::InvalidArgument(format!(
253 "engine snapshot group {:?} does not match this replica's group {:?}",
254 self.group_id, group_id
255 )));
256 }
257 if &self.cluster_id != cluster_id || &self.database_id != database_id {
258 return Err(MongrelError::InvalidArgument(
259 "engine snapshot database identity does not match this replica".into(),
260 ));
261 }
262 if self.wal_format != crate::wal::WAL_VERSION {
263 return Err(MongrelError::UnsupportedStorageVersion {
264 component: "wal",
265 found: self.wal_format,
266 supported: crate::wal::WAL_VERSION,
267 });
268 }
269 if self.storage_mode_format != crate::storage_mode::STORAGE_MODE_FORMAT_VERSION {
270 return Err(MongrelError::UnsupportedStorageVersion {
271 component: "storage-mode marker",
272 found: self.storage_mode_format,
273 supported: crate::storage_mode::STORAGE_MODE_FORMAT_VERSION,
274 });
275 }
276 let mut seen = std::collections::HashSet::new();
277 for file in &self.files {
278 crate::replication::validate_relative_path(&file.path)?;
279 if !seen.insert(file.path.clone()) {
280 return Err(MongrelError::InvalidArgument(format!(
281 "duplicate engine snapshot path {:?}",
282 file.path
283 )));
284 }
285 let digest: [u8; 32] = Sha256::digest(&file.data).into();
286 if digest != file.sha256 {
287 return Err(MongrelError::Other(format!(
288 "engine snapshot file {:?} failed its content hash",
289 file.path
290 )));
291 }
292 }
293 Ok(())
294 }
295
296 pub fn stage_into(&self, staging: &Path, node_id: NodeId) -> Result<()> {
301 let marker_relative =
302 Path::new(crate::database::META_DIR).join(crate::storage_mode::STORAGE_MODE_FILENAME);
303 for file in &self.files {
304 crate::replication::validate_relative_path(&file.path)?;
305 if file.path == marker_relative {
306 continue;
308 }
309 let path = staging.join(&file.path);
310 let parent = path.parent().expect("validated file has parent");
311 crate::durable_file::create_directory_all(parent)?;
312 let mut output = std::fs::OpenOptions::new()
313 .create_new(true)
314 .write(true)
315 .open(&path)?;
316 std::io::Write::write_all(&mut output, &file.data)?;
317 output.sync_all()?;
318 crate::durable_file::sync_directory(parent)?;
319 }
320 for table in &self.tables {
323 crate::durable_file::create_directory_all(
324 &staging
325 .join(crate::database::TABLES_DIR)
326 .join(table.table_id.to_string())
327 .join("_runs"),
328 )?;
329 }
330 if !staging.join(crate::catalog::CATALOG_FILENAME).is_file() {
331 return Err(MongrelError::InvalidArgument(
332 "engine snapshot has no CATALOG".into(),
333 ));
334 }
335 let durable_stage = crate::durable_file::DurableRoot::open(staging)?;
336 crate::storage_mode::rewrite(
337 &durable_stage,
338 &StorageMode::ClusterReplica {
339 cluster_id: self.cluster_id,
340 node_id,
341 database_id: self.database_id,
342 },
343 )?;
344 crate::durable_file::sync_directory(staging)?;
345 Ok(())
346 }
347
348 pub fn validate_staged(&self, staging: &Path) -> Result<()> {
353 let db = crate::Database::open_offline_validation(staging)?;
354 let snapshot = crate::epoch::Snapshot::at(crate::epoch::Epoch(self.epoch));
355 for table in &self.tables {
356 let handle = db.table(&table.name).map_err(|error| {
357 MongrelError::Other(format!(
358 "staged engine snapshot is missing table {:?}: {error}",
359 table.name
360 ))
361 })?;
362 let rows = handle.lock().visible_rows(snapshot)?.len() as u64;
363 if rows != table.visible_rows {
364 return Err(MongrelError::Other(format!(
365 "staged engine snapshot table {:?} has {rows} visible rows at epoch {}, expected {}",
366 table.name, self.epoch, table.visible_rows
367 )));
368 }
369 }
370 Ok(())
371 }
372
373 pub fn install(self, live: &mut Option<Arc<crate::Database>>, node_id: NodeId) -> Result<()> {
384 let db = live.as_ref().ok_or_else(|| {
385 MongrelError::Other("engine snapshot install without a live database".into())
386 })?;
387 if Arc::strong_count(db) > 1 {
388 return Err(MongrelError::Conflict(
389 "engine snapshot install refused over live state: database is busy".into(),
390 ));
391 }
392 let destination = db.root().to_path_buf();
393 match db.storage_mode()? {
395 Some(StorageMode::ClusterReplica {
396 cluster_id,
397 database_id,
398 ..
399 }) if cluster_id == self.cluster_id && database_id == self.database_id => {}
400 other => {
401 return Err(MongrelError::InvalidArgument(format!(
402 "refusing to install an engine snapshot over storage mode {other:?}"
403 )));
404 }
405 }
406 let parent = destination
407 .parent()
408 .filter(|path| !path.as_os_str().is_empty())
409 .unwrap_or_else(|| Path::new("."))
410 .to_path_buf();
411 let name = destination
412 .file_name()
413 .and_then(|name| name.to_str())
414 .ok_or_else(|| MongrelError::InvalidArgument("invalid database root".into()))?;
415 let nonce = std::time::SystemTime::now()
416 .duration_since(std::time::UNIX_EPOCH)
417 .unwrap_or_default()
418 .as_nanos();
419 let stage = parent.join(format!(
420 ".{name}.engine-stage-{}-{nonce}",
421 std::process::id()
422 ));
423 let backup = parent.join(format!(".{name}.engine-old-{}-{nonce}", std::process::id()));
424 if stage.exists() || backup.exists() {
425 return Err(MongrelError::Conflict(
426 "engine snapshot staging path already exists".into(),
427 ));
428 }
429 let result = (|| -> Result<()> {
430 crate::durable_file::create_directory(&stage)?;
431 self.stage_into(&stage, node_id)?;
432 self.validate_staged(&stage)?;
433 let owned = live.take().expect("checked above");
436 owned.shutdown()?;
437 if let Err(failure) = crate::replication::rename_entry(&destination, &backup) {
440 return Err(failure.error.into());
441 }
442 if let Err(failure) = crate::replication::rename_entry(&stage, &destination) {
443 if let Err(rollback) = crate::replication::rename_entry(&backup, &destination) {
444 return Err(crate::replication::uncertain_install_error(
445 self.epoch,
446 &failure.error,
447 &rollback.error,
448 ));
449 }
450 return Err(failure.error.into());
451 }
452 crate::replication::remove_directory(&backup)?;
453 Ok(())
454 })();
455 if let Err(error) = result {
456 if stage.exists() {
457 let _ = crate::replication::remove_directory(&stage);
458 }
459 return Err(error);
460 }
461 let expected = StorageMode::ClusterReplica {
462 cluster_id: self.cluster_id,
463 node_id,
464 database_id: self.database_id,
465 };
466 let reopened = crate::Database::open_cluster_replica(&destination, &expected)?;
467 *live = Some(Arc::new(reopened));
468 Ok(())
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn txn_payload_round_trip_and_version_gate() {
478 let payload = ReplicatedTxnPayload::new(vec![crate::wal::Record::new(
479 crate::epoch::Epoch(1),
480 7,
481 crate::wal::Op::TxnCommit {
482 epoch: 1,
483 added_runs: Vec::new(),
484 },
485 )]);
486 let bytes = payload.encode().unwrap();
487 let decoded = ReplicatedTxnPayload::decode(&bytes).unwrap();
488 assert_eq!(decoded.records.len(), 1);
489
490 let mut corrupt = payload.clone();
491 corrupt.version = REPLICATED_TXN_FORMAT_VERSION + 1;
492 let bytes = bincode::serialize(&corrupt).unwrap();
493 assert!(matches!(
494 ReplicatedTxnPayload::decode(&bytes),
495 Err(MongrelError::UnsupportedStorageVersion { .. })
496 ));
497 }
498
499 #[test]
500 fn validate_rejects_bad_hash_and_foreign_identity() {
501 let snapshot = EngineSnapshot {
502 version: ENGINE_SNAPSHOT_FORMAT_VERSION,
503 group_id: RaftGroupId::from_bytes([1; 16]),
504 last_included: LogPosition { term: 1, index: 2 },
505 commit_ts: None,
506 epoch: 2,
507 cluster_id: ClusterId::from_bytes([2; 16]),
508 database_id: DatabaseId::from_bytes([3; 16]),
509 catalog_version: 0,
510 tables: Vec::new(),
511 files: vec![EngineSnapshotFile {
512 path: PathBuf::from("CATALOG"),
513 sha256: [9; 32],
514 data: b"catalog".to_vec(),
515 }],
516 wal_format: crate::wal::WAL_VERSION,
517 storage_mode_format: crate::storage_mode::STORAGE_MODE_FORMAT_VERSION,
518 };
519 let group = RaftGroupId::from_bytes([1; 16]);
520 let cluster = ClusterId::from_bytes([2; 16]);
521 let database = DatabaseId::from_bytes([3; 16]);
522 assert!(snapshot.validate(&group, &cluster, &database).is_err());
524 let wrong_group = RaftGroupId::from_bytes([9; 16]);
525 assert!(snapshot
526 .validate(&wrong_group, &cluster, &database)
527 .is_err());
528 let wrong_database = DatabaseId::from_bytes([9; 16]);
529 assert!(snapshot
530 .validate(&group, &cluster, &wrong_database)
531 .is_err());
532
533 let mut good = snapshot.clone();
534 good.files[0].sha256 = Sha256::digest(&good.files[0].data).into();
535 good.validate(&group, &cluster, &database).unwrap();
536 }
537}