use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::Path;
use std::time::Instant;
use mentedb_core::MemoryNode;
use mentedb_core::error::{MenteError, MenteResult};
use mentedb_core::types::MemoryId;
use tracing::{info, warn};
use crate::memory_map::{self, RecordEntry, RecordTable, Token, entry_stamp};
use crate::record::{self, DATA_FILE, Extent, WAL_FILE, le_u32, le_u64};
use crate::wal::{Wal, WalEntry, WalEntryType};
pub const LEGACY_PAGE_SIZE: usize = 64 * 1024;
const LEGACY_HEADER_SIZE: usize = 32;
const LEGACY_DATA_SIZE: usize = LEGACY_PAGE_SIZE - LEGACY_HEADER_SIZE;
const LEGACY_MAGIC: u64 = 0x4D454E_5445444231;
const LEGACY_VERSION: u32 = 1;
const LEGACY_DATA_PAGE: u8 = 1;
pub const LEGACY_PAGE_FILE: &str = "pages.db";
pub const LEGACY_WAL_FILE: &str = "wal.log";
pub const LEGACY_MAP_FILE: &str = "memory_map.bin";
const LEGACY_MAP_MAGIC: &[u8; 8] = b"MDBMMAP1";
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MigrationFault {
MidDataWrite,
AfterDataSync,
AfterWal,
BeforeCommit,
AfterCommit,
}
#[derive(Debug, Clone, Default)]
pub struct MigrationReport {
pub memories: u64,
pub legacy_pages: u64,
pub skipped_pages: u64,
pub wal_ops: u64,
pub stamps_kept: u64,
pub bytes_before: u64,
pub bytes_after: u64,
pub elapsed_ms: u64,
}
fn file_len(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
pub(crate) fn is_legacy(dir: &Path) -> bool {
!dir.join(DATA_FILE).exists() && file_len(&dir.join(LEGACY_PAGE_FILE)) > 0
}
pub const LEGACY_KEPT_SUFFIX: &str = ".v1";
pub(crate) fn remove_leftovers(dir: &Path) -> MenteResult<()> {
let mut kept = Vec::new();
for name in [LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE] {
let p = dir.join(name);
if p.exists() {
std::fs::rename(&p, dir.join(format!("{name}{LEGACY_KEPT_SUFFIX}")))?;
kept.push(name);
}
}
let mut removed = Vec::new();
for name in ["wal.log.tmp", "memory_map.bin.tmp", "records.db.tmp"] {
let p = dir.join(name);
if p.exists() {
std::fs::remove_file(&p)?;
removed.push(name);
}
}
if !kept.is_empty() || !removed.is_empty() {
record::sync_dir(dir)?;
info!(
?kept,
?removed,
suffix = LEGACY_KEPT_SUFFIX,
"settled format 1 files after migration"
);
}
Ok(())
}
fn decode_legacy_memory(data: &[u8]) -> Option<MemoryNode> {
let data = &data[..data.len().min(LEGACY_DATA_SIZE)];
if data.len() < 4 {
return None;
}
let len = le_u32(&data[..4]) as usize;
if len == 0 || len + 4 > LEGACY_DATA_SIZE || 4 + len > data.len() {
return None;
}
serde_json::from_slice(&data[4..4 + len]).ok()
}
fn decode_legacy_page(page_id: u64, buf: &[u8]) -> MenteResult<Option<MemoryNode>> {
let header = &buf[..LEGACY_HEADER_SIZE];
let data = &buf[LEGACY_HEADER_SIZE..LEGACY_PAGE_SIZE];
let checksum = le_u32(&header[16..20]);
if checksum != 0 {
let mut h = crc32fast::Hasher::new();
h.update(&header[0..8]); h.update(&header[8..16]); h.update(&header[20..22]); h.update(&header[22..24]); h.update(&header[24..25]); h.update(data);
if h.finalize() != checksum {
return Err(MenteError::Storage(format!(
"legacy page {page_id} checksum mismatch"
)));
}
}
if header[24] != LEGACY_DATA_PAGE {
return Ok(None);
}
Ok(decode_legacy_memory(data))
}
fn legacy_stamps(
dir: &Path,
entries: &[WalEntry],
) -> Option<std::collections::HashMap<u64, (MemoryId, u64)>> {
let bytes = std::fs::read(dir.join(LEGACY_MAP_FILE)).ok()?;
if bytes.len() < 8 + 4 + 16 + 8 + 8 + 8 + 4 {
return None;
}
let (body, crc) = bytes.split_at(bytes.len() - 4);
if crc32fast::hash(body) != le_u32(crc) || &body[..8] != LEGACY_MAP_MAGIC {
return None;
}
if le_u32(&body[8..12]) != 1 {
return None;
}
let mut token: Token = [0; 16];
token.copy_from_slice(&body[12..28]);
let mut pos = 28 + 8; let take = |pos: &mut usize, n: usize| -> Option<&[u8]> {
let s = body.get(*pos..*pos + n)?;
*pos += n;
Some(s)
};
let n = le_u64(take(&mut pos, 8)?);
let mut stamps = std::collections::HashMap::new();
for _ in 0..n {
let page = le_u64(take(&mut pos, 8)?);
let id = uuid::Uuid::from_slice(take(&mut pos, 16)?).ok()?;
let stamp = le_u64(take(&mut pos, 8)?);
stamps.insert(page, (MemoryId(id), stamp));
}
let at = entries
.iter()
.rposition(|e| e.entry_type == WalEntryType::Checkpoint && e.data == token)?;
for e in &entries[at + 1..] {
match e.entry_type {
WalEntryType::PageWrite => match decode_legacy_memory(&e.data) {
Some(node) => {
stamps.insert(e.page_id, (node.id, entry_stamp(&token, e.lsn)));
}
None => {
stamps.remove(&e.page_id);
}
},
WalEntryType::PageFree => {
stamps.remove(&e.page_id);
}
_ => {}
}
}
Some(stamps)
}
fn legacy_page_count(file: &mut File) -> MenteResult<u64> {
let mut hdr = [0u8; 32];
file.read_exact(&mut hdr)?;
if le_u64(&hdr[0..8]) != LEGACY_MAGIC {
return Err(MenteError::Storage("invalid page file magic number".into()));
}
let version = le_u32(&hdr[8..12]);
if version != LEGACY_VERSION {
return Err(MenteError::Storage(format!(
"unsupported page file version: {version}"
)));
}
Ok(le_u64(&hdr[16..24]))
}
fn fault(at: Option<MigrationFault>, here: MigrationFault) -> MenteResult<()> {
if at == Some(here) {
return Err(MenteError::Storage(format!(
"injected migration fault: {here:?}"
)));
}
Ok(())
}
pub(crate) fn migrate(dir: &Path, at: Option<MigrationFault>) -> MenteResult<MigrationReport> {
let started = Instant::now();
let pages_path = dir.join(LEGACY_PAGE_FILE);
let tmp_path = dir.join(format!("{DATA_FILE}.tmp"));
let wal_path = dir.join(WAL_FILE);
for p in [
tmp_path.clone(),
wal_path.clone(),
dir.join(format!("{WAL_FILE}.tmp")),
dir.join(memory_map::MEMORY_MAP_FILE),
dir.join(format!("{}.tmp", memory_map::MEMORY_MAP_FILE)),
] {
if p.exists() {
std::fs::remove_file(&p)?;
}
}
let mut report = MigrationReport {
bytes_before: [LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE]
.iter()
.map(|n| file_len(&dir.join(n)))
.sum(),
..Default::default()
};
info!(
dir = %dir.display(),
bytes = report.bytes_before,
"migrating page file database to the record layout"
);
let entries = Wal::read_entries(&dir.join(LEGACY_WAL_FILE))?;
let mut last_op: std::collections::HashMap<u64, &WalEntry> = Default::default();
for e in &entries {
if matches!(
e.entry_type,
WalEntryType::PageWrite | WalEntryType::PageFree
) {
last_op.insert(e.page_id, e);
}
}
report.wal_ops = last_op.len() as u64;
let stamps = legacy_stamps(dir, &entries);
let mut pages_file = File::open(&pages_path)?;
let header_count = legacy_page_count(&mut pages_file)?;
let file_pages = file_len(&pages_path) / LEGACY_PAGE_SIZE as u64;
let wal_max = last_op.keys().max().map_or(0, |m| m + 1);
let page_count = header_count.max(wal_max);
report.legacy_pages = page_count;
let mut reader = BufReader::with_capacity(64 * LEGACY_PAGE_SIZE, pages_file);
let mut skip = vec![0u8; LEGACY_PAGE_SIZE - 32];
reader.read_exact(&mut skip)?;
let mut out = BufWriter::with_capacity(4 << 20, File::create(&tmp_path)?);
out.write_all(&record::file_header())?;
let mut block = 1u64;
let mut table = RecordTable::default();
let mut page = vec![0u8; LEGACY_PAGE_SIZE];
for page_id in 1..page_count {
let on_disk = page_id < file_pages;
if on_disk {
reader.read_exact(&mut page)?;
}
let node = match last_op.get(&page_id) {
Some(e) if e.entry_type == WalEntryType::PageWrite => decode_legacy_memory(&e.data),
Some(_) => None,
None if on_disk => match decode_legacy_page(page_id, &page) {
Ok(n) => n,
Err(e) => {
warn!(page_id, error = %e, "skipping unreadable legacy page");
None
}
},
None => None,
};
let Some(node) = node else {
report.skipped_pages += 1;
continue;
};
let payload = record::encode_node(&node)?;
let blocks = record::blocks_for(payload.len());
out.write_all(&record::live_image(page_id, node.id, &payload, blocks))?;
let stamp = match stamps.as_ref().and_then(|s| s.get(&page_id)) {
Some(&(id, stamp)) if id == node.id => {
report.stamps_kept += 1;
stamp
}
_ => memory_map::random_u64(),
};
table.records.insert(
page_id,
RecordEntry {
id: node.id,
stamp,
extent: Extent {
start: block,
blocks,
},
},
);
block += blocks;
report.memories += 1;
if report.memories == 1 {
fault(at, MigrationFault::MidDataWrite)?;
}
}
table.next_locator = page_count.max(1);
let out = out
.into_inner()
.map_err(|e| MenteError::Storage(format!("record file write failed: {e}")))?;
out.sync_all()?;
drop(out);
fault(at, MigrationFault::AfterDataSync)?;
let token = memory_map::new_token();
{
let mut wal = Wal::open_file(&wal_path)?;
wal.append(WalEntryType::Checkpoint, 0, &token)?;
wal.sync()?;
}
fault(at, MigrationFault::AfterWal)?;
memory_map::write_snapshot(dir, &token, &table)?;
record::sync_dir(dir)?;
fault(at, MigrationFault::BeforeCommit)?;
std::fs::rename(&tmp_path, dir.join(DATA_FILE))?;
record::sync_dir(dir)?;
fault(at, MigrationFault::AfterCommit)?;
remove_leftovers(dir)?;
report.bytes_after = [DATA_FILE, WAL_FILE, memory_map::MEMORY_MAP_FILE]
.iter()
.map(|n| file_len(&dir.join(n)))
.sum();
report.elapsed_ms = started.elapsed().as_millis() as u64;
info!(
dir = %dir.display(),
memories = report.memories,
legacy_pages = report.legacy_pages,
skipped_pages = report.skipped_pages,
wal_ops = report.wal_ops,
stamps_kept = report.stamps_kept,
bytes_before = report.bytes_before,
bytes_after = report.bytes_after,
elapsed_ms = report.elapsed_ms,
"migrated page file database to the record layout"
);
Ok(report)
}