Skip to main content

mongreldb_core/
cluster_import.rs

1//! Standalone → cluster import groundwork (spec section 5.2, Stage 2E).
2//!
3//! The first cluster release never converts a database in place. The import
4//! process is:
5//!
6//! 1. Open the source read-only.  ← [`cluster_import_prepare`] (this module)
7//! 2. Capture a consistent snapshot. ← this module
8//! 3. Create a new cluster database and initial tablet. — server/cluster wave
9//! 4. Stream rows and schema into the replicated tablet. — server/cluster wave
10//! 5. Validate counts, hashes, constraints, and index definitions. ← the
11//!    [`ImportPlan`] carries every count and hash that step needs
12//! 6. Publish the new database. — server/cluster wave
13//! 7. Leave the source untouched. ← the read-only offline open guarantees it
14//!
15//! This module is the library form of steps 1–2 and the validation inputs of
16//! step 5: it opens the source through the read-only offline-validation API
17//! (spec section 5.3), pins one consistent MVCC epoch across every table,
18//! and produces the deterministic stream plan — per-table schema (including
19//! index and constraint definitions), row counts, and content hashes. The
20//! streamed write into a replicated tablet lands with the server/cluster
21//! wave; this plan is what that wave validates against.
22
23use std::path::Path;
24
25use serde::Serialize;
26use sha2::{Digest, Sha256};
27
28use crate::memtable::Row;
29use crate::schema::Schema;
30use crate::storage_mode::StorageMode;
31use crate::{MongrelError, Result};
32
33/// One table's slice of an [`ImportPlan`]: the complete schema (columns,
34/// index definitions, constraints — everything the replicated tablet must
35/// recreate) plus the deterministic row-stream validation totals.
36#[derive(Debug, Clone)]
37pub struct ImportTablePlan {
38    /// Catalog table id in the source.
39    pub table_id: u64,
40    /// Catalog name.
41    pub name: String,
42    /// Full source schema (columns, indexes, constraints).
43    pub schema: Schema,
44    /// Rows visible at the plan's snapshot epoch.
45    pub row_count: u64,
46    /// SHA-256 over the table's canonical row stream (see
47    /// [`hash_rows_canonical`]).
48    pub rows_sha256: [u8; 32],
49}
50
51/// A deterministic, validation-ready plan for streaming one standalone
52/// database into a fresh cluster database (spec section 5.2).
53#[derive(Debug, Clone)]
54pub struct ImportPlan {
55    /// Name of the cluster database the import targets.
56    pub database: String,
57    /// The source's storage mode (informational; the import source is
58    /// normally [`StorageMode::Standalone`], `None` for pre-marker databases).
59    pub source_storage_mode: Option<StorageMode>,
60    /// The consistent MVCC epoch every table was read at.
61    pub snapshot_epoch: u64,
62    /// Per-table plans, ordered by table id (deterministic stream order).
63    pub tables: Vec<ImportTablePlan>,
64    /// Total rows across every table.
65    pub total_rows: u64,
66    /// SHA-256 over every table's canonical schema encoding, in stream order.
67    pub schema_sha256: [u8; 32],
68    /// SHA-256 over every table's `rows_sha256`, in stream order.
69    pub rows_sha256: [u8; 32],
70}
71
72/// Canonical, order-independent SHA-256 over a row set.
73///
74/// Rows are sorted by [`crate::RowId`]; each row contributes its id, commit
75/// epoch, delete flag, and its columns sorted by column id (the in-memory
76/// `HashMap` column order is process-random and must never leak into a
77/// validation hash). Two databases holding the same logical rows at the same
78/// epochs hash identically in any process.
79pub fn hash_rows_canonical(rows: &[Row]) -> [u8; 32] {
80    let mut ordered: Vec<&Row> = rows.iter().collect();
81    ordered.sort_by_key(|row| row.row_id);
82    let mut hasher = Sha256::new();
83    for row in ordered {
84        hasher.update(row.row_id.0.to_le_bytes());
85        hasher.update(row.committed_epoch.0.to_le_bytes());
86        hasher.update([u8::from(row.deleted)]);
87        let mut columns: Vec<(&u16, &crate::memtable::Value)> = row.columns.iter().collect();
88        columns.sort_by_key(|(column_id, _)| *column_id);
89        hasher.update((columns.len() as u64).to_le_bytes());
90        for (column_id, value) in columns {
91            hasher.update(column_id.to_le_bytes());
92            let encoded =
93                bincode::serialize(value).expect("Value serialization is infallible for hashing");
94            hasher.update((encoded.len() as u64).to_le_bytes());
95            hasher.update(encoded);
96        }
97    }
98    hasher.finalize().into()
99}
100
101/// Canonical JSON encoding of `value` with object keys sorted recursively,
102/// so structurally equal values hash identically regardless of any map
103/// ordering inside them.
104fn canonical_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
105    let mut value = serde_json::to_value(value)
106        .map_err(|error| MongrelError::Other(format!("canonical json: {error}")))?;
107    canonicalize(&mut value);
108    serde_json::to_vec(&value)
109        .map_err(|error| MongrelError::Other(format!("canonical json: {error}")))
110}
111
112fn canonicalize(value: &mut serde_json::Value) {
113    match value {
114        serde_json::Value::Object(map) => {
115            let sorted: serde_json::Map<String, serde_json::Value> = map
116                .iter()
117                .map(|(key, value)| (key.clone(), value.clone()))
118                .collect();
119            *map = sorted;
120            for value in map.values_mut() {
121                canonicalize(value);
122            }
123        }
124        serde_json::Value::Array(items) => {
125            for item in items {
126                canonicalize(item);
127            }
128        }
129        _ => {}
130    }
131}
132
133/// Steps 1–2 of the spec section 5.2 import process: open `source` read-only
134/// (the offline-validation API of spec section 5.3 — any storage mode opens,
135/// and no write can reach the source), pin one consistent MVCC epoch, and
136/// produce the row/schema stream plan with the counts and hashes step 5
137/// validates. `database` is the name of the cluster database the plan
138/// targets; it is recorded, not created (creation lands with the
139/// server/cluster wave).
140pub fn cluster_import_prepare(source: impl AsRef<Path>, database: &str) -> Result<ImportPlan> {
141    if database.is_empty() {
142        return Err(MongrelError::InvalidArgument(
143            "cluster import requires a target database name".into(),
144        ));
145    }
146    let options = crate::OpenOptions::default().with_offline_validation(true);
147    let db = crate::Database::open_with_options(source.as_ref(), options)?;
148    let source_storage_mode = db.storage_mode()?;
149    let snapshot_epoch = db.visible_epoch();
150    let snapshot = crate::epoch::Snapshot::at(snapshot_epoch);
151
152    let mut tables = Vec::new();
153    for name in db.table_names() {
154        let table_id = db.table_id(&name)?;
155        let handle = db.table(&name)?;
156        let (schema, rows) = {
157            let table = handle.lock();
158            (table.schema().clone(), table.visible_rows(snapshot)?)
159        };
160        tables.push(ImportTablePlan {
161            table_id,
162            name,
163            schema,
164            row_count: rows.len() as u64,
165            rows_sha256: hash_rows_canonical(&rows),
166        });
167    }
168    drop(db);
169    tables.sort_by_key(|table| table.table_id);
170
171    let mut schema_hasher = Sha256::new();
172    let mut rows_hasher = Sha256::new();
173    let mut total_rows = 0_u64;
174    for table in &tables {
175        let encoded = canonical_json(&table.schema)?;
176        schema_hasher.update((encoded.len() as u64).to_le_bytes());
177        schema_hasher.update(encoded);
178        rows_hasher.update(table.rows_sha256);
179        total_rows = total_rows.saturating_add(table.row_count);
180    }
181    Ok(ImportPlan {
182        database: database.to_string(),
183        source_storage_mode,
184        snapshot_epoch: snapshot_epoch.0,
185        tables,
186        total_rows,
187        schema_sha256: schema_hasher.finalize().into(),
188        rows_sha256: rows_hasher.finalize().into(),
189    })
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::memtable::Value;
196    use crate::rowid::RowId;
197
198    #[test]
199    fn canonical_row_hash_is_order_independent() {
200        let mut a = Row::new(RowId(1), crate::epoch::Epoch(3));
201        a.columns.insert(1, Value::Int64(10));
202        a.columns.insert(2, Value::Bytes(b"x".to_vec()));
203        a.columns.insert(7, Value::Null);
204        let mut b = Row::new(RowId(2), crate::epoch::Epoch(3));
205        b.columns.insert(1, Value::Bool(true));
206
207        let forward = hash_rows_canonical(&[a.clone(), b.clone()]);
208        let backward = hash_rows_canonical(&[b, a.clone()]);
209        assert_eq!(forward, backward);
210
211        let mut changed = Row::new(RowId(2), crate::epoch::Epoch(3));
212        changed.columns.insert(1, Value::Bool(false));
213        assert_ne!(forward, hash_rows_canonical(&[a, changed]));
214    }
215
216    #[test]
217    fn canonical_json_sorts_object_keys() {
218        let first = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 2, "c": 3}})).unwrap();
219        let second = canonical_json(&serde_json::json!({"a": {"c": 3, "d": 2}, "b": 1})).unwrap();
220        assert_eq!(first, second);
221    }
222
223    #[test]
224    fn import_plan_counts_and_hashes_match_source() {
225        use crate::memtable::Value;
226        use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
227
228        let dir = tempfile::tempdir().unwrap();
229        let db = crate::Database::create(dir.path()).unwrap();
230        let schema = Schema {
231            columns: vec![ColumnDef {
232                id: 1,
233                name: "id".into(),
234                ty: TypeId::Int64,
235                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
236                default_value: None,
237                embedding_source: None,
238            }],
239            ..Schema::default()
240        };
241        db.create_table("items", schema).unwrap();
242        let mut txn = db.begin();
243        for value in [10_i64, 20, 30] {
244            txn.put("items", vec![(1, Value::Int64(value))]).unwrap();
245        }
246        txn.commit().unwrap();
247        let snapshot_epoch = db.visible_epoch();
248        let handle = db.table("items").unwrap();
249        let expected_rows = handle
250            .lock()
251            .visible_rows(crate::epoch::Snapshot::at(snapshot_epoch))
252            .unwrap();
253        drop(handle);
254        drop(db);
255
256        let plan = cluster_import_prepare(dir.path(), "app").unwrap();
257        assert_eq!(plan.database, "app");
258        assert_eq!(plan.source_storage_mode, Some(StorageMode::Standalone));
259        assert_eq!(plan.snapshot_epoch, snapshot_epoch.0);
260        assert_eq!(plan.tables.len(), 1);
261        let table = &plan.tables[0];
262        assert_eq!(table.name, "items");
263        assert_eq!(table.row_count, 3);
264        assert_eq!(table.rows_sha256, hash_rows_canonical(&expected_rows));
265        assert_eq!(plan.total_rows, 3);
266        assert_eq!(plan.schema_sha256.len(), 32);
267
268        // The plan is reproducible: a second prepare over the untouched
269        // source yields identical counts and hashes.
270        let second = cluster_import_prepare(dir.path(), "app").unwrap();
271        assert_eq!(plan.schema_sha256, second.schema_sha256);
272        assert_eq!(plan.rows_sha256, second.rows_sha256);
273        assert_eq!(plan.total_rows, second.total_rows);
274
275        // The source is untouched: still opens normally with its rows.
276        let db = crate::Database::open(dir.path()).unwrap();
277        let handle = db.table("items").unwrap();
278        let rows = handle
279            .lock()
280            .visible_rows(crate::epoch::Snapshot::at(snapshot_epoch))
281            .unwrap();
282        assert_eq!(rows.len(), 3);
283        assert_eq!(plan.database, "app");
284    }
285
286    #[test]
287    fn import_plan_rejects_empty_database_name() {
288        let dir = tempfile::tempdir().unwrap();
289        let db = crate::Database::create(dir.path()).unwrap();
290        drop(db);
291        assert!(cluster_import_prepare(dir.path(), "").is_err());
292    }
293}