Skip to main content

mongreldb_core/
replication.rs

1//! Replication bootstrap image and follower metadata.
2
3use 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/// A consistent database-directory image plus the leader commit epoch it
60/// covers. The image is opaque to HTTP; encode/decode use the core's versioned
61/// bincode envelope so server and client share one format.
62#[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/// Complete committed WAL transactions available after a follower epoch.
71#[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    /// Atomically replace `destination` with this snapshot and mark it as a
284    /// read-only replica. Files are first written and fsynced in a sibling
285    /// staging directory; an existing destination is retained until install.
286    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    /// Install after the caller has semantically opened and validated the
295    /// staged database. Authenticated or encrypted followers must use this
296    /// form so their credentials/key are checked before the working replica is
297    /// renamed away.
298    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                    // A pre-v2 replica has no source marker. Only a complete
335                    // staged snapshot install may establish that binding; WAL
336                    // apply never does so.
337                }
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        // Database open performs durable recovery bookkeeping. Validate an
384        // identical disposable tree so those writes cannot alter the exact
385        // snapshot that will be published.
386        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        // FND-006: the snapshot is staged and validated; fire before the
423        // rename sequence publishes it. The staging tree is removed so a
424        // fired fault leaves the destination untouched and the install
425        // retriable.
426        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                        // Restoring the old destination below syncs the same
463                        // parent and therefore also publishes this move.
464                    }
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        // FND-006: the snapshot is installed and the old tree is gone.
503        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        // Empty run directories carry no file entry in the snapshot, but every
548        // mounted table requires `_runs` to exist during semantic open.
549        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}