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    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/// 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        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                        // Restoring the old destination below syncs the same
450                        // parent and therefore also publishes this move.
451                    }
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        // Empty run directories carry no file entry in the snapshot, but every
533        // mounted table requires `_runs` to exist during semantic open.
534        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}