Skip to main content

mongreldb_core/
replicated_apply.rs

1//! Replicated-mode apply payloads and the engine snapshot image (spec
2//! sections 4.4, 11.5; Stage 2E).
3//!
4//! In replicated mode the consensus log is the single commit authority (spec
5//! section 4.4, ADR-0002): the leader stages commands, the group commits them,
6//! and every replica applies the identical bytes deterministically. This
7//! module owns the wire contracts the apply sink decodes:
8//!
9//! - [`ReplicatedTxnPayload`]: the staged records of one committed
10//!   transaction (`command_type == COMMAND_TYPE_TRANSACTION`). The sink
11//!   replays them through [`Database::apply_replicated_records`] — the same
12//!   logic the WAL recovery path uses.
13//! - `Catalog` envelopes carry one [`crate::catalog_cmds::CatalogCommandRecord`]
14//!   (`command_type == COMMAND_TYPE_CATALOG_COMMAND`) and route through
15//!   [`Database::apply_replicated_catalog_command`].
16//! - `Maintenance` and `Noop` commands are documented no-ops for applied
17//!   state: maintenance commands drive node-local actions owned by the
18//!   cluster runtime (membership, decommission), never engine state.
19//!
20//! [`EngineSnapshot`] is the sink's snapshot payload (spec section 11.5): the
21//! group id, the last included term/index, the catalog checkpoint, the MVCC
22//! snapshot epoch, the table/run manifest with the required run/index files
23//! and their hashes, and the format versions. Install never writes over live
24//! state: the image is staged in a sibling directory, verified (hashes,
25//! versions, semantic open), and only then atomically swapped with the live
26//! root through the same rename idiom as
27//! [`crate::replication::ReplicationSnapshot::install_validated`].
28
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35use mongreldb_log::commit_log::LogPosition;
36use mongreldb_types::hlc::HlcTimestamp;
37use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId, RaftGroupId};
38
39use crate::storage_mode::StorageMode;
40use crate::{MongrelError, Result};
41
42/// [`mongreldb_log::CommandEnvelope::command_type`] for one replicated
43/// catalog command (a [`crate::catalog_cmds::CatalogCommandRecord`] payload).
44/// `1` is the transaction command (`crate::commit_log::COMMAND_TYPE_TRANSACTION`);
45/// discriminants are never reused (spec section 9.3).
46pub const COMMAND_TYPE_CATALOG_COMMAND: u32 = 2;
47
48/// [`mongreldb_log::CommandEnvelope::command_type`] reserved for replicated
49/// maintenance commands. Maintenance commands are node-runtime directives and
50/// documented no-ops for engine applied state this wave.
51pub const COMMAND_TYPE_MAINTENANCE: u32 = 3;
52
53// ---------------------------------------------------------------------------
54// Replicated transaction payload
55// ---------------------------------------------------------------------------
56
57/// The only payload format version this build reads and writes.
58pub const REPLICATED_TXN_FORMAT_VERSION: u16 = 1;
59
60/// The staged record sequence of one committed transaction (spec section
61/// 4.4): exactly the records the leader's commit sequencer appended for the
62/// transaction — data ops, `Op::CommitTimestamp`, and one trailing
63/// `Op::TxnCommit` carrying the leader-assigned commit epoch. Replicas apply
64/// the identical bytes, so applied state diverges nowhere.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ReplicatedTxnPayload {
67    /// Payload format version; must equal [`REPLICATED_TXN_FORMAT_VERSION`].
68    pub version: u16,
69    /// The transaction's complete record sequence.
70    pub records: Vec<crate::wal::Record>,
71}
72
73impl ReplicatedTxnPayload {
74    /// Wraps `records` at the current format version.
75    pub fn new(records: Vec<crate::wal::Record>) -> Self {
76        Self {
77            version: REPLICATED_TXN_FORMAT_VERSION,
78            records,
79        }
80    }
81
82    /// Serializes deterministically (bincode over the versioned struct).
83    pub fn encode(&self) -> Result<Vec<u8>> {
84        Ok(bincode::serialize(self)?)
85    }
86
87    /// Decodes, failing closed on an unknown format version.
88    pub fn decode(bytes: &[u8]) -> Result<Self> {
89        let payload: Self = bincode::deserialize(bytes)?;
90        if payload.version != REPLICATED_TXN_FORMAT_VERSION {
91            return Err(MongrelError::UnsupportedStorageVersion {
92                component: "replicated transaction payload",
93                found: payload.version,
94                supported: REPLICATED_TXN_FORMAT_VERSION,
95            });
96        }
97        Ok(payload)
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Engine snapshot (spec section 11.5)
103// ---------------------------------------------------------------------------
104
105/// The only engine-snapshot format version this build reads and writes.
106pub const ENGINE_SNAPSHOT_FORMAT_VERSION: u16 = 1;
107
108/// One required file of an [`EngineSnapshot`] (run, index, catalog checkpoint,
109/// WAL segment): its database-relative path, content hash, and bytes.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct EngineSnapshotFile {
112    /// Database-relative path (validated: plain relative, no `..`).
113    pub path: PathBuf,
114    /// SHA-256 over `data`.
115    pub sha256: [u8; 32],
116    /// File content.
117    pub data: Vec<u8>,
118}
119
120/// One table's entry in the snapshot's table/run manifest.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct EngineSnapshotTable {
123    /// Catalog table id.
124    pub table_id: u64,
125    /// Catalog name.
126    pub name: String,
127    /// Rows visible at the snapshot's MVCC epoch.
128    pub visible_rows: u64,
129}
130
131/// The engine apply sink's snapshot payload (spec section 11.5): applied
132/// state at one log boundary, plus everything needed to verify and
133/// semantically validate it before it replaces a replica's state.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct EngineSnapshot {
136    /// Payload format version; must equal [`ENGINE_SNAPSHOT_FORMAT_VERSION`].
137    pub version: u16,
138    /// The consensus group this snapshot belongs to.
139    pub group_id: RaftGroupId,
140    /// Last log position whose effects the image includes.
141    pub last_included: LogPosition,
142    /// Commit timestamp recorded at `last_included`, when any command has
143    /// been applied.
144    pub commit_ts: Option<HlcTimestamp>,
145    /// MVCC snapshot epoch (the core's visible watermark at capture).
146    pub epoch: u64,
147    /// Owning cluster (identity of the replicated database).
148    pub cluster_id: ClusterId,
149    /// Replicated logical database.
150    pub database_id: DatabaseId,
151    /// Catalog command version at capture (S1F-001).
152    pub catalog_version: u64,
153    /// Table/run manifest: every live table and its visible row count.
154    pub tables: Vec<EngineSnapshotTable>,
155    /// Every required file (catalog checkpoint, manifests, run/index files,
156    /// WAL), hashed.
157    pub files: Vec<EngineSnapshotFile>,
158    /// On-disk WAL format version at capture (verified on install).
159    pub wal_format: u16,
160    /// Storage-mode marker format version at capture (verified on install).
161    pub storage_mode_format: u16,
162}
163
164impl EngineSnapshot {
165    /// Captures the live core's applied state. The caller (the apply sink)
166    /// holds the apply mutex, so no replicated command is applying and the
167    /// read-only core is quiescent: the copied files and the recorded
168    /// position describe one log boundary.
169    pub fn capture(
170        db: &crate::Database,
171        group_id: RaftGroupId,
172        last_included: LogPosition,
173        commit_ts: Option<HlcTimestamp>,
174    ) -> Result<Self> {
175        let mode = db.storage_mode()?.ok_or_else(|| {
176            MongrelError::Other("engine snapshot capture before marker write".into())
177        })?;
178        let (cluster_id, _node_id, database_id) = mode.cluster_identity().ok_or_else(|| {
179            MongrelError::InvalidArgument(format!(
180                "engine snapshots capture cluster replicas, got mode {mode:?}"
181            ))
182        })?;
183        let epoch = db.visible_epoch();
184        let snapshot = crate::epoch::Snapshot::at(epoch);
185        let mut tables = Vec::new();
186        for name in db.table_names() {
187            let table_id = db.table_id(&name)?;
188            let handle = db.table(&name)?;
189            let visible_rows = handle.lock().visible_rows(snapshot)?.len() as u64;
190            tables.push(EngineSnapshotTable {
191                table_id,
192                name,
193                visible_rows,
194            });
195        }
196        tables.sort_by_key(|table| table.table_id);
197        let files = crate::replication::capture_files(db.root())?
198            .into_iter()
199            .map(|file| {
200                let sha256: [u8; 32] = Sha256::digest(&file.data).into();
201                Ok(EngineSnapshotFile {
202                    path: file.path,
203                    sha256,
204                    data: file.data,
205                })
206            })
207            .collect::<Result<Vec<_>>>()?;
208        Ok(Self {
209            version: ENGINE_SNAPSHOT_FORMAT_VERSION,
210            group_id,
211            last_included,
212            commit_ts,
213            epoch: epoch.0,
214            cluster_id,
215            database_id,
216            catalog_version: db.catalog_version(),
217            tables,
218            files,
219            wal_format: crate::wal::WAL_VERSION,
220            storage_mode_format: crate::storage_mode::STORAGE_MODE_FORMAT_VERSION,
221        })
222    }
223
224    /// Serializes deterministically (bincode over the versioned struct).
225    pub fn encode(&self) -> Result<Vec<u8>> {
226        Ok(bincode::serialize(self)?)
227    }
228
229    /// Decodes, failing closed on an unknown format version.
230    pub fn decode(bytes: &[u8]) -> Result<Self> {
231        let snapshot: Self = bincode::deserialize(bytes)?;
232        if snapshot.version != ENGINE_SNAPSHOT_FORMAT_VERSION {
233            return Err(MongrelError::UnsupportedStorageVersion {
234                component: "engine snapshot",
235                found: snapshot.version,
236                supported: ENGINE_SNAPSHOT_FORMAT_VERSION,
237            });
238        }
239        Ok(snapshot)
240    }
241
242    /// Spec step "verify hashes and versions": the group and database
243    /// identity must match this replica, every file hash must hold, and the
244    /// recorded format versions must be readable by this build.
245    pub fn validate(
246        &self,
247        group_id: &RaftGroupId,
248        cluster_id: &ClusterId,
249        database_id: &DatabaseId,
250    ) -> Result<()> {
251        if &self.group_id != group_id {
252            return Err(MongrelError::InvalidArgument(format!(
253                "engine snapshot group {:?} does not match this replica's group {:?}",
254                self.group_id, group_id
255            )));
256        }
257        if &self.cluster_id != cluster_id || &self.database_id != database_id {
258            return Err(MongrelError::InvalidArgument(
259                "engine snapshot database identity does not match this replica".into(),
260            ));
261        }
262        if self.wal_format != crate::wal::WAL_VERSION {
263            return Err(MongrelError::UnsupportedStorageVersion {
264                component: "wal",
265                found: self.wal_format,
266                supported: crate::wal::WAL_VERSION,
267            });
268        }
269        if self.storage_mode_format != crate::storage_mode::STORAGE_MODE_FORMAT_VERSION {
270            return Err(MongrelError::UnsupportedStorageVersion {
271                component: "storage-mode marker",
272                found: self.storage_mode_format,
273                supported: crate::storage_mode::STORAGE_MODE_FORMAT_VERSION,
274            });
275        }
276        let mut seen = std::collections::HashSet::new();
277        for file in &self.files {
278            crate::replication::validate_relative_path(&file.path)?;
279            if !seen.insert(file.path.clone()) {
280                return Err(MongrelError::InvalidArgument(format!(
281                    "duplicate engine snapshot path {:?}",
282                    file.path
283                )));
284            }
285            let digest: [u8; 32] = Sha256::digest(&file.data).into();
286            if digest != file.sha256 {
287                return Err(MongrelError::Other(format!(
288                    "engine snapshot file {:?} failed its content hash",
289                    file.path
290                )));
291            }
292        }
293        Ok(())
294    }
295
296    /// Spec step "download to staging": write the image into the (fresh,
297    /// empty) `staging` directory, fsyncing every file. The storage-mode
298    /// marker is rewritten with the LOCAL node identity: the image came from
299    /// a peer replica of the same database, and a marker must name its owner.
300    pub fn stage_into(&self, staging: &Path, node_id: NodeId) -> Result<()> {
301        let marker_relative =
302            Path::new(crate::database::META_DIR).join(crate::storage_mode::STORAGE_MODE_FILENAME);
303        for file in &self.files {
304            crate::replication::validate_relative_path(&file.path)?;
305            if file.path == marker_relative {
306                // Rewritten below with the local identity.
307                continue;
308            }
309            let path = staging.join(&file.path);
310            let parent = path.parent().expect("validated file has parent");
311            crate::durable_file::create_directory_all(parent)?;
312            let mut output = std::fs::OpenOptions::new()
313                .create_new(true)
314                .write(true)
315                .open(&path)?;
316            std::io::Write::write_all(&mut output, &file.data)?;
317            output.sync_all()?;
318            crate::durable_file::sync_directory(parent)?;
319        }
320        // Every mounted table requires `_runs` during semantic open (mirrors
321        // the replication install path).
322        for table in &self.tables {
323            crate::durable_file::create_directory_all(
324                &staging
325                    .join(crate::database::TABLES_DIR)
326                    .join(table.table_id.to_string())
327                    .join("_runs"),
328            )?;
329        }
330        if !staging.join(crate::catalog::CATALOG_FILENAME).is_file() {
331            return Err(MongrelError::InvalidArgument(
332                "engine snapshot has no CATALOG".into(),
333            ));
334        }
335        let durable_stage = crate::durable_file::DurableRoot::open(staging)?;
336        crate::storage_mode::rewrite(
337            &durable_stage,
338            &StorageMode::ClusterReplica {
339                cluster_id: self.cluster_id,
340                node_id,
341                database_id: self.database_id,
342            },
343        )?;
344        crate::durable_file::sync_directory(staging)?;
345        Ok(())
346    }
347
348    /// Spec step "open and semantically validate": open the staged image
349    /// through the offline-validation API (read-only, any storage mode) and
350    /// confirm every manifest table mounts with the recorded visible row
351    /// count at the snapshot epoch.
352    pub fn validate_staged(&self, staging: &Path) -> Result<()> {
353        let db = crate::Database::open_offline_validation(staging)?;
354        let snapshot = crate::epoch::Snapshot::at(crate::epoch::Epoch(self.epoch));
355        for table in &self.tables {
356            let handle = db.table(&table.name).map_err(|error| {
357                MongrelError::Other(format!(
358                    "staged engine snapshot is missing table {:?}: {error}",
359                    table.name
360                ))
361            })?;
362            let rows = handle.lock().visible_rows(snapshot)?.len() as u64;
363            if rows != table.visible_rows {
364                return Err(MongrelError::Other(format!(
365                    "staged engine snapshot table {:?} has {rows} visible rows at epoch {}, expected {}",
366                    table.name, self.epoch, table.visible_rows
367                )));
368            }
369        }
370        Ok(())
371    }
372
373    /// Spec steps "pause apply → atomically replace → resume → remove old
374    /// state". The apply mutex the caller holds is the pause. The staged,
375    /// validated image is swapped with the live root through the rename idiom
376    /// of [`crate::replication::ReplicationSnapshot::install_validated`] —
377    /// never installed over live state: the live core is shut down first
378    /// (refusing with [`MongrelError::Conflict`] while other owners hold it,
379    /// leaving `live` untouched), the old root is renamed aside and retained
380    /// until success, the replica is reopened as the cluster runtime, and the
381    /// old tree is removed. On success `live` holds the reopened database;
382    /// on a pre-shutdown failure it is unchanged.
383    pub fn install(self, live: &mut Option<Arc<crate::Database>>, node_id: NodeId) -> Result<()> {
384        let db = live.as_ref().ok_or_else(|| {
385            MongrelError::Other("engine snapshot install without a live database".into())
386        })?;
387        if Arc::strong_count(db) > 1 {
388            return Err(MongrelError::Conflict(
389                "engine snapshot install refused over live state: database is busy".into(),
390            ));
391        }
392        let destination = db.root().to_path_buf();
393        // Refuse to replace anything but this database's own replica.
394        match db.storage_mode()? {
395            Some(StorageMode::ClusterReplica {
396                cluster_id,
397                database_id,
398                ..
399            }) if cluster_id == self.cluster_id && database_id == self.database_id => {}
400            other => {
401                return Err(MongrelError::InvalidArgument(format!(
402                    "refusing to install an engine snapshot over storage mode {other:?}"
403                )));
404            }
405        }
406        let parent = destination
407            .parent()
408            .filter(|path| !path.as_os_str().is_empty())
409            .unwrap_or_else(|| Path::new("."))
410            .to_path_buf();
411        let name = destination
412            .file_name()
413            .and_then(|name| name.to_str())
414            .ok_or_else(|| MongrelError::InvalidArgument("invalid database root".into()))?;
415        let nonce = std::time::SystemTime::now()
416            .duration_since(std::time::UNIX_EPOCH)
417            .unwrap_or_default()
418            .as_nanos();
419        let stage = parent.join(format!(
420            ".{name}.engine-stage-{}-{nonce}",
421            std::process::id()
422        ));
423        let backup = parent.join(format!(".{name}.engine-old-{}-{nonce}", std::process::id()));
424        if stage.exists() || backup.exists() {
425            return Err(MongrelError::Conflict(
426                "engine snapshot staging path already exists".into(),
427            ));
428        }
429        let result = (|| -> Result<()> {
430            crate::durable_file::create_directory(&stage)?;
431            self.stage_into(&stage, node_id)?;
432            self.validate_staged(&stage)?;
433            // Pause live state. The busy check ran above, so the shutdown
434            // succeeds; no live file is ever mutated in place.
435            let owned = live.take().expect("checked above");
436            owned.shutdown()?;
437            // Atomically swap the staged tree in (never over live state: the
438            // old root is renamed aside first and retained until success).
439            if let Err(failure) = crate::replication::rename_entry(&destination, &backup) {
440                return Err(failure.error.into());
441            }
442            if let Err(failure) = crate::replication::rename_entry(&stage, &destination) {
443                if let Err(rollback) = crate::replication::rename_entry(&backup, &destination) {
444                    return Err(crate::replication::uncertain_install_error(
445                        self.epoch,
446                        &failure.error,
447                        &rollback.error,
448                    ));
449                }
450                return Err(failure.error.into());
451            }
452            crate::replication::remove_directory(&backup)?;
453            Ok(())
454        })();
455        if let Err(error) = result {
456            if stage.exists() {
457                let _ = crate::replication::remove_directory(&stage);
458            }
459            return Err(error);
460        }
461        let expected = StorageMode::ClusterReplica {
462            cluster_id: self.cluster_id,
463            node_id,
464            database_id: self.database_id,
465        };
466        let reopened = crate::Database::open_cluster_replica(&destination, &expected)?;
467        *live = Some(Arc::new(reopened));
468        Ok(())
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn txn_payload_round_trip_and_version_gate() {
478        let payload = ReplicatedTxnPayload::new(vec![crate::wal::Record::new(
479            crate::epoch::Epoch(1),
480            7,
481            crate::wal::Op::TxnCommit {
482                epoch: 1,
483                added_runs: Vec::new(),
484            },
485        )]);
486        let bytes = payload.encode().unwrap();
487        let decoded = ReplicatedTxnPayload::decode(&bytes).unwrap();
488        assert_eq!(decoded.records.len(), 1);
489
490        let mut corrupt = payload.clone();
491        corrupt.version = REPLICATED_TXN_FORMAT_VERSION + 1;
492        let bytes = bincode::serialize(&corrupt).unwrap();
493        assert!(matches!(
494            ReplicatedTxnPayload::decode(&bytes),
495            Err(MongrelError::UnsupportedStorageVersion { .. })
496        ));
497    }
498
499    #[test]
500    fn validate_rejects_bad_hash_and_foreign_identity() {
501        let snapshot = EngineSnapshot {
502            version: ENGINE_SNAPSHOT_FORMAT_VERSION,
503            group_id: RaftGroupId::from_bytes([1; 16]),
504            last_included: LogPosition { term: 1, index: 2 },
505            commit_ts: None,
506            epoch: 2,
507            cluster_id: ClusterId::from_bytes([2; 16]),
508            database_id: DatabaseId::from_bytes([3; 16]),
509            catalog_version: 0,
510            tables: Vec::new(),
511            files: vec![EngineSnapshotFile {
512                path: PathBuf::from("CATALOG"),
513                sha256: [9; 32],
514                data: b"catalog".to_vec(),
515            }],
516            wal_format: crate::wal::WAL_VERSION,
517            storage_mode_format: crate::storage_mode::STORAGE_MODE_FORMAT_VERSION,
518        };
519        let group = RaftGroupId::from_bytes([1; 16]);
520        let cluster = ClusterId::from_bytes([2; 16]);
521        let database = DatabaseId::from_bytes([3; 16]);
522        // Content hash mismatch fails closed.
523        assert!(snapshot.validate(&group, &cluster, &database).is_err());
524        let wrong_group = RaftGroupId::from_bytes([9; 16]);
525        assert!(snapshot
526            .validate(&wrong_group, &cluster, &database)
527            .is_err());
528        let wrong_database = DatabaseId::from_bytes([9; 16]);
529        assert!(snapshot
530            .validate(&group, &cluster, &wrong_database)
531            .is_err());
532
533        let mut good = snapshot.clone();
534        good.files[0].sha256 = Sha256::digest(&good.files[0].data).into();
535        good.validate(&group, &cluster, &database).unwrap();
536    }
537}