use crate::api::errors::{Error, Result};
use crate::engine::compact::CompactReason;
use crate::layout::BlobGuid;
use super::txn_op::TxnOp;
pub const RECORD_MAGIC: u32 = 0x5243_4552;
pub const RECORD_HEADER_SIZE: usize = 4 + 4 + 8 + 1;
pub const RECORD_FOOTER_SIZE: usize = 4;
pub const RECORD_OVERHEAD: usize = RECORD_HEADER_SIZE + RECORD_FOOTER_SIZE;
pub const FILE_MAGIC: u32 = 0x414C_4157;
pub const FORMAT_VERSION: u32 = 1;
pub const FILE_HEADER_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileHeader {
pub tree_id: u64,
pub created_at: u64,
}
impl FileHeader {
#[must_use]
pub fn now(tree_id: u64) -> Self {
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
Self {
tree_id,
created_at,
}
}
}
pub fn encode_file_header(h: &FileHeader, out: &mut Vec<u8>) {
out.extend_from_slice(&FILE_MAGIC.to_le_bytes());
out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
out.extend_from_slice(&h.tree_id.to_le_bytes());
out.extend_from_slice(&h.created_at.to_le_bytes());
out.extend_from_slice(&0u64.to_le_bytes());
debug_assert_eq!(out.len(), FILE_HEADER_SIZE);
}
pub fn decode_file_header(buf: &[u8]) -> Result<FileHeader> {
if buf.len() < FILE_HEADER_SIZE {
return Err(sanity("WAL file header truncated"));
}
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
if magic != FILE_MAGIC {
return Err(sanity("WAL file magic mismatch"));
}
let version = u32::from_le_bytes(buf[4..8].try_into().unwrap());
if version != FORMAT_VERSION {
return Err(sanity("WAL file format version unsupported"));
}
let tree_id = u64::from_le_bytes(buf[8..16].try_into().unwrap());
let created_at = u64::from_le_bytes(buf[16..24].try_into().unwrap());
Ok(FileHeader {
tree_id,
created_at,
})
}
const TY_INSERT: u8 = 0;
const TY_ERASE: u8 = 1;
const TY_SPLIT: u8 = 2;
const TY_MERGE: u8 = 3;
const TY_COMPACT: u8 = 4;
const TY_RENAME_OBJECT: u8 = 5;
const TY_RENAME: u8 = 6;
const TY_NEW_TREE: u8 = 7;
const TY_RM_TREE: u8 = 8;
const TY_MEM_MARKER: u8 = 9;
const TY_BATCH: u8 = 10;
const REASON_SPLIT_TOMBSTONE: u8 = 0;
const REASON_SPLIT_GAP_SPACE: u8 = 1;
const REASON_OUT_OF_BLOB_FRAME: u8 = 2;
const CRC32_TABLE: [u32; 256] = build_crc32_table();
const fn build_crc32_table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0usize;
while i < 256 {
let mut crc = i as u32;
let mut j = 0;
while j < 8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
j += 1;
}
table[i] = crc;
i += 1;
}
table
}
pub fn crc32(bytes: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in bytes {
let idx = ((crc ^ u32::from(b)) & 0xFF) as usize;
crc = (crc >> 8) ^ CRC32_TABLE[idx];
}
!crc
}
pub fn encode_record(op: &TxnOp, seq: u64, out: &mut Vec<u8>) -> Result<()> {
write_record(out, seq, variant_tag(op), |buf| encode_body(op, buf));
Ok(())
}
fn write_record<F>(out: &mut Vec<u8>, seq: u64, ty: u8, write_body: F)
where
F: FnOnce(&mut Vec<u8>),
{
let start = out.len();
out.extend_from_slice(&RECORD_MAGIC.to_le_bytes());
let len_pos = out.len();
out.extend_from_slice(&[0u8; 4]);
out.extend_from_slice(&seq.to_le_bytes());
out.push(ty);
let body_start = out.len();
write_body(out);
let body_end = out.len();
let body_len = u32::try_from(body_end - body_start).expect("body fits in u32");
out[len_pos..len_pos + 4].copy_from_slice(&body_len.to_le_bytes());
let crc = crc32(&out[start..body_end]);
out.extend_from_slice(&crc.to_le_bytes());
}
pub fn encode_insert_record(
out: &mut Vec<u8>,
seq: u64,
tree_id: u64,
key: &[u8],
value: &[u8],
prev_value: Option<&[u8]>,
) {
write_record(out, seq, TY_INSERT, |buf| {
buf.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(buf, key);
write_bytes(buf, value);
write_optional_bytes(buf, prev_value);
});
}
pub fn encode_erase_record(out: &mut Vec<u8>, seq: u64, tree_id: u64, key: &[u8], value: &[u8]) {
write_record(out, seq, TY_ERASE, |buf| {
buf.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(buf, key);
write_bytes(buf, value);
});
}
pub fn encode_rename_object_record(
out: &mut Vec<u8>,
seq: u64,
tree_id: u64,
src_key: &[u8],
dst_key: &[u8],
force: bool,
) {
write_record(out, seq, TY_RENAME_OBJECT, |buf| {
buf.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(buf, src_key);
write_bytes(buf, dst_key);
buf.push(u8::from(force));
});
}
fn variant_tag(op: &TxnOp) -> u8 {
match op {
TxnOp::Insert { .. } => TY_INSERT,
TxnOp::Erase { .. } => TY_ERASE,
TxnOp::Split { .. } => TY_SPLIT,
TxnOp::Merge { .. } => TY_MERGE,
TxnOp::Compact { .. } => TY_COMPACT,
TxnOp::RenameObject { .. } => TY_RENAME_OBJECT,
TxnOp::Rename { .. } => TY_RENAME,
TxnOp::NewTree { .. } => TY_NEW_TREE,
TxnOp::RmTree { .. } => TY_RM_TREE,
TxnOp::MemMarker { .. } => TY_MEM_MARKER,
TxnOp::Batch { .. } => TY_BATCH,
}
}
fn encode_body(op: &TxnOp, out: &mut Vec<u8>) {
match op {
TxnOp::Insert {
tree_id,
seq: _,
key,
value,
prev_value,
} => {
out.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(out, key);
write_bytes(out, value);
write_optional_bytes(out, prev_value.as_deref());
}
TxnOp::Erase {
tree_id,
seq: _,
key,
value,
} => {
out.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(out, key);
write_bytes(out, value);
}
TxnOp::Split {
parent_blob,
pre_split_node,
new_child_blob,
new_child_entry,
} => {
out.extend_from_slice(parent_blob);
out.extend_from_slice(&pre_split_node.to_le_bytes());
out.extend_from_slice(new_child_blob);
out.extend_from_slice(&new_child_entry.to_le_bytes());
}
TxnOp::Merge {
parent_blob,
pre_merge_node,
child_blob,
} => {
out.extend_from_slice(parent_blob);
out.extend_from_slice(&pre_merge_node.to_le_bytes());
out.extend_from_slice(child_blob);
}
TxnOp::Compact { blob, reason } => {
out.extend_from_slice(blob);
out.push(encode_reason(*reason));
}
TxnOp::RenameObject {
tree_id,
seq: _,
src_key,
dst_key,
force,
} => {
out.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(out, src_key);
write_bytes(out, dst_key);
out.push(u8::from(*force));
}
TxnOp::Rename {
src_tree_id,
dst_tree_id,
seq: _,
src_key,
dst_key,
force,
} => {
out.extend_from_slice(&src_tree_id.to_le_bytes());
out.extend_from_slice(&dst_tree_id.to_le_bytes());
write_bytes(out, src_key);
write_bytes(out, dst_key);
out.push(u8::from(*force));
}
TxnOp::NewTree { tree_id, name } => {
out.extend_from_slice(&tree_id.to_le_bytes());
write_bytes(out, name);
}
TxnOp::RmTree { tree_id } => {
out.extend_from_slice(&tree_id.to_le_bytes());
}
TxnOp::MemMarker { seq: _ } => {
}
TxnOp::Batch { tree_id, ops } => {
out.extend_from_slice(&tree_id.to_le_bytes());
let count = u32::try_from(ops.len()).expect("batch ops fit in u32");
out.extend_from_slice(&count.to_le_bytes());
for inner in ops {
let inner_ty = variant_tag(inner);
assert!(
inner_ty != TY_BATCH,
"nested Batch is rejected — Tree::txn must flatten",
);
out.push(inner_ty);
encode_body(inner, out);
}
}
}
}
fn encode_reason(r: CompactReason) -> u8 {
match r {
CompactReason::SplitTombstone => REASON_SPLIT_TOMBSTONE,
CompactReason::SplitGapSpace => REASON_SPLIT_GAP_SPACE,
CompactReason::OutOfBlobFrame => REASON_OUT_OF_BLOB_FRAME,
}
}
fn write_bytes(out: &mut Vec<u8>, b: &[u8]) {
let len = u32::try_from(b.len()).expect("byte string fits in u32");
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(b);
}
fn write_optional_bytes(out: &mut Vec<u8>, b: Option<&[u8]>) {
match b {
None => out.push(0),
Some(x) => {
out.push(1);
write_bytes(out, x);
}
}
}
#[derive(Debug)]
pub struct DecodedRecord {
pub op: TxnOp,
pub seq: u64,
pub bytes_consumed: usize,
}
pub fn decode_record(buf: &[u8]) -> Result<DecodedRecord> {
if buf.len() < RECORD_HEADER_SIZE {
return Err(sanity("record header truncated"));
}
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
if magic != RECORD_MAGIC {
return Err(sanity("record magic mismatch"));
}
let body_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize;
let seq = u64::from_le_bytes(buf[8..16].try_into().unwrap());
let ty = buf[16];
let total = RECORD_HEADER_SIZE + body_len + RECORD_FOOTER_SIZE;
if buf.len() < total {
return Err(sanity("record body truncated"));
}
let body_end = RECORD_HEADER_SIZE + body_len;
let crc_expected = u32::from_le_bytes(buf[body_end..body_end + 4].try_into().unwrap());
let crc_computed = crc32(&buf[..body_end]);
if crc_computed != crc_expected {
return Err(sanity("record CRC mismatch"));
}
let body = &buf[RECORD_HEADER_SIZE..body_end];
let op = decode_body(ty, body, seq)?;
Ok(DecodedRecord {
op,
seq,
bytes_consumed: total,
})
}
fn decode_body(ty: u8, body: &[u8], seq: u64) -> Result<TxnOp> {
let mut cursor = body;
let op = decode_body_into(ty, &mut cursor, seq)?;
if !cursor.is_empty() {
return Err(sanity("trailing bytes after variant body"));
}
Ok(op)
}
#[allow(clippy::too_many_lines)] fn decode_body_into(ty: u8, body: &mut &[u8], seq: u64) -> Result<TxnOp> {
let op = match ty {
TY_INSERT => {
let tree_id = read_u64(body)?;
let key = read_bytes(body)?;
let value = read_bytes(body)?;
let prev_value = read_optional_bytes(body)?;
TxnOp::Insert {
tree_id,
seq,
key,
value,
prev_value,
}
}
TY_ERASE => {
let tree_id = read_u64(body)?;
let key = read_bytes(body)?;
let value = read_bytes(body)?;
TxnOp::Erase {
tree_id,
seq,
key,
value,
}
}
TY_SPLIT => {
let parent_blob = read_guid(body)?;
let pre_split_node = read_u16(body)?;
let new_child_blob = read_guid(body)?;
let new_child_entry = read_u16(body)?;
TxnOp::Split {
parent_blob,
pre_split_node,
new_child_blob,
new_child_entry,
}
}
TY_MERGE => {
let parent_blob = read_guid(body)?;
let pre_merge_node = read_u16(body)?;
let child_blob = read_guid(body)?;
TxnOp::Merge {
parent_blob,
pre_merge_node,
child_blob,
}
}
TY_COMPACT => {
let blob = read_guid(body)?;
let reason = decode_reason(read_u8(body)?)?;
TxnOp::Compact { blob, reason }
}
TY_RENAME_OBJECT => {
let tree_id = read_u64(body)?;
let src_key = read_bytes(body)?;
let dst_key = read_bytes(body)?;
let force = read_u8(body)? != 0;
TxnOp::RenameObject {
tree_id,
seq,
src_key,
dst_key,
force,
}
}
TY_RENAME => {
let src_tree_id = read_u64(body)?;
let dst_tree_id = read_u64(body)?;
let src_key = read_bytes(body)?;
let dst_key = read_bytes(body)?;
let force = read_u8(body)? != 0;
TxnOp::Rename {
src_tree_id,
dst_tree_id,
seq,
src_key,
dst_key,
force,
}
}
TY_NEW_TREE => {
let tree_id = read_u64(body)?;
let name = read_bytes(body)?;
TxnOp::NewTree { tree_id, name }
}
TY_RM_TREE => {
let tree_id = read_u64(body)?;
TxnOp::RmTree { tree_id }
}
TY_MEM_MARKER => TxnOp::MemMarker { seq },
TY_BATCH => {
let tree_id = read_u64(body)?;
let count = read_u32(body)? as usize;
let mut ops = Vec::with_capacity(count);
for i in 0..count {
let inner_ty = read_u8(body)?;
if inner_ty == TY_BATCH {
return Err(sanity("nested Batch is rejected"));
}
let inner_seq = seq.wrapping_add(i as u64);
let inner = decode_body_into(inner_ty, body, inner_seq)?;
ops.push(inner);
}
TxnOp::Batch { tree_id, ops }
}
_ => return Err(sanity("unknown TxnOp variant tag")),
};
Ok(op)
}
fn decode_reason(t: u8) -> Result<CompactReason> {
match t {
REASON_SPLIT_TOMBSTONE => Ok(CompactReason::SplitTombstone),
REASON_SPLIT_GAP_SPACE => Ok(CompactReason::SplitGapSpace),
REASON_OUT_OF_BLOB_FRAME => Ok(CompactReason::OutOfBlobFrame),
_ => Err(sanity("unknown CompactReason tag")),
}
}
fn read_u8(body: &mut &[u8]) -> Result<u8> {
let (front, rest) = take(body, 1)?;
*body = rest;
Ok(front[0])
}
fn read_u16(body: &mut &[u8]) -> Result<u16> {
let (front, rest) = take(body, 2)?;
*body = rest;
Ok(u16::from_le_bytes(front.try_into().unwrap()))
}
fn read_u32(body: &mut &[u8]) -> Result<u32> {
let (front, rest) = take(body, 4)?;
*body = rest;
Ok(u32::from_le_bytes(front.try_into().unwrap()))
}
fn read_u64(body: &mut &[u8]) -> Result<u64> {
let (front, rest) = take(body, 8)?;
*body = rest;
Ok(u64::from_le_bytes(front.try_into().unwrap()))
}
fn read_guid(body: &mut &[u8]) -> Result<BlobGuid> {
let (front, rest) = take(body, 16)?;
*body = rest;
let mut g = [0u8; 16];
g.copy_from_slice(front);
Ok(g)
}
fn read_bytes(body: &mut &[u8]) -> Result<Vec<u8>> {
let len = read_u32(body)? as usize;
let (front, rest) = take(body, len)?;
*body = rest;
Ok(front.to_vec())
}
fn read_optional_bytes(body: &mut &[u8]) -> Result<Option<Vec<u8>>> {
match read_u8(body)? {
0 => Ok(None),
1 => Ok(Some(read_bytes(body)?)),
_ => Err(sanity("optional-bytes presence byte out of range")),
}
}
fn take(buf: &[u8], n: usize) -> Result<(&[u8], &[u8])> {
if buf.len() < n {
return Err(sanity("body truncated"));
}
Ok(buf.split_at(n))
}
fn sanity(context: &'static str) -> Error {
Error::ReplaySanityFailed {
context,
record_offset: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(op: TxnOp, seq: u64) {
let mut buf = Vec::new();
encode_record(&op, seq, &mut buf).unwrap();
let r = decode_record(&buf).unwrap();
assert_eq!(r.seq, seq);
assert_eq!(r.bytes_consumed, buf.len());
assert_eq!(format!("{:?}", r.op), format!("{op:?}"));
}
#[test]
fn crc32_matches_known_vector() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
#[test]
fn roundtrip_insert_with_prev_value() {
roundtrip(
TxnOp::Insert {
tree_id: 1,
seq: 42,
key: b"img/01.jpg".to_vec(),
value: b"v-new".to_vec(),
prev_value: Some(b"v-old".to_vec()),
},
42,
);
}
#[test]
fn roundtrip_insert_no_prev_value() {
roundtrip(
TxnOp::Insert {
tree_id: 0,
seq: 7,
key: b"new/key".to_vec(),
value: vec![0xAB; 200],
prev_value: None,
},
7,
);
}
#[test]
fn roundtrip_erase() {
roundtrip(
TxnOp::Erase {
tree_id: 3,
seq: 99,
key: b"img/02.jpg".to_vec(),
value: b"v".to_vec(),
},
99,
);
}
#[test]
fn roundtrip_split() {
roundtrip(
TxnOp::Split {
parent_blob: [0xAA; 16],
pre_split_node: 123,
new_child_blob: [0xBB; 16],
new_child_entry: 7,
},
500,
);
}
#[test]
fn roundtrip_merge() {
roundtrip(
TxnOp::Merge {
parent_blob: [0x33; 16],
pre_merge_node: 200,
child_blob: [0x44; 16],
},
501,
);
}
#[test]
fn roundtrip_compact_each_reason() {
for reason in [
CompactReason::SplitTombstone,
CompactReason::SplitGapSpace,
CompactReason::OutOfBlobFrame,
] {
roundtrip(
TxnOp::Compact {
blob: [0x77; 16],
reason,
},
700,
);
}
}
#[test]
fn roundtrip_rename_object() {
roundtrip(
TxnOp::RenameObject {
tree_id: 2,
seq: 10,
src_key: b"a/b".to_vec(),
dst_key: b"a/c".to_vec(),
force: true,
},
10,
);
}
#[test]
fn roundtrip_cross_tree_rename() {
roundtrip(
TxnOp::Rename {
src_tree_id: 1,
dst_tree_id: 2,
seq: 11,
src_key: b"x".to_vec(),
dst_key: b"y".to_vec(),
force: false,
},
11,
);
}
#[test]
fn roundtrip_new_and_rm_tree() {
roundtrip(
TxnOp::NewTree {
tree_id: 5,
name: b"bucket-images".to_vec(),
},
1,
);
roundtrip(TxnOp::RmTree { tree_id: 5 }, 2);
}
#[test]
fn roundtrip_mem_marker() {
roundtrip(TxnOp::MemMarker { seq: 999 }, 999);
}
#[test]
fn record_length_breakdown_is_predictable() {
let op = TxnOp::Insert {
tree_id: 0,
seq: 0,
key: b"k".to_vec(),
value: b"v".to_vec(),
prev_value: None,
};
let mut buf = Vec::new();
encode_record(&op, 0, &mut buf).unwrap();
assert_eq!(buf.len(), 40);
}
#[test]
fn corrupt_crc_is_caught() {
let op = TxnOp::Insert {
tree_id: 0,
seq: 1,
key: b"k".to_vec(),
value: b"v".to_vec(),
prev_value: None,
};
let mut buf = Vec::new();
encode_record(&op, 1, &mut buf).unwrap();
let last = buf.len() - 1;
buf[last] ^= 0x01;
match decode_record(&buf) {
Err(Error::ReplaySanityFailed { context, .. }) => {
assert!(context.contains("CRC"));
}
other => panic!("expected CRC sanity failure, got {other:?}"),
}
}
#[test]
fn corrupt_magic_is_caught() {
let op = TxnOp::MemMarker { seq: 5 };
let mut buf = Vec::new();
encode_record(&op, 5, &mut buf).unwrap();
buf[0] ^= 0xFF;
match decode_record(&buf) {
Err(Error::ReplaySanityFailed { context, .. }) => {
assert!(context.contains("magic"));
}
other => panic!("expected magic sanity failure, got {other:?}"),
}
}
#[test]
fn truncated_record_is_caught() {
let op = TxnOp::Insert {
tree_id: 0,
seq: 1,
key: vec![0xAB; 100],
value: vec![0xCD; 100],
prev_value: None,
};
let mut buf = Vec::new();
encode_record(&op, 1, &mut buf).unwrap();
let len = buf.len();
buf.truncate(len - 10);
match decode_record(&buf) {
Err(Error::ReplaySanityFailed { context, .. }) => {
assert!(context.contains("truncated"));
}
other => panic!("expected truncation sanity failure, got {other:?}"),
}
}
#[test]
fn unknown_variant_tag_is_caught() {
let op = TxnOp::MemMarker { seq: 1 };
let mut buf = Vec::new();
encode_record(&op, 1, &mut buf).unwrap();
buf[16] = 0xFF;
let body_end = RECORD_HEADER_SIZE; let crc = crc32(&buf[..body_end]);
buf[body_end..body_end + 4].copy_from_slice(&crc.to_le_bytes());
match decode_record(&buf) {
Err(Error::ReplaySanityFailed { context, .. }) => {
assert!(context.contains("variant"));
}
other => panic!("expected unknown-variant sanity failure, got {other:?}"),
}
}
#[test]
fn back_to_back_records_concatenate_cleanly() {
let mut buf = Vec::new();
encode_record(
&TxnOp::Insert {
tree_id: 0,
seq: 1,
key: b"k1".to_vec(),
value: b"v1".to_vec(),
prev_value: None,
},
1,
&mut buf,
)
.unwrap();
encode_record(
&TxnOp::Erase {
tree_id: 0,
seq: 2,
key: b"k1".to_vec(),
value: b"v1".to_vec(),
},
2,
&mut buf,
)
.unwrap();
let r1 = decode_record(&buf).unwrap();
assert_eq!(r1.seq, 1);
let r2 = decode_record(&buf[r1.bytes_consumed..]).unwrap();
assert_eq!(r2.seq, 2);
assert_eq!(r1.bytes_consumed + r2.bytes_consumed, buf.len());
}
#[test]
fn roundtrip_batch_three_inner_ops() {
let base = 100u64;
let batch = TxnOp::Batch {
tree_id: 0,
ops: vec![
TxnOp::Insert {
tree_id: 0,
seq: base,
key: b"a".to_vec(),
value: b"v-a".to_vec(),
prev_value: None,
},
TxnOp::Erase {
tree_id: 0,
seq: base + 1,
key: b"b".to_vec(),
value: b"v-b".to_vec(),
},
TxnOp::RenameObject {
tree_id: 0,
seq: base + 2,
src_key: b"c".to_vec(),
dst_key: b"d".to_vec(),
force: false,
},
],
};
let mut buf = Vec::new();
encode_record(&batch, base, &mut buf).unwrap();
let r = decode_record(&buf).unwrap();
assert_eq!(r.seq, base);
assert_eq!(r.bytes_consumed, buf.len());
match r.op {
TxnOp::Batch { tree_id, ops } => {
assert_eq!(tree_id, 0);
assert_eq!(ops.len(), 3);
match &ops[0] {
TxnOp::Insert { seq, key, .. } => {
assert_eq!(*seq, base);
assert_eq!(key, b"a");
}
other => panic!("expected Insert, got {other:?}"),
}
match &ops[1] {
TxnOp::Erase { seq, key, .. } => {
assert_eq!(*seq, base + 1);
assert_eq!(key, b"b");
}
other => panic!("expected Erase, got {other:?}"),
}
match &ops[2] {
TxnOp::RenameObject {
seq,
src_key,
dst_key,
force,
..
} => {
assert_eq!(*seq, base + 2);
assert_eq!(src_key, b"c");
assert_eq!(dst_key, b"d");
assert!(!force);
}
other => panic!("expected RenameObject, got {other:?}"),
}
}
other => panic!("expected Batch, got {other:?}"),
}
}
#[test]
fn roundtrip_batch_empty() {
let batch = TxnOp::Batch {
tree_id: 0,
ops: vec![],
};
let mut buf = Vec::new();
encode_record(&batch, 7, &mut buf).unwrap();
let r = decode_record(&buf).unwrap();
assert_eq!(r.seq, 7);
match r.op {
TxnOp::Batch { ops, .. } => assert!(ops.is_empty()),
other => panic!("expected Batch, got {other:?}"),
}
}
#[test]
#[should_panic(expected = "nested Batch is rejected")]
fn nested_batch_encode_panics() {
let inner = TxnOp::Batch {
tree_id: 0,
ops: vec![],
};
let outer = TxnOp::Batch {
tree_id: 0,
ops: vec![inner],
};
let mut buf = Vec::new();
let _ = encode_record(&outer, 0, &mut buf);
}
}