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 pub(crate) path: PathBuf,
23 pub(crate) 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 if let Err(error) = crate::catalog::inject_hook("snapshot.install.before") {
427 if let Err(cleanup) = remove_directory(&stage) {
428 return Err(MongrelError::Other(format!(
429 "{error}; failed to remove replication staging directory: {cleanup}"
430 )));
431 }
432 return Err(error);
433 }
434
435 let had_destination = destination.exists();
436 if had_destination {
437 if let Err(failure) = rename_entry(destination, &backup) {
438 if failure.published {
439 if let Err(rollback) = rename_entry(&backup, destination) {
440 return Err(uncertain_install_error(
441 self.epoch,
442 &failure.error,
443 &rollback.error,
444 ));
445 }
446 }
447 if let Err(cleanup) = remove_directory(&stage) {
448 return Err(MongrelError::Other(format!(
449 "{}; previous replica was restored, but staging cleanup failed: {cleanup}",
450 failure.error
451 )));
452 }
453 return Err(failure.error.into());
454 }
455 }
456
457 if let Err(failure) = rename_entry(&stage, destination) {
458 if failure.published {
459 match rename_entry(destination, &stage) {
460 Ok(()) => {}
461 Err(rollback) if rollback.published => {
462 }
465 Err(rollback) => {
466 return Err(uncertain_install_error(
467 self.epoch,
468 &failure.error,
469 &rollback.error,
470 ));
471 }
472 }
473 }
474 if had_destination {
475 if let Err(rollback) = rename_entry(&backup, destination) {
476 return Err(uncertain_install_error(
477 self.epoch,
478 &failure.error,
479 &rollback.error,
480 ));
481 }
482 }
483 if stage.exists() {
484 if let Err(cleanup) = remove_directory(&stage) {
485 return Err(MongrelError::Other(format!(
486 "{}; previous destination was restored, but staging cleanup failed: {cleanup}",
487 failure.error
488 )));
489 }
490 }
491 return Err(failure.error.into());
492 }
493
494 if had_destination {
495 if let Err(error) = remove_directory(&backup) {
496 return Err(MongrelError::Other(format!(
497 "replication snapshot at epoch {} is installed, but old snapshot cleanup failed: {error}",
498 self.epoch
499 )));
500 }
501 }
502 crate::catalog::inject_hook("snapshot.install.after")?;
504 Ok(())
505 }
506
507 fn write_into(&self, root: &Path) -> Result<()> {
508 let mut seen = HashSet::new();
509 let mut table_directories = HashSet::new();
510 for file in &self.files {
511 validate_relative_path(&file.path)?;
512 if !seen.insert(file.path.clone()) {
513 return Err(MongrelError::InvalidArgument(format!(
514 "duplicate replication snapshot path {:?}",
515 file.path
516 )));
517 }
518 if file.path == Path::new("_meta/replica")
519 || file.path == Path::new("_meta/repl_epoch")
520 || file.path == Path::new("_meta/replication_id")
521 || file.path == Path::new("_meta/replication_source_id")
522 || file.path == Path::new("_meta/replication_wal_floor")
523 {
524 return Err(MongrelError::InvalidArgument(format!(
525 "reserved replication snapshot path {:?}",
526 file.path
527 )));
528 }
529 if let Ok(relative) = file.path.strip_prefix("tables") {
530 if let Some(Component::Normal(table_id)) = relative.components().next() {
531 table_directories.insert(Path::new("tables").join(table_id));
532 }
533 }
534 }
535 for file in &self.files {
536 let path = root.join(&file.path);
537 let parent = path.parent().expect("validated file has parent");
538 crate::durable_file::create_directory_all(parent)?;
539 let mut output = std::fs::OpenOptions::new()
540 .create_new(true)
541 .write(true)
542 .open(&path)?;
543 output.write_all(&file.data)?;
544 output.sync_all()?;
545 crate::durable_file::sync_directory(parent)?;
546 }
547 for table in table_directories {
550 crate::durable_file::create_directory_all(&root.join(table).join("_runs"))?;
551 }
552 if !root.join(crate::catalog::CATALOG_FILENAME).is_file() {
553 return Err(MongrelError::InvalidArgument(
554 "replication snapshot has no CATALOG".into(),
555 ));
556 }
557 let meta = root.join("_meta");
558 crate::durable_file::create_directory_all(&meta)?;
559 write_new_synced(&meta.join(REPLICA_MARKER), b"read-only replica\n")?;
560 write_new_synced(&meta.join(REPLICATION_ID), &self.source_id)?;
561 write_new_synced(&meta.join(REPLICATION_SOURCE_ID), &self.source_id)?;
562 write_replica_epoch(root, self.epoch)?;
563 crate::durable_file::sync_directory(&meta)?;
564 crate::durable_file::sync_directory(root)?;
565 Ok(())
566 }
567}
568
569pub fn is_replica(root: impl AsRef<Path>) -> bool {
570 root.as_ref().join("_meta").join(REPLICA_MARKER).is_file()
571}
572
573fn validate_source_id(source_id: [u8; REPLICATION_ID_LEN]) -> Result<()> {
574 if source_id.iter().all(|byte| *byte == 0) {
575 return Err(MongrelError::InvalidArgument(
576 "replication source identity must not be zero".into(),
577 ));
578 }
579 Ok(())
580}
581
582fn read_source_id_file(mut file: std::fs::File, label: &str) -> Result<[u8; REPLICATION_ID_LEN]> {
583 let length = file.metadata()?.len();
584 if length != REPLICATION_ID_LEN as u64 {
585 return Err(MongrelError::InvalidArgument(format!(
586 "invalid {label} length {length}; expected {REPLICATION_ID_LEN}"
587 )));
588 }
589 let mut source_id = [0_u8; REPLICATION_ID_LEN];
590 file.read_exact(&mut source_id)?;
591 validate_source_id(source_id)?;
592 Ok(source_id)
593}
594
595pub fn replica_source_id(root: impl AsRef<Path>) -> Result<[u8; REPLICATION_ID_LEN]> {
596 let path = root.as_ref().join("_meta").join(REPLICATION_SOURCE_ID);
597 let file = match crate::durable_file::open_regular_nofollow(&path) {
598 Ok(file) => file,
599 Err(MongrelError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
600 return Err(MongrelError::NotFound(format!(
601 "{}: {error}",
602 path.display()
603 )))
604 }
605 Err(error) => return Err(error),
606 };
607 read_source_id_file(file, "replication source identity")
608}
609
610pub(crate) fn replication_identity_durable(
611 root: &crate::durable_file::DurableRoot,
612) -> Result<[u8; REPLICATION_ID_LEN]> {
613 let relative = Path::new("_meta").join(REPLICATION_ID);
614 match root.open_regular(&relative) {
615 Ok(file) => return read_source_id_file(file, "database replication identity"),
616 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
617 Err(error) => return Err(error.into()),
618 }
619
620 root.create_directory_all("_meta")?;
621 let mut source_id = [0_u8; REPLICATION_ID_LEN];
622 loop {
623 getrandom::getrandom(&mut source_id)
624 .map_err(|error| MongrelError::EntropyUnavailable(error.to_string()))?;
625 if source_id.iter().any(|byte| *byte != 0) {
626 break;
627 }
628 }
629 match root.write_new(&relative, &source_id) {
630 Ok(()) => Ok(source_id),
631 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => read_source_id_file(
632 root.open_regular(&relative)?,
633 "database replication identity",
634 ),
635 Err(error) => Err(error.into()),
636 }
637}
638
639pub(crate) fn replica_source_id_durable(
640 root: &crate::durable_file::DurableRoot,
641) -> Result<[u8; REPLICATION_ID_LEN]> {
642 read_source_id_file(
643 root.open_regular(Path::new("_meta").join(REPLICATION_SOURCE_ID))?,
644 "replication source identity",
645 )
646}
647
648pub fn replica_epoch(root: impl AsRef<Path>) -> Result<u64> {
649 let path = root.as_ref().join("_meta").join(REPLICA_EPOCH);
650 let value = std::fs::read_to_string(&path)
651 .map_err(|error| MongrelError::NotFound(format!("{}: {error}", path.display())))?;
652 value.trim().parse().map_err(|error| {
653 MongrelError::InvalidArgument(format!(
654 "invalid replica epoch in {}: {error}",
655 path.display()
656 ))
657 })
658}
659
660pub fn write_replica_epoch(root: impl AsRef<Path>, epoch: u64) -> Result<()> {
661 match replica_epoch(root.as_ref()) {
662 Ok(current) if epoch < current => {
663 return Err(MongrelError::Conflict(format!(
664 "replica epoch cannot move backward from {current} to {epoch}"
665 )))
666 }
667 Ok(current) if epoch == current => return Ok(()),
668 Ok(_) | Err(MongrelError::NotFound(_)) => {}
669 Err(error) => return Err(error),
670 }
671 write_meta_u64(root.as_ref(), REPLICA_EPOCH, epoch, "replica epoch")
672}
673
674pub(crate) fn reconcile_replica_epoch_durable(
675 root: &crate::durable_file::DurableRoot,
676 recovered_epoch: u64,
677) -> Result<()> {
678 let relative = Path::new("_meta").join(REPLICA_EPOCH);
679 let current = if root.entry_exists(&relative)? {
680 let mut value = String::new();
681 root.open_regular(&relative)?.read_to_string(&mut value)?;
682 Some(value.trim().parse::<u64>().map_err(|error| {
683 MongrelError::InvalidArgument(format!("invalid replica epoch: {error}"))
684 })?)
685 } else {
686 None
687 };
688 match current {
689 Some(current) if recovered_epoch < current => Err(MongrelError::Conflict(format!(
690 "recovered replica epoch {recovered_epoch} precedes durable watermark {current}"
691 ))),
692 Some(current) if recovered_epoch == current => Ok(()),
693 _ => {
694 root.create_directory_all("_meta")?;
695 root.write_atomic(&relative, recovered_epoch.to_string().as_bytes())?;
696 Ok(())
697 }
698 }
699}
700
701pub(crate) fn replication_wal_floor(root: impl AsRef<Path>) -> Result<u64> {
702 let path = root.as_ref().join("_meta").join(REPLICATION_WAL_FLOOR);
703 match std::fs::read_to_string(&path) {
704 Ok(value) => value.trim().parse().map_err(|error| {
705 MongrelError::InvalidArgument(format!(
706 "invalid replication WAL floor in {}: {error}",
707 path.display()
708 ))
709 }),
710 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(0),
711 Err(error) => Err(error.into()),
712 }
713}
714
715pub(crate) fn replication_wal_floor_durable(
716 root: &crate::durable_file::DurableRoot,
717) -> Result<u64> {
718 let relative = Path::new("_meta").join(REPLICATION_WAL_FLOOR);
719 if !root.entry_exists(&relative)? {
720 return Ok(0);
721 }
722 let mut value = String::new();
723 root.open_regular(&relative)?.read_to_string(&mut value)?;
724 value.trim().parse().map_err(|error| {
725 MongrelError::InvalidArgument(format!("invalid replication WAL floor: {error}"))
726 })
727}
728
729pub(crate) fn advance_replication_wal_floor_durable(
730 root: &crate::durable_file::DurableRoot,
731 epoch: u64,
732) -> Result<()> {
733 if epoch <= replication_wal_floor_durable(root)? {
734 return Ok(());
735 }
736 root.create_directory_all("_meta")?;
737 root.write_atomic(
738 Path::new("_meta").join(REPLICATION_WAL_FLOOR),
739 epoch.to_string().as_bytes(),
740 )?;
741 Ok(())
742}
743
744fn write_meta_u64(root: &Path, name: &str, value: u64, label: &str) -> Result<()> {
745 let meta = root.join("_meta");
746 crate::durable_file::create_directory_all(&meta)?;
747 let path = meta.join(name);
748 let temp = meta.join(format!(
749 ".{name}.tmp-{}-{}",
750 std::process::id(),
751 std::time::SystemTime::now()
752 .duration_since(std::time::UNIX_EPOCH)
753 .unwrap_or_default()
754 .as_nanos()
755 ));
756 if let Err(error) = write_new_synced(&temp, value.to_string().as_bytes()) {
757 if temp.exists() {
758 if let Err(cleanup) = remove_file(&temp) {
759 return Err(MongrelError::Other(format!(
760 "{error}; failed to remove replica epoch temporary file: {cleanup}"
761 )));
762 }
763 }
764 return Err(error);
765 }
766 let published = Cell::new(false);
767 if let Err(error) =
768 crate::durable_file::replace_with_after(&temp, &path, || published.set(true))
769 {
770 if published.get() {
771 return match crate::durable_file::sync_directory(&meta) {
772 Ok(()) => Ok(()),
773 Err(retry) => Err(MongrelError::Other(format!(
774 "{label} {value} was atomically replaced, but its durability is unknown: {error}; directory sync retry failed: {retry}"
775 ))),
776 };
777 }
778 if let Err(cleanup) = remove_file(&temp) {
779 return Err(MongrelError::Other(format!(
780 "{error}; failed to remove replica epoch temporary file: {cleanup}"
781 )));
782 }
783 return Err(error.into());
784 }
785 Ok(())
786}
787
788pub(crate) fn validate_relative_path(path: &Path) -> Result<()> {
789 if path.as_os_str().is_empty()
790 || path
791 .components()
792 .any(|component| !matches!(component, Component::Normal(_)))
793 {
794 return Err(MongrelError::InvalidArgument(format!(
795 "unsafe replication snapshot path {:?}",
796 path
797 )));
798 }
799 Ok(())
800}
801
802fn write_new_synced(path: &Path, bytes: &[u8]) -> Result<()> {
803 let mut file = std::fs::OpenOptions::new()
804 .create_new(true)
805 .write(true)
806 .open(path)?;
807 file.write_all(bytes)?;
808 file.sync_all()?;
809 Ok(())
810}
811
812pub(crate) fn remove_directory(path: &Path) -> io::Result<()> {
813 let parent = path.parent().unwrap_or_else(|| Path::new("."));
814 std::fs::remove_dir_all(path)?;
815 crate::durable_file::sync_directory(parent)
816}
817
818fn remove_file(path: &Path) -> io::Result<()> {
819 let parent = path.parent().unwrap_or_else(|| Path::new("."));
820 std::fs::remove_file(path)?;
821 crate::durable_file::sync_directory(parent)
822}
823
824#[derive(Debug)]
825pub(crate) struct RenameFailure {
826 pub(crate) error: io::Error,
827 pub(crate) published: bool,
828}
829
830pub(crate) fn rename_entry(
831 source: &Path,
832 destination: &Path,
833) -> std::result::Result<(), RenameFailure> {
834 let published = Cell::new(false);
835 match crate::durable_file::rename_with_after(source, destination, || published.set(true)) {
836 Ok(()) => Ok(()),
837 Err(error) if published.get() => match sync_rename_directories(source, destination) {
838 Ok(()) => Ok(()),
839 Err(retry) => Err(RenameFailure {
840 error: io::Error::new(
841 retry.kind(),
842 format!("{error}; directory sync retry failed: {retry}"),
843 ),
844 published: true,
845 }),
846 },
847 Err(error) => Err(RenameFailure {
848 error,
849 published: false,
850 }),
851 }
852}
853
854fn sync_rename_directories(source: &Path, destination: &Path) -> io::Result<()> {
855 let destination_parent = destination.parent().unwrap_or_else(|| Path::new("."));
856 crate::durable_file::sync_directory(destination_parent)?;
857 let source_parent = source.parent().unwrap_or_else(|| Path::new("."));
858 if source_parent != destination_parent {
859 crate::durable_file::sync_directory(source_parent)?;
860 }
861 Ok(())
862}
863
864pub(crate) fn uncertain_install_error(
865 epoch: u64,
866 error: &io::Error,
867 rollback: &io::Error,
868) -> MongrelError {
869 MongrelError::Other(format!(
870 "replication snapshot install outcome at epoch {epoch} is unknown: {error}; rollback failed: {rollback}"
871 ))
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 const TEST_SOURCE_ID: [u8; REPLICATION_ID_LEN] = [7; REPLICATION_ID_LEN];
879
880 fn mark_replica(path: &Path, epoch: u64, include_source: bool) {
881 let meta = path.join("_meta");
882 std::fs::create_dir_all(&meta).unwrap();
883 std::fs::write(meta.join(REPLICA_MARKER), b"read-only replica\n").unwrap();
884 std::fs::write(meta.join(REPLICA_EPOCH), epoch.to_string()).unwrap();
885 if include_source {
886 std::fs::write(meta.join(REPLICATION_SOURCE_ID), TEST_SOURCE_ID).unwrap();
887 }
888 }
889
890 fn snapshot(epoch: u64, files: Vec<(&str, &[u8])>) -> ReplicationSnapshot {
891 ReplicationSnapshot {
892 version: FORMAT_VERSION,
893 source_id: TEST_SOURCE_ID,
894 epoch,
895 files: files
896 .into_iter()
897 .map(|(path, data)| ReplicationFile::new(path.into(), data.to_vec()))
898 .collect(),
899 }
900 }
901
902 #[test]
903 fn snapshot_install_rejects_path_traversal() {
904 let dir = tempfile::tempdir().unwrap();
905 let snapshot = ReplicationSnapshot {
906 version: FORMAT_VERSION,
907 source_id: TEST_SOURCE_ID,
908 epoch: 1,
909 files: vec![ReplicationFile::new("../escape".into(), vec![1])],
910 };
911 assert!(snapshot
912 .install_validated(dir.path().join("replica"), |_| Ok(()))
913 .is_err());
914 assert!(!dir.path().join("escape").exists());
915 }
916
917 #[test]
918 fn snapshot_install_durably_replaces_existing_destination() {
919 let dir = tempfile::tempdir().unwrap();
920 let destination = dir.path().join("replica");
921 std::fs::create_dir(&destination).unwrap();
922 std::fs::write(destination.join("old"), b"old").unwrap();
923 mark_replica(&destination, 41, true);
924
925 snapshot(
926 42,
927 vec![
928 (crate::catalog::CATALOG_FILENAME, b"new"),
929 ("tables/1/data", b"nested"),
930 ],
931 )
932 .install_validated(&destination, |_| Ok(()))
933 .unwrap();
934
935 assert_eq!(
936 std::fs::read(destination.join(crate::catalog::CATALOG_FILENAME)).unwrap(),
937 b"new"
938 );
939 assert_eq!(
940 std::fs::read(destination.join("tables/1/data")).unwrap(),
941 b"nested"
942 );
943 assert!(!destination.join("old").exists());
944 assert_eq!(replica_epoch(&destination).unwrap(), 42);
945 assert!(is_replica(&destination));
946 let names = std::fs::read_dir(dir.path())
947 .unwrap()
948 .map(|entry| entry.unwrap().file_name())
949 .collect::<Vec<_>>();
950 assert_eq!(names, vec![destination.file_name().unwrap()]);
951 }
952
953 #[test]
954 fn invalid_snapshot_leaves_existing_destination_untouched() {
955 let dir = tempfile::tempdir().unwrap();
956 let destination = dir.path().join("replica");
957 std::fs::create_dir(&destination).unwrap();
958 std::fs::write(destination.join("old"), b"old").unwrap();
959 mark_replica(&destination, 41, true);
960 let invalid = snapshot(
961 42,
962 vec![
963 (crate::catalog::CATALOG_FILENAME, b"new"),
964 ("_meta/replica", b"forged"),
965 ],
966 );
967
968 assert!(invalid.install_validated(&destination, |_| Ok(())).is_err());
969 assert_eq!(std::fs::read(destination.join("old")).unwrap(), b"old");
970 assert!(!destination.join(crate::catalog::CATALOG_FILENAME).exists());
971 let names = std::fs::read_dir(dir.path())
972 .unwrap()
973 .map(|entry| entry.unwrap().file_name())
974 .collect::<Vec<_>>();
975 assert_eq!(names, vec![destination.file_name().unwrap()]);
976 }
977
978 #[test]
979 fn replica_epoch_replace_leaves_no_temporary_file() {
980 let dir = tempfile::tempdir().unwrap();
981 write_replica_epoch(dir.path(), 1).unwrap();
982 write_replica_epoch(dir.path(), 2).unwrap();
983
984 assert_eq!(replica_epoch(dir.path()).unwrap(), 2);
985 let meta = dir.path().join("_meta");
986 assert_eq!(
987 std::fs::read_dir(meta)
988 .unwrap()
989 .map(|entry| entry.unwrap().file_name())
990 .collect::<Vec<_>>(),
991 vec![std::ffi::OsString::from(REPLICA_EPOCH)]
992 );
993 }
994
995 #[test]
996 fn snapshot_rejects_stale_or_foreign_replacement() {
997 let dir = tempfile::tempdir().unwrap();
998 let destination = dir.path().join("replica");
999 std::fs::create_dir(&destination).unwrap();
1000 std::fs::write(destination.join("old"), b"old").unwrap();
1001 mark_replica(&destination, 50, true);
1002
1003 let stale = snapshot(49, vec![(crate::catalog::CATALOG_FILENAME, b"new")]);
1004 assert!(stale
1005 .install_validated(&destination, |_| Ok(()))
1006 .unwrap_err()
1007 .to_string()
1008 .contains("precedes destination epoch"));
1009
1010 let mut foreign = snapshot(51, vec![(crate::catalog::CATALOG_FILENAME, b"new")]);
1011 foreign.source_id = [8; REPLICATION_ID_LEN];
1012 assert!(foreign
1013 .install_validated(&destination, |_| Ok(()))
1014 .unwrap_err()
1015 .to_string()
1016 .contains("source does not match"));
1017 assert_eq!(std::fs::read(destination.join("old")).unwrap(), b"old");
1018 }
1019
1020 #[test]
1021 fn validator_failure_and_legacy_binding_preserve_or_upgrade_destination() {
1022 let dir = tempfile::tempdir().unwrap();
1023 let destination = dir.path().join("replica");
1024 std::fs::create_dir(&destination).unwrap();
1025 std::fs::write(destination.join("old"), b"old").unwrap();
1026 mark_replica(&destination, 1, false);
1027 let next = snapshot(2, vec![(crate::catalog::CATALOG_FILENAME, b"new")]);
1028
1029 let error = next
1030 .install_validated(&destination, |_| {
1031 Err(MongrelError::InvalidArgument("bad staged catalog".into()))
1032 })
1033 .unwrap_err();
1034 assert!(error.to_string().contains("bad staged catalog"));
1035 assert_eq!(std::fs::read(destination.join("old")).unwrap(), b"old");
1036
1037 next.install_validated(&destination, |_| Ok(())).unwrap();
1038 assert_eq!(replica_source_id(&destination).unwrap(), TEST_SOURCE_ID);
1039 assert_eq!(replica_epoch(&destination).unwrap(), 2);
1040 }
1041
1042 #[test]
1043 fn replica_epoch_never_moves_backward() {
1044 let dir = tempfile::tempdir().unwrap();
1045 write_replica_epoch(dir.path(), 2).unwrap();
1046 assert!(write_replica_epoch(dir.path(), 1)
1047 .unwrap_err()
1048 .to_string()
1049 .contains("cannot move backward"));
1050 assert_eq!(replica_epoch(dir.path()).unwrap(), 2);
1051 }
1052
1053 #[test]
1054 fn corrupt_semantic_snapshot_never_replaces_working_replica() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let leader_path = dir.path().join("leader");
1057 let destination = dir.path().join("replica");
1058 let leader = crate::Database::create(&leader_path).unwrap();
1059 let good = leader.replication_snapshot().unwrap();
1060 good.install(&destination).unwrap();
1061 drop(crate::Database::open(&destination).unwrap());
1062
1063 let mut corrupt = leader.replication_snapshot().unwrap();
1064 let catalog = corrupt
1065 .files
1066 .iter_mut()
1067 .find(|file| file.path == Path::new(crate::catalog::CATALOG_FILENAME))
1068 .unwrap();
1069 catalog.data[0] ^= 0x80;
1070 assert!(corrupt.install(&destination).is_err());
1071
1072 let existing = crate::Database::open(&destination).unwrap();
1073 assert!(existing.is_read_only_replica());
1074 assert_eq!(replica_epoch(&destination).unwrap(), good.epoch());
1075 }
1076}