mod support;
use std::collections::BTreeMap;
use std::path::Path;
use mentedb_core::MemoryNode;
use mentedb_core::config::StorageConfig;
use mentedb_core::types::MemoryId;
use mentedb_storage::StorageEngine;
use mentedb_storage::legacy::{
LEGACY_MAP_FILE, LEGACY_PAGE_FILE, LEGACY_PAGE_SIZE, LEGACY_WAL_FILE, MigrationFault,
};
use mentedb_storage::memory_map::MEMORY_MAP_FILE;
use mentedb_storage::record::{DATA_FILE, WAL_FILE};
use support::{typical, v1};
type Expected = BTreeMap<u64, (MemoryId, u64, MemoryNode)>;
fn legacy_fixture(dir: &Path) -> Expected {
let e = v1::StorageEngine::open(dir).unwrap();
let mut pages = Vec::new();
for i in 0..40 {
let mut n = typical(i);
if i % 9 == 0 {
n.content = n.content.repeat(150);
}
if i % 7 == 0 {
n.embedding.clear();
}
pages.push((e.store_memory(&n).unwrap(), n));
}
let batch: Vec<MemoryNode> = (40..48).map(typical).collect();
for (p, n) in e.store_memory_batch(&batch).unwrap().into_iter().zip(batch) {
pages.push((p, n));
}
for i in [3, 11, 20] {
e.delete_memory(pages[i].0).unwrap();
}
let mut grow = pages[5].1.clone();
grow.content = grow.content.repeat(40);
e.update_memory(pages[5].0, &grow).unwrap();
e.memory_map().unwrap();
e.checkpoint().unwrap();
for i in 48..53 {
e.store_memory(&typical(i)).unwrap();
}
e.delete_memory(pages[30].0).unwrap();
let reuse = typical(60);
e.store_memory(&reuse).unwrap();
let mut changed = pages[8].1.clone();
changed.content = "changed after the checkpoint".into();
e.update_memory(pages[8].0, &changed).unwrap();
let mut expected = Expected::new();
for m in e.memory_map().unwrap() {
let node = e.load_memory(m.page).unwrap();
assert_eq!(node.id, m.id);
expected.insert(m.page.0, (m.id, m.stamp, node));
}
assert_eq!(expected.len(), 48 - 4 + 5 + 1);
e.release_process_lock();
expected
}
fn assert_same_node(a: &MemoryNode, b: &MemoryNode) {
assert_eq!(
serde_json::to_value(a).unwrap(),
serde_json::to_value(b).unwrap()
);
}
fn assert_migrated(engine: &StorageEngine, expected: &Expected, stamps: bool) {
let map = engine.memory_map().unwrap();
assert_eq!(map.len(), expected.len());
for m in &map {
let (id, stamp, node) = &expected[&m.page.0];
assert_eq!(m.id, *id, "page {}", m.page.0);
if stamps {
assert_eq!(m.stamp, *stamp, "stamp of page {}", m.page.0);
}
assert_same_node(&engine.load_memory(m.page).unwrap(), node);
}
}
fn legacy_files(dir: &Path) -> Vec<(String, Vec<u8>)> {
[LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE]
.iter()
.filter_map(|n| std::fs::read(dir.join(n)).ok().map(|b| (n.to_string(), b)))
.collect()
}
fn copy_dir(from: &Path, to: &Path) {
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap();
}
}
#[test]
fn migration_keeps_every_memory_locator_and_stamp() {
let dir = tempfile::tempdir().unwrap();
let expected = legacy_fixture(dir.path());
let before = std::fs::metadata(dir.path().join(LEGACY_PAGE_FILE))
.unwrap()
.len();
let engine = StorageEngine::open(dir.path()).unwrap();
assert_migrated(&engine, &expected, true);
for n in [LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE] {
assert!(!dir.path().join(n).exists(), "{n} removed");
}
let after = engine.data_bytes();
assert!(after * 5 < before, "{after} bytes vs {before} before");
let max_old = *expected.keys().max().unwrap();
let fresh = typical(1000);
let p = engine.store_memory(&fresh).unwrap();
assert!(p.0 > max_old);
engine.release_process_lock();
drop(engine);
let engine = StorageEngine::open(dir.path()).unwrap();
assert_eq!(engine.page_reads(), 0);
assert_same_node(&engine.load_memory(p).unwrap(), &fresh);
let mut with_fresh = expected.clone();
with_fresh.insert(p.0, (fresh.id, 0, fresh));
assert_migrated(&engine, &with_fresh, false);
assert_eq!(engine.scan_all_memories().len(), with_fresh.len());
}
#[test]
fn migration_without_a_snapshot_still_carries_every_memory() {
let dir = tempfile::tempdir().unwrap();
let expected = legacy_fixture(dir.path());
std::fs::remove_file(dir.path().join(LEGACY_MAP_FILE)).unwrap();
let engine = StorageEngine::open(dir.path()).unwrap();
assert_migrated(&engine, &expected, false);
}
#[test]
fn migration_skips_corrupt_pages_and_keeps_the_rest() {
let dir = tempfile::tempdir().unwrap();
let expected = legacy_fixture(dir.path());
let victim = *expected.keys().next().unwrap();
let path = dir.path().join(LEGACY_PAGE_FILE);
let mut raw = std::fs::read(&path).unwrap();
raw[victim as usize * LEGACY_PAGE_SIZE + 100] ^= 0xff;
std::fs::write(&path, &raw).unwrap();
let engine = StorageEngine::open(dir.path()).unwrap();
let mut rest = expected.clone();
rest.remove(&victim);
assert_migrated(&engine, &rest, true);
}
#[test]
fn a_crash_at_any_migration_step_leaves_a_whole_database() {
for fault in [
MigrationFault::MidDataWrite,
MigrationFault::AfterDataSync,
MigrationFault::AfterWal,
MigrationFault::BeforeCommit,
MigrationFault::AfterCommit,
] {
let dir = tempfile::tempdir().unwrap();
let expected = legacy_fixture(dir.path());
let old = legacy_files(dir.path());
let err =
StorageEngine::open_with_migration_fault(dir.path(), &StorageConfig::default(), fault);
assert!(err.is_err(), "{fault:?}: the injected crash stops the open");
if fault == MigrationFault::AfterCommit {
assert!(dir.path().join(DATA_FILE).exists());
assert_eq!(legacy_files(dir.path()), old);
} else {
assert_eq!(legacy_files(dir.path()), old, "{fault:?}");
assert!(!dir.path().join(DATA_FILE).exists(), "{fault:?}");
let copy = tempfile::tempdir().unwrap();
copy_dir(dir.path(), copy.path());
for stale in [
DATA_FILE.to_string(),
format!("{DATA_FILE}.tmp"),
WAL_FILE.to_string(),
MEMORY_MAP_FILE.to_string(),
] {
let _ = std::fs::remove_file(copy.path().join(stale));
}
let old_engine = v1::StorageEngine::open(copy.path()).unwrap();
let mut seen: Vec<(MemoryId, u64)> = old_engine
.memory_map()
.unwrap()
.into_iter()
.map(|m| (m.id, m.page.0))
.collect();
seen.sort_by_key(|(_, p)| *p);
let want: Vec<(MemoryId, u64)> =
expected.iter().map(|(p, (id, _, _))| (*id, *p)).collect();
assert_eq!(seen, want, "{fault:?}: old engine sees the old data");
old_engine.close().unwrap();
}
let engine = StorageEngine::open(dir.path()).unwrap();
assert_migrated(&engine, &expected, true);
for n in [LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE] {
assert!(!dir.path().join(n).exists(), "{fault:?}: {n} moved aside");
}
assert!(
dir.path().join(format!("{LEGACY_PAGE_FILE}.v1")).exists(),
"{fault:?}: the pre-migration page file is kept"
);
assert!(!dir.path().join(format!("{DATA_FILE}.tmp")).exists());
engine.close().unwrap();
}
}
#[test]
fn an_empty_legacy_database_migrates() {
let dir = tempfile::tempdir().unwrap();
{
let e = v1::StorageEngine::open(dir.path()).unwrap();
e.close().unwrap();
}
let engine = StorageEngine::open(dir.path()).unwrap();
assert!(engine.memory_map().unwrap().is_empty());
assert!(!dir.path().join(LEGACY_PAGE_FILE).exists());
let p = engine.store_memory(&typical(1)).unwrap();
assert!(p.0 >= 1);
}