Initial commit: ClawSync v0.1.0

8-crate pure-Rust workspace for revision-aware HDF5 sync.

## Crates
- clawhdf5-onion: ClawOnion VFD — page-level versioned HDF5 storage,
  binary format, writer/reader, branch DAG, GC, snapshots, provenance
- clawsync-core: BLAKE3, xxHash3, FastCDC (+ SIMD NEON), zstd/lz4
- clawsync-onion: IBLT sketch, Merkle tree differ, packet differ/merger,
  ClawSyncManifest, SyncSelector
- clawsync-hdf5: dataset-level manifest, differ, patcher, wire payload
  reconstruction (apply_received_payloads)
- clawsync-transport: TCP, QUIC (quinn 0.11/TLS 1.3), SyncPeer abstraction,
  length-prefixed rkyv wire protocol (21 SyncMessage variants)
- clawsync-agent: OnionMemory, SyncScheduler, TcpSyncBackend,
  PeerCapabilities negotiation
- clawsync-fs: CDC-based delta sync for any file type; FsSyncClient/Server,
  W=16 pipelining, atomic writes
- clawsync-cli: push/pull/serve/hdf5-sync/serve-hdf5/sync/serve-fs +
  all local management commands; --quic on all network commands

## Key features
- IBLT pre-flight: O(revision count) vs rsync's O(file size)
- W=16 sliding-window push: 13–15x speedup over stop-and-wait at WAN RTT
- Dataset-granular HDF5 sync: only modified datasets transferred
- CDC delta for any file type: insertion-stable chunk boundaries
- Full revision DAG: branch, merge, rollback, export, snapshot, GC
- QUIC transport: TLS 1.3, per-message streams via quinn 0.11

## Tests
~573 passing (default features); ~589 with --features simd-cdc

