feat: Implement Iroh-based peer networking, join protocol, and bidirectional store synchronization.

This commit is contained in:
2025-12-22 21:11:34 +01:00
parent 7c8e5cfa3d
commit e942da49ff
22 changed files with 1933 additions and 57 deletions
+189
View File
@@ -0,0 +1,189 @@
//! Causal Entry Iterator - yields entries in HLC (causal) order
//!
//! Implements merge-sort streaming across multiple author queues using a min-heap,
//! ensuring entries are returned in correct causal order for sync.
//! Complexity: O(N log K) where N = total entries, K = number of authors.
use crate::proto::{Entry, SignedEntry};
use prost::Message;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, VecDeque};
/// A heap entry that wraps an author queue index and the HLC of its front entry.
/// Uses Reverse for min-heap behavior (lowest HLC first).
struct HeapEntry {
hlc: (u64, u32),
queue_idx: usize,
}
impl PartialEq for HeapEntry {
fn eq(&self, other: &Self) -> bool {
self.hlc == other.hlc
}
}
impl Eq for HeapEntry {}
impl PartialOrd for HeapEntry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeapEntry {
fn cmp(&self, other: &Self) -> Ordering {
// Reverse order for min-heap (BinaryHeap is max-heap by default)
other.hlc.cmp(&self.hlc)
}
}
/// Iterator that yields SignedEntry in HLC (causal) order.
///
/// Takes multiple VecDeques (one per author) and yields entries
/// from lowest to highest HLC, ensuring causal ordering for sync.
/// Uses a min-heap for O(log K) per-entry overhead instead of O(K) linear scan.
pub struct CausalEntryIter {
queues: Vec<VecDeque<SignedEntry>>,
heap: BinaryHeap<HeapEntry>,
}
impl CausalEntryIter {
/// Create a new iterator from a list of entry queues (one per author)
pub fn new(queues: Vec<VecDeque<SignedEntry>>) -> Self {
let mut heap = BinaryHeap::with_capacity(queues.len());
// Initialize heap with the front entry from each non-empty queue
for (idx, queue) in queues.iter().enumerate() {
if let Some(entry) = queue.front() {
heap.push(HeapEntry {
hlc: Self::get_hlc(entry),
queue_idx: idx,
});
}
}
Self { queues, heap }
}
/// Extract HLC (wall_time, counter) from a SignedEntry
fn get_hlc(entry: &SignedEntry) -> (u64, u32) {
Entry::decode(&entry.entry_bytes[..])
.ok()
.and_then(|e| e.timestamp)
.map(|t| (t.wall_time, t.counter))
.unwrap_or((0, 0))
}
}
impl Iterator for CausalEntryIter {
type Item = SignedEntry;
fn next(&mut self) -> Option<Self::Item> {
// Pop the queue with lowest HLC
let HeapEntry { queue_idx, .. } = self.heap.pop()?;
// Remove entry from that queue
let entry = self.queues[queue_idx].pop_front()?;
// If queue still has entries, push its new front back to heap
if let Some(next_entry) = self.queues[queue_idx].front() {
self.heap.push(HeapEntry {
hlc: Self::get_hlc(next_entry),
queue_idx,
});
}
Some(entry)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hlc::HLC;
use crate::clock::MockClock;
use crate::node::Node;
use crate::signed_entry::EntryBuilder;
fn make_entry(node: &Node, seq: u64, clock_ms: u64) -> SignedEntry {
let clock = MockClock::new(clock_ms);
EntryBuilder::new(seq, HLC::now_with_clock(&clock))
.store_id(vec![0u8; 16])
.prev_hash(vec![0u8; 32])
.put(b"/test".to_vec(), format!("seq{}", seq).into_bytes())
.sign(node)
}
#[test]
fn test_empty_iter() {
let iter = CausalEntryIter::new(vec![]);
assert_eq!(iter.count(), 0);
}
#[test]
fn test_single_queue() {
let node = Node::generate();
let entries: VecDeque<_> = vec![
make_entry(&node, 1, 1000),
make_entry(&node, 2, 2000),
].into();
let iter = CausalEntryIter::new(vec![entries]);
let result: Vec<_> = iter.collect();
assert_eq!(result.len(), 2);
}
#[test]
fn test_merge_multiple_queues() {
let node_a = Node::generate();
let node_b = Node::generate();
// Author A: entries at time 1000, 3000
let queue_a: VecDeque<_> = vec![
make_entry(&node_a, 1, 1000),
make_entry(&node_a, 2, 3000),
].into();
// Author B: entries at time 2000
let queue_b: VecDeque<_> = vec![
make_entry(&node_b, 1, 2000),
].into();
let iter = CausalEntryIter::new(vec![queue_a, queue_b]);
let result: Vec<_> = iter.collect();
// Should be in HLC order: 1000, 2000, 3000
assert_eq!(result.len(), 3);
// Verify order by checking HLC values
let hlcs: Vec<_> = result.iter()
.map(|e| CausalEntryIter::get_hlc(e))
.collect();
assert_eq!(hlcs[0].0, 1000);
assert_eq!(hlcs[1].0, 2000);
assert_eq!(hlcs[2].0, 3000);
}
#[test]
fn test_many_authors() {
// Test with 10 authors to verify heap behavior
let nodes: Vec<_> = (0..10).map(|_| Node::generate()).collect();
let queues: Vec<VecDeque<_>> = nodes.iter().enumerate().map(|(i, node)| {
vec![make_entry(node, 1, (i * 100 + 50) as u64)].into()
}).collect();
let iter = CausalEntryIter::new(queues);
let result: Vec<_> = iter.collect();
assert_eq!(result.len(), 10);
// Verify strictly increasing HLC order
let hlcs: Vec<_> = result.iter()
.map(|e| CausalEntryIter::get_hlc(e).0)
.collect();
for window in hlcs.windows(2) {
assert!(window[0] < window[1], "HLCs should be strictly increasing");
}
}
}
+5 -1
View File
@@ -12,6 +12,7 @@
//! - **SignedEntry**: Entry creation, signing, and verification
//! - **Log**: Append-only log file I/O
//! - **Store**: Persistent KV state from log replay
//! - **CausalIter**: Merge-sort iterator for HLC-ordered sync
pub mod node;
pub mod sigchain;
@@ -25,13 +26,14 @@ pub mod signed_entry;
pub mod log;
pub mod store;
pub mod meta_store;
pub mod causal_iter;
// Constants
/// Maximum size of a serialized SignedEntry (16 MB)
pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024;
pub use node::Node;
pub use sigchain::SigChain;
pub use sigchain::{SigChain, SigChainManager};
pub use entry::Entry;
pub use sync_state::{SyncState, AuthorInfo, MissingRange};
pub use hlc::HLC;
@@ -43,3 +45,5 @@ pub use store::Store;
pub use meta_store::MetaStore;
pub use proto::HeadInfo;
pub use uuid::Uuid;
pub use causal_iter::CausalEntryIter;
+6
View File
@@ -101,6 +101,12 @@ impl Node {
&self.signing_key
}
/// Get the secret key bytes (32 bytes) for Iroh integration.
/// WARNING: Handle with care - this exposes the private key material.
pub fn secret_key_bytes(&self) -> [u8; 32] {
self.signing_key.to_bytes()
}
/// Sign a message.
pub fn sign(&self, message: &[u8]) -> Signature {
self.signing_key.sign(message)
+69
View File
@@ -146,6 +146,11 @@ impl SigChain {
&self.last_hash
}
/// Get the log file path
pub fn log_path(&self) -> &std::path::Path {
&self.log_path
}
/// Get the current length of the chain
pub fn len(&self) -> u64 {
self.next_seq - 1
@@ -248,6 +253,70 @@ impl SigChain {
}
}
/// Manages multiple SigChains (one per author) for a store.
/// Provides unified interface for appending entries from any author.
pub struct SigChainManager {
/// Directory containing log files (one per author)
logs_dir: PathBuf,
/// Store UUID (16 bytes)
store_id: [u8; 16],
/// Cache of loaded SigChains by author
chains: std::collections::HashMap<[u8; 32], SigChain>,
}
impl SigChainManager {
/// Create a new manager for a store's logs directory
pub fn new(logs_dir: impl AsRef<Path>, store_id: [u8; 16]) -> Self {
Self {
logs_dir: logs_dir.as_ref().to_path_buf(),
store_id,
chains: std::collections::HashMap::new(),
}
}
/// Get or create a SigChain for an author
pub fn get_or_create(&mut self, author: [u8; 32]) -> &mut SigChain {
self.chains.entry(author).or_insert_with(|| {
let author_hex = hex::encode(author);
let log_path = self.logs_dir.join(format!("{}.log", author_hex));
// Try to load existing log, or create new
SigChain::from_log(&log_path, self.store_id, author)
.unwrap_or_else(|_| SigChain::new(&log_path, self.store_id, author))
})
}
/// Get the local node's sigchain (for creating new entries)
pub fn get(&self, author: &[u8; 32]) -> Option<&SigChain> {
self.chains.get(author)
}
/// Append an entry to the appropriate author's log
/// This is the main entry point for all entry writes (from put, sync, etc.)
pub fn append_entry(&mut self, entry: &SignedEntry) -> Result<(), SigChainError> {
let author: [u8; 32] = entry.author_id.clone()
.try_into()
.map_err(|_| SigChainError::WrongAuthor {
expected: "32 bytes".to_string(),
got: format!("{} bytes", entry.author_id.len()),
})?;
// For synced entries, we can't validate seq/prev_hash since they may arrive
// out of order. Just append to the log file directly.
let chain = self.get_or_create(author);
append_entry(chain.log_path(), entry)?;
Ok(())
}
/// Get the logs directory path
pub fn logs_dir(&self) -> &Path {
&self.logs_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
+203
View File
@@ -8,6 +8,7 @@
use crate::log::{read_entries, LogError};
use crate::proto::{operation, AuthorState, Entry, HeadInfo, HeadList, SignedEntry};
use crate::sigchain::SigChainError;
use crate::signed_entry::hash_signed_entry;
use prost::Message;
use redb::{Database, ReadableTable, TableDefinition};
@@ -41,6 +42,9 @@ pub enum StoreError {
#[error("Decode error: {0}")]
Decode(#[from] prost::DecodeError),
#[error("Sigchain error: {0}")]
SigChain(#[from] SigChainError),
}
/// Persistent store for KV state with DAG conflict resolution
@@ -1469,4 +1473,203 @@ mod tests {
let _ = std::fs::remove_file(path);
}
/// Test case for multi-node sync: 3 nodes create multi-heads, then merge, then sync to new node.
///
/// Scenario:
/// 1. Node A, B, C each write to key "/a" independently (creating 3 heads)
/// 2. Node A does a final put to merge all heads
/// 3. After merge, node A should have only 1 head
/// 4. Simulate sync to new node D using SyncState diff
/// 5. Node D should end up with same state as A (1 head, not 3)
#[test]
fn test_multinode_sync_after_merge() {
let path_a = temp_db_path("multinode_a");
let path_d = temp_db_path("multinode_d");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_d);
// Create stores
let store_a = Store::open(&path_a).unwrap();
let store_d = Store::open(&path_d).unwrap();
// Create 3 nodes (virtual peers)
let node_a = Node::generate();
let node_b = Node::generate();
let node_c = Node::generate();
let clock = MockClock::new(1000);
// 1. Each node writes to "/a" independently (simulating offline concurrent writes)
// Node A: seq 1
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/a", b"from_a".to_vec())
.sign(&node_a);
store_a.apply_entry(&entry_a).unwrap();
// Node B: seq 1 (different author, same key - creates fork)
let entry_b = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/a", b"from_b".to_vec())
.sign(&node_b);
store_a.apply_entry(&entry_b).unwrap();
// Node C: seq 1 (third author, same key - creates third fork)
let entry_c = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/a", b"from_c".to_vec())
.sign(&node_c);
store_a.apply_entry(&entry_c).unwrap();
// After applying all 3 entries, store_a has 3 heads for "/a"
let heads_before_merge = store_a.get_heads(b"/a").unwrap();
assert_eq!(heads_before_merge.len(), 3, "Should have 3 heads before merge");
// 2. Node A does a final put referencing all heads (merge)
// Get the hashes of all current heads as parent_hashes
let parent_hashes: Vec<Vec<u8>> = heads_before_merge.iter()
.map(|h| h.hash.clone())
.collect();
let merge_entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash(hash_signed_entry(&entry_a).to_vec()) // Continues A's chain
.parent_hashes(parent_hashes) // References all heads
.put("/a", b"merged".to_vec())
.sign(&node_a);
store_a.apply_entry(&merge_entry).unwrap();
// After merge, should have only 1 head
let heads_after_merge = store_a.get_heads(b"/a").unwrap();
assert_eq!(heads_after_merge.len(), 1, "Should have 1 head after merge");
assert_eq!(heads_after_merge[0].value, b"merged");
// 3. Get sync state from store_a
let sync_state_a = store_a.sync_state().unwrap();
println!("Store A sync state:");
for (author, info) in sync_state_a.authors() {
println!(" author {:?}: seq={}, heads={:?}",
hex::encode(&author[..4]), info.seq,
info.heads.iter().map(|h| hex::encode(&h[..4])).collect::<Vec<_>>());
}
// 4. Store D is empty, compute diff
let sync_state_d = store_d.sync_state().unwrap();
let missing = sync_state_d.diff(&sync_state_a);
println!("Missing ranges: {:?}", missing.len());
for m in &missing {
println!(" author {:?}: from_seq={}, to_seq={}",
hex::encode(&m.author[..4]), m.from_seq, m.to_seq);
}
// We should get missing ranges for all authors that have entries
assert!(!missing.is_empty(), "Should have missing entries to sync");
// 5. Apply all entries to store_d (simulating sync)
// In a real sync, we'd read entries from logs, but for this test,
// we just apply the same entries in order
store_d.apply_entry(&entry_a).unwrap();
store_d.apply_entry(&entry_b).unwrap();
store_d.apply_entry(&entry_c).unwrap();
store_d.apply_entry(&merge_entry).unwrap();
// 6. Check state on store_d
let heads_d = store_d.get_heads(b"/a").unwrap();
println!("Store D heads count: {}", heads_d.len());
for (i, h) in heads_d.iter().enumerate() {
println!(" head[{}]: value={:?}, author={}", i, String::from_utf8_lossy(&h.value), hex::encode(&h.author[..4]));
}
// BUG CHECK: Store D should have same state as Store A (1 head, not 3)
assert_eq!(heads_d.len(), 1,
"BUG: Store D should have 1 head (merged) but has {} heads", heads_d.len());
assert_eq!(heads_d[0].value, b"merged");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_d);
}
/// Test what happens when entries are applied in "wrong" order.
/// This simulates the real sync bug where:
/// - Sync iterates by author
/// - Author A's entries (including merge) are sent first
/// - Author B and C's entries are sent after
/// - The merge entry arrives BEFORE the entries it merges!
#[test]
fn test_multinode_sync_wrong_order() {
let path = temp_db_path("wrongorder");
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
// Create 3 nodes
let node_a = Node::generate();
let node_b = Node::generate();
let node_c = Node::generate();
let clock = MockClock::new(1000);
// Create entries (same as before)
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/a", b"from_a".to_vec())
.sign(&node_a);
let entry_b = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/a", b"from_b".to_vec())
.sign(&node_b);
let entry_c = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/a", b"from_c".to_vec())
.sign(&node_c);
// We need the hashes for parent_hashes - compute them
let hash_a = hash_signed_entry(&entry_a);
let hash_b = hash_signed_entry(&entry_b);
let hash_c = hash_signed_entry(&entry_c);
let merge_entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash(hash_a.to_vec())
.parent_hashes(vec![hash_a.to_vec(), hash_b.to_vec(), hash_c.to_vec()])
.put("/a", b"merged".to_vec())
.sign(&node_a);
// Apply in WRONG order: A's chain first (entry_a + merge), then B, then C
// This is what happens in sync when iterating by author
println!("Applying entry_a (A seq 1)...");
store.apply_entry(&entry_a).unwrap();
println!("Applying merge_entry (A seq 2) BEFORE B and C...");
store.apply_entry(&merge_entry).unwrap();
println!("Applying entry_b (B seq 1)...");
store.apply_entry(&entry_b).unwrap();
println!("Applying entry_c (C seq 1)...");
store.apply_entry(&entry_c).unwrap();
// Check final state
let heads = store.get_heads(b"/a").unwrap();
println!("Final heads count: {}", heads.len());
for (i, h) in heads.iter().enumerate() {
println!(" head[{}]: value={:?}", i, String::from_utf8_lossy(&h.value));
}
assert_eq!(heads.len(), 3,
"Wrong order application creates 3 heads (expected - sync handles ordering)");
let _ = std::fs::remove_file(&path);
}
}
+165 -19
View File
@@ -1,21 +1,33 @@
//! Sync state for causality tracking and reconciliation
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
/// Author ID type (32-byte Ed25519 public key)
pub type Author = [u8; 32];
/// Per-author sync information (seq + hash for resume).
/// Per-author sync information: seq + all head hashes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorInfo {
pub seq: u64,
pub hash: [u8; 32],
pub heads: HashSet<[u8; 32]>, // All head hashes for this author
}
/// Sync state tracking per-author sequence numbers and hashes.
impl AuthorInfo {
pub fn new(seq: u64, hash: [u8; 32]) -> Self {
let mut heads = HashSet::new();
heads.insert(hash);
Self { seq, heads }
}
pub fn with_heads(seq: u64, heads: HashSet<[u8; 32]>) -> Self {
Self { seq, heads }
}
}
/// Sync state tracking per-author sequence numbers and head hashes.
///
/// Used during reconciliation to identify missing entries between peers.
/// Each author's highest seen sequence number and hash is tracked.
/// Tracks all head hashes per author to handle forks correctly.
#[derive(Debug, Clone, Default)]
pub struct SyncState {
authors: HashMap<Author, AuthorInfo>,
@@ -26,7 +38,7 @@ pub struct SyncState {
pub struct MissingRange {
pub author: Author,
pub from_seq: u64, // exclusive - we have up to this
pub from_hash: [u8; 32], // hash to resume reading after
pub from_hash: [u8; 32], // hash to resume reading after (zero = start)
pub to_seq: u64, // inclusive - peer has up to this
}
@@ -47,10 +59,32 @@ impl SyncState {
pub fn seq(&self, author: &Author) -> u64 {
self.authors.get(author).map(|i| i.seq).unwrap_or(0)
}
/// Get head hashes for an author (returns empty set if not present).
pub fn heads(&self, author: &Author) -> HashSet<[u8; 32]> {
self.authors.get(author).map(|i| i.heads.clone()).unwrap_or_default()
}
/// Set the info for an author.
/// Set the info for an author (single hash convenience method).
pub fn set(&mut self, author: Author, seq: u64, hash: [u8; 32]) {
self.authors.insert(author, AuthorInfo { seq, hash });
self.authors.insert(author, AuthorInfo::new(seq, hash));
}
/// Set the info for an author with multiple heads.
pub fn set_heads(&mut self, author: Author, seq: u64, heads: HashSet<[u8; 32]>) {
self.authors.insert(author, AuthorInfo::with_heads(seq, heads));
}
/// Add a head hash for an author (updates seq if higher).
pub fn add_head(&mut self, author: Author, seq: u64, hash: [u8; 32]) {
if let Some(info) = self.authors.get_mut(&author) {
info.heads.insert(hash);
if seq > info.seq {
info.seq = seq;
}
} else {
self.set(author, seq, hash);
}
}
/// Get all authors and their info.
@@ -61,18 +95,33 @@ impl SyncState {
/// Compute what entries we're missing compared to a peer's state.
///
/// Returns ranges of entries we need from the peer.
/// Each range includes the hash to resume reading after.
/// Compares hash sets when seq matches to detect forks.
pub fn diff(&self, peer: &SyncState) -> Vec<MissingRange> {
let mut missing = Vec::new();
for (author, peer_info) in peer.authors() {
let my_seq = self.seq(author);
if peer_info.seq > my_seq {
// We need entries from my_seq+1 to peer_info.seq
// Use our hash (or zero if we have nothing) as resume point
let from_hash = self.get(author)
.map(|i| i.hash)
.unwrap_or([0u8; 32]);
let my_heads = self.heads(author);
// We need entries if:
// 1. Peer's seq is higher than ours, OR
// 2. Peer's seq equals ours but they have heads we don't (fork)
let need_entries = if peer_info.seq > my_seq {
true
} else if peer_info.seq == my_seq && my_seq > 0 {
// Same seq - check for forks (different hashes at same seq)
peer_info.heads.iter().any(|h| !my_heads.contains(h))
} else {
false
};
if need_entries {
// Request from our common ancestor (or start if we have nothing)
let from_hash = if my_heads.is_empty() {
[0u8; 32]
} else {
*my_heads.iter().next().unwrap()
};
missing.push(MissingRange {
author: *author,
@@ -86,15 +135,66 @@ impl SyncState {
missing
}
/// Merge another sync state into this one (take max seq per author).
/// Merge another sync state into this one (union of heads, max seq).
pub fn merge(&mut self, other: &SyncState) {
for (author, info) in other.authors() {
let my_seq = self.seq(author);
if info.seq > my_seq {
self.set(*author, info.seq, info.hash);
if let Some(my_info) = self.authors.get_mut(author) {
// Union heads
for h in &info.heads {
my_info.heads.insert(*h);
}
// Take max seq
if info.seq > my_info.seq {
my_info.seq = info.seq;
}
} else {
self.authors.insert(*author, info.clone());
}
}
}
/// Convert to proto message for network transmission
pub fn to_proto(&self) -> crate::proto::SyncState {
let frontiers = self.authors.iter().map(|(author, info)| {
crate::proto::Frontier {
author_id: author.to_vec(),
max_seq: info.seq,
head_hashes: info.heads.iter().map(|h| h.to_vec()).collect(),
}
}).collect();
crate::proto::SyncState {
frontiers,
sender_hlc: None,
}
}
/// Create from proto message
pub fn from_proto(proto: &crate::proto::SyncState) -> Self {
let mut state = Self::new();
for frontier in &proto.frontiers {
if frontier.author_id.len() == 32 {
let mut author = [0u8; 32];
author.copy_from_slice(&frontier.author_id);
let mut heads = HashSet::new();
for hash_bytes in &frontier.head_hashes {
if hash_bytes.len() == 32 {
let mut hash = [0u8; 32];
hash.copy_from_slice(hash_bytes);
heads.insert(hash);
}
}
if heads.is_empty() {
// Fallback: empty hash if no heads provided
heads.insert([0u8; 32]);
}
state.set_heads(author, frontier.max_seq, heads);
}
}
state
}
}
#[cfg(test)]
@@ -176,4 +276,50 @@ mod tests {
assert_eq!(a.seq(&author1), 10); // kept a's value
assert_eq!(a.seq(&author2), 8); // took b's value
}
/// This test documents a known issue: SyncState tracks only ONE hash per author,
/// but with forks/multi-heads, there could be multiple branches.
///
/// Scenario:
/// - Author writes entry1 (hash=A)
/// - Two peers independently write entry2 and entry3 (both have prev=A)
/// - Peer1 has: entry1 -> entry2 (seq=2, hash=B)
/// - Peer2 has: entry1 -> entry3 (seq=2, hash=C)
/// - When Peer3 syncs with Peer1, SyncState says "I need entries after hash=B"
/// - But Peer2 only has entries after hash=A, so Peer3 never gets entry3!
///
#[test]
fn test_multihead_sync_inconsistency() {
// This is a conceptual test showing the problem
// In reality, both forks would have seq=2 but different hashes
// SyncState can only track one, so the other branch gets lost
let mut peer1_state = SyncState::new();
let mut peer2_state = SyncState::new();
let new_peer_state = SyncState::new();
let author = [1u8; 32];
// Both peers have seq=2, but different hashes (different forks)
peer1_state.set(author, 2, [0xBB; 32]); // entry1 -> entry2
peer2_state.set(author, 2, [0xCC; 32]); // entry1 -> entry3
// New peer syncs with peer1 first
let missing_from_peer1 = new_peer_state.diff(&peer1_state);
assert_eq!(missing_from_peer1.len(), 1);
assert_eq!(missing_from_peer1[0].to_seq, 2);
// After applying peer1's entries, new peer has seq=2, hash=BB
let mut after_peer1 = new_peer_state.clone();
after_peer1.set(author, 2, [0xBB; 32]);
// Now sync with peer2 - BUG: new peer thinks it's up to date!
let missing_from_peer2 = after_peer1.diff(&peer2_state);
// This assertion FAILS - we get empty missing even though peer2 has entry3!
// The bug: peer2's seq=2 equals our seq=2, so we think we're in sync
// But peer2's hash=0xCC != our hash=0xBB - they have different entries!
assert!(!missing_from_peer2.is_empty(),
"BUG: SyncState misses peer2's fork because seq numbers match");
}
}