use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
};
use sparrowdb_common::{Error, Lsn, Result};
use super::codec::{
WalPayload, WalRecord, WalRecordKind, WAL_FORMAT_VERSION, WAL_FORMAT_VERSION_LEGACY,
};
use super::writer::segment_path;
use crate::encryption::EncryptionContext;
pub struct ReplayResult {
pub last_applied_lsn: Lsn,
pub txns_replayed: usize,
pub pages_applied: usize,
}
pub struct WalReplayer;
impl WalReplayer {
pub fn replay(
wal_dir: &Path,
last_applied_lsn: Lsn,
apply_fn: impl FnMut(u64, &[u8], Lsn) -> Result<()>,
) -> Result<ReplayResult> {
Self::replay_inner(
wal_dir,
last_applied_lsn,
EncryptionContext::none(),
apply_fn,
)
}
pub fn replay_encrypted(
wal_dir: &Path,
last_applied_lsn: Lsn,
key: [u8; 32],
apply_fn: impl FnMut(u64, &[u8], Lsn) -> Result<()>,
) -> Result<ReplayResult> {
Self::replay_inner(
wal_dir,
last_applied_lsn,
EncryptionContext::with_key(key),
apply_fn,
)
}
fn replay_inner(
wal_dir: &Path,
last_applied_lsn: Lsn,
enc: EncryptionContext,
mut apply_fn: impl FnMut(u64, &[u8], Lsn) -> Result<()>,
) -> Result<ReplayResult> {
let segments = collect_segments(wal_dir)?;
let mut all_records: BTreeMap<u64, WalRecord> = BTreeMap::new();
let mut torn_at: Option<u64> = None;
'outer: for seg_no in &segments {
let path = segment_path(wal_dir, *seg_no);
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) => return Err(Error::Io(e)),
};
if data.is_empty() {
continue;
}
let version = data[0];
if version != WAL_FORMAT_VERSION && version != WAL_FORMAT_VERSION_LEGACY {
return Err(Error::Corruption(format!(
"WAL segment {seg_no} has unrecognised version byte {version}. \
Supported versions: {WAL_FORMAT_VERSION} (current), \
{WAL_FORMAT_VERSION_LEGACY} (legacy 0.1.2)."
)));
}
let mut offset = 1usize;
while offset < data.len() {
if data[offset..].iter().all(|&b| b == 0) {
break;
}
match WalRecord::decode_with_version(&data[offset..], version) {
Ok((rec, consumed)) => {
all_records.insert(rec.lsn.0, rec);
offset += consumed;
}
Err(Error::ChecksumMismatch) => {
torn_at = Some(offset as u64);
break 'outer;
}
Err(Error::Corruption(_)) => {
break 'outer;
}
Err(e) => return Err(e),
}
}
}
let mut begun: HashMap<u64, bool> = HashMap::new(); let mut committed: HashSet<u64> = HashSet::new();
let mut aborted: HashSet<u64> = HashSet::new();
for rec in all_records.values() {
match rec.kind {
WalRecordKind::Begin => {
begun.insert(rec.txn_id.0, true);
}
WalRecordKind::Commit => {
if begun.contains_key(&rec.txn_id.0) {
committed.insert(rec.txn_id.0);
}
}
WalRecordKind::Abort => {
begun.remove(&rec.txn_id.0);
aborted.insert(rec.txn_id.0);
}
_ => {}
}
}
let mut last_applied = last_applied_lsn.0;
let mut txns_replayed_set: HashSet<u64> = HashSet::new();
let mut pages_applied = 0usize;
for (lsn, rec) in &all_records {
if *lsn <= last_applied_lsn.0 {
continue;
}
if let Some(lsn_limit) = torn_at {
let _ = lsn_limit;
}
if rec.kind == WalRecordKind::Write && committed.contains(&rec.txn_id.0) {
let resolved = match &rec.payload {
WalPayload::Raw(raw_bytes) => {
let plaintext = if enc.is_encrypted() {
enc.decrypt_wal_payload(*lsn, raw_bytes)?
} else {
raw_bytes.clone()
};
WalPayload::decode_plaintext(rec.kind, &plaintext)?
}
other => other.clone(),
};
if let WalPayload::Write { page_id, image } = &resolved {
apply_fn(*page_id, image, Lsn(*lsn))?;
pages_applied += 1;
txns_replayed_set.insert(rec.txn_id.0);
if *lsn > last_applied {
last_applied = *lsn;
}
}
}
}
for (lsn, rec) in &all_records {
if *lsn <= last_applied_lsn.0 {
continue;
}
if rec.kind == WalRecordKind::Commit
&& committed.contains(&rec.txn_id.0)
&& *lsn > last_applied
{
last_applied = *lsn;
}
}
Ok(ReplayResult {
last_applied_lsn: Lsn(last_applied),
txns_replayed: txns_replayed_set.len(),
pages_applied,
})
}
}
#[derive(Debug, Clone)]
pub struct CommittedMutation {
pub lsn: u64,
pub txn_id: u64,
pub payload: WalPayload,
}
impl WalReplayer {
pub fn scan_mutations(wal_dir: &Path) -> Result<Vec<CommittedMutation>> {
Self::scan_mutations_inner(wal_dir, EncryptionContext::none())
}
pub fn scan_mutations_encrypted(
wal_dir: &Path,
key: [u8; 32],
) -> Result<Vec<CommittedMutation>> {
Self::scan_mutations_inner(wal_dir, EncryptionContext::with_key(key))
}
fn scan_mutations_inner(
wal_dir: &Path,
enc: EncryptionContext,
) -> Result<Vec<CommittedMutation>> {
let segments = match collect_segments(wal_dir) {
Ok(s) => s,
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
if segments.is_empty() {
return Ok(Vec::new());
}
let mut all_records: BTreeMap<u64, WalRecord> = BTreeMap::new();
'outer: for seg_no in &segments {
let path = segment_path(wal_dir, *seg_no);
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(Error::Io(e)),
};
if data.is_empty() {
continue;
}
let version = data[0];
if version != WAL_FORMAT_VERSION && version != WAL_FORMAT_VERSION_LEGACY {
return Err(Error::Corruption(format!(
"WAL segment {seg_no} has unrecognised version byte {version}."
)));
}
let mut offset = 1usize;
while offset < data.len() {
if data[offset..].iter().all(|&b| b == 0) {
break;
}
match WalRecord::decode_with_version(&data[offset..], version) {
Ok((rec, consumed)) => {
all_records.insert(rec.lsn.0, rec);
offset += consumed;
}
Err(Error::ChecksumMismatch) | Err(Error::Corruption(_)) => break 'outer,
Err(e) => return Err(e),
}
}
}
let mut begun: HashSet<u64> = HashSet::new();
let mut committed: HashSet<u64> = HashSet::new();
for rec in all_records.values() {
match rec.kind {
WalRecordKind::Begin => {
begun.insert(rec.txn_id.0);
}
WalRecordKind::Commit => {
if begun.contains(&rec.txn_id.0) {
committed.insert(rec.txn_id.0);
}
}
WalRecordKind::Abort => {
begun.remove(&rec.txn_id.0);
}
_ => {}
}
}
let structural_kinds = [
WalRecordKind::NodeCreate,
WalRecordKind::NodeUpdate,
WalRecordKind::NodeDelete,
WalRecordKind::EdgeCreate,
WalRecordKind::EdgeDelete,
];
let mut mutations = Vec::new();
for (lsn, rec) in &all_records {
if !committed.contains(&rec.txn_id.0) {
continue;
}
if !structural_kinds.contains(&rec.kind) {
continue;
}
let payload = match &rec.payload {
WalPayload::Raw(raw_bytes) => {
let plaintext = if enc.is_encrypted() {
enc.decrypt_wal_payload(*lsn, raw_bytes)?
} else {
raw_bytes.clone()
};
WalPayload::decode_plaintext(rec.kind, &plaintext)?
}
other => other.clone(),
};
mutations.push(CommittedMutation {
lsn: *lsn,
txn_id: rec.txn_id.0,
payload,
});
}
Ok(mutations)
}
}
pub struct WalSchema {
pub node_props: HashMap<u32, HashSet<String>>,
pub rel_props: HashMap<String, HashSet<String>>,
}
impl WalReplayer {
pub fn scan_schema(wal_dir: &Path) -> Result<WalSchema> {
let segments = match collect_segments(wal_dir) {
Ok(s) => s,
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(WalSchema {
node_props: HashMap::new(),
rel_props: HashMap::new(),
})
}
Err(e) => return Err(e),
};
let mut all_records: BTreeMap<u64, WalRecord> = BTreeMap::new();
'outer: for seg_no in &segments {
let path = segment_path(wal_dir, *seg_no);
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(Error::Io(e)),
};
if data.is_empty() {
continue;
}
let version = data[0];
if version != WAL_FORMAT_VERSION && version != WAL_FORMAT_VERSION_LEGACY {
return Err(Error::Corruption(format!(
"WAL segment {seg_no} has unrecognised version byte {version}. \
Supported versions: {WAL_FORMAT_VERSION} (current), \
{WAL_FORMAT_VERSION_LEGACY} (legacy 0.1.2)."
)));
}
let mut offset = 1usize;
while offset < data.len() {
if data[offset..].iter().all(|&b| b == 0) {
break;
}
match WalRecord::decode_with_version(&data[offset..], version) {
Ok((rec, consumed)) => {
all_records.insert(rec.lsn.0, rec);
offset += consumed;
}
Err(Error::ChecksumMismatch) | Err(Error::Corruption(_)) => break 'outer,
Err(e) => return Err(e),
}
}
}
let mut begun: HashSet<u64> = HashSet::new();
let mut committed: HashSet<u64> = HashSet::new();
for rec in all_records.values() {
match rec.kind {
WalRecordKind::Begin => {
begun.insert(rec.txn_id.0);
}
WalRecordKind::Commit => {
if begun.contains(&rec.txn_id.0) {
committed.insert(rec.txn_id.0);
}
}
WalRecordKind::Abort => {
begun.remove(&rec.txn_id.0);
}
_ => {}
}
}
let mut node_label: HashMap<u64, u32> = HashMap::new();
let mut schema = WalSchema {
node_props: HashMap::new(),
rel_props: HashMap::new(),
};
for rec in all_records.values() {
if !committed.contains(&rec.txn_id.0) {
continue;
}
match &rec.payload {
WalPayload::NodeCreate {
node_id,
label_id,
props,
} => {
node_label.insert(*node_id, *label_id);
let entry = schema.node_props.entry(*label_id).or_default();
for (name, _) in props {
entry.insert(name.clone());
}
}
WalPayload::NodeUpdate { node_id, key, .. } => {
if !key.is_empty() {
if let Some(&label_id) = node_label.get(node_id) {
schema
.node_props
.entry(label_id)
.or_default()
.insert(key.clone());
}
}
}
WalPayload::EdgeCreate {
rel_type, props, ..
} => {
let entry = schema.rel_props.entry(rel_type.clone()).or_default();
for (name, _) in props {
entry.insert(name.clone());
}
}
_ => {}
}
}
Ok(schema)
}
}
fn collect_segments(wal_dir: &Path) -> Result<Vec<u64>> {
let mut segments = Vec::new();
let entries = std::fs::read_dir(wal_dir).map_err(Error::Io)?;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy().to_string();
if name.starts_with("segment-") && name.ends_with(".wal") {
let num_str = &name["segment-".len()..name.len() - ".wal".len()];
if let Ok(n) = num_str.parse::<u64>() {
segments.push(n);
}
}
}
segments.sort();
Ok(segments)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wal::writer::WalWriter;
use sparrowdb_common::TxnId;
use std::collections::HashMap;
use std::io::Write as _;
use tempfile::TempDir;
fn write_txns(writer: &mut WalWriter, count: u64, start_page: u64) -> Vec<(u64, Vec<u8>)> {
let mut written = Vec::new();
for i in 0..count {
let txn_id = TxnId(100 + i);
let page_id = start_page + i;
let image = vec![(i as u8).wrapping_add(0xAA); 32];
writer
.commit_transaction(txn_id, &[(page_id, image.clone())])
.unwrap();
written.push((page_id, image));
}
written
}
#[test]
fn test_wal_append_and_replay() {
let dir = TempDir::new().unwrap();
let mut writer = WalWriter::open(dir.path()).unwrap();
let txns = write_txns(&mut writer, 3, 0);
drop(writer);
let mut applied: HashMap<u64, Vec<u8>> = HashMap::new();
let result = WalReplayer::replay(dir.path(), Lsn(0), |page_id, image, _lsn| {
applied.insert(page_id, image.to_vec());
Ok(())
})
.unwrap();
assert_eq!(result.pages_applied, 3);
for (page_id, image) in &txns {
assert_eq!(applied.get(page_id), Some(image));
}
}
#[test]
fn test_wal_replay_idempotent() {
let dir = TempDir::new().unwrap();
let mut writer = WalWriter::open(dir.path()).unwrap();
write_txns(&mut writer, 2, 0);
drop(writer);
let mut apply_count = 0usize;
let r1 = WalReplayer::replay(dir.path(), Lsn(0), |_, _, _| {
apply_count += 1;
Ok(())
})
.unwrap();
let mut apply_count2 = 0usize;
WalReplayer::replay(dir.path(), Lsn(0), |_, _, _| {
apply_count2 += 1;
Ok(())
})
.unwrap();
assert_eq!(apply_count, apply_count2, "replay must be idempotent");
let mut apply_count3 = 0usize;
WalReplayer::replay(dir.path(), r1.last_applied_lsn, |_, _, _| {
apply_count3 += 1;
Ok(())
})
.unwrap();
assert_eq!(
apply_count3, 0,
"replaying past commit horizon must apply nothing"
);
}
#[test]
fn test_torn_page_detected_and_recovered() {
let dir = TempDir::new().unwrap();
let mut writer = WalWriter::open(dir.path()).unwrap();
writer
.commit_transaction(TxnId(1), &[(0, vec![0xAA; 32])])
.unwrap();
drop(writer);
let seg_path = super::super::writer::segment_path(dir.path(), 0);
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&seg_path)
.unwrap();
let torn: &[u8] = &[
21, 0, 0, 0, 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0, 0, 0, 0, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF, ];
f.write_all(torn).unwrap();
drop(f);
let mut applied: HashMap<u64, Vec<u8>> = HashMap::new();
let result = WalReplayer::replay(dir.path(), Lsn(0), |page_id, image, _| {
applied.insert(page_id, image.to_vec());
Ok(())
})
.unwrap();
assert!(
applied.contains_key(&0),
"committed page must be applied before torn record"
);
assert_eq!(applied.len(), 1, "only the committed page must be applied");
assert!(result.pages_applied >= 1);
}
#[test]
fn test_empty_wal_replay_returns_zero() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path()).unwrap();
let result = WalReplayer::replay(dir.path(), Lsn(0), |_, _, _| Ok(())).unwrap();
assert_eq!(result.pages_applied, 0);
assert_eq!(result.txns_replayed, 0);
}
#[test]
fn test_replay_aborted_txn_not_applied() {
let dir = TempDir::new().unwrap();
let mut writer = WalWriter::open(dir.path()).unwrap();
writer
.commit_transaction(TxnId(1), &[(0, vec![0xAA; 32])])
.unwrap();
let txn2 = TxnId(2);
writer
.append(WalRecordKind::Begin, txn2, WalPayload::Empty)
.unwrap();
writer
.append(
WalRecordKind::Write,
txn2,
WalPayload::Write {
page_id: 1,
image: vec![0xBB; 32],
},
)
.unwrap();
writer
.append(WalRecordKind::Abort, txn2, WalPayload::Empty)
.unwrap();
writer.fsync().unwrap();
drop(writer);
let mut applied: HashMap<u64, Vec<u8>> = HashMap::new();
WalReplayer::replay(dir.path(), Lsn(0), |page_id, image, _| {
applied.insert(page_id, image.to_vec());
Ok(())
})
.unwrap();
assert!(
applied.contains_key(&0),
"committed txn page must be applied"
);
assert!(
!applied.contains_key(&1),
"aborted txn page must NOT be applied"
);
}
#[test]
fn test_replay_multiple_writes_same_page() {
let dir = TempDir::new().unwrap();
let mut writer = WalWriter::open(dir.path()).unwrap();
writer
.commit_transaction(TxnId(1), &[(0, vec![0x11; 32])])
.unwrap();
writer
.commit_transaction(TxnId(2), &[(0, vec![0x22; 32])])
.unwrap();
drop(writer);
let mut last_image: Option<Vec<u8>> = None;
WalReplayer::replay(dir.path(), Lsn(0), |_, image, _| {
last_image = Some(image.to_vec());
Ok(())
})
.unwrap();
assert_eq!(last_image, Some(vec![0x22; 32]));
}
}