1use crate::{MongrelError, Result};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::cell::Cell;
7use std::collections::HashSet;
8use std::io::{self, Read, Write};
9use std::path::{Component, Path, PathBuf};
10
11const FORMAT_VERSION: u16 = 2;
12const REPLICA_MARKER: &str = "replica";
13const REPLICA_EPOCH: &str = "repl_epoch";
14const REPLICATION_ID: &str = "replication_id";
15const REPLICATION_SOURCE_ID: &str = "replication_source_id";
16const REPLICATION_WAL_FLOOR: &str = "replication_wal_floor";
17const REPLICATION_PROOF_DOMAIN: &[u8] = b"mongreldb-replication-batch-v2\0";
18const REPLICATION_ID_LEN: usize = 32;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub(crate) struct ReplicationFile {
22 path: PathBuf,
23 data: Vec<u8>,
24}
25
26impl ReplicationFile {
27 pub(crate) fn new(path: PathBuf, data: Vec<u8>) -> Self {
28 Self { path, data }
29 }
30}
31
32pub(crate) fn capture_files(root: &Path) -> Result<Vec<ReplicationFile>> {
33 let mut files = Vec::new();
34 crate::durable_file::walk_regular_files_nofollow(
35 root,
36 |relative, _| {
37 Ok(!(relative == Path::new("_meta/.lock")
38 || relative == Path::new("_meta/replica")
39 || relative == Path::new("_meta/repl_epoch")
40 || relative == Path::new("_meta/replication_id")
41 || relative == Path::new("_meta/replication_source_id")
42 || relative == Path::new("_meta/replication_wal_floor")
43 || relative.components().any(
44 |component| matches!(component, Component::Normal(name) if name == "_cache"),
45 )))
46 },
47 |_| Ok(()),
48 |relative, file| {
49 let mut data = Vec::new();
50 file.read_to_end(&mut data)?;
51 files.push(ReplicationFile::new(relative.to_path_buf(), data));
52 Ok(())
53 },
54 )?;
55 files.sort_by(|a, b| a.path.cmp(&b.path));
56 Ok(files)
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ReplicationSnapshot {
64 version: u16,
65 source_id: [u8; REPLICATION_ID_LEN],
66 epoch: u64,
67 files: Vec<ReplicationFile>,
68}
69
70#[derive(Debug, Clone)]
72pub struct ReplicationBatch {
73 pub source_id: [u8; REPLICATION_ID_LEN],
74 source_bound: bool,
75 pub from_epoch: u64,
76 pub current_epoch: u64,
77 pub earliest_epoch: Option<u64>,
78 pub requires_snapshot: bool,
79 pub retention_gap: bool,
80 pub contains_spilled_commits: bool,
81 pub commit_count: u64,
82 pub records_sha256: [u8; 32],
83 pub records: Vec<crate::wal::Record>,
84}
85
86impl ReplicationBatch {
87 pub(crate) fn is_source_bound(&self) -> bool {
88 self.source_bound
89 }
90
91 pub(crate) fn complete(
92 from_epoch: u64,
93 current_epoch: u64,
94 earliest_epoch: Option<u64>,
95 retention_gap: bool,
96 contains_spilled_commits: bool,
97 records: Vec<crate::wal::Record>,
98 ) -> Result<Self> {
99 Self::complete_inner(
100 None,
101 from_epoch,
102 current_epoch,
103 earliest_epoch,
104 retention_gap,
105 contains_spilled_commits,
106 records,
107 )
108 }
109
110 pub(crate) fn complete_for_source(
111 source_id: [u8; REPLICATION_ID_LEN],
112 from_epoch: u64,
113 current_epoch: u64,
114 earliest_epoch: Option<u64>,
115 retention_gap: bool,
116 contains_spilled_commits: bool,
117 records: Vec<crate::wal::Record>,
118 ) -> Result<Self> {
119 Self::complete_inner(
120 Some(source_id),
121 from_epoch,
122 current_epoch,
123 earliest_epoch,
124 retention_gap,
125 contains_spilled_commits,
126 records,
127 )
128 }
129
130 fn complete_inner(
131 source_id: Option<[u8; REPLICATION_ID_LEN]>,
132 from_epoch: u64,
133 current_epoch: u64,
134 earliest_epoch: Option<u64>,
135 retention_gap: bool,
136 contains_spilled_commits: bool,
137 records: Vec<crate::wal::Record>,
138 ) -> Result<Self> {
139 let commit_count = records
140 .iter()
141 .filter(|record| matches!(record.op, crate::wal::Op::TxnCommit { .. }))
142 .count() as u64;
143 let source_bound = source_id.is_some();
144 let source_id = source_id.unwrap_or([0; REPLICATION_ID_LEN]);
145 if source_bound {
146 validate_source_id(source_id)?;
147 }
148 let records_sha256 =
149 replication_records_sha256(source_id, from_epoch, current_epoch, &records)?;
150 Ok(Self {
151 source_id,
152 source_bound,
153 from_epoch,
154 current_epoch,
155 earliest_epoch,
156 requires_snapshot: retention_gap || contains_spilled_commits,
157 retention_gap,
158 contains_spilled_commits,
159 commit_count,
160 records_sha256,
161 records,
162 })
163 }
164
165 pub fn from_wire(
166 source_id: [u8; REPLICATION_ID_LEN],
167 from_epoch: u64,
168 current_epoch: u64,
169 earliest_epoch: Option<u64>,
170 commit_count: u64,
171 records_sha256: [u8; 32],
172 records: Vec<crate::wal::Record>,
173 ) -> Self {
174 let contains_spilled_commits = records.iter().any(|record| {
175 matches!(
176 &record.op,
177 crate::wal::Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
178 )
179 });
180 Self {
181 source_id,
182 source_bound: true,
183 from_epoch,
184 current_epoch,
185 earliest_epoch,
186 requires_snapshot: contains_spilled_commits,
187 retention_gap: false,
188 contains_spilled_commits,
189 commit_count,
190 records_sha256,
191 records,
192 }
193 }
194
195 pub(crate) fn validate_proof(&self) -> Result<()> {
196 if self.source_bound {
197 validate_source_id(self.source_id)?;
198 }
199 let actual_count = self
200 .records
201 .iter()
202 .filter(|record| matches!(record.op, crate::wal::Op::TxnCommit { .. }))
203 .count() as u64;
204 if actual_count != self.commit_count {
205 return Err(MongrelError::InvalidArgument(format!(
206 "replication commit count mismatch: expected {}, received {actual_count}",
207 self.commit_count
208 )));
209 }
210 let digest = replication_records_sha256(
211 self.source_id,
212 self.from_epoch,
213 self.current_epoch,
214 &self.records,
215 )?;
216 if digest != self.records_sha256 {
217 return Err(MongrelError::InvalidArgument(
218 "replication batch digest mismatch".into(),
219 ));
220 }
221 Ok(())
222 }
223}
224
225fn replication_records_sha256(
226 source_id: [u8; REPLICATION_ID_LEN],
227 from_epoch: u64,
228 current_epoch: u64,
229 records: &[crate::wal::Record],
230) -> Result<[u8; 32]> {
231 let mut digest = Sha256::new();
232 digest.update(REPLICATION_PROOF_DOMAIN);
233 digest.update(source_id);
234 digest.update(from_epoch.to_le_bytes());
235 digest.update(current_epoch.to_le_bytes());
236 digest.update((records.len() as u64).to_le_bytes());
237 for record in records {
238 let encoded = bincode::serialize(record)?;
239 digest.update((encoded.len() as u64).to_le_bytes());
240 digest.update(encoded);
241 }
242 Ok(digest.finalize().into())
243}
244
245impl ReplicationSnapshot {
246 pub(crate) fn new(
247 source_id: [u8; REPLICATION_ID_LEN],
248 epoch: u64,
249 files: Vec<ReplicationFile>,
250 ) -> Self {
251 Self {
252 version: FORMAT_VERSION,
253 source_id,
254 epoch,
255 files,
256 }
257 }
258
259 pub fn source_id(&self) -> [u8; REPLICATION_ID_LEN] {
260 self.source_id
261 }
262
263 pub fn epoch(&self) -> u64 {
264 self.epoch
265 }
266
267 pub fn encode(&self) -> Result<Vec<u8>> {
268 Ok(bincode::serialize(self)?)
269 }
270
271 pub fn decode(bytes: &[u8]) -> Result<Self> {
272 let snapshot: Self = bincode::deserialize(bytes)?;
273 if snapshot.version != FORMAT_VERSION {
274 return Err(MongrelError::InvalidArgument(format!(
275 "unsupported replication snapshot version {}",
276 snapshot.version
277 )));
278 }
279 validate_source_id(snapshot.source_id)?;
280 Ok(snapshot)
281 }
282
283 pub fn install(&self, destination: impl AsRef<Path>) -> Result<()> {
287 self.install_validated(destination, |stage| {
288 let database = crate::Database::open(stage)?;
289 drop(database);
290 Ok(())
291 })
292 }
293
294 pub fn install_validated<F>(
299 &self,
300 destination: impl AsRef<Path>,
301 validate_stage: F,
302 ) -> Result<()>
303 where
304 F: FnOnce(&Path) -> Result<()>,
305 {
306 validate_source_id(self.source_id)?;
307 let destination = destination.as_ref();
308 let parent = destination
309 .parent()
310 .filter(|path| !path.as_os_str().is_empty())
311 .unwrap_or_else(|| Path::new("."));
312 crate::durable_file::create_directory_all(parent)?;
313 if destination.exists() {
314 let metadata = std::fs::symlink_metadata(destination)?;
315 if metadata.file_type().is_symlink() || !metadata.is_dir() {
316 return Err(MongrelError::InvalidArgument(format!(
317 "replica destination is not a directory: {}",
318 destination.display()
319 )));
320 }
321 if !is_replica(destination) {
322 return Err(MongrelError::Conflict(format!(
323 "refusing to replace non-replica destination {}",
324 destination.display()
325 )));
326 }
327 match replica_source_id(destination) {
328 Ok(current_source) if current_source != self.source_id => {
329 return Err(MongrelError::Conflict(
330 "replication snapshot source does not match destination binding".into(),
331 ))
332 }
333 Ok(_) | Err(MongrelError::NotFound(_)) => {
334 }
338 Err(error) => return Err(error),
339 }
340 let current_epoch = replica_epoch(destination)?;
341 if self.epoch < current_epoch {
342 return Err(MongrelError::Conflict(format!(
343 "replication snapshot epoch {} precedes destination epoch {current_epoch}",
344 self.epoch
345 )));
346 }
347 }
348 let name = destination
349 .file_name()
350 .and_then(|name| name.to_str())
351 .ok_or_else(|| MongrelError::InvalidArgument("invalid replica destination".into()))?;
352 let nonce = std::time::SystemTime::now()
353 .duration_since(std::time::UNIX_EPOCH)
354 .unwrap_or_default()
355 .as_nanos();
356 let stage = parent.join(format!(
357 ".{name}.replica-stage-{}-{nonce}",
358 std::process::id()
359 ));
360 let validation = parent.join(format!(
361 ".{name}.replica-validate-{}-{nonce}",
362 std::process::id()
363 ));
364 let backup = parent.join(format!(
365 ".{name}.replica-old-{}-{nonce}",
366 std::process::id()
367 ));
368
369 if stage.exists() || validation.exists() || backup.exists() {
370 return Err(MongrelError::Conflict(
371 "replication staging path already exists".into(),
372 ));
373 }
374 crate::durable_file::create_directory(&stage)?;
375 if let Err(error) = self.write_into(&stage) {
376 if let Err(cleanup) = remove_directory(&stage) {
377 return Err(MongrelError::Other(format!(
378 "{error}; failed to remove replication staging directory: {cleanup}"
379 )));
380 }
381 return Err(error);
382 }
383 let validation_write = (|| -> Result<()> {
387 crate::durable_file::create_directory(&validation)?;
388 self.write_into(&validation)
389 })();
390 if let Err(error) = validation_write {
391 let validation_cleanup = validation
392 .exists()
393 .then(|| remove_directory(&validation))
394 .transpose();
395 let stage_cleanup = remove_directory(&stage);
396 if let Err(cleanup) = validation_cleanup.and(stage_cleanup) {
397 return Err(MongrelError::Other(format!(
398 "{error}; failed to remove replication validation trees: {cleanup}"
399 )));
400 }
401 return Err(error);
402 }
403 if let Err(error) = validate_stage(&validation) {
404 let validation_cleanup = remove_directory(&validation);
405 let stage_cleanup = remove_directory(&stage);
406 if let Err(cleanup) = validation_cleanup.and(stage_cleanup) {
407 return Err(MongrelError::Other(format!(
408 "{error}; failed to remove invalid replication staging directories: {cleanup}"
409 )));
410 }
411 return Err(error);
412 }
413 if let Err(error) = remove_directory(&validation) {
414 if let Err(cleanup) = remove_directory(&stage) {
415 return Err(MongrelError::Other(format!(
416 "failed to remove replication validation directory: {error}; failed to remove staging directory: {cleanup}"
417 )));
418 }
419 return Err(error.into());
420 }
421
422 let had_destination = destination.exists();
423 if had_destination {
424 if let Err(failure) = rename_entry(destination, &backup) {
425 if failure.published {
426 if let Err(rollback) = rename_entry(&backup, destination) {
427 return Err(uncertain_install_error(
428 self.epoch,
429 &failure.error,
430 &rollback.error,
431 ));
432 }
433 }
434 if let Err(cleanup) = remove_directory(&stage) {
435 return Err(MongrelError::Other(format!(
436 "{}; previous replica was restored, but staging cleanup failed: {cleanup}",
437 failure.error
438 )));
439 }
440 return Err(failure.error.into());
441 }
442 }
443
444 if let Err(failure) = rename_entry(&stage, destination) {
445 if failure.published {
446 match rename_entry(destination, &stage) {
447 Ok(()) => {}
448 Err(rollback) if rollback.published => {
449 }
452 Err(rollback) => {
453 return Err(uncertain_install_error(
454 self.epoch,
455 &failure.error,
456 &rollback.error,
457 ));
458 }
459 }
460 }
461 if had_destination {
462 if let Err(rollback) = rename_entry(&backup, destination) {
463 return Err(uncertain_install_error(
464 self.epoch,
465 &failure.error,
466 &rollback.error,
467 ));
468 }
469 }
470 if stage.exists() {
471 if let Err(cleanup) = remove_directory(&stage) {
472 return Err(MongrelError::Other(format!(
473 "{}; previous destination was restored, but staging cleanup failed: {cleanup}",
474 failure.error
475 )));
476 }
477 }
478 return Err(failure.error.into());
479 }
480
481 if had_destination {
482 if let Err(error) = remove_directory(&backup) {
483 return Err(MongrelError::Other(format!(
484 "replication snapshot at epoch {} is installed, but old snapshot cleanup failed: {error}",
485 self.epoch
486 )));
487 }
488 }
489 Ok(())
490 }
491
492 fn write_into(&self, root: &Path) -> Result<()> {
493 let mut seen = HashSet::new();
494 let mut table_directories = HashSet::new();
495 for file in &self.files {
496 validate_relative_path(&file.path)?;
497 if !seen.insert(file.path.clone()) {
498 return Err(MongrelError::InvalidArgument(format!(
499 "duplicate replication snapshot path {:?}",
500 file.path
501 )));
502 }
503 if file.path == Path::new("_meta/replica")
504 || file.path == Path::new("_meta/repl_epoch")
505 || file.path == Path::new("_meta/replication_id")
506 || file.path == Path::new("_meta/replication_source_id")
507 || file.path == Path::new("_meta/replication_wal_floor")
508 {
509 return Err(MongrelError::InvalidArgument(format!(
510 "reserved replication snapshot path {:?}",
511 file.path
512 )));
513 }
514 if let Ok(relative) = file.path.strip_prefix("tables") {
515 if let Some(Component::Normal(table_id)) = relative.components().next() {
516 table_directories.insert(Path::new("tables").join(table_id));
517 }
518 }
519 }
520 for file in &self.files {
521 let path = root.join(&file.path);
522 let parent = path.parent().expect("validated file has parent");
523 crate::durable_file::create_directory_all(parent)?;
524 let mut output = std::fs::OpenOptions::new()
525 .create_new(true)
526 .write(true)
527 .open(&path)?;
528 output.write_all(&file.data)?;
529 output.sync_all()?;
530 crate::durable_file::sync_directory(parent)?;
531 }
532 for table in table_directories {
535 crate::durable_file::create_directory_all(&root.join(table).join("_runs"))?;
536 }
537 if !root.join(crate::catalog::CATALOG_FILENAME).is_file() {
538 return Err(MongrelError::InvalidArgument(
539 "replication snapshot has no CATALOG".into(),
540 ));
541 }
542 let meta = root.join("_meta");
543 crate::durable_file::create_directory_all(&meta)?;
544 write_new_synced(&meta.join(REPLICA_MARKER), b"read-only replica\n")?;
545 write_new_synced(&meta.join(REPLICATION_ID), &self.source_id)?;
546 write_new_synced(&meta.join(REPLICATION_SOURCE_ID), &self.source_id)?;
547 write_replica_epoch(root, self.epoch)?;
548 crate::durable_file::sync_directory(&meta)?;
549 crate::durable_file::sync_directory(root)?;
550 Ok(())
551 }
552}
553
554pub fn is_replica(root: impl AsRef<Path>) -> bool {
555 root.as_ref().join("_meta").join(REPLICA_MARKER).is_file()
556}
557
558fn validate_source_id(source_id: [u8; REPLICATION_ID_LEN]) -> Result<()> {
559 if source_id.iter().all(|byte| *byte == 0) {
560 return Err(MongrelError::InvalidArgument(
561 "replication source identity must not be zero".into(),
562 ));
563 }
564 Ok(())
565}
566
567fn read_source_id_file(mut file: std::fs::File, label: &str) -> Result<[u8; REPLICATION_ID_LEN]> {
568 let length = file.metadata()?.len();
569 if length != REPLICATION_ID_LEN as u64 {
570 return Err(MongrelError::InvalidArgument(format!(
571 "invalid {label} length {length}; expected {REPLICATION_ID_LEN}"
572 )));
573 }
574 let mut source_id = [0_u8; REPLICATION_ID_LEN];
575 file.read_exact(&mut source_id)?;
576 validate_source_id(source_id)?;
577 Ok(source_id)
578}
579
580pub fn replica_source_id(root: impl AsRef<Path>) -> Result<[u8; REPLICATION_ID_LEN]> {
581 let path = root.as_ref().join("_meta").join(REPLICATION_SOURCE_ID);
582 let file = match crate::durable_file::open_regular_nofollow(&path) {
583 Ok(file) => file,
584 Err(MongrelError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
585 return Err(MongrelError::NotFound(format!(
586 "{}: {error}",
587 path.display()
588 )))
589 }
590 Err(error) => return Err(error),
591 };
592 read_source_id_file(file, "replication source identity")
593}
594
595pub(crate) fn replication_identity_durable(
596 root: &crate::durable_file::DurableRoot,
597) -> Result<[u8; REPLICATION_ID_LEN]> {
598 let relative = Path::new("_meta").join(REPLICATION_ID);
599 match root.open_regular(&relative) {
600 Ok(file) => return read_source_id_file(file, "database replication identity"),
601 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
602 Err(error) => return Err(error.into()),
603 }
604
605 root.create_directory_all("_meta")?;
606 let mut source_id = [0_u8; REPLICATION_ID_LEN];
607 loop {
608 getrandom::getrandom(&mut source_id)
609 .map_err(|error| MongrelError::EntropyUnavailable(error.to_string()))?;
610 if source_id.iter().any(|byte| *byte != 0) {
611 break;
612 }
613 }
614 match root.write_new(&relative, &source_id) {
615 Ok(()) => Ok(source_id),
616 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => read_source_id_file(
617 root.open_regular(&relative)?,
618 "database replication identity",
619 ),
620 Err(error) => Err(error.into()),
621 }
622}
623
624pub(crate) fn replica_source_id_durable(
625 root: &crate::durable_file::DurableRoot,
626) -> Result<[u8; REPLICATION_ID_LEN]> {
627 read_source_id_file(
628 root.open_regular(Path::new("_meta").join(REPLICATION_SOURCE_ID))?,
629 "replication source identity",
630 )
631}
632
633pub fn replica_epoch(root: impl AsRef<Path>) -> Result<u64> {
634 let path = root.as_ref().join("_meta").join(REPLICA_EPOCH);
635 let value = std::fs::read_to_string(&path)
636 .map_err(|error| MongrelError::NotFound(format!("{}: {error}", path.display())))?;
637 value.trim().parse().map_err(|error| {
638 MongrelError::InvalidArgument(format!(
639 "invalid replica epoch in {}: {error}",
640 path.display()
641 ))
642 })
643}
644
645pub fn write_replica_epoch(root: impl AsRef<Path>, epoch: u64) -> Result<()> {
646 match replica_epoch(root.as_ref()) {
647 Ok(current) if epoch < current => {
648 return Err(MongrelError::Conflict(format!(
649 "replica epoch cannot move backward from {current} to {epoch}"
650 )))
651 }
652 Ok(current) if epoch == current => return Ok(()),
653 Ok(_) | Err(MongrelError::NotFound(_)) => {}
654 Err(error) => return Err(error),
655 }
656 write_meta_u64(root.as_ref(), REPLICA_EPOCH, epoch, "replica epoch")
657}
658
659pub(crate) fn reconcile_replica_epoch_durable(
660 root: &crate::durable_file::DurableRoot,
661 recovered_epoch: u64,
662) -> Result<()> {
663 let relative = Path::new("_meta").join(REPLICA_EPOCH);
664 let current = if root.entry_exists(&relative)? {
665 let mut value = String::new();
666 root.open_regular(&relative)?.read_to_string(&mut value)?;
667 Some(value.trim().parse::<u64>().map_err(|error| {
668 MongrelError::InvalidArgument(format!("invalid replica epoch: {error}"))
669 })?)
670 } else {
671 None
672 };
673 match current {
674 Some(current) if recovered_epoch < current => Err(MongrelError::Conflict(format!(
675 "recovered replica epoch {recovered_epoch} precedes durable watermark {current}"
676 ))),
677 Some(current) if recovered_epoch == current => Ok(()),
678 _ => {
679 root.create_directory_all("_meta")?;
680 root.write_atomic(&relative, recovered_epoch.to_string().as_bytes())?;
681 Ok(())
682 }
683 }
684}
685
686pub(crate) fn replication_wal_floor(root: impl AsRef<Path>) -> Result<u64> {
687 let path = root.as_ref().join("_meta").join(REPLICATION_WAL_FLOOR);
688 match std::fs::read_to_string(&path) {
689 Ok(value) => value.trim().parse().map_err(|error| {
690 MongrelError::InvalidArgument(format!(
691 "invalid replication WAL floor in {}: {error}",
692 path.display()
693 ))
694 }),
695 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(0),
696 Err(error) => Err(error.into()),
697 }
698}
699
700pub(crate) fn replication_wal_floor_durable(
701 root: &crate::durable_file::DurableRoot,
702) -> Result<u64> {
703 let relative = Path::new("_meta").join(REPLICATION_WAL_FLOOR);
704 if !root.entry_exists(&relative)? {
705 return Ok(0);
706 }
707 let mut value = String::new();
708 root.open_regular(&relative)?.read_to_string(&mut value)?;
709 value.trim().parse().map_err(|error| {
710 MongrelError::InvalidArgument(format!("invalid replication WAL floor: {error}"))
711 })
712}
713
714pub(crate) fn advance_replication_wal_floor_durable(
715 root: &crate::durable_file::DurableRoot,
716 epoch: u64,
717) -> Result<()> {
718 if epoch <= replication_wal_floor_durable(root)? {
719 return Ok(());
720 }
721 root.create_directory_all("_meta")?;
722 root.write_atomic(
723 Path::new("_meta").join(REPLICATION_WAL_FLOOR),
724 epoch.to_string().as_bytes(),
725 )?;
726 Ok(())
727}
728
729fn write_meta_u64(root: &Path, name: &str, value: u64, label: &str) -> Result<()> {
730 let meta = root.join("_meta");
731 crate::durable_file::create_directory_all(&meta)?;
732 let path = meta.join(name);
733 let temp = meta.join(format!(
734 ".{name}.tmp-{}-{}",
735 std::process::id(),
736 std::time::SystemTime::now()
737 .duration_since(std::time::UNIX_EPOCH)
738 .unwrap_or_default()
739 .as_nanos()
740 ));
741 if let Err(error) = write_new_synced(&temp, value.to_string().as_bytes()) {
742 if temp.exists() {
743 if let Err(cleanup) = remove_file(&temp) {
744 return Err(MongrelError::Other(format!(
745 "{error}; failed to remove replica epoch temporary file: {cleanup}"
746 )));
747 }
748 }
749 return Err(error);
750 }
751 let published = Cell::new(false);
752 if let Err(error) =
753 crate::durable_file::replace_with_after(&temp, &path, || published.set(true))
754 {
755 if published.get() {
756 return match crate::durable_file::sync_directory(&meta) {
757 Ok(()) => Ok(()),
758 Err(retry) => Err(MongrelError::Other(format!(
759 "{label} {value} was atomically replaced, but its durability is unknown: {error}; directory sync retry failed: {retry}"
760 ))),
761 };
762 }
763 if let Err(cleanup) = remove_file(&temp) {
764 return Err(MongrelError::Other(format!(
765 "{error}; failed to remove replica epoch temporary file: {cleanup}"
766 )));
767 }
768 return Err(error.into());
769 }
770 Ok(())
771}
772
773fn validate_relative_path(path: &Path) -> Result<()> {
774 if path.as_os_str().is_empty()
775 || path
776 .components()
777 .any(|component| !matches!(component, Component::Normal(_)))
778 {
779 return Err(MongrelError::InvalidArgument(format!(
780 "unsafe replication snapshot path {:?}",
781 path
782 )));
783 }
784 Ok(())
785}
786
787fn write_new_synced(path: &Path, bytes: &[u8]) -> Result<()> {
788 let mut file = std::fs::OpenOptions::new()
789 .create_new(true)
790 .write(true)
791 .open(path)?;
792 file.write_all(bytes)?;
793 file.sync_all()?;
794 Ok(())
795}
796
797fn remove_directory(path: &Path) -> io::Result<()> {
798 let parent = path.parent().unwrap_or_else(|| Path::new("."));
799 std::fs::remove_dir_all(path)?;
800 crate::durable_file::sync_directory(parent)
801}
802
803fn remove_file(path: &Path) -> io::Result<()> {
804 let parent = path.parent().unwrap_or_else(|| Path::new("."));
805 std::fs::remove_file(path)?;
806 crate::durable_file::sync_directory(parent)
807}
808
809#[derive(Debug)]
810struct RenameFailure {
811 error: io::Error,
812 published: bool,
813}
814
815fn rename_entry(source: &Path, destination: &Path) -> std::result::Result<(), RenameFailure> {
816 let published = Cell::new(false);
817 match crate::durable_file::rename_with_after(source, destination, || published.set(true)) {
818 Ok(()) => Ok(()),
819 Err(error) if published.get() => match sync_rename_directories(source, destination) {
820 Ok(()) => Ok(()),
821 Err(retry) => Err(RenameFailure {
822 error: io::Error::new(
823 retry.kind(),
824 format!("{error}; directory sync retry failed: {retry}"),
825 ),
826 published: true,
827 }),
828 },
829 Err(error) => Err(RenameFailure {
830 error,
831 published: false,
832 }),
833 }
834}
835
836fn sync_rename_directories(source: &Path, destination: &Path) -> io::Result<()> {
837 let destination_parent = destination.parent().unwrap_or_else(|| Path::new("."));
838 crate::durable_file::sync_directory(destination_parent)?;
839 let source_parent = source.parent().unwrap_or_else(|| Path::new("."));
840 if source_parent != destination_parent {
841 crate::durable_file::sync_directory(source_parent)?;
842 }
843 Ok(())
844}
845
846fn uncertain_install_error(epoch: u64, error: &io::Error, rollback: &io::Error) -> MongrelError {
847 MongrelError::Other(format!(
848 "replication snapshot install outcome at epoch {epoch} is unknown: {error}; rollback failed: {rollback}"
849 ))
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 const TEST_SOURCE_ID: [u8; REPLICATION_ID_LEN] = [7; REPLICATION_ID_LEN];
857
858 fn mark_replica(path: &Path, epoch: u64, include_source: bool) {
859 let meta = path.join("_meta");
860 std::fs::create_dir_all(&meta).unwrap();
861 std::fs::write(meta.join(REPLICA_MARKER), b"read-only replica\n").unwrap();
862 std::fs::write(meta.join(REPLICA_EPOCH), epoch.to_string()).unwrap();
863 if include_source {
864 std::fs::write(meta.join(REPLICATION_SOURCE_ID), TEST_SOURCE_ID).unwrap();
865 }
866 }
867
868 fn snapshot(epoch: u64, files: Vec<(&str, &[u8])>) -> ReplicationSnapshot {
869 ReplicationSnapshot {
870 version: FORMAT_VERSION,
871 source_id: TEST_SOURCE_ID,
872 epoch,
873 files: files
874 .into_iter()
875 .map(|(path, data)| ReplicationFile::new(path.into(), data.to_vec()))
876 .collect(),
877 }
878 }
879
880 #[test]
881 fn snapshot_install_rejects_path_traversal() {
882 let dir = tempfile::tempdir().unwrap();
883 let snapshot = ReplicationSnapshot {
884 version: FORMAT_VERSION,
885 source_id: TEST_SOURCE_ID,
886 epoch: 1,
887 files: vec![ReplicationFile::new("../escape".into(), vec![1])],
888 };
889 assert!(snapshot
890 .install_validated(dir.path().join("replica"), |_| Ok(()))
891 .is_err());
892 assert!(!dir.path().join("escape").exists());
893 }
894
895 #[test]
896 fn snapshot_install_durably_replaces_existing_destination() {
897 let dir = tempfile::tempdir().unwrap();
898 let destination = dir.path().join("replica");
899 std::fs::create_dir(&destination).unwrap();
900 std::fs::write(destination.join("old"), b"old").unwrap();
901 mark_replica(&destination, 41, true);
902
903 snapshot(
904 42,
905 vec![
906 (crate::catalog::CATALOG_FILENAME, b"new"),
907 ("tables/1/data", b"nested"),
908 ],
909 )
910 .install_validated(&destination, |_| Ok(()))
911 .unwrap();
912
913 assert_eq!(
914 std::fs::read(destination.join(crate::catalog::CATALOG_FILENAME)).unwrap(),
915 b"new"
916 );
917 assert_eq!(
918 std::fs::read(destination.join("tables/1/data")).unwrap(),
919 b"nested"
920 );
921 assert!(!destination.join("old").exists());
922 assert_eq!(replica_epoch(&destination).unwrap(), 42);
923 assert!(is_replica(&destination));
924 let names = std::fs::read_dir(dir.path())
925 .unwrap()
926 .map(|entry| entry.unwrap().file_name())
927 .collect::<Vec<_>>();
928 assert_eq!(names, vec![destination.file_name().unwrap()]);
929 }
930
931 #[test]
932 fn invalid_snapshot_leaves_existing_destination_untouched() {
933 let dir = tempfile::tempdir().unwrap();
934 let destination = dir.path().join("replica");
935 std::fs::create_dir(&destination).unwrap();
936 std::fs::write(destination.join("old"), b"old").unwrap();
937 mark_replica(&destination, 41, true);
938 let invalid = snapshot(
939 42,
940 vec![
941 (crate::catalog::CATALOG_FILENAME, b"new"),
942 ("_meta/replica", b"forged"),
943 ],
944 );
945
946 assert!(invalid.install_validated(&destination, |_| Ok(())).is_err());
947 assert_eq!(std::fs::read(destination.join("old")).unwrap(), b"old");
948 assert!(!destination.join(crate::catalog::CATALOG_FILENAME).exists());
949 let names = std::fs::read_dir(dir.path())
950 .unwrap()
951 .map(|entry| entry.unwrap().file_name())
952 .collect::<Vec<_>>();
953 assert_eq!(names, vec![destination.file_name().unwrap()]);
954 }
955
956 #[test]
957 fn replica_epoch_replace_leaves_no_temporary_file() {
958 let dir = tempfile::tempdir().unwrap();
959 write_replica_epoch(dir.path(), 1).unwrap();
960 write_replica_epoch(dir.path(), 2).unwrap();
961
962 assert_eq!(replica_epoch(dir.path()).unwrap(), 2);
963 let meta = dir.path().join("_meta");
964 assert_eq!(
965 std::fs::read_dir(meta)
966 .unwrap()
967 .map(|entry| entry.unwrap().file_name())
968 .collect::<Vec<_>>(),
969 vec![std::ffi::OsString::from(REPLICA_EPOCH)]
970 );
971 }
972
973 #[test]
974 fn snapshot_rejects_stale_or_foreign_replacement() {
975 let dir = tempfile::tempdir().unwrap();
976 let destination = dir.path().join("replica");
977 std::fs::create_dir(&destination).unwrap();
978 std::fs::write(destination.join("old"), b"old").unwrap();
979 mark_replica(&destination, 50, true);
980
981 let stale = snapshot(49, vec![(crate::catalog::CATALOG_FILENAME, b"new")]);
982 assert!(stale
983 .install_validated(&destination, |_| Ok(()))
984 .unwrap_err()
985 .to_string()
986 .contains("precedes destination epoch"));
987
988 let mut foreign = snapshot(51, vec![(crate::catalog::CATALOG_FILENAME, b"new")]);
989 foreign.source_id = [8; REPLICATION_ID_LEN];
990 assert!(foreign
991 .install_validated(&destination, |_| Ok(()))
992 .unwrap_err()
993 .to_string()
994 .contains("source does not match"));
995 assert_eq!(std::fs::read(destination.join("old")).unwrap(), b"old");
996 }
997
998 #[test]
999 fn validator_failure_and_legacy_binding_preserve_or_upgrade_destination() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let destination = dir.path().join("replica");
1002 std::fs::create_dir(&destination).unwrap();
1003 std::fs::write(destination.join("old"), b"old").unwrap();
1004 mark_replica(&destination, 1, false);
1005 let next = snapshot(2, vec![(crate::catalog::CATALOG_FILENAME, b"new")]);
1006
1007 let error = next
1008 .install_validated(&destination, |_| {
1009 Err(MongrelError::InvalidArgument("bad staged catalog".into()))
1010 })
1011 .unwrap_err();
1012 assert!(error.to_string().contains("bad staged catalog"));
1013 assert_eq!(std::fs::read(destination.join("old")).unwrap(), b"old");
1014
1015 next.install_validated(&destination, |_| Ok(())).unwrap();
1016 assert_eq!(replica_source_id(&destination).unwrap(), TEST_SOURCE_ID);
1017 assert_eq!(replica_epoch(&destination).unwrap(), 2);
1018 }
1019
1020 #[test]
1021 fn replica_epoch_never_moves_backward() {
1022 let dir = tempfile::tempdir().unwrap();
1023 write_replica_epoch(dir.path(), 2).unwrap();
1024 assert!(write_replica_epoch(dir.path(), 1)
1025 .unwrap_err()
1026 .to_string()
1027 .contains("cannot move backward"));
1028 assert_eq!(replica_epoch(dir.path()).unwrap(), 2);
1029 }
1030
1031 #[test]
1032 fn corrupt_semantic_snapshot_never_replaces_working_replica() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let leader_path = dir.path().join("leader");
1035 let destination = dir.path().join("replica");
1036 let leader = crate::Database::create(&leader_path).unwrap();
1037 let good = leader.replication_snapshot().unwrap();
1038 good.install(&destination).unwrap();
1039 drop(crate::Database::open(&destination).unwrap());
1040
1041 let mut corrupt = leader.replication_snapshot().unwrap();
1042 let catalog = corrupt
1043 .files
1044 .iter_mut()
1045 .find(|file| file.path == Path::new(crate::catalog::CATALOG_FILENAME))
1046 .unwrap();
1047 catalog.data[0] ^= 0x80;
1048 assert!(corrupt.install(&destination).is_err());
1049
1050 let existing = crate::Database::open(&destination).unwrap();
1051 assert!(existing.is_read_only_replica());
1052 assert_eq!(replica_epoch(&destination).unwrap(), good.epoch());
1053 }
1054}