use super::*;
use crate::wal::binary::{decode_binary_wal_record, BinaryWalRecordDecode};
use crate::wal::frame::{BinaryWalReadEvent, BinaryWalReader};
struct Fixture {
root: PathBuf,
store: GraphStore,
pinned: GraphStore,
candidate: CheckpointCandidate,
}
fn copy_tree(source: &Path, destination: &Path) {
fs::create_dir(destination).unwrap();
for entry in fs::read_dir(source).unwrap() {
let entry = entry.unwrap();
let path = destination.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_tree(&entry.path(), &path);
} else {
fs::copy(entry.path(), path).unwrap();
}
}
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"hawdb-checkpoint-verbatim-{}",
hawdb_core::generate_uuidv7().unwrap()
));
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&root, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Memory",
BTreeMap::from([
("id".into(), Value::Int(1)),
("payload".into(), Value::Binary(vec![5; 17])),
]),
)
.unwrap();
let pinned = store.checkpoint_source();
let candidate = pinned
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
BTreeMap::from([
("id".into(), Value::Int(2)),
("payload".into(), Value::Binary(vec![7; 70_001])),
]),
)
.unwrap();
Self {
root,
store,
pinned,
candidate,
}
}
fn replace_suffix(&mut self, edit: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
let durable = self.store.durable.as_ref().unwrap();
let mut reader = BinaryWalReader::range(
fs::File::open(&durable.wal_path).unwrap(),
durable.wal_generation,
None,
self.candidate.captured_wal_bytes,
durable.wal_bytes,
)
.unwrap();
let mut payload = match reader.next_event().unwrap() {
BinaryWalReadEvent::Record { payload, .. } => payload,
_ => panic!("fixture suffix must contain one complete transaction"),
};
assert!(matches!(
reader.next_event().unwrap(),
BinaryWalReadEvent::Eof
));
drop(reader);
edit(&mut payload);
let mut bytes = fs::read(&durable.wal_path).unwrap();
bytes.truncate(self.candidate.captured_wal_bytes as usize);
bytes.extend(crate::wal::frame::frame_binary_wal_record(
durable.wal_generation,
&payload,
self.candidate.captured_wal_bytes - WAL_BINARY_FILE_HEADER_BYTES as u64,
));
fs::write(&durable.wal_path, &bytes).unwrap();
self.store.durable.as_mut().unwrap().wal_bytes = bytes.len() as u64;
payload
}
fn private_wal(&self) -> Vec<u8> {
fs::read(
&self
.candidate
.store
.as_ref()
.unwrap()
.durable
.as_ref()
.unwrap()
.wal_path,
)
.unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
let _ = fs::remove_dir_all(self.root.with_extension("reopen"));
}
}
#[test]
fn checkpoint_suffix_preserves_valid_noncanonical_payload_bytes() {
let mut fixture = Fixture::new();
let payload = fixture.replace_suffix(|payload| {
assert!(payload[21] < 128);
payload[21] |= 128;
payload.insert(22, 0);
});
let decoded = match decode_binary_wal_record(&payload).unwrap() {
BinaryWalRecordDecode::Entry {
entry,
commit_epoch,
} => (entry, commit_epoch),
_ => panic!("fixture must remain valid under the ordinary decoder"),
};
assert_ne!(
crate::wal::binary::encode_binary_wal_record(&decoded.0, decoded.1).unwrap(),
payload,
);
let expected = fixture
.store
.node_records_owned()
.collect::<Result<Vec<_>>>()
.unwrap();
let epoch = fixture.store.commit_epoch();
fixture.candidate.catch_up(&fixture.store).unwrap();
let private = fixture.private_wal();
let mut reader = BinaryWalReader::range(
std::io::Cursor::new(&private),
fixture.candidate.prepared.as_ref().unwrap().generation,
None,
WAL_BINARY_FILE_HEADER_BYTES as u64,
private.len() as u64,
)
.unwrap();
match reader.next_event().unwrap() {
BinaryWalReadEvent::Record {
payload: actual, ..
} => assert_eq!(actual, payload),
_ => panic!("private WAL must contain the complete original payload"),
}
assert!(matches!(
reader.next_event().unwrap(),
BinaryWalReadEvent::Eof
));
fixture
.store
.publish_checkpoint_candidate(&mut fixture.candidate, None, &BTreeSet::new())
.unwrap();
assert_eq!(fixture.pinned.scan_nodes(None).count(), 1);
let reopened_root = fixture.root.with_extension("reopen");
copy_tree(&fixture.root, &reopened_root);
let mut reopened_catalog = Catalog::default();
let reopened = GraphStore::open_read_only_with_durability(
&reopened_root,
&mut reopened_catalog,
DurabilityPolicy::SyncOnEveryWrite,
RecoveryMode::Strict,
)
.unwrap();
assert_eq!(reopened.commit_epoch(), epoch);
assert_eq!(
reopened
.node_records_owned()
.collect::<Result<Vec<_>>>()
.unwrap(),
expected
);
}
#[test]
fn checkpoint_suffix_rejects_embedded_relational_corruption_before_mutation() {
for invalid_epoch in [false, true] {
let mut fixture = Fixture::new();
let epoch = fixture.store.commit_epoch();
let mut record = crate::relational::encode_relational_wal_batch(
epoch + u64::from(invalid_epoch),
&crate::relational::RelationalTransaction::default(),
)
.unwrap();
if !invalid_epoch {
let last = record.len() - 1;
record[last] ^= 0xff;
}
fixture.replace_suffix(|payload| {
let BinaryWalRecordDecode::Entry {
mut entry,
commit_epoch,
} = decode_binary_wal_record(payload).unwrap()
else {
panic!("fixture suffix must contain a transaction");
};
entry.op = WalOp::Batch(vec![
entry.op,
WalOp::Relational {
record: record.into(),
},
]);
*payload = crate::wal::binary::encode_binary_wal_record(&entry, commit_epoch).unwrap();
});
let manifest = fs::read(fixture.store.durable.as_ref().unwrap().manifest_path()).unwrap();
let private = fixture.private_wal();
let complete_bytes = fixture.candidate.candidate_wal_bytes;
let result = fixture.candidate.catch_up(&fixture.store);
assert!(
matches!(result, Err(HawDBError::StorageIntegrity(_))),
"{result:?}"
);
assert!(fixture.private_wal().starts_with(&private));
assert_eq!(fixture.candidate.candidate_wal_bytes, complete_bytes);
assert_eq!(
fixture.candidate.commit_epoch(),
fixture.pinned.commit_epoch()
);
assert!(!fixture.candidate.can_continue_from(&fixture.store));
assert!(fixture.store.ensure_usable().is_err());
assert_eq!(
fs::read(fixture.store.durable.as_ref().unwrap().manifest_path()).unwrap(),
manifest
);
}
}
#[test]
fn checkpoint_suffix_rejects_valid_framing_with_wrong_commit_epoch() {
let mut fixture = Fixture::new();
let epoch = fixture.store.commit_epoch();
fixture.replace_suffix(|payload| payload[9..17].copy_from_slice(&(epoch + 5).to_le_bytes()));
let manifest = fs::read(fixture.store.durable.as_ref().unwrap().manifest_path()).unwrap();
let private = fixture.private_wal();
let result = fixture.candidate.catch_up(&fixture.store);
assert!(
matches!(result, Err(HawDBError::StorageIntegrity(_))),
"incorrect epoch must not be normalized: {result:?}"
);
assert_eq!(fixture.private_wal(), private);
assert_eq!(
fixture.candidate.commit_epoch(),
fixture.pinned.commit_epoch()
);
assert!(!fixture.candidate.can_continue_from(&fixture.store));
assert!(fixture.store.ensure_usable().is_err());
assert_eq!(
fs::read(fixture.store.durable.as_ref().unwrap().manifest_path()).unwrap(),
manifest
);
}