1use 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#[derive(Debug, Clone)]
37pub struct ImportTablePlan {
38 pub table_id: u64,
40 pub name: String,
42 pub schema: Schema,
44 pub row_count: u64,
46 pub rows_sha256: [u8; 32],
49}
50
51#[derive(Debug, Clone)]
54pub struct ImportPlan {
55 pub database: String,
57 pub source_storage_mode: Option<StorageMode>,
60 pub snapshot_epoch: u64,
62 pub tables: Vec<ImportTablePlan>,
64 pub total_rows: u64,
66 pub schema_sha256: [u8; 32],
68 pub rows_sha256: [u8; 32],
70}
71
72pub 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
101fn 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
133pub 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 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 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}