Apply rustfmt to entire workspace
Runs cargo fmt --all; all 573 tests still passing, clippy still clean. No logic changes — formatting only. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3d524e2d63
commit
1c107fe58a
@@ -37,10 +37,7 @@ use crate::writer::OnionFile;
|
||||
/// let (bytes, onion) = open_revision("agent.h5", 7)?;
|
||||
/// let file = clawhdf5::File::from_bytes(bytes)?;
|
||||
/// ```
|
||||
pub fn open_revision(
|
||||
h5_path: &Path,
|
||||
revision: u64,
|
||||
) -> Result<(Vec<u8>, OnionFile), OnionError> {
|
||||
pub fn open_revision(h5_path: &Path, revision: u64) -> Result<(Vec<u8>, OnionFile), OnionError> {
|
||||
let h5_base = std::fs::read(h5_path)?;
|
||||
let onion = OnionFile::open(h5_path)?;
|
||||
let reconstructed = onion.reconstruct_revision(revision, &h5_base)?;
|
||||
@@ -48,10 +45,7 @@ pub fn open_revision(
|
||||
}
|
||||
|
||||
/// Open the HEAD of a named branch.
|
||||
pub fn open_branch(
|
||||
h5_path: &Path,
|
||||
branch: &str,
|
||||
) -> Result<(Vec<u8>, OnionFile), OnionError> {
|
||||
pub fn open_branch(h5_path: &Path, branch: &str) -> Result<(Vec<u8>, OnionFile), OnionError> {
|
||||
let h5_base = std::fs::read(h5_path)?;
|
||||
let onion = OnionFile::open(h5_path)?;
|
||||
let reconstructed = onion.open_rev(OpenRevision::Branch(branch.to_owned()), &h5_base)?;
|
||||
@@ -66,8 +60,10 @@ pub fn open_branch_at(
|
||||
) -> Result<(Vec<u8>, OnionFile), OnionError> {
|
||||
let h5_base = std::fs::read(h5_path)?;
|
||||
let onion = OnionFile::open(h5_path)?;
|
||||
let reconstructed =
|
||||
onion.open_rev(OpenRevision::BranchAt(branch.to_owned(), revision), &h5_base)?;
|
||||
let reconstructed = onion.open_rev(
|
||||
OpenRevision::BranchAt(branch.to_owned(), revision),
|
||||
&h5_base,
|
||||
)?;
|
||||
Ok((reconstructed, onion))
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,7 @@ impl OnionFile {
|
||||
/// Create a new named branch forked from the current HEAD of `source_branch`.
|
||||
///
|
||||
/// Returns the new branch ID.
|
||||
pub fn create_branch(
|
||||
&mut self,
|
||||
name: &str,
|
||||
source_branch: &str,
|
||||
) -> Result<u32, OnionError> {
|
||||
pub fn create_branch(&mut self, name: &str, source_branch: &str) -> Result<u32, OnionError> {
|
||||
if self.branch_by_name(name).is_some() {
|
||||
return Err(OnionError::BranchExists(name.to_string()));
|
||||
}
|
||||
@@ -133,11 +129,7 @@ impl OnionFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_latest_wins(
|
||||
&mut self,
|
||||
source_id: u32,
|
||||
target_id: u32,
|
||||
) -> Result<u64, OnionError> {
|
||||
fn merge_latest_wins(&mut self, source_id: u32, target_id: u32) -> Result<u64, OnionError> {
|
||||
let fork_rev = self
|
||||
.branches
|
||||
.iter()
|
||||
@@ -231,21 +223,21 @@ impl OnionFile {
|
||||
self.commit_session(session, Some(&annotation))
|
||||
}
|
||||
|
||||
fn merge_three_way(
|
||||
&mut self,
|
||||
source_id: u32,
|
||||
target_id: u32,
|
||||
) -> Result<u64, OnionError> {
|
||||
fn merge_three_way(&mut self, source_id: u32, target_id: u32) -> Result<u64, OnionError> {
|
||||
let source_head = self
|
||||
.index
|
||||
.branch_head(source_id)
|
||||
.ok_or_else(|| OnionError::Malformed(format!("source branch {source_id} has no revisions")))?
|
||||
.ok_or_else(|| {
|
||||
OnionError::Malformed(format!("source branch {source_id} has no revisions"))
|
||||
})?
|
||||
.revision;
|
||||
|
||||
let target_head = self
|
||||
.index
|
||||
.branch_head(target_id)
|
||||
.ok_or_else(|| OnionError::Malformed(format!("target branch {target_id} has no revisions")))?
|
||||
.ok_or_else(|| {
|
||||
OnionError::Malformed(format!("target branch {target_id} has no revisions"))
|
||||
})?
|
||||
.revision;
|
||||
|
||||
// Find common ancestor of the two branch HEADs
|
||||
@@ -253,13 +245,24 @@ impl OnionFile {
|
||||
.index
|
||||
.common_ancestor(source_head, target_head)
|
||||
.ok_or_else(|| {
|
||||
let src_name = self.branches.iter().find(|b| b.id == source_id)
|
||||
let src_name = self
|
||||
.branches
|
||||
.iter()
|
||||
.find(|b| b.id == source_id)
|
||||
.and_then(|b| self.annotations.get(b.name_off))
|
||||
.unwrap_or("?").to_owned();
|
||||
let tgt_name = self.branches.iter().find(|b| b.id == target_id)
|
||||
.unwrap_or("?")
|
||||
.to_owned();
|
||||
let tgt_name = self
|
||||
.branches
|
||||
.iter()
|
||||
.find(|b| b.id == target_id)
|
||||
.and_then(|b| self.annotations.get(b.name_off))
|
||||
.unwrap_or("?").to_owned();
|
||||
OnionError::NoCommonAncestor { a: src_name, b: tgt_name }
|
||||
.unwrap_or("?")
|
||||
.to_owned();
|
||||
OnionError::NoCommonAncestor {
|
||||
a: src_name,
|
||||
b: tgt_name,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Collect source delta since ancestor (latest write per offset)
|
||||
@@ -321,7 +324,8 @@ impl OnionFile {
|
||||
|
||||
if merged.is_empty() {
|
||||
return Err(OnionError::Malformed(
|
||||
"three-way merge: no source changes to apply (target already has all changes)".to_string(),
|
||||
"three-way merge: no source changes to apply (target already has all changes)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -329,9 +333,8 @@ impl OnionFile {
|
||||
for (h5_off, bytes) in &merged {
|
||||
session.record_page(*h5_off, bytes);
|
||||
}
|
||||
let annotation = format!(
|
||||
"3-way merge {source_id} → {target_id} [ancestor rev {ancestor_rev}]"
|
||||
);
|
||||
let annotation =
|
||||
format!("3-way merge {source_id} → {target_id} [ancestor rev {ancestor_rev}]");
|
||||
self.commit_session(session, Some(&annotation))
|
||||
}
|
||||
|
||||
@@ -371,11 +374,7 @@ impl OnionFile {
|
||||
.iter()
|
||||
.map(|b| BranchInfo {
|
||||
id: b.id,
|
||||
name: self
|
||||
.annotations
|
||||
.get(b.name_off)
|
||||
.unwrap_or("?")
|
||||
.to_owned(),
|
||||
name: self.annotations.get(b.name_off).unwrap_or("?").to_owned(),
|
||||
head_rev: b.head_rev,
|
||||
fork_rev: b.fork_rev,
|
||||
})
|
||||
@@ -383,7 +382,10 @@ impl OnionFile {
|
||||
}
|
||||
|
||||
/// Return revision entries for a named branch in chronological order.
|
||||
pub fn branch_history(&self, name: &str) -> Result<Vec<&crate::format::RevisionEntry>, OnionError> {
|
||||
pub fn branch_history(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Vec<&crate::format::RevisionEntry>, OnionError> {
|
||||
let branch = self
|
||||
.branch_by_name(name)
|
||||
.ok_or_else(|| OnionError::BranchNotFound(name.to_string()))?;
|
||||
@@ -414,7 +416,9 @@ impl OnionFile {
|
||||
.branch_by_name(name)
|
||||
.ok_or_else(|| OnionError::BranchNotFound(name.to_string()))?;
|
||||
if branch.id == crate::format::BRANCH_MAIN {
|
||||
return Err(OnionError::Malformed("cannot delete the main branch".into()));
|
||||
return Err(OnionError::Malformed(
|
||||
"cannot delete the main branch".into(),
|
||||
));
|
||||
}
|
||||
let id = branch.id;
|
||||
self.branches.retain(|b| b.id != id);
|
||||
@@ -605,14 +609,18 @@ mod tests {
|
||||
fn merge_latest_wins_produces_new_revision() {
|
||||
let (_h5, mut onion, _feat_id) = make_fork_scenario();
|
||||
let pre_count = onion.revision_count();
|
||||
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::LatestWins)
|
||||
.unwrap();
|
||||
assert_eq!(onion.revision_count(), pre_count + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_latest_wins_page_content_correct() {
|
||||
let (h5, mut onion, _feat_id) = make_fork_scenario();
|
||||
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::LatestWins)
|
||||
.unwrap();
|
||||
onion.flush().unwrap();
|
||||
|
||||
// Reload from disk and reconstruct main HEAD
|
||||
@@ -621,8 +629,10 @@ mod tests {
|
||||
let base = std::fs::read(&h5).unwrap();
|
||||
let state = onion2.reconstruct_revision(main_head, &base).unwrap();
|
||||
// Page 0 should now be BB (from feat)
|
||||
assert!(state[0..4096].iter().all(|&b| b == 0xBB),
|
||||
"page 0 should be BB after latest-wins merge");
|
||||
assert!(
|
||||
state[0..4096].iter().all(|&b| b == 0xBB),
|
||||
"page 0 should be BB after latest-wins merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -646,15 +656,19 @@ mod tests {
|
||||
s2.record_page(0, &vec![0x22u8; 4096]);
|
||||
onion.commit_session(s2, None).unwrap();
|
||||
|
||||
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::LatestWins)
|
||||
.unwrap();
|
||||
onion.flush().unwrap();
|
||||
|
||||
let onion2 = OnionFile::open(&h5).unwrap();
|
||||
let head = onion2.revision_count() - 1;
|
||||
let base = std::fs::read(&h5).unwrap();
|
||||
let state = onion2.reconstruct_revision(head, &base).unwrap();
|
||||
assert!(state[0..4096].iter().all(|&b| b == 0x22),
|
||||
"latest write (0x22) must win in LatestWins merge");
|
||||
assert!(
|
||||
state[0..4096].iter().all(|&b| b == 0x22),
|
||||
"latest write (0x22) must win in LatestWins merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -666,7 +680,9 @@ mod tests {
|
||||
onion.commit_session(s, None).unwrap();
|
||||
// fork but make no commits on feat
|
||||
onion.create_branch("feat", "main").unwrap();
|
||||
let err = onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap_err();
|
||||
let err = onion
|
||||
.merge_into("feat", "main", MergeStrategy::LatestWins)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, OnionError::Malformed(_)));
|
||||
}
|
||||
|
||||
@@ -682,20 +698,30 @@ mod tests {
|
||||
source.to_vec() // just return source
|
||||
};
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::DatasetLevel(Box::new(resolver)))
|
||||
.merge_into(
|
||||
"feat",
|
||||
"main",
|
||||
MergeStrategy::DatasetLevel(Box::new(resolver)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst), "resolver must be called");
|
||||
assert!(
|
||||
called.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"resolver must be called"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_dataset_level_resolver_controls_output() {
|
||||
let (h5, mut onion, _feat_id) = make_fork_scenario();
|
||||
// Resolver always returns 0xCC regardless of inputs
|
||||
let resolver = |_path: &str, _target: &[u8], _source: &[u8]| -> Vec<u8> {
|
||||
vec![0xCCu8; 4096]
|
||||
};
|
||||
let resolver =
|
||||
|_path: &str, _target: &[u8], _source: &[u8]| -> Vec<u8> { vec![0xCCu8; 4096] };
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::DatasetLevel(Box::new(resolver)))
|
||||
.merge_into(
|
||||
"feat",
|
||||
"main",
|
||||
MergeStrategy::DatasetLevel(Box::new(resolver)),
|
||||
)
|
||||
.unwrap();
|
||||
onion.flush().unwrap();
|
||||
|
||||
@@ -703,8 +729,10 @@ mod tests {
|
||||
let head = onion2.revision_count() - 1;
|
||||
let base = std::fs::read(&h5).unwrap();
|
||||
let state = onion2.reconstruct_revision(head, &base).unwrap();
|
||||
assert!(state[0..4096].iter().all(|&b| b == 0xCC),
|
||||
"resolver output 0xCC should be in merged state");
|
||||
assert!(
|
||||
state[0..4096].iter().all(|&b| b == 0xCC),
|
||||
"resolver output 0xCC should be in merged state"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Merge: ThreeWay ──────────────────────────────────────────────────────
|
||||
@@ -736,7 +764,9 @@ mod tests {
|
||||
sf.record_page(0, &vec![0xBBu8; 4096]);
|
||||
onion.commit_session(sf, None).unwrap();
|
||||
|
||||
onion.merge_into("feat", "main", MergeStrategy::ThreeWay).unwrap();
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::ThreeWay)
|
||||
.unwrap();
|
||||
onion.flush().unwrap();
|
||||
|
||||
let onion2 = OnionFile::open(&h5).unwrap();
|
||||
@@ -744,10 +774,14 @@ mod tests {
|
||||
let base = std::fs::read(&h5).unwrap();
|
||||
let state = onion2.reconstruct_revision(head, &base).unwrap();
|
||||
|
||||
assert!(state[0..4096].iter().all(|&b| b == 0xBB),
|
||||
"page 0 should be BB (from feat)");
|
||||
assert!(state[4096..8192].iter().all(|&b| b == 0xCC),
|
||||
"page 4096 should stay CC (from main — not overwritten by 3-way)");
|
||||
assert!(
|
||||
state[0..4096].iter().all(|&b| b == 0xBB),
|
||||
"page 0 should be BB (from feat)"
|
||||
);
|
||||
assert!(
|
||||
state[4096..8192].iter().all(|&b| b == 0xCC),
|
||||
"page 4096 should stay CC (from main — not overwritten by 3-way)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -772,22 +806,28 @@ mod tests {
|
||||
sf.record_page(0, &vec![0xCCu8; 4096]);
|
||||
onion.commit_session(sf, None).unwrap();
|
||||
|
||||
onion.merge_into("feat", "main", MergeStrategy::ThreeWay).unwrap();
|
||||
onion
|
||||
.merge_into("feat", "main", MergeStrategy::ThreeWay)
|
||||
.unwrap();
|
||||
onion.flush().unwrap();
|
||||
|
||||
let onion2 = OnionFile::open(&h5).unwrap();
|
||||
let head = onion2.revision_count() - 1;
|
||||
let base = std::fs::read(&h5).unwrap();
|
||||
let state = onion2.reconstruct_revision(head, &base).unwrap();
|
||||
assert!(state[0..4096].iter().all(|&b| b == 0xCC),
|
||||
"on conflict, source (CC) should win");
|
||||
assert!(
|
||||
state[0..4096].iter().all(|&b| b == 0xCC),
|
||||
"on conflict, source (CC) should win"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_nonexistent_source_errors() {
|
||||
let h5 = tmp_h5();
|
||||
let mut onion = OnionFile::create(&h5, 4096).unwrap();
|
||||
let err = onion.merge_into("no-such", "main", MergeStrategy::LatestWins).unwrap_err();
|
||||
let err = onion
|
||||
.merge_into("no-such", "main", MergeStrategy::LatestWins)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, OnionError::BranchNotFound(_)));
|
||||
}
|
||||
|
||||
@@ -799,7 +839,9 @@ mod tests {
|
||||
s.record_page(0, &vec![0u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
onion.create_branch("feat", "main").unwrap();
|
||||
let err = onion.merge_into("feat", "no-target", MergeStrategy::LatestWins).unwrap_err();
|
||||
let err = onion
|
||||
.merge_into("feat", "no-target", MergeStrategy::LatestWins)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, OnionError::BranchNotFound(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,26 +13,22 @@ use crate::tdt;
|
||||
pub fn compress_page(data: &[u8], codec: Codec) -> Result<Vec<u8>, OnionError> {
|
||||
match codec {
|
||||
Codec::None => Ok(data.to_vec()),
|
||||
Codec::Zstd => zstd::bulk::compress(data, 3)
|
||||
.map_err(|e| OnionError::Compress(e.to_string())),
|
||||
Codec::Zstd => {
|
||||
zstd::bulk::compress(data, 3).map_err(|e| OnionError::Compress(e.to_string()))
|
||||
}
|
||||
Codec::Lz4 => Ok(lz4_flex::compress_prepend_size(data)),
|
||||
Codec::Brotli => Err(OnionError::Compress(
|
||||
"brotli not yet implemented".to_string(),
|
||||
)),
|
||||
Codec::ZstdTdt => {
|
||||
let interleaved = tdt::encode(data, 4);
|
||||
zstd::bulk::compress(&interleaved, 3)
|
||||
.map_err(|e| OnionError::Compress(e.to_string()))
|
||||
zstd::bulk::compress(&interleaved, 3).map_err(|e| OnionError::Compress(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompress `data` using the given codec back to `orig_size` bytes.
|
||||
pub fn decompress_page(
|
||||
data: &[u8],
|
||||
codec: Codec,
|
||||
orig_size: u32,
|
||||
) -> Result<Vec<u8>, OnionError> {
|
||||
pub fn decompress_page(data: &[u8], codec: Codec, orig_size: u32) -> Result<Vec<u8>, OnionError> {
|
||||
match codec {
|
||||
Codec::None => {
|
||||
if data.len() != orig_size as usize {
|
||||
@@ -161,7 +157,7 @@ mod tests {
|
||||
}
|
||||
let orig = data.len() as u32;
|
||||
let zstd_size = compress_page(&data, Codec::Zstd).unwrap().len();
|
||||
let tdt_size = compress_page(&data, Codec::ZstdTdt).unwrap().len();
|
||||
let tdt_size = compress_page(&data, Codec::ZstdTdt).unwrap().len();
|
||||
assert!(
|
||||
tdt_size < zstd_size,
|
||||
"ZstdTdt ({tdt_size} B) should beat plain Zstd ({zstd_size} B) on smooth f32 data ({orig} B)"
|
||||
|
||||
@@ -94,7 +94,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let f = vf.current().unwrap();
|
||||
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![1.0, 2.0, 3.0]);
|
||||
assert_eq!(
|
||||
f.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![1.0, 2.0, 3.0]
|
||||
);
|
||||
assert_eq!(vf.onion().revision_count(), 0);
|
||||
}
|
||||
|
||||
@@ -106,7 +109,10 @@ mod tests {
|
||||
let _ = make_builder(&[1.0]).with_onion(&h5_path, 4096).unwrap();
|
||||
|
||||
let onion_path = PathBuf::from(format!("{}.onion", h5_path.display()));
|
||||
assert!(onion_path.exists(), "sidecar should be flushed by with_onion");
|
||||
assert!(
|
||||
onion_path.exists(),
|
||||
"sidecar should be flushed by with_onion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -140,6 +146,9 @@ mod tests {
|
||||
vf.page_size().is_power_of_two(),
|
||||
"auto page size must be a power of two"
|
||||
);
|
||||
assert!(vf.page_size() >= 4096, "auto page size must be at least 4 KiB");
|
||||
assert!(
|
||||
vf.page_size() >= 4096,
|
||||
"auto page size must be at least 4 KiB"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ pub const HEADER_SIZE: usize = 128;
|
||||
|
||||
pub mod feature_flags {
|
||||
pub const COMPRESSION: u64 = 1 << 0;
|
||||
pub const BRANCHING: u64 = 1 << 1;
|
||||
pub const PROVENANCE: u64 = 1 << 2;
|
||||
pub const SNAPSHOTS: u64 = 1 << 3;
|
||||
pub const BRANCHING: u64 = 1 << 1;
|
||||
pub const PROVENANCE: u64 = 1 << 2;
|
||||
pub const SNAPSHOTS: u64 = 1 << 3;
|
||||
}
|
||||
|
||||
pub const DEFAULT_FEATURE_FLAGS: u64 =
|
||||
@@ -64,13 +64,13 @@ pub const DEFAULT_FEATURE_FLAGS: u64 =
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Codec {
|
||||
None = 0,
|
||||
Zstd = 1,
|
||||
Lz4 = 2,
|
||||
Brotli = 3,
|
||||
None = 0,
|
||||
Zstd = 1,
|
||||
Lz4 = 2,
|
||||
Brotli = 3,
|
||||
/// zstd applied after TDT byte-interleaving transform (arXiv:2506.18062).
|
||||
/// Improves compression ratio ~16% for `f32`/`f16` numeric pages.
|
||||
ZstdTdt = 4,
|
||||
ZstdTdt = 4,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
@@ -108,19 +108,19 @@ impl Codec {
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
|
||||
#[repr(C)]
|
||||
pub struct OnionHeader {
|
||||
pub magic: [u8; 9],
|
||||
pub magic: [u8; 9],
|
||||
pub format_version: u8,
|
||||
pub _pad_align: [u8; 6],
|
||||
pub feature_flags: u64,
|
||||
pub page_size: u32,
|
||||
pub _pad_ps: [u8; 4],
|
||||
pub _pad_align: [u8; 6],
|
||||
pub feature_flags: u64,
|
||||
pub page_size: u32,
|
||||
pub _pad_ps: [u8; 4],
|
||||
pub revision_count: u64,
|
||||
pub branch_count: u32,
|
||||
pub _pad_bc: [u8; 4],
|
||||
pub index_offset: u64,
|
||||
pub branch_offset: u64,
|
||||
pub created_at: f64,
|
||||
pub reserved: [u8; 56],
|
||||
pub branch_count: u32,
|
||||
pub _pad_bc: [u8; 4],
|
||||
pub index_offset: u64,
|
||||
pub branch_offset: u64,
|
||||
pub created_at: f64,
|
||||
pub reserved: [u8; 56],
|
||||
}
|
||||
|
||||
const _: () = assert!(size_of::<OnionHeader>() == HEADER_SIZE);
|
||||
@@ -180,19 +180,19 @@ impl OnionHeader {
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
|
||||
#[repr(C)]
|
||||
pub struct RevisionEntry {
|
||||
pub revision: u64,
|
||||
pub branch_id: u32,
|
||||
pub _pad_bi: [u8; 4],
|
||||
pub parent_rev: u64,
|
||||
pub page_count: u32,
|
||||
pub _pad_pc: [u8; 4],
|
||||
pub revision: u64,
|
||||
pub branch_id: u32,
|
||||
pub _pad_bi: [u8; 4],
|
||||
pub parent_rev: u64,
|
||||
pub page_count: u32,
|
||||
pub _pad_pc: [u8; 4],
|
||||
pub page_table_off: u64,
|
||||
pub timestamp: f64,
|
||||
pub blake3: [u8; 32],
|
||||
pub session_uuid: [u8; 16],
|
||||
pub timestamp: f64,
|
||||
pub blake3: [u8; 32],
|
||||
pub session_uuid: [u8; 16],
|
||||
pub annotation_off: u64,
|
||||
pub flags: u8,
|
||||
pub _pad_flags: [u8; 7],
|
||||
pub flags: u8,
|
||||
pub _pad_flags: [u8; 7],
|
||||
}
|
||||
|
||||
pub const REV_FLAG_SNAPSHOT: u8 = 1 << 0;
|
||||
@@ -233,11 +233,11 @@ impl RevisionEntry {
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
|
||||
#[repr(C)]
|
||||
pub struct BranchEntry {
|
||||
pub id: u32,
|
||||
pub _pad_id: [u8; 4],
|
||||
pub name_off: u64,
|
||||
pub head_rev: u64,
|
||||
pub fork_rev: u64,
|
||||
pub id: u32,
|
||||
pub _pad_id: [u8; 4],
|
||||
pub name_off: u64,
|
||||
pub head_rev: u64,
|
||||
pub fork_rev: u64,
|
||||
pub created_at: f64,
|
||||
}
|
||||
|
||||
@@ -255,25 +255,25 @@ pub struct BranchEntry {
|
||||
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable, Debug, Clone)]
|
||||
#[repr(C)]
|
||||
pub struct PageTableEntry {
|
||||
pub h5_offset: u64,
|
||||
pub h5_offset: u64,
|
||||
pub data_offset: u64,
|
||||
pub orig_size: u32,
|
||||
pub data_size: u32,
|
||||
pub codec: u8,
|
||||
pub _pad: [u8; 7],
|
||||
pub orig_size: u32,
|
||||
pub data_size: u32,
|
||||
pub codec: u8,
|
||||
pub _pad: [u8; 7],
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Compile-time size/alignment assertions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const _: () = assert!(size_of::<OnionHeader>() == 128);
|
||||
const _: () = assert!(size_of::<RevisionEntry>() == 112);
|
||||
const _: () = assert!(size_of::<BranchEntry>() == 40);
|
||||
const _: () = assert!(size_of::<OnionHeader>() == 128);
|
||||
const _: () = assert!(size_of::<RevisionEntry>() == 112);
|
||||
const _: () = assert!(size_of::<BranchEntry>() == 40);
|
||||
const _: () = assert!(size_of::<PageTableEntry>() == 32);
|
||||
|
||||
const _: () = assert!(size_of::<RevisionEntry>() % 8 == 0);
|
||||
const _: () = assert!(size_of::<BranchEntry>() % 8 == 0);
|
||||
const _: () = assert!(size_of::<RevisionEntry>() % 8 == 0);
|
||||
const _: () = assert!(size_of::<BranchEntry>() % 8 == 0);
|
||||
const _: () = assert!(size_of::<PageTableEntry>() % 8 == 0);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -321,7 +321,10 @@ mod tests {
|
||||
fn header_unknown_version_rejected() {
|
||||
let mut hdr = OnionHeader::new(4096, DEFAULT_FEATURE_FLAGS, 0.0);
|
||||
hdr.format_version = 42;
|
||||
assert!(matches!(hdr.validate(), Err(OnionError::UnknownVersion(42))));
|
||||
assert!(matches!(
|
||||
hdr.validate(),
|
||||
Err(OnionError::UnknownVersion(42))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -488,7 +491,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn codec_unknown_returns_error() {
|
||||
assert!(matches!(Codec::from_u8(99), Err(OnionError::UnknownCodec(99))));
|
||||
assert!(matches!(
|
||||
Codec::from_u8(99),
|
||||
Err(OnionError::UnknownCodec(99))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,7 +69,10 @@ impl OnionFile {
|
||||
entry.set_epoch(EPOCH_DEAD);
|
||||
}
|
||||
}
|
||||
Ok(GcStats { revisions_removed: count, bytes_reclaimed: 0 })
|
||||
Ok(GcStats {
|
||||
revisions_removed: count,
|
||||
bytes_reclaimed: 0,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
// ── Immediate path: compact now ───────────────────────────
|
||||
@@ -100,11 +103,7 @@ impl OnionFile {
|
||||
GcPolicy::KeepSince(cutoff) => all_revs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&rev| {
|
||||
self.index
|
||||
.get(rev)
|
||||
.is_some_and(|e| e.timestamp >= *cutoff)
|
||||
})
|
||||
.filter(|&rev| self.index.get(rev).is_some_and(|e| e.timestamp >= *cutoff))
|
||||
.collect(),
|
||||
GcPolicy::KeepRevisions(explicit) => {
|
||||
let mut set: HashSet<u64> = explicit.iter().copied().collect();
|
||||
@@ -118,7 +117,11 @@ impl OnionFile {
|
||||
GcPolicy::EpochFlip(inner) => return self.compute_to_remove(inner),
|
||||
};
|
||||
|
||||
all_revs.iter().copied().filter(|rev| !keep.contains(rev)).collect()
|
||||
all_revs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|rev| !keep.contains(rev))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Consolidate + compact the given revision set immediately.
|
||||
@@ -204,8 +207,7 @@ impl OnionFile {
|
||||
}
|
||||
|
||||
// Compact page_data blob.
|
||||
let surviving_revs: Vec<u64> =
|
||||
self.index.entries().iter().map(|e| e.revision).collect();
|
||||
let surviving_revs: Vec<u64> = self.index.entries().iter().map(|e| e.revision).collect();
|
||||
let mut new_page_data: Vec<u8> = Vec::new();
|
||||
for &rev in &surviving_revs {
|
||||
if let Some(table) = self.page_tables.get_mut(rev as usize) {
|
||||
@@ -222,7 +224,10 @@ impl OnionFile {
|
||||
}
|
||||
self.page_data = new_page_data;
|
||||
|
||||
Ok(GcStats { revisions_removed, bytes_reclaimed })
|
||||
Ok(GcStats {
|
||||
revisions_removed,
|
||||
bytes_reclaimed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find all entries marked [`EPOCH_DEAD`] and compact them.
|
||||
@@ -270,9 +275,7 @@ mod tests {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
onion
|
||||
.commit_session(s, annotation.as_deref())
|
||||
.unwrap();
|
||||
onion.commit_session(s, annotation.as_deref()).unwrap();
|
||||
}
|
||||
onion
|
||||
}
|
||||
@@ -369,7 +372,10 @@ mod tests {
|
||||
assert_eq!(stats.revisions_removed, 4);
|
||||
let after = onion.page_data.len();
|
||||
// page_data must be smaller (or at most equal for all-same pages that compress to 0)
|
||||
assert!(after <= before, "page_data must shrink after GC: {before} -> {after}");
|
||||
assert!(
|
||||
after <= before,
|
||||
"page_data must shrink after GC: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// After GC + flush, the on-disk file is smaller than before.
|
||||
@@ -421,7 +427,10 @@ mod tests {
|
||||
// The three surviving revisions must still reconstruct without error.
|
||||
for rev in 3..6u64 {
|
||||
let bytes = onion.reconstruct_revision(rev, &h5_base).unwrap();
|
||||
assert!(!bytes.is_empty(), "rev {rev} should produce non-empty bytes");
|
||||
assert!(
|
||||
!bytes.is_empty(),
|
||||
"rev {rev} should produce non-empty bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +465,11 @@ mod tests {
|
||||
let before_gc = onion.reconstruct_revision(2, &h5_base).unwrap();
|
||||
assert_eq!(&before_gc[0..4096], &vec![0xAA_u8; 4096], "pre-GC page0");
|
||||
assert_eq!(&before_gc[4096..8192], &vec![0xBB_u8; 4096], "pre-GC page1");
|
||||
assert_eq!(&before_gc[8192..12288], &vec![0xCC_u8; 4096], "pre-GC page2");
|
||||
assert_eq!(
|
||||
&before_gc[8192..12288],
|
||||
&vec![0xCC_u8; 4096],
|
||||
"pre-GC page2"
|
||||
);
|
||||
|
||||
// GC: keep only rev 2 — revs 0 and 1 are ancestors that will be pruned.
|
||||
onion.gc(GcPolicy::KeepLastN(1)).unwrap();
|
||||
@@ -490,15 +503,20 @@ mod tests {
|
||||
let mut onion = make_onion_with_n_revisions(10);
|
||||
let page_data_len_before = onion.page_data.len();
|
||||
|
||||
let stats = onion.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(3)))).unwrap();
|
||||
let stats = onion
|
||||
.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(3))))
|
||||
.unwrap();
|
||||
|
||||
// Reports the count that will be removed.
|
||||
assert_eq!(stats.revisions_removed, 7);
|
||||
// bytes_reclaimed is 0 until flush compacts.
|
||||
assert_eq!(stats.bytes_reclaimed, 0);
|
||||
// page_data must NOT have changed yet.
|
||||
assert_eq!(onion.page_data.len(), page_data_len_before,
|
||||
"epoch flip must not compact page_data immediately");
|
||||
assert_eq!(
|
||||
onion.page_data.len(),
|
||||
page_data_len_before,
|
||||
"epoch flip must not compact page_data immediately"
|
||||
);
|
||||
// Index still has all 10 entries (removal deferred).
|
||||
assert_eq!(onion.revision_count(), 10);
|
||||
// Entries 0..6 should be marked EPOCH_DEAD.
|
||||
@@ -525,7 +543,9 @@ mod tests {
|
||||
}
|
||||
|
||||
// Epoch flip: defer compaction.
|
||||
onion.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(2)))).unwrap();
|
||||
onion
|
||||
.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepLastN(2))))
|
||||
.unwrap();
|
||||
assert_eq!(onion.revision_count(), 5, "not compacted yet");
|
||||
|
||||
// After flush, deferred compaction runs.
|
||||
@@ -533,7 +553,11 @@ mod tests {
|
||||
|
||||
// Reload and verify.
|
||||
let reloaded = OnionFile::open(&h5).unwrap();
|
||||
assert_eq!(reloaded.revision_count(), 2, "2 revisions survive after flush");
|
||||
assert_eq!(
|
||||
reloaded.revision_count(),
|
||||
2,
|
||||
"2 revisions survive after flush"
|
||||
);
|
||||
|
||||
// Surviving revisions are still reconstructable.
|
||||
for rev in 3u64..5 {
|
||||
@@ -559,16 +583,25 @@ mod tests {
|
||||
#[test]
|
||||
fn gc_epoch_flip_nested_policy_keep_tagged() {
|
||||
let mut onion = make_onion_with_n_revisions(9); // tagged at 0,3,6
|
||||
let stats = onion.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepTagged))).unwrap();
|
||||
let stats = onion
|
||||
.gc(GcPolicy::EpochFlip(Box::new(GcPolicy::KeepTagged)))
|
||||
.unwrap();
|
||||
assert_eq!(stats.revisions_removed, 6); // keeps 0,3,6
|
||||
assert_eq!(onion.revision_count(), 9, "deferred — index intact");
|
||||
// 0,3,6 are live; rest are dead.
|
||||
for rev in [0u64, 3, 6] {
|
||||
assert_eq!(onion.index.get(rev).unwrap().epoch(), 0, "tagged rev {rev} must be live");
|
||||
assert_eq!(
|
||||
onion.index.get(rev).unwrap().epoch(),
|
||||
0,
|
||||
"tagged rev {rev} must be live"
|
||||
);
|
||||
}
|
||||
for rev in [1u64, 2, 4, 5, 7, 8] {
|
||||
assert_eq!(onion.index.get(rev).unwrap().epoch(), EPOCH_DEAD,
|
||||
"untagged rev {rev} must be EPOCH_DEAD");
|
||||
assert_eq!(
|
||||
onion.index.get(rev).unwrap().epoch(),
|
||||
EPOCH_DEAD,
|
||||
"untagged rev {rev} must be EPOCH_DEAD"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! RevisionIndex: O(1) lookup + branch-filtered iteration.
|
||||
|
||||
use crate::format::{RevisionEntry, NO_PARENT};
|
||||
use crate::format::{NO_PARENT, RevisionEntry};
|
||||
|
||||
/// In-memory index of all revision entries.
|
||||
///
|
||||
@@ -53,7 +53,9 @@ impl RevisionIndex {
|
||||
/// monotonically greater than the current maximum.
|
||||
pub fn append(&mut self, entry: RevisionEntry) {
|
||||
debug_assert!(
|
||||
self.entries.last().is_none_or(|e| e.revision < entry.revision),
|
||||
self.entries
|
||||
.last()
|
||||
.is_none_or(|e| e.revision < entry.revision),
|
||||
"revision numbers must be monotonically increasing"
|
||||
);
|
||||
self.entries.push(entry);
|
||||
@@ -61,15 +63,14 @@ impl RevisionIndex {
|
||||
|
||||
/// Iterate over all revisions on a specific branch (by `branch_id`).
|
||||
pub fn branch_revisions(&self, branch_id: u32) -> impl Iterator<Item = &RevisionEntry> {
|
||||
self.entries.iter().filter(move |e| e.branch_id == branch_id)
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(move |e| e.branch_id == branch_id)
|
||||
}
|
||||
|
||||
/// Return the HEAD revision entry for a branch (highest revision number).
|
||||
pub fn branch_head(&self, branch_id: u32) -> Option<&RevisionEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.branch_id == branch_id)
|
||||
self.entries.iter().rev().find(|e| e.branch_id == branch_id)
|
||||
}
|
||||
|
||||
/// Walk the DAG from `start_rev` to the root, following `parent_rev`.
|
||||
@@ -191,8 +192,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(main_revs, vec![0, 1, 4]);
|
||||
|
||||
let branch1_revs: Vec<u64> =
|
||||
idx.branch_revisions(1).map(|e| e.revision).collect();
|
||||
let branch1_revs: Vec<u64> = idx.branch_revisions(1).map(|e| e.revision).collect();
|
||||
assert_eq!(branch1_revs, vec![2, 3]);
|
||||
}
|
||||
|
||||
@@ -252,7 +252,11 @@ mod tests {
|
||||
fn remove_revisions() {
|
||||
let mut idx = RevisionIndex::new();
|
||||
for i in 0u64..5 {
|
||||
idx.append(make_entry(i, BRANCH_MAIN, if i == 0 { NO_PARENT } else { i - 1 }));
|
||||
idx.append(make_entry(
|
||||
i,
|
||||
BRANCH_MAIN,
|
||||
if i == 0 { NO_PARENT } else { i - 1 },
|
||||
));
|
||||
}
|
||||
let to_remove = [1u64, 2].into_iter().collect();
|
||||
idx.remove_revisions(&to_remove);
|
||||
|
||||
@@ -36,19 +36,18 @@ pub mod tdt;
|
||||
pub mod versioned_file;
|
||||
pub mod writer;
|
||||
|
||||
pub use error::OnionError;
|
||||
pub use format::{
|
||||
BranchEntry, Codec, OnionHeader, PageTableEntry, RevisionEntry,
|
||||
BRANCH_MAIN, DEFAULT_FEATURE_FLAGS, EPOCH_DEAD, FORMAT_VERSION, HEADER_SIZE, MAGIC, NO_PARENT,
|
||||
REV_FLAG_SNAPSHOT, feature_flags,
|
||||
};
|
||||
pub use api::{
|
||||
open_revision, open_branch, open_branch_at,
|
||||
list_revisions, rollback,
|
||||
list_branches, create_branch, delete_branch, rename_branch,
|
||||
create_branch, delete_branch, list_branches, list_revisions, open_branch, open_branch_at,
|
||||
open_revision, rename_branch, rollback,
|
||||
};
|
||||
pub use branch::{BranchInfo, DatasetResolver, MergeStrategy};
|
||||
pub use merkle::{MerkleError, MerkleNode, RevisionMerkleTree, WalkStep};
|
||||
pub use error::OnionError;
|
||||
pub use ext::FileBuilderExt;
|
||||
pub use versioned_file::{VersionedFile, open_at_revision, open_at_branch};
|
||||
pub use format::{
|
||||
BRANCH_MAIN, BranchEntry, Codec, DEFAULT_FEATURE_FLAGS, EPOCH_DEAD, FORMAT_VERSION,
|
||||
HEADER_SIZE, MAGIC, NO_PARENT, OnionHeader, PageTableEntry, REV_FLAG_SNAPSHOT, RevisionEntry,
|
||||
feature_flags,
|
||||
};
|
||||
pub use merkle::{MerkleError, MerkleNode, RevisionMerkleTree, WalkStep};
|
||||
pub use versioned_file::{VersionedFile, open_at_branch, open_at_revision};
|
||||
pub use writer::eof_to_page_size;
|
||||
|
||||
@@ -97,7 +97,11 @@ impl RevisionMerkleTree {
|
||||
/// The slice **must** be sorted by `revision` in ascending order.
|
||||
pub fn build(entries: &[(u64, [u8; 32])]) -> Self {
|
||||
let leaf_count = entries.len();
|
||||
let capacity = if leaf_count == 0 { 1 } else { leaf_count.next_power_of_two() };
|
||||
let capacity = if leaf_count == 0 {
|
||||
1
|
||||
} else {
|
||||
leaf_count.next_power_of_two()
|
||||
};
|
||||
|
||||
// 1-indexed BFS array; index 0 unused.
|
||||
let mut hashes = vec![[0u8; 32]; 2 * capacity + 1];
|
||||
@@ -113,7 +117,12 @@ impl RevisionMerkleTree {
|
||||
// Build internal nodes bottom-up
|
||||
build_internal(&mut hashes, capacity);
|
||||
|
||||
RevisionMerkleTree { hashes, leaf_revisions, leaf_count, capacity }
|
||||
RevisionMerkleTree {
|
||||
hashes,
|
||||
leaf_revisions,
|
||||
leaf_count,
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────
|
||||
@@ -311,7 +320,7 @@ impl RevisionMerkleTree {
|
||||
/// `hashes` must have length `2 * capacity + 1`.
|
||||
fn build_internal(hashes: &mut [[u8; 32]], capacity: usize) {
|
||||
for i in (1..capacity).rev() {
|
||||
let left = hashes[2 * i];
|
||||
let left = hashes[2 * i];
|
||||
let right = hashes[2 * i + 1];
|
||||
hashes[i] = node_hash(&left, &right);
|
||||
}
|
||||
@@ -484,7 +493,10 @@ mod tests {
|
||||
let header_only = &full[..13]; // 5 + 8 bytes
|
||||
assert_eq!(
|
||||
RevisionMerkleTree::deserialise(header_only),
|
||||
Err(MerkleError::TruncatedPayload { expected: 5 + 8 + 5 * 40, got: 13 })
|
||||
Err(MerkleError::TruncatedPayload {
|
||||
expected: 5 + 8 + 5 * 40,
|
||||
got: 13
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,10 @@ mod tests {
|
||||
// Same pages, different insertion order
|
||||
let h1 = hash_pages(&[(0, a.as_ref()), (4096, b.as_ref())]);
|
||||
let h2 = hash_pages(&[(4096, b.as_ref()), (0, a.as_ref())]);
|
||||
assert_eq!(h1, h2, "hash must be order-independent (sorted by h5_offset)");
|
||||
assert_eq!(
|
||||
h1, h2,
|
||||
"hash must be order-independent (sorted by h5_offset)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
//! Opening a historical revision reads only the `.onion` sidecar —
|
||||
//! the primary `.h5` file is always at the latest committed state.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use crate::compress::decompress_page;
|
||||
use crate::error::OnionError;
|
||||
use crate::format::Codec;
|
||||
use crate::writer::OnionFile;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `(h5_offset, compressed_data, codec_byte, orig_size)` — raw page as stored.
|
||||
pub type RawPage = (u64, Vec<u8>, u8, u32);
|
||||
@@ -41,7 +41,11 @@ impl OnionFile {
|
||||
|
||||
// Collect all ancestor revisions in order from oldest → newest
|
||||
let ancestors: Vec<u64> = {
|
||||
let mut chain = self.index.ancestors(rev).map(|e| e.revision).collect::<Vec<_>>();
|
||||
let mut chain = self
|
||||
.index
|
||||
.ancestors(rev)
|
||||
.map(|e| e.revision)
|
||||
.collect::<Vec<_>>();
|
||||
chain.reverse(); // oldest first
|
||||
chain
|
||||
};
|
||||
@@ -53,13 +57,11 @@ impl OnionFile {
|
||||
// Optimisation: find the newest snapshot in the ancestor chain and
|
||||
// start from there instead of from `h5_base`. This bounds
|
||||
// reconstruction depth to O(N_since_snapshot · P).
|
||||
let snapshot_start_idx = ancestors
|
||||
.iter()
|
||||
.rposition(|&r| {
|
||||
self.index
|
||||
.get(r)
|
||||
.is_some_and(|e| e.flags & REV_FLAG_SNAPSHOT != 0)
|
||||
});
|
||||
let snapshot_start_idx = ancestors.iter().rposition(|&r| {
|
||||
self.index
|
||||
.get(r)
|
||||
.is_some_and(|e| e.flags & REV_FLAG_SNAPSHOT != 0)
|
||||
});
|
||||
|
||||
let (start_idx, mut file_bytes) = match snapshot_start_idx {
|
||||
Some(idx) => {
|
||||
@@ -76,7 +78,9 @@ impl OnionFile {
|
||||
let table = self
|
||||
.page_tables
|
||||
.get(*ancestor_rev as usize)
|
||||
.ok_or_else(|| OnionError::Malformed(format!("missing page table for rev {ancestor_rev}")))?;
|
||||
.ok_or_else(|| {
|
||||
OnionError::Malformed(format!("missing page table for rev {ancestor_rev}"))
|
||||
})?;
|
||||
|
||||
for pt_entry in table {
|
||||
let codec = Codec::from_u8(pt_entry.codec)?;
|
||||
@@ -111,11 +115,7 @@ impl OnionFile {
|
||||
}
|
||||
|
||||
/// Open a specific revision using an [`OpenRevision`] selector.
|
||||
pub fn open_rev(
|
||||
&self,
|
||||
selector: OpenRevision,
|
||||
h5_base: &[u8],
|
||||
) -> Result<Vec<u8>, OnionError> {
|
||||
pub fn open_rev(&self, selector: OpenRevision, h5_base: &[u8]) -> Result<Vec<u8>, OnionError> {
|
||||
let rev = self.resolve_selector(selector)?;
|
||||
self.reconstruct_revision(rev, h5_base)
|
||||
}
|
||||
@@ -128,12 +128,11 @@ impl OnionFile {
|
||||
self.branch_head_rev(crate::format::BRANCH_MAIN)
|
||||
.ok_or(OnionError::RevisionNotFound(0))
|
||||
}
|
||||
OpenRevision::At(rev) => {
|
||||
self.index
|
||||
.get(rev)
|
||||
.map(|_| rev)
|
||||
.ok_or(OnionError::RevisionNotFound(rev))
|
||||
}
|
||||
OpenRevision::At(rev) => self
|
||||
.index
|
||||
.get(rev)
|
||||
.map(|_| rev)
|
||||
.ok_or(OnionError::RevisionNotFound(rev)),
|
||||
OpenRevision::Branch(name) => {
|
||||
let branch = self
|
||||
.branch_by_name(&name)
|
||||
@@ -201,10 +200,7 @@ impl OnionFile {
|
||||
/// compressed bytes directly and let the receiver decompress.
|
||||
///
|
||||
/// Returns `(h5_offset, compressed_data, codec_byte, orig_size)`.
|
||||
pub fn revision_pages_raw(
|
||||
&self,
|
||||
rev: u64,
|
||||
) -> Result<Vec<RawPage>, OnionError> {
|
||||
pub fn revision_pages_raw(&self, rev: u64) -> Result<Vec<RawPage>, OnionError> {
|
||||
let table = self
|
||||
.page_tables
|
||||
.get(rev as usize)
|
||||
|
||||
@@ -117,7 +117,10 @@ mod tests {
|
||||
// byte_pos=1: elements 1,5,9,13
|
||||
// byte_pos=2: elements 2,6,10,14
|
||||
// byte_pos=3: elements 3,7,11,15
|
||||
assert_eq!(enc, vec![0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15]);
|
||||
assert_eq!(
|
||||
enc,
|
||||
vec![0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15]
|
||||
);
|
||||
let dec = decode(&enc, 4, 16);
|
||||
assert_eq!(dec, data);
|
||||
}
|
||||
@@ -180,7 +183,8 @@ mod tests {
|
||||
assert!(
|
||||
tdt_compressed.len() <= raw_compressed.len(),
|
||||
"TDT+zstd ({} bytes) should be no worse than raw zstd ({} bytes) on structured float data",
|
||||
tdt_compressed.len(), raw_compressed.len()
|
||||
tdt_compressed.len(),
|
||||
raw_compressed.len()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,21 +57,21 @@ use crate::writer::OnionFile;
|
||||
/// [`export_revision`](VersionedFile::export_revision) to materialise a
|
||||
/// specific revision to disk.
|
||||
pub struct VersionedFile {
|
||||
onion: OnionFile,
|
||||
h5_path: PathBuf,
|
||||
onion: OnionFile,
|
||||
h5_path: PathBuf,
|
||||
/// Original `.h5` bytes — the reconstruction base. **Never mutated.**
|
||||
h5_base: Vec<u8>,
|
||||
h5_base: Vec<u8>,
|
||||
/// In-memory state of the most recently committed revision on the **default**
|
||||
/// branch. Kept in sync with `branch_state[BRANCH_MAIN]`.
|
||||
/// Used by [`snapshot`](VersionedFile::snapshot) and
|
||||
/// [`auto_snapshot`](VersionedFile::auto_snapshot).
|
||||
current_h5: Vec<u8>,
|
||||
current_h5: Vec<u8>,
|
||||
/// Per-branch diff baseline: `branch_id → last-committed bytes on that branch`.
|
||||
///
|
||||
/// Ensures that interleaved commits on different branches always diff against
|
||||
/// the correct prior state for each branch, not just the globally last commit.
|
||||
branch_state: HashMap<u32, Vec<u8>>,
|
||||
page_size: u32,
|
||||
page_size: u32,
|
||||
}
|
||||
|
||||
impl VersionedFile {
|
||||
@@ -105,7 +105,14 @@ impl VersionedFile {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| h5_base.clone());
|
||||
|
||||
Ok(Self { onion, h5_path, h5_base, current_h5, branch_state, page_size })
|
||||
Ok(Self {
|
||||
onion,
|
||||
h5_path,
|
||||
h5_base,
|
||||
current_h5,
|
||||
branch_state,
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new versioned HDF5 file from a [`clawhdf5::FileBuilder`].
|
||||
@@ -144,7 +151,14 @@ impl VersionedFile {
|
||||
let h5_base = std::fs::read(&h5_path)?;
|
||||
let current_h5 = h5_base.clone();
|
||||
let onion = OnionFile::create(&h5_path, page_size)?;
|
||||
Ok(Self { onion, h5_path, h5_base, current_h5, branch_state: HashMap::new(), page_size })
|
||||
Ok(Self {
|
||||
onion,
|
||||
h5_path,
|
||||
h5_base,
|
||||
current_h5,
|
||||
branch_state: HashMap::new(),
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new versioned HDF5 file, automatically choosing the page size.
|
||||
@@ -163,7 +177,14 @@ impl VersionedFile {
|
||||
let current_h5 = h5_base.clone();
|
||||
let onion = OnionFile::create_auto(&h5_path)?;
|
||||
let page_size = onion.page_size();
|
||||
Ok(Self { onion, h5_path, h5_base, current_h5, branch_state: HashMap::new(), page_size })
|
||||
Ok(Self {
|
||||
onion,
|
||||
h5_path,
|
||||
h5_base,
|
||||
current_h5,
|
||||
branch_state: HashMap::new(),
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Read API ──────────────────────────────────────────────────────────────
|
||||
@@ -352,7 +373,9 @@ impl VersionedFile {
|
||||
/// vf.auto_snapshot(500)?; // snapshot every 500 revisions
|
||||
/// ```
|
||||
pub fn auto_snapshot(&mut self, interval: u64) -> Result<Option<u64>, OnionError> {
|
||||
let result = self.onion.auto_snapshot_if_needed(&self.current_h5, interval)?;
|
||||
let result = self
|
||||
.onion
|
||||
.auto_snapshot_if_needed(&self.current_h5, interval)?;
|
||||
if result.is_some() {
|
||||
self.onion.flush()?;
|
||||
}
|
||||
@@ -383,7 +406,8 @@ impl VersionedFile {
|
||||
// Refresh the target branch's cached state so future commits on it
|
||||
// use the merged content as their diff baseline.
|
||||
let (target_id, target_head) = {
|
||||
let entry = self.onion
|
||||
let entry = self
|
||||
.onion
|
||||
.branch_by_name(target)
|
||||
.ok_or_else(|| OnionError::BranchNotFound(target.to_string()))?;
|
||||
(entry.id, entry.head_rev)
|
||||
@@ -435,8 +459,7 @@ impl VersionedFile {
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn hdf5_from_bytes(&self, bytes: Vec<u8>) -> Result<clawhdf5::File, OnionError> {
|
||||
clawhdf5::File::from_bytes(bytes)
|
||||
.map_err(|e| OnionError::Hdf5(e.to_string()))
|
||||
clawhdf5::File::from_bytes(bytes).map_err(|e| OnionError::Hdf5(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,11 +533,17 @@ mod tests {
|
||||
|
||||
// Read back rev 0 — should still be [1.0, 2.0]
|
||||
let f0 = vf.revision(0).unwrap();
|
||||
assert_eq!(f0.dataset("data").unwrap().read_f64().unwrap(), vec![1.0, 2.0]);
|
||||
assert_eq!(
|
||||
f0.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![1.0, 2.0]
|
||||
);
|
||||
|
||||
// Read back rev 1 — should be [3.0, 4.0]
|
||||
let f1 = vf.revision(1).unwrap();
|
||||
assert_eq!(f1.dataset("data").unwrap().read_f64().unwrap(), vec![3.0, 4.0]);
|
||||
assert_eq!(
|
||||
f1.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![3.0, 4.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -580,9 +609,15 @@ mod tests {
|
||||
let vf2 = VersionedFile::open(&h5_path).unwrap();
|
||||
assert_eq!(vf2.onion().revision_count(), 2);
|
||||
let f0 = vf2.revision(0).unwrap();
|
||||
assert_eq!(f0.dataset("data").unwrap().read_f64().unwrap(), vec![10.0, 20.0]);
|
||||
assert_eq!(
|
||||
f0.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![10.0, 20.0]
|
||||
);
|
||||
let f1 = vf2.revision(1).unwrap();
|
||||
assert_eq!(f1.dataset("data").unwrap().read_f64().unwrap(), vec![30.0, 40.0]);
|
||||
assert_eq!(
|
||||
f1.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![30.0, 40.0]
|
||||
);
|
||||
}
|
||||
|
||||
// ── merge ────────────────────────────────────────────────────────────────
|
||||
@@ -598,7 +633,8 @@ mod tests {
|
||||
// Fork feat
|
||||
let feat_id = vf.onion_mut().create_branch("feat", "main").unwrap();
|
||||
// Rev 1: feat writes [3.0, 4.0]
|
||||
vf.commit_on(Some("feat"), make_h5(&[3.0, 4.0]), Some("feat-v1")).unwrap();
|
||||
vf.commit_on(Some("feat"), make_h5(&[3.0, 4.0]), Some("feat-v1"))
|
||||
.unwrap();
|
||||
|
||||
// Merge feat → main
|
||||
let merge_rev = vf.merge("feat", "main", MergeStrategy::LatestWins).unwrap();
|
||||
@@ -610,7 +646,10 @@ mod tests {
|
||||
|
||||
// Content at merge revision reflects feat's state (latest wins)
|
||||
let f = vf.revision(merge_rev).unwrap();
|
||||
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![3.0, 4.0]);
|
||||
assert_eq!(
|
||||
f.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![3.0, 4.0]
|
||||
);
|
||||
|
||||
let _ = feat_id;
|
||||
}
|
||||
@@ -622,7 +661,8 @@ mod tests {
|
||||
|
||||
vf.commit(make_h5(&[1.0]), Some("main-v1")).unwrap();
|
||||
vf.onion_mut().create_branch("feat", "main").unwrap();
|
||||
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1")).unwrap();
|
||||
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1"))
|
||||
.unwrap();
|
||||
|
||||
vf.merge("feat", "main", MergeStrategy::LatestWins).unwrap();
|
||||
|
||||
@@ -640,7 +680,9 @@ mod tests {
|
||||
let mut vf = VersionedFile::create(&h5_path, 4096).unwrap();
|
||||
vf.commit(make_h5(&[1.0]), None).unwrap();
|
||||
|
||||
let err = vf.merge("ghost", "main", MergeStrategy::LatestWins).unwrap_err();
|
||||
let err = vf
|
||||
.merge("ghost", "main", MergeStrategy::LatestWins)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, OnionError::BranchNotFound(_)));
|
||||
}
|
||||
|
||||
@@ -653,11 +695,12 @@ mod tests {
|
||||
|
||||
vf.commit(make_h5(&[1.0]), Some("main-v1")).unwrap();
|
||||
let feat_id = vf.onion_mut().create_branch("feat", "main").unwrap();
|
||||
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1")).unwrap();
|
||||
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1"))
|
||||
.unwrap();
|
||||
|
||||
let revs = vf.onion().list_revisions();
|
||||
assert_eq!(revs.len(), 2);
|
||||
assert_eq!(revs[0].branch_id, 0, "rev 0 is on main");
|
||||
assert_eq!(revs[0].branch_id, 0, "rev 0 is on main");
|
||||
assert_eq!(revs[1].branch_id, feat_id, "rev 1 is on feat");
|
||||
}
|
||||
|
||||
@@ -679,7 +722,9 @@ mod tests {
|
||||
let (_tmp, h5_path) = tmp_h5(&[1.0]);
|
||||
let mut vf = VersionedFile::create(&h5_path, 4096).unwrap();
|
||||
|
||||
let err = vf.commit_on(Some("ghost"), make_h5(&[2.0]), None).unwrap_err();
|
||||
let err = vf
|
||||
.commit_on(Some("ghost"), make_h5(&[2.0]), None)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, OnionError::BranchNotFound(_)));
|
||||
}
|
||||
|
||||
@@ -698,7 +743,8 @@ mod tests {
|
||||
vf.onion_mut().create_branch("feat", "main").unwrap();
|
||||
|
||||
// Commit v2 on feat (rev 1: [2.0])
|
||||
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1")).unwrap();
|
||||
vf.commit_on(Some("feat"), make_h5(&[2.0]), Some("feat-v1"))
|
||||
.unwrap();
|
||||
|
||||
// Commit v2 on main (rev 2: [3.0]) — diff must be against main's last
|
||||
// commit ([1.0]), not feat's last commit ([2.0]).
|
||||
@@ -706,16 +752,25 @@ mod tests {
|
||||
|
||||
// Reconstruct each revision and verify round-trip fidelity
|
||||
let f0 = vf.revision(0).unwrap();
|
||||
assert_eq!(f0.dataset("data").unwrap().read_f64().unwrap(), vec![1.0],
|
||||
"rev 0 (main-v1)");
|
||||
assert_eq!(
|
||||
f0.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![1.0],
|
||||
"rev 0 (main-v1)"
|
||||
);
|
||||
|
||||
let f1 = vf.revision(1).unwrap();
|
||||
assert_eq!(f1.dataset("data").unwrap().read_f64().unwrap(), vec![2.0],
|
||||
"rev 1 (feat-v1)");
|
||||
assert_eq!(
|
||||
f1.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![2.0],
|
||||
"rev 1 (feat-v1)"
|
||||
);
|
||||
|
||||
let f2 = vf.revision(2).unwrap();
|
||||
assert_eq!(f2.dataset("data").unwrap().read_f64().unwrap(), vec![3.0],
|
||||
"rev 2 (main-v2) — would be wrong if diffed against feat state");
|
||||
assert_eq!(
|
||||
f2.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![3.0],
|
||||
"rev 2 (main-v2) — would be wrong if diffed against feat state"
|
||||
);
|
||||
}
|
||||
|
||||
/// After a reload, `open()` must restore per-branch state from the sidecar
|
||||
@@ -730,13 +785,21 @@ mod tests {
|
||||
let mut s = vf.onion_mut().begin_session(Some(feat_id)).unwrap();
|
||||
let feat_bytes = make_h5(&[9.0]);
|
||||
let ps = 4096usize;
|
||||
let base = vf.onion().reconstruct_revision(0,
|
||||
&std::fs::read(&h5_path).unwrap()).unwrap();
|
||||
let base = vf
|
||||
.onion()
|
||||
.reconstruct_revision(0, &std::fs::read(&h5_path).unwrap())
|
||||
.unwrap();
|
||||
for i in 0..(feat_bytes.len().max(base.len())).div_ceil(ps) {
|
||||
let start = i * ps;
|
||||
if start >= feat_bytes.len() { break; }
|
||||
let ns = &feat_bytes[start..feat_bytes.len().min(start+ps)];
|
||||
let os: &[u8] = if start < base.len() { &base[start..base.len().min(start+ps)] } else { &[] };
|
||||
if start >= feat_bytes.len() {
|
||||
break;
|
||||
}
|
||||
let ns = &feat_bytes[start..feat_bytes.len().min(start + ps)];
|
||||
let os: &[u8] = if start < base.len() {
|
||||
&base[start..base.len().min(start + ps)]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
if ns != os {
|
||||
let mut pad = vec![0u8; ps];
|
||||
pad[..ns.len()].copy_from_slice(ns);
|
||||
@@ -752,8 +815,11 @@ mod tests {
|
||||
vf2.commit(make_h5(&[2.0]), Some("main-v2")).unwrap();
|
||||
|
||||
let f_main = vf2.revision(2).unwrap();
|
||||
assert_eq!(f_main.dataset("data").unwrap().read_f64().unwrap(), vec![2.0],
|
||||
"main-v2 content must be [2.0] after reload");
|
||||
assert_eq!(
|
||||
f_main.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![2.0],
|
||||
"main-v2 content must be [2.0] after reload"
|
||||
);
|
||||
}
|
||||
|
||||
// ── create_auto ──────────────────────────────────────────────────────────
|
||||
@@ -767,7 +833,10 @@ mod tests {
|
||||
"auto page size must be a power of two, got {}",
|
||||
vf.page_size()
|
||||
);
|
||||
assert!(vf.page_size() >= 4096, "auto page size must be at least 4 KiB");
|
||||
assert!(
|
||||
vf.page_size() >= 4096,
|
||||
"auto page size must be at least 4 KiB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -778,7 +847,10 @@ mod tests {
|
||||
vf.commit(make_h5(&[7.0, 8.0]), Some("auto-v1")).unwrap();
|
||||
|
||||
let f = vf.revision(0).unwrap();
|
||||
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![7.0, 8.0]);
|
||||
assert_eq!(
|
||||
f.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![7.0, 8.0]
|
||||
);
|
||||
}
|
||||
|
||||
// ── snapshot / auto_snapshot ─────────────────────────────────────────────
|
||||
@@ -797,7 +869,10 @@ mod tests {
|
||||
// The snapshot revision must be flagged as a snapshot.
|
||||
let revs = vf.onion().list_revisions();
|
||||
let snap = revs.iter().find(|r| r.revision == snap_rev).unwrap();
|
||||
assert!(snap.is_snapshot, "snapshot revision must have is_snapshot == true");
|
||||
assert!(
|
||||
snap.is_snapshot,
|
||||
"snapshot revision must have is_snapshot == true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -843,7 +918,10 @@ mod tests {
|
||||
vf.commit(make_h5(&[i as f64]), None).unwrap();
|
||||
}
|
||||
let result = vf.auto_snapshot(5).unwrap();
|
||||
assert!(result.is_some(), "auto_snapshot should fire when count % interval == 0");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"auto_snapshot should fire when count % interval == 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -855,7 +933,10 @@ mod tests {
|
||||
vf.commit(make_h5(&[2.0]), None).unwrap();
|
||||
// 2 revisions, interval 5 — should not trigger
|
||||
let result = vf.auto_snapshot(5).unwrap();
|
||||
assert!(result.is_none(), "auto_snapshot should be a no-op when count % interval != 0");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"auto_snapshot should be a no-op when count % interval != 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -908,12 +989,21 @@ mod tests {
|
||||
// Record a changed page for the feat branch rev
|
||||
let feat_bytes = make_h5(&[3.0]);
|
||||
let ps = 4096usize;
|
||||
let current = vf.onion().reconstruct_revision(1, &std::fs::read(&h5_path).unwrap()).unwrap();
|
||||
let current = vf
|
||||
.onion()
|
||||
.reconstruct_revision(1, &std::fs::read(&h5_path).unwrap())
|
||||
.unwrap();
|
||||
for i in 0..(feat_bytes.len().max(current.len())).div_ceil(ps) {
|
||||
let start = i * ps;
|
||||
if start >= feat_bytes.len() { break; }
|
||||
if start >= feat_bytes.len() {
|
||||
break;
|
||||
}
|
||||
let new_sl = &feat_bytes[start..feat_bytes.len().min(start + ps)];
|
||||
let old_sl: &[u8] = if start < current.len() { ¤t[start..current.len().min(start + ps)] } else { &[] };
|
||||
let old_sl: &[u8] = if start < current.len() {
|
||||
¤t[start..current.len().min(start + ps)]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
if new_sl != old_sl {
|
||||
let mut pad = vec![0u8; ps];
|
||||
pad[..new_sl.len()].copy_from_slice(new_sl);
|
||||
@@ -1009,7 +1099,10 @@ mod tests {
|
||||
p.set_extension(&new_ext);
|
||||
p
|
||||
};
|
||||
assert!(onion_path.exists(), ".onion sidecar should exist after from_builder: checked {onion_path:?}");
|
||||
assert!(
|
||||
onion_path.exists(),
|
||||
".onion sidecar should exist after from_builder: checked {onion_path:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1026,7 +1119,10 @@ mod tests {
|
||||
assert_eq!(rev, 0);
|
||||
|
||||
let f = vf.revision(0).unwrap();
|
||||
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![3.0, 4.0]);
|
||||
assert_eq!(
|
||||
f.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![3.0, 4.0]
|
||||
);
|
||||
}
|
||||
|
||||
// ── export_revision ───────────────────────────────────────────────────────
|
||||
@@ -1046,7 +1142,10 @@ mod tests {
|
||||
// Re-open the exported file as a raw clawhdf5::File
|
||||
let bytes = std::fs::read(&export_path).unwrap();
|
||||
let f = clawhdf5::File::from_bytes(bytes).unwrap();
|
||||
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![1.0, 2.0]);
|
||||
assert_eq!(
|
||||
f.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![1.0, 2.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1075,7 +1174,10 @@ mod tests {
|
||||
|
||||
let bytes = std::fs::read(&export_path).unwrap();
|
||||
let f = clawhdf5::File::from_bytes(bytes).unwrap();
|
||||
assert_eq!(f.dataset("data").unwrap().read_f64().unwrap(), vec![8.0, 9.0]);
|
||||
assert_eq!(
|
||||
f.dataset("data").unwrap().read_f64().unwrap(),
|
||||
vec![8.0, 9.0]
|
||||
);
|
||||
}
|
||||
|
||||
// ── multiple datasets ─────────────────────────────────────────────────────
|
||||
@@ -1098,11 +1200,23 @@ mod tests {
|
||||
vf.commit(bytes_v2, Some("update x only")).unwrap();
|
||||
|
||||
let f0 = vf.revision(0).unwrap();
|
||||
assert_eq!(f0.dataset("x").unwrap().read_f64().unwrap(), vec![1.0, 2.0, 3.0]);
|
||||
assert_eq!(f0.dataset("y").unwrap().read_f64().unwrap(), vec![4.0, 5.0, 6.0]);
|
||||
assert_eq!(
|
||||
f0.dataset("x").unwrap().read_f64().unwrap(),
|
||||
vec![1.0, 2.0, 3.0]
|
||||
);
|
||||
assert_eq!(
|
||||
f0.dataset("y").unwrap().read_f64().unwrap(),
|
||||
vec![4.0, 5.0, 6.0]
|
||||
);
|
||||
|
||||
let f1 = vf.revision(1).unwrap();
|
||||
assert_eq!(f1.dataset("x").unwrap().read_f64().unwrap(), vec![10.0, 20.0, 30.0]);
|
||||
assert_eq!(f1.dataset("y").unwrap().read_f64().unwrap(), vec![4.0, 5.0, 6.0]);
|
||||
assert_eq!(
|
||||
f1.dataset("x").unwrap().read_f64().unwrap(),
|
||||
vec![10.0, 20.0, 30.0]
|
||||
);
|
||||
assert_eq!(
|
||||
f1.dataset("y").unwrap().read_f64().unwrap(),
|
||||
vec![4.0, 5.0, 6.0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::annotation::AnnotationHeap;
|
||||
use crate::compress::compress_page;
|
||||
use crate::error::OnionError;
|
||||
use crate::format::{
|
||||
BranchEntry, Codec, OnionHeader, PageTableEntry, RevisionEntry,
|
||||
BRANCH_MAIN, DEFAULT_FEATURE_FLAGS, HEADER_SIZE, NO_PARENT,
|
||||
BRANCH_MAIN, BranchEntry, Codec, DEFAULT_FEATURE_FLAGS, HEADER_SIZE, NO_PARENT, OnionHeader,
|
||||
PageTableEntry, RevisionEntry,
|
||||
};
|
||||
use crate::index::RevisionIndex;
|
||||
use crate::provenance::{SessionId, hash_pages};
|
||||
@@ -204,9 +204,7 @@ impl OnionFile {
|
||||
annotation: Option<&str>,
|
||||
) -> Result<u64, OnionError> {
|
||||
let revision = self.index.len() as u64;
|
||||
let parent_rev = self
|
||||
.branch_head_rev(session.branch_id)
|
||||
.unwrap_or(NO_PARENT);
|
||||
let parent_rev = self.branch_head_rev(session.branch_id).unwrap_or(NO_PARENT);
|
||||
|
||||
// Compress pages and build BLAKE3 input
|
||||
let mut pages: Vec<(u64, Vec<u8>, u32, Codec)> = Vec::new();
|
||||
@@ -226,9 +224,7 @@ impl OnionFile {
|
||||
);
|
||||
|
||||
// Annotation
|
||||
let annotation_off = annotation
|
||||
.map(|a| self.annotations.push(a))
|
||||
.unwrap_or(0);
|
||||
let annotation_off = annotation.map(|a| self.annotations.push(a)).unwrap_or(0);
|
||||
|
||||
// Append page table entries
|
||||
let mut table: Vec<PageTableEntry> = Vec::new();
|
||||
@@ -451,9 +447,9 @@ impl OnionFile {
|
||||
}
|
||||
|
||||
pub fn branch_by_name(&self, name: &str) -> Option<&BranchEntry> {
|
||||
self.branches.iter().find(|b| {
|
||||
self.annotations.get(b.name_off) == Some(name)
|
||||
})
|
||||
self.branches
|
||||
.iter()
|
||||
.find(|b| self.annotations.get(b.name_off) == Some(name))
|
||||
}
|
||||
|
||||
/// Serialise the entire `.onion` file to bytes.
|
||||
@@ -480,8 +476,7 @@ impl OnionFile {
|
||||
let branch_offset = index_offset + (n_revisions * rev_entry_size) as u64;
|
||||
|
||||
// Calculate PageTable section start
|
||||
let page_table_section_start =
|
||||
branch_offset + (n_branches * branch_entry_size) as u64;
|
||||
let page_table_section_start = branch_offset + (n_branches * branch_entry_size) as u64;
|
||||
|
||||
// Calculate per-revision page_table_off values and total page table size
|
||||
let mut page_table_offsets: Vec<u64> = Vec::with_capacity(n_revisions);
|
||||
@@ -599,7 +594,11 @@ impl OnionFile {
|
||||
// 0-4). We extend the Vec with empty entries so that
|
||||
// page_tables[rev_number] is always valid.
|
||||
let max_rev = entries.iter().map(|e| e.revision).max().unwrap_or(0);
|
||||
let table_len = if entries.is_empty() { 0 } else { max_rev as usize + 1 };
|
||||
let table_len = if entries.is_empty() {
|
||||
0
|
||||
} else {
|
||||
max_rev as usize + 1
|
||||
};
|
||||
let mut page_tables: Vec<Vec<PageTableEntry>> = vec![Vec::new(); table_len];
|
||||
for entry in &entries {
|
||||
let pt_start = entry.page_table_off as usize;
|
||||
@@ -792,7 +791,10 @@ mod tests {
|
||||
fn create_accepts_valid_page_sizes() {
|
||||
for &size in &[512u32, 1024, 4096, 65536] {
|
||||
let path = Path::new("/tmp/dummy.h5");
|
||||
assert!(OnionFile::create(path, size).is_ok(), "size {size} rejected");
|
||||
assert!(
|
||||
OnionFile::create(path, size).is_ok(),
|
||||
"size {size} rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,7 +975,9 @@ mod tests {
|
||||
.list_revisions()
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
onion.index.get(r.revision)
|
||||
onion
|
||||
.index
|
||||
.get(r.revision)
|
||||
.map_or(false, |e| e.flags & crate::format::REV_FLAG_SNAPSHOT != 0)
|
||||
})
|
||||
.collect();
|
||||
@@ -1041,7 +1045,10 @@ mod tests {
|
||||
fn eof_to_page_size_always_power_of_two() {
|
||||
for eof in [1, 100, 1_000, 10_000, 100_000, 1_000_000, 100_000_000u64] {
|
||||
let ps = eof_to_page_size(eof);
|
||||
assert!(ps.is_power_of_two(), "page_size {ps} is not a power of two for eof={eof}");
|
||||
assert!(
|
||||
ps.is_power_of_two(),
|
||||
"page_size {ps} is not a power of two for eof={eof}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user