use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
const MAGIC_LE: u32 = 0x377f_0682;
const MAGIC_BE: u32 = 0x377f_0683;
const WAL_HEADER: usize = 32;
const FRAME_HEADER: usize = 24;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub index: u32,
pub page: u32,
pub db_size: u32,
pub commit: bool,
pub live: bool,
}
#[derive(Debug, Clone)]
pub struct WalTail {
pub path: PathBuf,
pub page_size: u32,
pub checkpoint_seq: u32,
pub salt1: u32,
pub salt2: u32,
pub live_frames: u32,
pub total_frames: u32,
pub commits: u32,
pub size: u64,
pub frames: Vec<Frame>,
}
impl WalTail {
pub fn distinct_pages(&self) -> usize {
let mut seen: Vec<u32> = self
.frames
.iter()
.filter(|f| f.live)
.map(|f| f.page)
.collect();
seen.sort_unstable();
seen.dedup();
seen.len()
}
pub fn uncommitted(&self) -> usize {
self.frames
.iter()
.rev()
.take_while(|f| f.live && !f.commit)
.count()
}
}
pub fn wal_path(db: &Path) -> PathBuf {
let mut name = db.as_os_str().to_os_string();
name.push("-wal");
PathBuf::from(name)
}
pub fn read_tail(db: &Path, window: usize) -> Option<WalTail> {
let path = wal_path(db);
let size = std::fs::metadata(&path).ok()?.len();
if size < WAL_HEADER as u64 {
return None;
}
let mut f = std::fs::File::open(&path).ok()?;
let mut head = [0u8; WAL_HEADER];
f.read_exact(&mut head).ok()?;
let magic = be32(&head[0..4]);
if magic != MAGIC_LE && magic != MAGIC_BE {
return None;
}
let page_size = be32(&head[8..12]);
if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
return None;
}
let (salt1, salt2) = (be32(&head[16..20]), be32(&head[20..24]));
let stride = FRAME_HEADER as u64 + page_size as u64;
let total_frames = ((size - WAL_HEADER as u64) / stride) as u32;
let mut frames: Vec<Frame> = Vec::new();
let (mut live_frames, mut commits) = (0u32, 0u32);
let mut buf = [0u8; FRAME_HEADER];
for i in 0..total_frames {
let at = WAL_HEADER as u64 + i as u64 * stride;
if f.seek(SeekFrom::Start(at)).is_err() || f.read_exact(&mut buf).is_err() {
break;
}
let page = be32(&buf[0..4]);
let db_size = be32(&buf[4..8]);
let live = be32(&buf[8..12]) == salt1 && be32(&buf[12..16]) == salt2;
if live {
live_frames += 1;
if db_size != 0 {
commits += 1;
}
}
frames.push(Frame {
index: i + 1,
page,
db_size,
commit: db_size != 0,
live,
});
}
if frames.len() > window {
frames.drain(..frames.len() - window);
}
Some(WalTail {
path,
page_size,
checkpoint_seq: be32(&head[12..16]),
salt1,
salt2,
live_frames,
total_frames,
commits,
size,
frames,
})
}
pub struct NewFrames {
pub page_size: u32,
pub salt1: u32,
pub salt2: u32,
pub restarted: bool,
pub frames: Vec<Frame>,
pub total_frames: u32,
}
pub fn read_frames_after(db: &Path, after: u32, salts: Option<(u32, u32)>) -> Option<NewFrames> {
let path = wal_path(db);
let size = std::fs::metadata(&path).ok()?.len();
if size < WAL_HEADER as u64 {
return None;
}
let mut f = std::fs::File::open(&path).ok()?;
let mut head = [0u8; WAL_HEADER];
f.read_exact(&mut head).ok()?;
let magic = be32(&head[0..4]);
if magic != MAGIC_LE && magic != MAGIC_BE {
return None;
}
let page_size = be32(&head[8..12]);
if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
return None;
}
let (salt1, salt2) = (be32(&head[16..20]), be32(&head[20..24]));
let restarted = salts.is_some_and(|(s1, s2)| s1 != salt1 || s2 != salt2);
let start = if restarted { 0 } else { after };
let stride = FRAME_HEADER as u64 + page_size as u64;
let total_frames = ((size - WAL_HEADER as u64) / stride) as u32;
let mut frames = Vec::new();
let mut buf = [0u8; FRAME_HEADER];
for i in start..total_frames {
let at = WAL_HEADER as u64 + i as u64 * stride;
if f.seek(SeekFrom::Start(at)).is_err() || f.read_exact(&mut buf).is_err() {
break;
}
let db_size = be32(&buf[4..8]);
frames.push(Frame {
index: i + 1,
page: be32(&buf[0..4]),
db_size,
commit: db_size != 0,
live: be32(&buf[8..12]) == salt1 && be32(&buf[12..16]) == salt2,
});
}
Some(NewFrames {
page_size,
salt1,
salt2,
restarted,
frames,
total_frames,
})
}
pub fn latest_pages(db: &Path) -> Option<(u32, std::collections::HashMap<u32, Vec<u8>>, u32)> {
let path = wal_path(db);
let size = std::fs::metadata(&path).ok()?.len();
if size < WAL_HEADER as u64 {
return None;
}
let mut f = std::fs::File::open(&path).ok()?;
let mut head = [0u8; WAL_HEADER];
f.read_exact(&mut head).ok()?;
let magic = be32(&head[0..4]);
if magic != MAGIC_LE && magic != MAGIC_BE {
return None;
}
let page_size = be32(&head[8..12]);
if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
return None;
}
let (salt1, salt2) = (be32(&head[16..20]), be32(&head[20..24]));
let stride = FRAME_HEADER as u64 + page_size as u64;
let total = ((size - WAL_HEADER as u64) / stride) as u32;
let mut pages: std::collections::HashMap<u32, Vec<u8>> = std::collections::HashMap::new();
let mut db_size = 0u32;
let mut fh = [0u8; FRAME_HEADER];
let mut image = vec![0u8; page_size as usize];
for i in 0..total {
let at = WAL_HEADER as u64 + i as u64 * stride;
if f.seek(SeekFrom::Start(at)).is_err() || f.read_exact(&mut fh).is_err() {
break;
}
if be32(&fh[8..12]) != salt1 || be32(&fh[12..16]) != salt2 {
break;
}
if f.read_exact(&mut image).is_err() {
break;
}
let page = be32(&fh[0..4]);
let after = be32(&fh[4..8]);
if after != 0 {
db_size = after;
}
pages.insert(page, image.clone());
}
Some((page_size, pages, db_size))
}
pub fn frame_page(db: &Path, index: u32) -> Option<(u32, Vec<u8>)> {
let path = wal_path(db);
let mut f = std::fs::File::open(&path).ok()?;
let mut head = [0u8; WAL_HEADER];
f.read_exact(&mut head).ok()?;
let page_size = be32(&head[8..12]);
if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() || index == 0 {
return None;
}
let stride = FRAME_HEADER as u64 + page_size as u64;
let at = WAL_HEADER as u64 + (index as u64 - 1) * stride;
f.seek(SeekFrom::Start(at)).ok()?;
let mut fh = [0u8; FRAME_HEADER];
f.read_exact(&mut fh).ok()?;
let mut page = vec![0u8; page_size as usize];
f.read_exact(&mut page).ok()?;
Some((be32(&fh[0..4]), page))
}
fn be32(b: &[u8]) -> u32 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("zdbview_wal_{}_{name}", std::process::id()));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(wal_path(&p));
p
}
fn wal_db(name: &str, rows: usize) -> (PathBuf, rusqlite::Connection) {
let path = scratch(name);
let conn = rusqlite::Connection::open(&path).expect("open");
conn.pragma_update(None, "journal_mode", "WAL")
.expect("wal");
conn.pragma_update(None, "wal_autocheckpoint", 0)
.expect("no autockpt");
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])
.expect("create");
for i in 0..rows {
conn.execute("INSERT INTO t (v) VALUES (?1)", [format!("row-{i}")])
.expect("insert");
}
(path, conn)
}
#[test]
fn no_wal_file_is_not_an_error() {
let p = scratch("absent");
std::fs::write(&p, b"not a database").unwrap();
assert!(
read_tail(&p, 32).is_none(),
"a missing log reads as None, not a failure"
);
}
#[test]
fn a_non_wal_file_is_rejected_by_its_magic() {
let p = scratch("garbage");
std::fs::write(&p, b"db").unwrap();
std::fs::write(wal_path(&p), vec![0xab; 4096]).unwrap();
assert!(
read_tail(&p, 32).is_none(),
"the header magic gates the walk"
);
}
#[test]
fn frames_carry_pages_and_commit_boundaries() {
let (path, _conn) = wal_db("frames", 5);
let tail = read_tail(&path, 64).expect("a log exists");
assert!(tail.page_size >= 512 && tail.page_size.is_power_of_two());
assert!(tail.total_frames > 0, "writes produced frames");
assert_eq!(
tail.live_frames, tail.total_frames,
"nothing checkpointed yet"
);
assert!(tail.commits >= 5, "each insert commits: {}", tail.commits);
assert!(
tail.frames.iter().any(|f| f.commit && f.db_size > 0),
"a commit frame carries the post-commit page count"
);
assert!(
tail.frames.iter().all(|f| f.page > 0),
"page numbers are 1-based"
);
assert!(tail.distinct_pages() > 0);
}
#[test]
fn the_window_keeps_the_newest_frames() {
let (path, _conn) = wal_db("window", 12);
let all = read_tail(&path, 1_000).expect("log");
let last3 = read_tail(&path, 3).expect("log");
assert_eq!(last3.frames.len(), 3, "the window caps what is returned");
assert_eq!(
last3.frames.last().map(|f| f.index),
all.frames.last().map(|f| f.index),
"the tail is the NEWEST frames, not the oldest"
);
assert_eq!(
last3.total_frames, all.total_frames,
"the counts describe the whole log, not the window"
);
}
#[test]
fn a_checkpoint_makes_older_frames_stale() {
let (path, conn) = wal_db("checkpoint", 4);
let before = read_tail(&path, 1_000).expect("log");
assert_eq!(before.live_frames, before.total_frames);
conn.pragma_update(None, "wal_checkpoint", "RESTART")
.expect("checkpoint");
conn.execute("INSERT INTO t (v) VALUES ('after')", [])
.expect("insert");
let after = read_tail(&path, 1_000).expect("log");
assert_ne!(
(after.salt1, after.salt2),
(before.salt1, before.salt2),
"a checkpoint rolls the salts"
);
assert!(
after.live_frames < after.total_frames,
"frames from the previous log are still present but no longer live: {} of {}",
after.live_frames,
after.total_frames
);
assert!(
after.frames.iter().any(|f| !f.live),
"the stale region is reported, not hidden"
);
}
}