1use std::fs::File;
34use std::io::{BufReader, BufWriter, Read, Write};
35use std::path::Path;
36use std::time::Instant;
37
38use mentedb_core::MemoryNode;
39use mentedb_core::error::{MenteError, MenteResult};
40use mentedb_core::types::MemoryId;
41use tracing::{info, warn};
42
43use crate::memory_map::{self, RecordEntry, RecordTable, Token, entry_stamp};
44use crate::record::{self, DATA_FILE, Extent, WAL_FILE, le_u32, le_u64};
45use crate::wal::{Wal, WalEntry, WalEntryType};
46
47pub const LEGACY_PAGE_SIZE: usize = 64 * 1024;
49const LEGACY_HEADER_SIZE: usize = 32;
51const LEGACY_DATA_SIZE: usize = LEGACY_PAGE_SIZE - LEGACY_HEADER_SIZE;
53const LEGACY_MAGIC: u64 = 0x4D454E_5445444231;
54const LEGACY_VERSION: u32 = 1;
55const LEGACY_DATA_PAGE: u8 = 1;
56
57pub const LEGACY_PAGE_FILE: &str = "pages.db";
59pub const LEGACY_WAL_FILE: &str = "wal.log";
61pub const LEGACY_MAP_FILE: &str = "memory_map.bin";
63
64const LEGACY_MAP_MAGIC: &[u8; 8] = b"MDBMMAP1";
65
66#[doc(hidden)]
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum MigrationFault {
70 MidDataWrite,
72 AfterDataSync,
74 AfterWal,
76 BeforeCommit,
78 AfterCommit,
80}
81
82#[derive(Debug, Clone, Default)]
84pub struct MigrationReport {
85 pub memories: u64,
87 pub legacy_pages: u64,
89 pub skipped_pages: u64,
91 pub wal_ops: u64,
93 pub stamps_kept: u64,
95 pub bytes_before: u64,
97 pub bytes_after: u64,
99 pub elapsed_ms: u64,
101}
102
103fn file_len(path: &Path) -> u64 {
104 std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
105}
106
107pub(crate) fn is_legacy(dir: &Path) -> bool {
109 !dir.join(DATA_FILE).exists() && file_len(&dir.join(LEGACY_PAGE_FILE)) > 0
110}
111
112pub const LEGACY_KEPT_SUFFIX: &str = ".v1";
114
115pub(crate) fn remove_leftovers(dir: &Path) -> MenteResult<()> {
121 let mut kept = Vec::new();
122 for name in [LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE] {
123 let p = dir.join(name);
124 if p.exists() {
125 std::fs::rename(&p, dir.join(format!("{name}{LEGACY_KEPT_SUFFIX}")))?;
126 kept.push(name);
127 }
128 }
129 let mut removed = Vec::new();
130 for name in ["wal.log.tmp", "memory_map.bin.tmp", "records.db.tmp"] {
131 let p = dir.join(name);
132 if p.exists() {
133 std::fs::remove_file(&p)?;
134 removed.push(name);
135 }
136 }
137 if !kept.is_empty() || !removed.is_empty() {
138 record::sync_dir(dir)?;
139 info!(
140 ?kept,
141 ?removed,
142 suffix = LEGACY_KEPT_SUFFIX,
143 "settled format 1 files after migration"
144 );
145 }
146 Ok(())
147}
148
149fn decode_legacy_memory(data: &[u8]) -> Option<MemoryNode> {
151 let data = &data[..data.len().min(LEGACY_DATA_SIZE)];
152 if data.len() < 4 {
153 return None;
154 }
155 let len = le_u32(&data[..4]) as usize;
156 if len == 0 || len + 4 > LEGACY_DATA_SIZE || 4 + len > data.len() {
157 return None;
158 }
159 serde_json::from_slice(&data[4..4 + len]).ok()
160}
161
162fn decode_legacy_page(page_id: u64, buf: &[u8]) -> MenteResult<Option<MemoryNode>> {
165 let header = &buf[..LEGACY_HEADER_SIZE];
166 let data = &buf[LEGACY_HEADER_SIZE..LEGACY_PAGE_SIZE];
167 let checksum = le_u32(&header[16..20]);
168 if checksum != 0 {
169 let mut h = crc32fast::Hasher::new();
170 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);
176 if h.finalize() != checksum {
177 return Err(MenteError::Storage(format!(
178 "legacy page {page_id} checksum mismatch"
179 )));
180 }
181 }
182 if header[24] != LEGACY_DATA_PAGE {
183 return Ok(None);
184 }
185 Ok(decode_legacy_memory(data))
186}
187
188fn legacy_stamps(
191 dir: &Path,
192 entries: &[WalEntry],
193) -> Option<std::collections::HashMap<u64, (MemoryId, u64)>> {
194 let bytes = std::fs::read(dir.join(LEGACY_MAP_FILE)).ok()?;
195 if bytes.len() < 8 + 4 + 16 + 8 + 8 + 8 + 4 {
196 return None;
197 }
198 let (body, crc) = bytes.split_at(bytes.len() - 4);
199 if crc32fast::hash(body) != le_u32(crc) || &body[..8] != LEGACY_MAP_MAGIC {
200 return None;
201 }
202 if le_u32(&body[8..12]) != 1 {
203 return None;
204 }
205 let mut token: Token = [0; 16];
206 token.copy_from_slice(&body[12..28]);
207 let mut pos = 28 + 8; let take = |pos: &mut usize, n: usize| -> Option<&[u8]> {
209 let s = body.get(*pos..*pos + n)?;
210 *pos += n;
211 Some(s)
212 };
213 let n = le_u64(take(&mut pos, 8)?);
214 let mut stamps = std::collections::HashMap::new();
215 for _ in 0..n {
216 let page = le_u64(take(&mut pos, 8)?);
217 let id = uuid::Uuid::from_slice(take(&mut pos, 16)?).ok()?;
218 let stamp = le_u64(take(&mut pos, 8)?);
219 stamps.insert(page, (MemoryId(id), stamp));
220 }
221 let at = entries
224 .iter()
225 .rposition(|e| e.entry_type == WalEntryType::Checkpoint && e.data == token)?;
226 for e in &entries[at + 1..] {
227 match e.entry_type {
228 WalEntryType::PageWrite => match decode_legacy_memory(&e.data) {
229 Some(node) => {
230 stamps.insert(e.page_id, (node.id, entry_stamp(&token, e.lsn)));
231 }
232 None => {
233 stamps.remove(&e.page_id);
234 }
235 },
236 WalEntryType::PageFree => {
237 stamps.remove(&e.page_id);
238 }
239 _ => {}
240 }
241 }
242 Some(stamps)
243}
244
245fn legacy_page_count(file: &mut File) -> MenteResult<u64> {
247 let mut hdr = [0u8; 32];
248 file.read_exact(&mut hdr)?;
249 if le_u64(&hdr[0..8]) != LEGACY_MAGIC {
250 return Err(MenteError::Storage("invalid page file magic number".into()));
251 }
252 let version = le_u32(&hdr[8..12]);
253 if version != LEGACY_VERSION {
254 return Err(MenteError::Storage(format!(
255 "unsupported page file version: {version}"
256 )));
257 }
258 Ok(le_u64(&hdr[16..24]))
259}
260
261fn fault(at: Option<MigrationFault>, here: MigrationFault) -> MenteResult<()> {
262 if at == Some(here) {
263 return Err(MenteError::Storage(format!(
264 "injected migration fault: {here:?}"
265 )));
266 }
267 Ok(())
268}
269
270pub(crate) fn migrate(dir: &Path, at: Option<MigrationFault>) -> MenteResult<MigrationReport> {
273 let started = Instant::now();
274 let pages_path = dir.join(LEGACY_PAGE_FILE);
275 let tmp_path = dir.join(format!("{DATA_FILE}.tmp"));
276 let wal_path = dir.join(WAL_FILE);
277
278 for p in [
280 tmp_path.clone(),
281 wal_path.clone(),
282 dir.join(format!("{WAL_FILE}.tmp")),
283 dir.join(memory_map::MEMORY_MAP_FILE),
284 dir.join(format!("{}.tmp", memory_map::MEMORY_MAP_FILE)),
285 ] {
286 if p.exists() {
287 std::fs::remove_file(&p)?;
288 }
289 }
290
291 let mut report = MigrationReport {
292 bytes_before: [LEGACY_PAGE_FILE, LEGACY_WAL_FILE, LEGACY_MAP_FILE]
293 .iter()
294 .map(|n| file_len(&dir.join(n)))
295 .sum(),
296 ..Default::default()
297 };
298 info!(
299 dir = %dir.display(),
300 bytes = report.bytes_before,
301 "migrating page file database to the record layout"
302 );
303
304 let entries = Wal::read_entries(&dir.join(LEGACY_WAL_FILE))?;
306 let mut last_op: std::collections::HashMap<u64, &WalEntry> = Default::default();
307 for e in &entries {
308 if matches!(
309 e.entry_type,
310 WalEntryType::PageWrite | WalEntryType::PageFree
311 ) {
312 last_op.insert(e.page_id, e);
313 }
314 }
315 report.wal_ops = last_op.len() as u64;
316 let stamps = legacy_stamps(dir, &entries);
317
318 let mut pages_file = File::open(&pages_path)?;
319 let header_count = legacy_page_count(&mut pages_file)?;
320 let file_pages = file_len(&pages_path) / LEGACY_PAGE_SIZE as u64;
321 let wal_max = last_op.keys().max().map_or(0, |m| m + 1);
322 let page_count = header_count.max(wal_max);
323 report.legacy_pages = page_count;
324
325 let mut reader = BufReader::with_capacity(64 * LEGACY_PAGE_SIZE, pages_file);
328 let mut skip = vec![0u8; LEGACY_PAGE_SIZE - 32];
329 reader.read_exact(&mut skip)?; let mut out = BufWriter::with_capacity(4 << 20, File::create(&tmp_path)?);
332 out.write_all(&record::file_header())?;
333 let mut block = 1u64;
334 let mut table = RecordTable::default();
335 let mut page = vec![0u8; LEGACY_PAGE_SIZE];
336 for page_id in 1..page_count {
337 let on_disk = page_id < file_pages;
338 if on_disk {
339 reader.read_exact(&mut page)?;
340 }
341 let node = match last_op.get(&page_id) {
342 Some(e) if e.entry_type == WalEntryType::PageWrite => decode_legacy_memory(&e.data),
343 Some(_) => None,
344 None if on_disk => match decode_legacy_page(page_id, &page) {
345 Ok(n) => n,
346 Err(e) => {
347 warn!(page_id, error = %e, "skipping unreadable legacy page");
348 None
349 }
350 },
351 None => None,
352 };
353 let Some(node) = node else {
354 report.skipped_pages += 1;
355 continue;
356 };
357 let payload = record::encode_node(&node)?;
358 let blocks = record::blocks_for(payload.len());
359 out.write_all(&record::live_image(page_id, node.id, &payload, blocks))?;
360 let stamp = match stamps.as_ref().and_then(|s| s.get(&page_id)) {
361 Some(&(id, stamp)) if id == node.id => {
362 report.stamps_kept += 1;
363 stamp
364 }
365 _ => memory_map::random_u64(),
366 };
367 table.records.insert(
368 page_id,
369 RecordEntry {
370 id: node.id,
371 stamp,
372 extent: Extent {
373 start: block,
374 blocks,
375 },
376 },
377 );
378 block += blocks;
379 report.memories += 1;
380 if report.memories == 1 {
381 fault(at, MigrationFault::MidDataWrite)?;
382 }
383 }
384 table.next_locator = page_count.max(1);
385 let out = out
386 .into_inner()
387 .map_err(|e| MenteError::Storage(format!("record file write failed: {e}")))?;
388 out.sync_all()?;
389 drop(out);
390 fault(at, MigrationFault::AfterDataSync)?;
391
392 let token = memory_map::new_token();
394 {
395 let mut wal = Wal::open_file(&wal_path)?;
396 wal.append(WalEntryType::Checkpoint, 0, &token)?;
397 wal.sync()?;
398 }
399 fault(at, MigrationFault::AfterWal)?;
400 memory_map::write_snapshot(dir, &token, &table)?;
401 record::sync_dir(dir)?;
402 fault(at, MigrationFault::BeforeCommit)?;
403
404 std::fs::rename(&tmp_path, dir.join(DATA_FILE))?;
406 record::sync_dir(dir)?;
407 fault(at, MigrationFault::AfterCommit)?;
408
409 remove_leftovers(dir)?;
410 report.bytes_after = [DATA_FILE, WAL_FILE, memory_map::MEMORY_MAP_FILE]
411 .iter()
412 .map(|n| file_len(&dir.join(n)))
413 .sum();
414 report.elapsed_ms = started.elapsed().as_millis() as u64;
415 info!(
416 dir = %dir.display(),
417 memories = report.memories,
418 legacy_pages = report.legacy_pages,
419 skipped_pages = report.skipped_pages,
420 wal_ops = report.wal_ops,
421 stamps_kept = report.stamps_kept,
422 bytes_before = report.bytes_before,
423 bytes_after = report.bytes_after,
424 elapsed_ms = report.elapsed_ms,
425 "migrated page file database to the record layout"
426 );
427 Ok(report)
428}