## Performance (Apple Silicon)
- Reconstruct rev=100: 68 µs (target ≤ 1 ms)
- BLAKE3 Rayon 1 MB: 10.3 GiB/s (target ≥ 5 GB/s)
- GC 500 revisions: 20.6 µs (target ≤ 2 s)
- W=16 vs W=1 at 5 ms RTT: 14.8x speedup
- No-op pre-flight at 16 MB: 4 ms vs rsync 35 ms (7.8x)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-04 18:41:22 -05:00
co-authored by Claude Sonnet 4.6
commit 260e15f5b6
102 changed files with 30098 additions and 0 deletions
@@ -0,0 +1,225 @@
//! Property-based tests for [`VersionedFile`].
//!
//! These tests verify that the high-level `VersionedFile` API preserves
//! reconstruction correctness under arbitrary sequences of interleaved
//! branch commits, snapshots, and flush+reload cycles.
//!
//! ## Key invariant
//!
//! For any sequence of `commit_on(branch, new_bytes, ...)` calls, reconstructing
//! the resulting revision must return exactly `new_bytes` (for the pages that
//! were written). This is the correctness guarantee of the per-branch diff
//! baseline (`branch_state` map inside `VersionedFile`).
//!
//! ## Fill-byte constraint
//!
//! All writes use fill bytes in 1..=255 so that every page always differs from
//! `h5_base` (a 4 KiB zero buffer). This avoids the documented edge case where
//! committing bytes equal to `h5_base` on a branch that has ancestors with
//! different content would silently record an empty diff, making the commit
//! indistinguishable from a no-op at the page level.
use clawhdf5_onion::versioned_file::VersionedFile;
use proptest::prelude::*;
use tempfile::TempDir;
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
const PAGE_SIZE: u32 = 4096;
// ─────────────────────────────────────────────────────────────────────────────
// Generators
// ─────────────────────────────────────────────────────────────────────────────
/// A single round of operations: optionally create a new branch, then commit
/// on `branch_idx % current_branch_count` with `fill`.
#[derive(Debug, Clone)]
struct Round {
/// If true, fork a new branch from main before this commit.
create_branch: bool,
/// Raw index — clamped to `% len(branches)` during execution.
branch_idx: usize,
/// Fill byte for the 4 KiB page. Kept in 1..=255 so the page always
/// differs from the zero-filled `h5_base`.
fill: u8,
}
fn arb_round() -> impl Strategy<Value = Round> {
(any::<bool>(), 0usize..8usize, 1u8..=255u8).prop_map(|(create_branch, branch_idx, fill)| {
Round { create_branch, branch_idx, fill }
})
}
fn arb_rounds(min: usize, max: usize) -> impl Strategy<Value = Vec<Round>> {
proptest::collection::vec(arb_round(), min..=max)
}
// ─────────────────────────────────────────────────────────────────────────────
// Test helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Create a temp dir with a 4 KiB zero base file and a fresh `VersionedFile`.
fn make_vf() -> (TempDir, std::path::PathBuf, VersionedFile) {
let dir = TempDir::new().unwrap();
let h5 = dir.path().join("base.h5");
std::fs::write(&h5, vec![0u8; PAGE_SIZE as usize]).unwrap();
let vf = VersionedFile::create(&h5, PAGE_SIZE).unwrap();
(dir, h5, vf)
}
/// Apply `rounds` to `vf`, returning a Vec of `(revision_number, expected_page_bytes)`.
///
/// `branches` starts as `["main"]` and grows as new branches are created.
fn apply_rounds(
vf: &mut VersionedFile,
rounds: &[Round],
) -> Vec<(u64, Vec<u8>)> {
let mut branches: Vec<String> = vec!["main".to_string()];
let mut expectations: Vec<(u64, Vec<u8>)> = Vec::new();
let mut branch_counter: usize = 0;
for round in rounds {
// Optionally create a new branch forked from main.
if round.create_branch {
let name = format!("b{branch_counter}");
branch_counter += 1;
// create_branch may fail if a branch with the same name already exists,
// which can't happen here since names are unique via the counter.
vf.onion_mut().create_branch(&name, "main").unwrap();
branches.push(name);
}
let branch_name = &branches[round.branch_idx % branches.len()];
let branch_opt = if branch_name == "main" {
None
} else {
Some(branch_name.as_str())
};
let new_bytes = vec![round.fill; PAGE_SIZE as usize];
let rev = vf.commit_on(branch_opt, new_bytes.clone(), None).unwrap();
expectations.push((rev, new_bytes));
}
expectations
}
/// Verify every `(rev, expected_bytes)` pair: reconstruct the revision from
/// `vf` and check that page 0 matches `expected_bytes`.
fn verify_expectations(
vf: &VersionedFile,
h5_base: &[u8],
expectations: &[(u64, Vec<u8>)],
) -> Result<(), TestCaseError> {
for (rev, expected) in expectations {
let actual = vf
.onion()
.reconstruct_revision(*rev, h5_base)
.map_err(|e| TestCaseError::fail(format!("reconstruct_revision({rev}) failed: {e}")))?;
// Check that the first page matches exactly.
let page_end = PAGE_SIZE as usize;
prop_assert_eq!(
&actual[..page_end],
expected.as_slice(),
"revision {}: page 0 mismatch", rev
);
}
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// Property tests
// ─────────────────────────────────────────────────────────────────────────────
proptest! {
#![proptest_config(ProptestConfig {
cases: 200,
max_shrink_iters: 100,
..ProptestConfig::default()
})]
/// After any sequence of interleaved commits on multiple branches, every
/// committed revision reconstructs to the exact bytes that were passed to
/// `commit_on`.
///
/// This is the core invariant of the per-branch diff baseline: even with
/// arbitrary interleaving of commits across branches, `reconstruct_revision`
/// always yields back the bytes that were committed.
#[test]
fn prop_vf_interleaved_commits_reconstruct_correctly(
rounds in arb_rounds(1, 16),
) {
let (_dir, h5, mut vf) = make_vf();
let h5_base = std::fs::read(&h5).unwrap();
let expectations = apply_rounds(&mut vf, &rounds);
verify_expectations(&vf, &h5_base, &expectations)?;
}
/// After commits, flushing the sidecar to disk and reloading it via
/// `VersionedFile::open` preserves every revision's content.
///
/// This checks that the on-disk serialisation + deserialization path is
/// lossless for the high-level API.
#[test]
fn prop_vf_flush_reload_preserves_all_commits(
rounds in arb_rounds(1, 12),
) {
let (_dir, h5, mut vf) = make_vf();
let h5_base = std::fs::read(&h5).unwrap();
let expectations = apply_rounds(&mut vf, &rounds);
// Flush is already called inside commit_on, but call it once more to
// exercise the explicit flush path.
vf.onion_mut().flush().unwrap();
// Reload from disk.
let reloaded = VersionedFile::open(&h5).unwrap();
verify_expectations(&reloaded, &h5_base, &expectations)?;
}
/// After any commits on main followed by a manual snapshot, the snapshot
/// revision reconstructs to the same bytes as the pre-snapshot HEAD.
/// Subsequent commits on main still reconstruct correctly.
///
/// This verifies that inserting a snapshot does not corrupt the revision
/// chain for future commits.
#[test]
fn prop_vf_snapshot_does_not_corrupt_reconstruction(
pre_fills in proptest::collection::vec(1u8..=255u8, 1..=6usize),
post_fills in proptest::collection::vec(1u8..=255u8, 0..=4usize),
) {
let (_dir, h5, mut vf) = make_vf();
let h5_base = std::fs::read(&h5).unwrap();
let mut expectations: Vec<(u64, Vec<u8>)> = Vec::new();
// Pre-snapshot commits on main.
for &fill in &pre_fills {
let bytes = vec![fill; PAGE_SIZE as usize];
let rev = vf.commit_on(None, bytes.clone(), None).unwrap();
expectations.push((rev, bytes));
}
// Snapshot — must reconstruct to the same bytes as the last pre-commit.
let snap_rev = vf.snapshot(Some("test snapshot")).unwrap();
let last_expected = expectations.last().unwrap().1.clone();
let snap_actual = vf.onion().reconstruct_revision(snap_rev, &h5_base).unwrap();
prop_assert_eq!(
&snap_actual[..PAGE_SIZE as usize],
last_expected.as_slice(),
"snapshot revision {} does not match pre-snapshot HEAD", snap_rev
);
// Post-snapshot commits — reconstruction must remain correct.
for &fill in &post_fills {
let bytes = vec![fill; PAGE_SIZE as usize];
let rev = vf.commit_on(None, bytes.clone(), None).unwrap();
expectations.push((rev, bytes));
}
verify_expectations(&vf, &h5_base, &expectations)?;
}
}