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]>
325 lines
13 KiB
Rust
325 lines
13 KiB
Rust
//! Integration tests for `SyncScheduler` with a real `TcpSyncBackend`.
|
|
//!
|
|
//! Spins up a direction-aware TCP server, then verifies that:
|
|
//! - `push_if_dirty` skips when nothing is new
|
|
//! - `push_if_dirty` pushes exactly the new delta
|
|
//! - `push_force` always fires regardless of dirty state
|
|
//! - `push_count` tracks calls correctly
|
|
|
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|
use std::path::PathBuf;
|
|
|
|
use clawhdf5_onion::format::NO_PARENT;
|
|
use clawhdf5_onion::writer::OnionFile;
|
|
use clawsync_agent::backend::TcpSyncBackend;
|
|
use clawsync_agent::scheduler::{SyncScheduler, tcp_scheduler};
|
|
use clawsync_onion::differ::diff_revisions;
|
|
use clawsync_onion::iblt::IbltSketch;
|
|
use clawsync_onion::manifest::{ClawSyncManifest, IbltManifest};
|
|
use clawsync_onion::merger::merge_packets;
|
|
use clawsync_onion::selector::SyncSelector;
|
|
use clawsync_transport::protocol::SyncMessage;
|
|
use clawsync_transport::tcp::TcpServer;
|
|
use tempfile::TempDir;
|
|
|
|
const H5_MAGIC: &[u8] = b"\x89HDF\r\n\x1a\n";
|
|
|
|
fn any_addr() -> SocketAddr {
|
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
|
|
}
|
|
|
|
/// Spin up one direction-aware server connection and return the resulting `OnionFile`.
|
|
async fn run_server(server: TcpServer, mut dst: OnionFile) -> OnionFile {
|
|
let (mut conn, _) = server.accept().await.unwrap();
|
|
|
|
match conn.recv().await.unwrap() {
|
|
// ── IBLT pre-flight (push direction) ─────────────────────────────
|
|
SyncMessage::IbltRequest {
|
|
sketch: client_iblt,
|
|
} => {
|
|
let server_revs: Vec<u64> = dst.list_revisions().iter().map(|s| s.revision).collect();
|
|
|
|
let diff = client_iblt.diff_against(&server_revs).unwrap();
|
|
|
|
let client_sketch = IbltSketch::from_bytes(&client_iblt.sketch).unwrap();
|
|
let seed = client_sketch.seed();
|
|
let srv_sketch = IbltSketch::from_keys(&server_revs, seed);
|
|
let server_iblt = IbltManifest {
|
|
agent_id: "test-server".to_string(),
|
|
file_blake3: [0u8; 32],
|
|
revision_count: server_revs.len() as u64,
|
|
head_revision: server_revs.last().copied().unwrap_or(0),
|
|
head_blake3: [0u8; 32],
|
|
last_write: 0.0,
|
|
sketch_cells: srv_sketch.cell_count() as u32,
|
|
sketch: srv_sketch.to_bytes(),
|
|
};
|
|
conn.send(&SyncMessage::IbltResponse {
|
|
sketch: server_iblt,
|
|
missing_from_remote: diff.only_in_b.clone(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut packets = Vec::new();
|
|
loop {
|
|
match conn.recv().await.unwrap() {
|
|
SyncMessage::LayerPacket { packet } => {
|
|
let rev = packet.revision;
|
|
conn.send(&SyncMessage::Ack { revision: rev })
|
|
.await
|
|
.unwrap();
|
|
packets.push(packet);
|
|
}
|
|
SyncMessage::SyncComplete { .. } => break,
|
|
other => panic!("server: unexpected {other:?}"),
|
|
}
|
|
}
|
|
if !packets.is_empty() {
|
|
merge_packets(&mut dst, packets, true).unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Flat manifest (pull direction) ────────────────────────────────
|
|
SyncMessage::ManifestRequest {
|
|
revision_count: remote_rev_count,
|
|
..
|
|
} => {
|
|
let local_rev_count = dst.revision_count();
|
|
let manifest = ClawSyncManifest::from_onion("server", &dst, H5_MAGIC);
|
|
conn.send(&SyncMessage::ManifestResponse { manifest })
|
|
.await
|
|
.unwrap();
|
|
|
|
if local_rev_count > remote_rev_count {
|
|
let remote_head = if remote_rev_count == 0 {
|
|
NO_PARENT
|
|
} else {
|
|
remote_rev_count - 1
|
|
};
|
|
let packets = diff_revisions(&dst, remote_head).unwrap();
|
|
let total = packets.len() as u64;
|
|
let mut bytes_sent = 0u64;
|
|
for packet in packets {
|
|
bytes_sent += packet.page_data_size() as u64;
|
|
conn.send(&SyncMessage::LayerPacket { packet })
|
|
.await
|
|
.unwrap();
|
|
match conn.recv().await.unwrap() {
|
|
SyncMessage::Ack { .. } => {}
|
|
other => panic!("expected Ack, got {other:?}"),
|
|
}
|
|
}
|
|
conn.send(&SyncMessage::SyncComplete {
|
|
revisions_transferred: total,
|
|
bytes_transferred: bytes_sent,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
} else {
|
|
let mut packets = Vec::new();
|
|
loop {
|
|
match conn.recv().await.unwrap() {
|
|
SyncMessage::LayerPacket { packet } => {
|
|
let rev = packet.revision;
|
|
conn.send(&SyncMessage::Ack { revision: rev })
|
|
.await
|
|
.unwrap();
|
|
packets.push(packet);
|
|
}
|
|
SyncMessage::SyncComplete { .. } => break,
|
|
other => panic!("server: unexpected {other:?}"),
|
|
}
|
|
}
|
|
if !packets.is_empty() {
|
|
merge_packets(&mut dst, packets, true).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
other => panic!("server: expected IbltRequest or ManifestRequest, got {other:?}"),
|
|
}
|
|
|
|
dst
|
|
}
|
|
|
|
/// Create a temp HDF5 file with `n` committed onion revisions.
|
|
fn make_onion(n: u8, dir: &TempDir) -> (PathBuf, OnionFile) {
|
|
let h5 = dir.path().join(format!(
|
|
"data_{n}_{}.h5",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.subsec_nanos()
|
|
));
|
|
std::fs::write(&h5, H5_MAGIC).unwrap();
|
|
let mut onion = OnionFile::create(&h5, 4096).unwrap();
|
|
for i in 0..n {
|
|
let mut s = onion.begin_session(None).unwrap();
|
|
s.record_page(0, &vec![i; 4096]);
|
|
onion.commit_session(s, Some(&format!("rev {i}"))).unwrap();
|
|
}
|
|
onion.flush().unwrap();
|
|
(h5, onion)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// `push_if_dirty` skips when the scheduler has already pushed the HEAD revision.
|
|
#[tokio::test]
|
|
async fn scheduler_push_if_dirty_skips_when_up_to_date() {
|
|
let dir = TempDir::new().unwrap();
|
|
let (src_h5, src_onion) = make_onion(3, &dir);
|
|
let (_, dst_onion) = make_onion(0, &dir);
|
|
|
|
// Pre-sync: run one push manually so the server has all revisions.
|
|
let server = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr = server.local_addr;
|
|
let server_task = tokio::spawn(run_server(server, dst_onion));
|
|
let backend = TcpSyncBackend::new(addr, "agent");
|
|
let sched = SyncScheduler::new(backend, src_h5.clone(), SyncSelector::All);
|
|
|
|
let stats = sched.push_if_dirty().await.unwrap().unwrap();
|
|
assert_eq!(stats.revisions_transferred, 3);
|
|
server_task.await.unwrap();
|
|
|
|
// Second call: scheduler remembers head_revision=2; should skip.
|
|
// (No server needed — the skip happens before connecting.)
|
|
let result = sched.push_if_dirty().await.unwrap();
|
|
assert!(result.is_none(), "should skip when already up-to-date");
|
|
assert_eq!(sched.push_count(), 1, "only one real push happened");
|
|
let _ = src_onion;
|
|
}
|
|
|
|
/// `push_if_dirty` fires when a new revision is added after the last push.
|
|
#[tokio::test]
|
|
async fn scheduler_push_if_dirty_fires_on_new_revision() {
|
|
let dir = TempDir::new().unwrap();
|
|
let (src_h5, mut src_onion) = make_onion(2, &dir);
|
|
let (_, dst_onion) = make_onion(0, &dir);
|
|
|
|
// First push — transfers 2 revisions.
|
|
let server = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr = server.local_addr;
|
|
let server_task = tokio::spawn(run_server(server, dst_onion));
|
|
let backend = TcpSyncBackend::new(addr, "agent");
|
|
let sched = SyncScheduler::new(backend, src_h5.clone(), SyncSelector::All);
|
|
sched.push_if_dirty().await.unwrap();
|
|
let dst_after_first = server_task.await.unwrap();
|
|
assert_eq!(dst_after_first.revision_count(), 2);
|
|
|
|
// Add a third revision to the source.
|
|
let mut session = src_onion.begin_session(None).unwrap();
|
|
session.record_page(0, &vec![99u8; 4096]);
|
|
src_onion.commit_session(session, Some("new rev")).unwrap();
|
|
src_onion.flush().unwrap();
|
|
|
|
// Second push — should detect the new revision and transfer exactly 1.
|
|
let server2 = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr2 = server2.local_addr;
|
|
let server_task2 = tokio::spawn(run_server(server2, dst_after_first));
|
|
let backend2 = TcpSyncBackend::new(addr2, "agent");
|
|
// New scheduler, so it starts without a remembered head.
|
|
let sched2 = SyncScheduler::new(backend2, src_h5.clone(), SyncSelector::All);
|
|
// Mark rev 1 as already pushed (simulates first push having happened).
|
|
sched2.mark_pushed(1);
|
|
let stats = sched2.push_if_dirty().await.unwrap().unwrap();
|
|
assert_eq!(stats.revisions_transferred, 1);
|
|
server_task2.await.unwrap();
|
|
}
|
|
|
|
/// `push_force` always transfers even when the scheduler thinks it's up-to-date.
|
|
#[tokio::test]
|
|
async fn scheduler_push_force_always_syncs() {
|
|
let dir = TempDir::new().unwrap();
|
|
let (src_h5, src_onion) = make_onion(4, &dir);
|
|
let (_, dst_onion) = make_onion(0, &dir);
|
|
|
|
let server = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr = server.local_addr;
|
|
let server_task = tokio::spawn(run_server(server, dst_onion));
|
|
let backend = TcpSyncBackend::new(addr, "agent");
|
|
let sched = SyncScheduler::new(backend, src_h5.clone(), SyncSelector::All);
|
|
|
|
// Pre-mark the HEAD as already pushed.
|
|
sched.mark_pushed(3);
|
|
|
|
// push_force should still push.
|
|
let stats = sched.push_force().await.unwrap();
|
|
assert_eq!(stats.revisions_transferred, 4);
|
|
assert_eq!(sched.push_count(), 1);
|
|
|
|
server_task.await.unwrap();
|
|
let _ = src_onion;
|
|
}
|
|
|
|
/// `push_count` increments on every successful push.
|
|
#[tokio::test]
|
|
async fn scheduler_push_count_tracks_calls() {
|
|
let dir = TempDir::new().unwrap();
|
|
let (src_h5, src_onion) = make_onion(2, &dir);
|
|
|
|
let backend0 = TcpSyncBackend::new(any_addr(), "agent"); // addr unused
|
|
let sched = SyncScheduler::new(backend0, src_h5.clone(), SyncSelector::All);
|
|
|
|
// Three force pushes, each with its own server.
|
|
for _ in 0..3 {
|
|
let (_, dst_onion) = make_onion(0, &dir);
|
|
let server = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr = server.local_addr;
|
|
let server_task = tokio::spawn(run_server(server, dst_onion));
|
|
|
|
// Swap the backend to the new addr by creating a fresh scheduler sharing the count.
|
|
// Since we can't mutate the backend in place, just use push_force via a fresh one.
|
|
let backend_i = TcpSyncBackend::new(addr, "agent");
|
|
let sched_i = SyncScheduler::new(backend_i, src_h5.clone(), SyncSelector::All);
|
|
sched_i.push_force().await.unwrap();
|
|
server_task.await.unwrap();
|
|
}
|
|
|
|
// The original sched tracks its own push_count separately.
|
|
// Verify push_count increments on the shared scheduler with a single backend.
|
|
let (_, dst_onion2) = make_onion(0, &dir);
|
|
let server = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr = server.local_addr;
|
|
let server_task = tokio::spawn(run_server(server, dst_onion2));
|
|
let backend_final = TcpSyncBackend::new(addr, "agent");
|
|
let sched_final = SyncScheduler::new(backend_final, src_h5.clone(), SyncSelector::All);
|
|
for _ in 0..3 {
|
|
// push_if_dirty for first call, mark head between calls
|
|
let dst_inner = make_onion(0, &dir);
|
|
let _ = dst_inner;
|
|
}
|
|
sched_final.push_force().await.unwrap();
|
|
server_task.await.unwrap();
|
|
assert_eq!(sched_final.push_count(), 1);
|
|
let _ = (src_onion, sched);
|
|
}
|
|
|
|
/// `tcp_scheduler` convenience constructor parses address and creates scheduler.
|
|
#[tokio::test]
|
|
async fn tcp_scheduler_constructor_and_push() {
|
|
let dir = TempDir::new().unwrap();
|
|
let (src_h5, src_onion) = make_onion(3, &dir);
|
|
let (_, dst_onion) = make_onion(0, &dir);
|
|
|
|
let server = TcpServer::bind(any_addr()).await.unwrap();
|
|
let addr = server.local_addr;
|
|
let server_task = tokio::spawn(run_server(server, dst_onion));
|
|
|
|
let sched = tcp_scheduler(
|
|
&addr.to_string(),
|
|
src_h5.clone(),
|
|
"test-agent",
|
|
SyncSelector::All,
|
|
)
|
|
.unwrap();
|
|
|
|
let stats = sched.push_force().await.unwrap();
|
|
assert_eq!(stats.revisions_transferred, 3);
|
|
server_task.await.unwrap();
|
|
let _ = src_onion;
|
|
}
|