feat: Refactor mesh server to use Arc<Node>, introduce JoinAcceptance for mesh joins, and enhance store listing with prefix filtering and deleted entry inclusion.

This commit is contained in:
2025-12-23 00:31:54 +01:00
parent 76810f8d8e
commit 9d4495b3d7
13 changed files with 341 additions and 206 deletions
+5 -5
View File
@@ -59,14 +59,14 @@ Networking modes:
- Identified by their Ed25519 public key. - Identified by their Ed25519 public key.
- Private key stored locally in `identity.key` (not replicated). - Private key stored locally in `identity.key` (not replicated).
- Node data stored in KV: - Node data stored in KV:
- `/nodes/{pubkey}/info` = static metadata (name, added_by, added_at) - `/nodes/{pubkey}/name` = display name
- `/nodes/{pubkey}/status` = `active` | `dormant` | `disabled` - `/nodes/{pubkey}/added_at` = timestamp when added
- `/nodes/{pubkey}/status` = `invited` | `active` | `dormant` (removal deletes keys)
- `/nodes/{pubkey}/role` = `server` | `device` (optional, hints sync priority) - `/nodes/{pubkey}/role` = `server` | `device` (optional, hints sync priority)
- `/nodes/{pubkey}/iroh` = Iroh NodeId for network connection
- Peer invitation flow: - Peer invitation flow:
1. Inviter runs `invite <peer_pubkey>` → writes `/nodes/{peer}/info` + `/status` 1. Inviter runs `invite <peer_pubkey>` → writes `/nodes/{peer}/info` + `/status`
2. Inviter shares their Iroh NodeId out-of-band (QR code, link, text) 2. Inviter shares their Iroh NodeId out-of-band (QR code, link, text)
3. Invited peer runs `connect <inviter_nodeid>` → syncs with inviter 3. Invited peer runs `join <inviter_nodeid>` → syncs with inviter
4. Sync pulls `/nodes/{self}/info` + `/status` → peer is authorized 4. Sync pulls `/nodes/{self}/info` + `/status` → peer is authorized
5. `connect` implicitly adds inviter to peer's `/nodes/*` (mutual awareness) 5. `connect` implicitly adds inviter to peer's `/nodes/*` (mutual awareness)
- Accepting = syncing. The invited peer discovers authorization by receiving the entries. - Accepting = syncing. The invited peer discovers authorization by receiving the entries.
@@ -181,7 +181,7 @@ Each node stores logs as one file per author:
Table Key Value Purpose Table Key Value Purpose
───────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────
kv Vec<u8> (key) Vec<HeadInfo> Current tips for each key kv Vec<u8> (key) Vec<HeadInfo> Current tips for each key
applied_frontiers [u8; 32] (author_id) (u64 seq, [u8; 32] hash) What's applied to this store AUTHOR_TABLE [u8; 32] (author_id) (u64 seq, [u8; 32] hash) Per-author frontier tracking
meta Vec<u8> Vec<u8> Store metadata (incl. merkle_root) meta Vec<u8> Vec<u8> Store metadata (incl. merkle_root)
``` ```
+25 -11
View File
@@ -127,10 +127,7 @@
- [x] Entry ordering: Per-author streaming is correct (hash chain per author, HLC for cross-author). - [x] Entry ordering: Per-author streaming is correct (hash chain per author, HLC for cross-author).
- [x] Multi-head sync fixed: SyncState now tracks HashSet of head hashes per author. - [x] Multi-head sync fixed: SyncState now tracks HashSet of head hashes per author.
- [x] Sync entry ordering: Entries sent in HLC order (merge-sort across authors) to ensure causal order. - [x] Sync entry ordering: Entries sent in HLC order (merge-sort across authors) to ensure causal order.
- [x] `join_mesh` doesn't populate `node.root_store`: Fixed with `complete_join` method.
*Background Sync:*
- [ ] Periodic sync with known peers
- [ ] Track last sync time per peer
### Success Criteria ### Success Criteria
@@ -138,26 +135,43 @@
- Works offline-first (sync when connected) - Works offline-first (sync when connected)
**Post-M2 Refactoring:** **Post-M2 Refactoring:**
- [ ] Unify `node.rs` from `lattice-cli` and `lattice-core` - [x] Unify `node.rs` from `lattice-cli` and `lattice-core`
- [ ] Use prost for node status in store - [x] Move network code to `lattice-net`
--- ---
## Milestone 3: Multi-Node Mesh ## Milestone 3: Multi-Node Mesh
**Goal:** N nodes form a gossip mesh with watermark consensus. **Goal:** N nodes form a gossip mesh for real-time sync.
### Deliverables ### Deliverables
- [ ] Gossip protocol **Phase 1: LatticeServer Refactor**
- [ ] Watermark tracking & log pruning - [ ] `LatticeServer` struct in `lattice-net` wrapping `Arc<Node>` + `Endpoint`
- [ ] Node invitation (sigchain membership) - [ ] Move `join_mesh`, `sync_with_peer`, `sync_all` to `LatticeServer` methods
- [ ] Conflict detection (LWW resolution) - [ ] Encapsulate `spawn_accept_loop` inside `LatticeServer`
- [ ] CLI uses `LatticeServer` instead of raw `Node` + `Endpoint`
- [ ] Route sync command through `LatticeServer` (not raw functions)
- [ ] Integration test: invite → join → sync end-to-end
- [ ] Periodic background sync with known peers
- [ ] Track last sync time per peer
**Phase 2: Gossip Protocol**
- [ ] Proto: `GossipAnnounce` message with author + latest seq + HLC
- [ ] `LatticeServer::spawn_gossip_loop` for periodic announcements
- [ ] On receiving announce: detect missing entries, trigger sync
- [ ] Track last-seen per peer for staleness detection
--- ---
## Future ## Future
- remove_peer should be a transactional operation on store
- Watermark tracking & log pruning
- Track minimum confirmed seq per author across all peers
- Log pruning: remove entries below watermark
- Multi-KV-Store sync
- Optimized sync on join. Only transfer current watermark state, then sync missing entries. This would allow pruning. Might need snapshot support in KV store.
- Mobile (iOS/Android) clients - Mobile (iOS/Android) clients
- Key rotation - Key rotation
- Secure storage (Keychain, TPM) - Secure storage (Keychain, TPM)
+4 -14
View File
@@ -10,7 +10,6 @@ use lattice_core::{NodeBuilder, StoreHandle};
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
use rustyline::DefaultEditor; use rustyline::DefaultEditor;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -18,7 +17,7 @@ async fn main() {
println!("Type 'help' for commands, 'quit' to exit.\n"); println!("Type 'help' for commands, 'quit' to exit.\n");
let node = match NodeBuilder::new().build() { let node = match NodeBuilder::new().build() {
Ok(n) => n, Ok(n) => Arc::new(n),
Err(e) => { Err(e) => {
eprintln!("Failed to initialize: {}", e); eprintln!("Failed to initialize: {}", e);
return; return;
@@ -37,12 +36,9 @@ async fn main() {
} }
}; };
// Shared store handle for accept loop (updated when store is opened/changed)
let shared_store: Arc<RwLock<Option<StoreHandle>>> = Arc::new(RwLock::new(None));
// Spawn accept loop for incoming connections // Spawn accept loop for incoming connections
if let Some(ref ep) = endpoint { if let Some(ref ep) = endpoint {
spawn_accept_loop(ep.endpoint().clone(), shared_store.clone()); spawn_accept_loop(node.clone(), ep.endpoint().clone());
} }
let info = node.info(); let info = node.info();
@@ -53,18 +49,14 @@ async fn main() {
println!("Stores: {}", info.stores.len()); println!("Stores: {}", info.stores.len());
} }
let mut current_store: Option<StoreHandle> = match node.open_root_store() { let mut current_store: Option<StoreHandle> = match node.open_root_store().await {
Ok(Some(open_info)) => { Ok(Some(open_info)) => {
if open_info.entries_replayed > 0 { if open_info.entries_replayed > 0 {
println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed); println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed);
} else { } else {
println!("Root: {}", open_info.store_id); println!("Root: {}", open_info.store_id);
} }
let h = node.root_store().as_ref().cloned(); node.root_store().await.as_ref().cloned()
if let Some(ref handle) = h {
*shared_store.write().await = Some(handle.clone());
}
h
} }
Ok(None) => { Ok(None) => {
println!("Status: Not initialized (use 'init')"); println!("Status: Not initialized (use 'init')");
@@ -111,8 +103,6 @@ async fn main() {
match (cmd.handler)(&node, current_store.as_ref(), endpoint.as_ref(), cmd_args) { match (cmd.handler)(&node, current_store.as_ref(), endpoint.as_ref(), cmd_args) {
CommandResult::Ok => {} CommandResult::Ok => {}
CommandResult::SwitchTo(h) => { CommandResult::SwitchTo(h) => {
// Update shared store for accept loop
*shared_store.write().await = Some(h.clone());
current_store = Some(h); current_store = Some(h);
} }
CommandResult::Quit => break, CommandResult::Quit => break,
+3 -3
View File
@@ -31,7 +31,7 @@ fn cmd_init(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&Lattic
Ok(store_id) => { Ok(store_id) => {
println!("Initialized with root store: {}", store_id); println!("Initialized with root store: {}", store_id);
println!("Node info stored in /nodes/{}/*", hex::encode(node.node_id())); println!("Node info stored in /nodes/{}/*", hex::encode(node.node_id()));
match node.root_store().as_ref() { match block_async(node.root_store()).as_ref() {
Some(h) => CommandResult::SwitchTo(h.clone()), Some(h) => CommandResult::SwitchTo(h.clone()),
None => CommandResult::Ok, None => CommandResult::Ok,
} }
@@ -179,8 +179,8 @@ fn cmd_peers(node: &Node, _store: Option<&StoreHandle>, _endpoint: Option<&Latti
by_status.entry(peer.status).or_default().push(peer); by_status.entry(peer.status).or_default().push(peer);
} }
// Print grouped by status in order: active, invited, removed // Print grouped by status in order: active, invited, dormant
let status_order = [PeerStatus::Active, PeerStatus::Invited, PeerStatus::Removed]; let status_order = [PeerStatus::Active, PeerStatus::Invited, PeerStatus::Dormant];
for status in &status_order { for status in &status_order {
if let Some(peer_list) = by_status.get(status) { if let Some(peer_list) = by_status.get(status) {
println!("\n[{}] ({}):", status.as_str(), peer_list.len()); println!("\n[{}] ({}):", status.as_str(), peer_list.len());
+16 -5
View File
@@ -11,7 +11,7 @@ pub fn store_commands() -> Vec<Command> {
Command { name: "put", args: "<key> <value>", desc: "Store a key-value pair", group: "store", min_args: 2, max_args: 2, handler: cmd_put as Handler }, Command { name: "put", args: "<key> <value>", desc: "Store a key-value pair", group: "store", min_args: 2, max_args: 2, handler: cmd_put as Handler },
Command { name: "get", args: "<key> [-v]", desc: "Get value for key", group: "store", min_args: 1, max_args: 2, handler: cmd_get as Handler }, Command { name: "get", args: "<key> [-v]", desc: "Get value for key", group: "store", min_args: 1, max_args: 2, handler: cmd_get as Handler },
Command { name: "delete", args: "<key>", desc: "Delete a key", group: "store", min_args: 1, max_args: 1, handler: cmd_delete as Handler }, Command { name: "delete", args: "<key>", desc: "Delete a key", group: "store", min_args: 1, max_args: 1, handler: cmd_delete as Handler },
Command { name: "list", args: "[-v]", desc: "List all keys", group: "store", min_args: 0, max_args: 1, handler: cmd_list as Handler }, Command { name: "list", args: "[prefix] [-v]", desc: "List keys (optionally filtered by prefix)", group: "store", min_args: 0, max_args: 2, handler: cmd_list as Handler },
Command { name: "author-state", args: "[pubkey]", desc: "Show author sync state", group: "store", min_args: 0, max_args: 1, handler: cmd_author_state as Handler }, Command { name: "author-state", args: "[pubkey]", desc: "Show author sync state", group: "store", min_args: 0, max_args: 1, handler: cmd_author_state as Handler },
] ]
} }
@@ -26,7 +26,7 @@ fn cmd_store_status(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option
println!("Log Seq: {}", block_async(h.log_seq())); println!("Log Seq: {}", block_async(h.log_seq()));
println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0)); println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0));
let all = block_async(h.list()).unwrap_or_default(); let all = block_async(h.list(false)).unwrap_or_default();
println!("Keys: {}", all.len()); println!("Keys: {}", all.len());
// Show log directory size // Show log directory size
@@ -120,9 +120,19 @@ fn cmd_list(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&Lattic
println!("No store selected. Use 'init' or 'use <uuid>'"); println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok; return CommandResult::Ok;
}; };
let verbose = args.first().map(|a| a == "-v").unwrap_or(false);
// Parse args: [prefix] [-v]
let verbose = args.iter().any(|a| a == "-v");
let prefix = args.iter().find(|a| *a != "-v").cloned();
let start = Instant::now(); let start = Instant::now();
match block_async(h.list()) { let result = if let Some(p) = &prefix {
block_async(h.list_by_prefix(p.as_bytes(), verbose))
} else {
block_async(h.list(verbose))
};
match result {
Ok(entries) => { Ok(entries) => {
if entries.is_empty() { if entries.is_empty() {
println!("(empty)"); println!("(empty)");
@@ -154,7 +164,8 @@ fn cmd_list(_node: &Node, store: Option<&StoreHandle>, _endpoint: Option<&Lattic
} }
} }
} }
println!("({} keys, {:.2?})", entries.len(), start.elapsed()); let prefix_str = prefix.as_ref().map(|p| format!(" (prefix: {})", p)).unwrap_or_default();
println!("({} keys{}, {:.2?})", entries.len(), prefix_str, start.elapsed());
} }
} }
Err(e) => eprintln!("Error: {}", e), Err(e) => eprintln!("Error: {}", e),
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod store_actor;
pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024; pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024;
pub use node_identity::{NodeIdentity, PeerStatus}; pub use node_identity::{NodeIdentity, PeerStatus};
pub use node::{Node, NodeBuilder, NodeInfo, StoreInfo, StoreHandle, NodeError, PeerInfo}; pub use node::{Node, NodeBuilder, NodeInfo, StoreInfo, StoreHandle, NodeError, PeerInfo, JoinAcceptance};
pub use sigchain::{SigChain, SigChainManager}; pub use sigchain::{SigChain, SigChainManager};
pub use entry::Entry; pub use entry::Entry;
pub use sync_state::{SyncState, AuthorInfo, MissingRange}; pub use sync_state::{SyncState, AuthorInfo, MissingRange};
+118 -46
View File
@@ -10,7 +10,6 @@ use crate::{
node_identity::NodeError as IdentityError, node_identity::NodeError as IdentityError,
}; };
use std::path::Path; use std::path::Path;
use std::rc::Rc;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@@ -54,6 +53,11 @@ pub struct StoreInfo {
pub entries_replayed: u64, pub entries_replayed: u64,
} }
/// Result of accepting a peer's join request
pub struct JoinAcceptance {
pub store_id: Uuid,
}
/// Information about a peer in the mesh /// Information about a peer in the mesh
pub struct PeerInfo { pub struct PeerInfo {
pub pubkey: String, pub pubkey: String,
@@ -97,9 +101,9 @@ impl NodeBuilder {
Ok(Node { Ok(Node {
data_dir: self.data_dir, data_dir: self.data_dir,
node: Rc::new(node), node: std::sync::Arc::new(node),
meta, meta,
root_store: std::cell::RefCell::new(None), root_store: tokio::sync::RwLock::new(None),
}) })
} }
} }
@@ -111,9 +115,9 @@ impl Default for NodeBuilder {
/// A local Lattice node (manages identity and store registry) /// A local Lattice node (manages identity and store registry)
pub struct Node { pub struct Node {
data_dir: DataDir, data_dir: DataDir,
node: Rc<NodeIdentity>, node: std::sync::Arc<NodeIdentity>,
meta: MetaStore, meta: MetaStore,
root_store: std::cell::RefCell<Option<StoreHandle>>, root_store: tokio::sync::RwLock<Option<StoreHandle>>,
} }
impl Node { impl Node {
@@ -146,16 +150,21 @@ impl Node {
/// Set the node's display name. /// Set the node's display name.
/// Updates meta.db and if root store is open, also updates /nodes/{pubkey}/name /// Updates meta.db and if root store is open, also updates /nodes/{pubkey}/name
pub async fn set_name(&self, name: &str) -> Result<(), NodeError> { pub async fn set_name(&self, name: &str) -> Result<(), NodeError> {
// Update meta.db
self.meta.set_name(name)?; self.meta.set_name(name)?;
self.publish_name().await
// If root store is open, update there too }
if let Some(handle) = self.root_store.borrow().as_ref() {
let pubkey_hex = hex::encode(self.node.public_key_bytes()); /// Publish this node's name from meta.db to the root store.
let name_key = format!("/nodes/{}/name", pubkey_hex); /// Used after joining a mesh to announce ourselves.
handle.put(name_key.as_bytes(), name.as_bytes()).await?; pub async fn publish_name(&self) -> Result<(), NodeError> {
if let Some(name) = self.name() {
let guard = self.root_store.read().await;
if let Some(handle) = guard.as_ref() {
let pubkey_hex = hex::encode(self.node.public_key_bytes());
let name_key = format!("/nodes/{}/name", pubkey_hex);
handle.put(name_key.as_bytes(), name.as_bytes()).await?;
}
} }
Ok(()) Ok(())
} }
@@ -165,17 +174,17 @@ impl Node {
} }
/// Get reference to the cached root store handle (if open) /// Get reference to the cached root store handle (if open)
pub fn root_store(&self) -> std::cell::Ref<'_, Option<StoreHandle>> { pub async fn root_store(&self) -> tokio::sync::RwLockReadGuard<'_, Option<StoreHandle>> {
self.root_store.borrow() self.root_store.read().await
} }
/// Open the root store if set. Node owns the handle internally. /// Open the root store if set. Node owns the handle internally.
/// Returns StoreInfo on success, or None if no root store is set. /// Returns StoreInfo on success, or None if no root store is set.
pub fn open_root_store(&self) -> Result<Option<StoreInfo>, NodeError> { pub async fn open_root_store(&self) -> Result<Option<StoreInfo>, NodeError> {
match self.meta.root_store()? { match self.meta.root_store()? {
Some(id) => { Some(id) => {
let (handle, info) = self.open_store(id)?; let (handle, info) = self.open_store(id)?;
*self.root_store.borrow_mut() = Some(handle); *self.root_store.write().await = Some(handle);
Ok(Some(info)) Ok(Some(info))
} }
None => Ok(None), None => Ok(None),
@@ -213,17 +222,34 @@ impl Node {
handle.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await?; handle.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await?;
// Store the handle - node owns it // Store the handle - node owns it
*self.root_store.borrow_mut() = Some(handle); *self.root_store.write().await = Some(handle);
Ok(store_id) Ok(store_id)
} }
/// Complete joining a mesh - creates store with given UUID, sets as root, caches handle.
/// Called after receiving store_id from peer's JoinResponse.
pub async fn complete_join(&self, store_id: Uuid) -> Result<StoreHandle, NodeError> {
// Create local store with that UUID
self.create_store_with_uuid(store_id)?;
self.meta.set_root_store(store_id)?;
// Open and cache the handle
let (handle, _) = self.open_store(store_id)?;
*self.root_store.write().await = Some(handle.clone());
// Publish our name to the store
let _ = self.publish_name().await;
Ok(handle)
}
// --- Peer Management --- // --- Peer Management ---
/// Invite a peer to the mesh. Writes their info with status = invited. /// Invite a peer to the mesh. Writes their info with status = invited.
pub async fn invite_peer(&self, pubkey: &[u8; 32]) -> Result<(), NodeError> { pub async fn invite_peer(&self, pubkey: &[u8; 32]) -> Result<(), NodeError> {
let store = self.root_store.borrow(); let guard = self.root_store.read().await;
let store = store.as_ref() let store = guard.as_ref()
.ok_or_else(|| NodeError::Actor("No root store open".to_string()))?; .ok_or_else(|| NodeError::Actor("No root store open".to_string()))?;
let pubkey_hex = hex::encode(pubkey); let pubkey_hex = hex::encode(pubkey);
@@ -251,11 +277,11 @@ impl Node {
/// List all peers in the mesh with their info /// List all peers in the mesh with their info
pub async fn list_peers(&self) -> Result<Vec<PeerInfo>, NodeError> { pub async fn list_peers(&self) -> Result<Vec<PeerInfo>, NodeError> {
let store = self.root_store.borrow(); let guard = self.root_store.read().await;
let store = store.as_ref() let store = guard.as_ref()
.ok_or_else(|| NodeError::Actor("No root store open".to_string()))?; .ok_or_else(|| NodeError::Actor("No root store open".to_string()))?;
let all = store.list().await?; let all = store.list(false).await?;
// Collect unique pubkeys with status // Collect unique pubkeys with status
let mut peers_map: std::collections::HashMap<String, PeerStatus> = std::collections::HashMap::new(); let mut peers_map: std::collections::HashMap<String, PeerStatus> = std::collections::HashMap::new();
@@ -299,10 +325,10 @@ impl Node {
Ok(peers) Ok(peers)
} }
/// Remove a peer from the mesh (sets status to removed) /// Remove a peer from the mesh (deletes all their /nodes/{pubkey}/* keys)
pub async fn remove_peer(&self, pubkey: &[u8; 32]) -> Result<(), NodeError> { pub async fn remove_peer(&self, pubkey: &[u8; 32]) -> Result<(), NodeError> {
let store = self.root_store.borrow(); let guard = self.root_store.read().await;
let store = store.as_ref() let store = guard.as_ref()
.ok_or_else(|| NodeError::Actor("No root store open".to_string()))?; .ok_or_else(|| NodeError::Actor("No root store open".to_string()))?;
let pubkey_hex = hex::encode(pubkey); let pubkey_hex = hex::encode(pubkey);
@@ -312,28 +338,26 @@ impl Node {
return Err(NodeError::Actor("Cannot remove yourself".to_string())); return Err(NodeError::Actor("Cannot remove yourself".to_string()));
} }
// Check if peer exists // Find all keys for this peer using prefix search
let status_key = format!("/nodes/{}/status", pubkey_hex); let prefix = format!("/nodes/{}/", pubkey_hex);
match store.get(status_key.as_bytes()).await? { let keys = store.list_by_prefix(prefix.as_bytes(), false).await?;
Some(status) if status == PeerStatus::Removed.as_str().as_bytes() => {
return Err(NodeError::Actor("Peer already removed".to_string())); if keys.is_empty() {
} return Err(NodeError::Actor("Peer not found".to_string()));
None => {
return Err(NodeError::Actor("Peer not found".to_string()));
}
_ => {}
} }
// Set status to removed // Delete all found keys
store.put(status_key.as_bytes(), PeerStatus::Removed.as_str().as_bytes()).await?; for (key, _) in keys {
store.delete(&key).await?;
}
Ok(()) Ok(())
} }
/// Get a peer's status /// Get a peer's status
pub async fn get_peer_status(&self, pubkey: &[u8; 32]) -> Result<Option<PeerStatus>, NodeError> { pub async fn get_peer_status(&self, pubkey: &[u8; 32]) -> Result<Option<PeerStatus>, NodeError> {
let store = self.root_store.borrow(); let guard = self.root_store.read().await;
let store = store.as_ref() let store = guard.as_ref()
.ok_or_else(|| NodeError::Actor("No root store open".to_string()))?; .ok_or_else(|| NodeError::Actor("No root store open".to_string()))?;
let pubkey_hex = hex::encode(pubkey); let pubkey_hex = hex::encode(pubkey);
@@ -347,6 +371,44 @@ impl Node {
None => Ok(None), None => Ok(None),
} }
} }
/// Set a peer's status
pub async fn set_peer_status(&self, pubkey: &[u8; 32], status: PeerStatus) -> Result<(), NodeError> {
let guard = self.root_store.read().await;
let store = guard.as_ref()
.ok_or_else(|| NodeError::Actor("No root store open".to_string()))?;
let pubkey_hex = hex::encode(pubkey);
let status_key = format!("/nodes/{}/status", pubkey_hex);
store.put(status_key.as_bytes(), status.as_str().as_bytes()).await?;
Ok(())
}
/// Verify a peer has one of the expected statuses
pub async fn verify_peer_status(&self, pubkey: &[u8; 32], expected: &[PeerStatus]) -> Result<(), NodeError> {
match self.get_peer_status(pubkey).await? {
Some(status) if expected.contains(&status) => Ok(()),
Some(status) => Err(NodeError::Actor(format!(
"Peer status is '{:?}', expected one of {:?}", status, expected
))),
None => Err(NodeError::Actor("Peer not found".to_string())),
}
}
/// Accept a peer's join request - verifies they're invited, sets active, returns join info
pub async fn accept_join(&self, pubkey: &[u8; 32]) -> Result<JoinAcceptance, NodeError> {
// Verify peer is invited
self.verify_peer_status(pubkey, &[PeerStatus::Invited]).await?;
// Get root store ID
let store_id = self.meta.root_store()?
.ok_or_else(|| NodeError::Actor("No root store configured".to_string()))?;
// Set peer status to active
self.set_peer_status(pubkey, PeerStatus::Active).await?;
Ok(JoinAcceptance { store_id })
}
pub fn list_stores(&self) -> Result<Vec<Uuid>, NodeError> { pub fn list_stores(&self) -> Result<Vec<Uuid>, NodeError> {
Ok(self.meta.list_stores()?) Ok(self.meta.list_stores()?)
@@ -454,10 +516,20 @@ impl StoreHandle {
.map_err(NodeError::Store) .map_err(NodeError::Store)
} }
pub async fn list(&self) -> Result<Vec<(Vec<u8>, Vec<u8>)>, NodeError> { pub async fn list(&self, include_deleted: bool) -> Result<Vec<(Vec<u8>, Vec<u8>)>, NodeError> {
use StoreCmd; use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::List { resp: resp_tx }).await self.tx.send(StoreCmd::List { include_deleted, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn list_by_prefix(&self, prefix: &[u8], include_deleted: bool) -> Result<Vec<(Vec<u8>, Vec<u8>)>, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::ListByPrefix { prefix: prefix.to_vec(), include_deleted, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?; .map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await resp_rx.await
.map_err(|_| NodeError::ChannelClosed)? .map_err(|_| NodeError::ChannelClosed)?
@@ -627,7 +699,7 @@ mod tests {
.expect("create node"); .expect("create node");
// Initially no root store // Initially no root store
assert!(node.root_store().is_none()); assert!(node.root_store().await.is_none());
// Init creates root store // Init creates root store
let root_id = node.init().await.expect("init failed"); let root_id = node.init().await.expect("init failed");
@@ -687,7 +759,7 @@ mod tests {
.build() .build()
.expect("create node"); .expect("create node");
node.init().await.expect("init"); node.init().await.expect("init");
let store = node.root_store(); let store = node.root_store().await;
let store = store.as_ref().unwrap(); let store = store.as_ref().unwrap();
// Get baseline seq after init // Get baseline seq after init
@@ -733,7 +805,7 @@ mod tests {
let pubkey_hex = hex::encode(node.node_id()); let pubkey_hex = hex::encode(node.node_id());
let name_key = format!("/nodes/{}/name", pubkey_hex); let name_key = format!("/nodes/{}/name", pubkey_hex);
{ {
let store = node.root_store(); let store = node.root_store().await;
let store = store.as_ref().unwrap(); let store = store.as_ref().unwrap();
let stored_name = store.get(name_key.as_bytes()).await.unwrap(); let stored_name = store.get(name_key.as_bytes()).await.unwrap();
assert_eq!(stored_name, Some(initial_name.as_bytes().to_vec())); assert_eq!(stored_name, Some(initial_name.as_bytes().to_vec()));
@@ -748,7 +820,7 @@ mod tests {
// Verify store updated // Verify store updated
{ {
let store = node.root_store(); let store = node.root_store().await;
let store = store.as_ref().unwrap(); let store = store.as_ref().unwrap();
let stored_name = store.get(name_key.as_bytes()).await.unwrap(); let stored_name = store.get(name_key.as_bytes()).await.unwrap();
assert_eq!(stored_name, Some(new_name.as_bytes().to_vec())); assert_eq!(stored_name, Some(new_name.as_bytes().to_vec()));
+4 -4
View File
@@ -138,8 +138,8 @@ pub enum PeerStatus {
Invited, Invited,
/// Peer is active and can sync /// Peer is active and can sync
Active, Active,
/// Peer has been removed from the mesh /// Peer is temporarily inactive
Removed, Dormant,
} }
impl PeerStatus { impl PeerStatus {
@@ -147,7 +147,7 @@ impl PeerStatus {
match self { match self {
PeerStatus::Invited => "invited", PeerStatus::Invited => "invited",
PeerStatus::Active => "active", PeerStatus::Active => "active",
PeerStatus::Removed => "removed", PeerStatus::Dormant => "dormant",
} }
} }
@@ -155,7 +155,7 @@ impl PeerStatus {
match s { match s {
"invited" => Some(PeerStatus::Invited), "invited" => Some(PeerStatus::Invited),
"active" => Some(PeerStatus::Active), "active" => Some(PeerStatus::Active),
"removed" => Some(PeerStatus::Removed), "dormant" => Some(PeerStatus::Dormant),
_ => None, _ => None,
} }
} }
+78 -3
View File
@@ -247,16 +247,36 @@ impl Store {
} }
/// List all key-value pairs (winner values only) /// List all key-value pairs (winner values only)
pub fn list_all(&self) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError> { /// If include_deleted is true, includes tombstoned entries
pub fn list_all(&self, include_deleted: bool) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError> {
self.list_by_prefix(&[], include_deleted)
}
/// List all key-value pairs matching a prefix (winner values only)
/// Uses efficient range query on redb's sorted B-tree
/// If include_deleted is true, includes tombstoned entries
pub fn list_by_prefix(&self, prefix: &[u8], include_deleted: bool) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError> {
let read_txn = self.db.begin_read()?; let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(KV_TABLE)?; let table = read_txn.open_table(KV_TABLE)?;
let mut result = Vec::new(); let mut result = Vec::new();
for entry in table.iter()? {
// Use range query: from prefix to first key that doesn't match
for entry in table.range(prefix..)? {
let (key, value) = entry?; let (key, value) = entry?;
let key_bytes = key.value();
// Stop when we've passed the prefix
if !key_bytes.starts_with(prefix) {
break;
}
let heads = HeadList::decode(value.value())?.heads; let heads = HeadList::decode(value.value())?.heads;
if let Some(winner) = Self::pick_winner(&heads) { if let Some(winner) = Self::pick_winner(&heads) {
result.push((key.value().to_vec(), winner.value.clone())); // Skip tombstones unless include_deleted is true
if include_deleted || !winner.tombstone {
result.push((key_bytes.to_vec(), winner.value.clone()));
}
} }
} }
Ok(result) Ok(result)
@@ -1672,4 +1692,59 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
#[test]
fn test_list_by_prefix_filters_tombstones() {
let path = temp_db_path("list_tombstones");
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = NodeIdentity::generate();
// Create a key under /test/ prefix
let clock1 = MockClock::new(1000);
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock1))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/test/key1", b"value1".to_vec())
.sign(&node);
store.apply_entry(&entry1).unwrap();
// Create another key
let clock2 = MockClock::new(2000);
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock2))
.store_id(TEST_STORE.to_vec())
.prev_hash(hash_signed_entry(&entry1).to_vec())
.put("/test/key2", b"value2".to_vec())
.sign(&node);
store.apply_entry(&entry2).unwrap();
// Delete key1
let clock3 = MockClock::new(3000);
let entry3 = EntryBuilder::new(3, HLC::now_with_clock(&clock3))
.store_id(TEST_STORE.to_vec())
.prev_hash(hash_signed_entry(&entry2).to_vec())
.parent_hashes(vec![hash_signed_entry(&entry1).to_vec()])
.delete(b"/test/key1")
.sign(&node);
store.apply_entry(&entry3).unwrap();
// list_by_prefix without include_deleted should only show key2
let entries = store.list_by_prefix(b"/test/", false).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, b"/test/key2");
// list_by_prefix with include_deleted should show both (key1 as tombstone)
let entries_all = store.list_by_prefix(b"/test/", true).unwrap();
assert_eq!(entries_all.len(), 2);
// Verify list_all also respects the flag
let all_entries = store.list_all(false).unwrap();
assert_eq!(all_entries.len(), 1);
let all_entries_incl_deleted = store.list_all(true).unwrap();
assert_eq!(all_entries_incl_deleted.len(), 2);
let _ = std::fs::remove_file(&path);
}
} }
+11 -2
View File
@@ -24,6 +24,12 @@ pub enum StoreCmd {
resp: oneshot::Sender<Result<Vec<HeadInfo>, StoreError>>, resp: oneshot::Sender<Result<Vec<HeadInfo>, StoreError>>,
}, },
List { List {
include_deleted: bool,
resp: oneshot::Sender<Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError>>,
},
ListByPrefix {
prefix: Vec<u8>,
include_deleted: bool,
resp: oneshot::Sender<Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError>>, resp: oneshot::Sender<Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError>>,
}, },
Put { Put {
@@ -142,8 +148,11 @@ impl StoreActor {
StoreCmd::GetHeads { key, resp } => { StoreCmd::GetHeads { key, resp } => {
let _ = resp.send(self.store.get_heads(&key)); let _ = resp.send(self.store.get_heads(&key));
} }
StoreCmd::List { resp } => { StoreCmd::List { include_deleted, resp } => {
let _ = resp.send(self.store.list_all()); let _ = resp.send(self.store.list_all(include_deleted));
}
StoreCmd::ListByPrefix { prefix, include_deleted, resp } => {
let _ = resp.send(self.store.list_by_prefix(&prefix, include_deleted));
} }
StoreCmd::Put { key, value, resp } => { StoreCmd::Put { key, value, resp } => {
let result = self.do_put(&key, &value); let result = self.do_put(&key, &value);
+57 -72
View File
@@ -1,27 +1,26 @@
//! Server - handle incoming peer connections for join and sync //! Server - handle incoming peer connections for join and sync
use crate::{MessageSink, MessageStream}; use crate::{MessageSink, MessageStream};
use lattice_core::{StoreHandle, PeerStatus}; use lattice_core::{Node, PeerStatus, Uuid};
use iroh::Endpoint; use iroh::Endpoint;
use iroh::endpoint::Connection; use iroh::endpoint::Connection;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
use lattice_core::proto::{PeerMessage, peer_message, JoinResponse}; use lattice_core::proto::{PeerMessage, peer_message, JoinResponse};
use super::protocol; use super::protocol;
/// Spawn the accept loop for incoming connections. /// Spawn the accept loop for incoming connections.
pub fn spawn_accept_loop( pub fn spawn_accept_loop(
node: Arc<Node>,
endpoint: Endpoint, endpoint: Endpoint,
shared_store: Arc<RwLock<Option<StoreHandle>>>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
if let Some(incoming) = endpoint.accept().await { if let Some(incoming) = endpoint.accept().await {
match incoming.await { match incoming.await {
Ok(conn) => { Ok(conn) => {
let store = shared_store.clone(); let node = node.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_connection(conn, store).await { if let Err(e) = handle_connection(node, conn).await {
eprintln!("[Accept] Error: {}", e); eprintln!("[Accept] Error: {}", e);
} }
}); });
@@ -35,104 +34,89 @@ pub fn spawn_accept_loop(
/// Handle a single incoming connection /// Handle a single incoming connection
async fn handle_connection( async fn handle_connection(
node: Arc<Node>,
conn: Connection, conn: Connection,
shared_store: Arc<RwLock<Option<StoreHandle>>>,
) -> Result<(), String> { ) -> Result<(), String> {
let remote_id = conn.remote_id(); let remote_id = conn.remote_id();
let remote_hex = hex::encode(remote_id.as_bytes()); let remote_hex = hex::encode(remote_id.as_bytes());
println!("\n[Incoming] {} (ALPN: {})", remote_id.fmt_short(), String::from_utf8_lossy(conn.alpn())); println!("\n[Incoming] {} (ALPN: {})", remote_id.fmt_short(), String::from_utf8_lossy(conn.alpn()));
let store = { // Parse remote pubkey
let guard = shared_store.read().await; let remote_pubkey: [u8; 32] = hex::decode(&remote_hex)
match &*guard { .map_err(|_| "Invalid pubkey hex")?
Some(s) => s.clone(), .try_into()
None => return Err("No store available".to_string()), .map_err(|_| "Invalid pubkey length")?;
}
};
let (send, recv) = conn.accept_bi().await let (send, recv) = conn.accept_bi().await
.map_err(|e| format!("Accept stream error: {}", e))?; .map_err(|e| format!("Accept stream error: {}", e))?;
// Wrap in framed message streams let sink = MessageSink::new(send);
let mut sink = MessageSink::new(send);
let mut stream = MessageStream::new(recv); let mut stream = MessageStream::new(recv);
// Read first message // Read first message to determine request type
let msg = stream.recv().await? let msg = stream.recv().await?
.ok_or_else(|| "Peer closed stream".to_string())?; .ok_or_else(|| "Peer closed stream".to_string())?;
match msg.message { match msg.message {
Some(peer_message::Message::JoinRequest(req)) => { Some(peer_message::Message::JoinRequest(req)) => {
// For join: verify peer is invited handle_join_request(&node, &remote_pubkey, req, sink).await
verify_peer_status(&store, &remote_hex, PeerStatusCheck::Exactly(PeerStatus::Invited)).await?;
println!("[Peer] Verified as invited");
println!("[Join] Got JoinRequest from {}", hex::encode(&req.node_pubkey));
let resp = PeerMessage {
message: Some(peer_message::Message::JoinResponse(JoinResponse {
store_uuid: store.id().as_bytes().to_vec(),
inviter_pubkey: vec![],
})),
};
sink.send(&resp).await?;
sink.finish().await?;
// Set peer status to 'active' now that they've joined
let status_key = format!("/nodes/{}/status", remote_hex);
if let Err(e) = store.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await {
eprintln!("[Join] Warning: Failed to set peer status: {}", e);
}
println!("[Join] Sent JoinResponse, peer now active");
Ok(())
} }
Some(peer_message::Message::SyncRequest(req)) => { Some(peer_message::Message::SyncRequest(req)) => {
// For sync: verify peer is active (or invited for first sync after join) handle_sync_request(&node, &remote_pubkey, req, sink, stream).await
verify_peer_status(&store, &remote_hex, PeerStatusCheck::ActiveOrInvited).await?;
println!("[Peer] Verified for sync");
handle_sync_request(sink, stream, req, &store).await
} }
_ => Err("Unexpected message type".to_string()), _ => Err("Unexpected message type".to_string()),
} }
} }
/// Expected peer status check mode /// Handle a join request from an invited peer
#[derive(Debug, Clone, Copy)] async fn handle_join_request(
enum PeerStatusCheck { node: &Node,
Exactly(PeerStatus), remote_pubkey: &[u8; 32],
ActiveOrInvited, req: lattice_core::proto::JoinRequest,
} mut sink: MessageSink,
) -> Result<(), String> {
/// Verify a peer has the expected status println!("[Join] Got JoinRequest from {}", hex::encode(&req.node_pubkey));
async fn verify_peer_status(store: &StoreHandle, remote_hex: &str, expected: PeerStatusCheck) -> Result<(), String> {
let status_key = format!("/nodes/{}/status", remote_hex);
let status = match store.get(status_key.as_bytes()).await {
Ok(Some(s)) => String::from_utf8_lossy(&s).to_string(),
Ok(None) => return Err(format!("Peer not found")),
Err(e) => return Err(format!("Error checking peer status: {}", e)),
};
let valid = match expected { // Accept the join - verifies invited, sets active, returns store ID
PeerStatusCheck::Exactly(ps) => status == ps.as_str(), let acceptance = node.accept_join(remote_pubkey).await
PeerStatusCheck::ActiveOrInvited => status == PeerStatus::Active.as_str() || status == PeerStatus::Invited.as_str(), .map_err(|e| e.to_string())?;
};
if valid { let resp = PeerMessage {
Ok(()) message: Some(peer_message::Message::JoinResponse(JoinResponse {
} else { store_uuid: acceptance.store_id.as_bytes().to_vec(),
Err(format!("Peer status is '{}', expected {:?}", status, expected)) inviter_pubkey: vec![],
} })),
};
sink.send(&resp).await?;
sink.finish().await?;
println!("[Join] Sent JoinResponse, peer now active");
Ok(())
} }
/// Handle a sync request - bidirectional exchange of entries /// Handle a sync request - bidirectional exchange of entries
async fn handle_sync_request( async fn handle_sync_request(
node: &Node,
remote_pubkey: &[u8; 32],
peer_request: lattice_core::proto::SyncRequest,
mut sink: MessageSink, mut sink: MessageSink,
mut stream: MessageStream, mut stream: MessageStream,
peer_request: lattice_core::proto::SyncRequest,
store: &StoreHandle,
) -> Result<(), String> { ) -> Result<(), String> {
// Verify peer is active (allowed to sync)
node.verify_peer_status(remote_pubkey, &[PeerStatus::Active]).await
.map_err(|e| e.to_string())?;
println!("[Sync] Verified peer as active");
// Parse store_id from request
let store_id = Uuid::from_slice(&peer_request.store_id)
.map_err(|_| "Invalid store_id in SyncRequest".to_string())?;
println!("[Sync] Received SyncRequest for store {}", store_id);
// Open the requested store
let (store, _info) = node.open_store(store_id)
.map_err(|e| format!("Failed to open store {}: {}", store_id, e))?;
println!("[Sync] Received SyncRequest"); println!("[Sync] Received SyncRequest");
// Get our sync state // Get our sync state
@@ -142,6 +126,7 @@ async fn handle_sync_request(
// 1. Send our sync state as response // 1. Send our sync state as response
let resp = PeerMessage { let resp = PeerMessage {
message: Some(peer_message::Message::SyncResponse(lattice_core::proto::SyncResponse { message: Some(peer_message::Message::SyncResponse(lattice_core::proto::SyncResponse {
store_id: store.id().as_bytes().to_vec(),
state: Some(my_state.to_proto()), state: Some(my_state.to_proto()),
})), })),
}; };
@@ -152,11 +137,11 @@ async fn handle_sync_request(
.map(|s| lattice_core::sync_state::SyncState::from_proto(&s)) .map(|s| lattice_core::sync_state::SyncState::from_proto(&s))
.unwrap_or_default(); .unwrap_or_default();
let entries_sent = protocol::send_missing_entries(&mut sink, store, &my_state, &peer_state).await?; let entries_sent = protocol::send_missing_entries(&mut sink, &store, &my_state, &peer_state).await?;
println!("[Sync] Sent {} entries, now receiving from peer...", entries_sent); println!("[Sync] Sent {} entries, now receiving from peer...", entries_sent);
// 3. Receive entries from requester (bidirectional) // 3. Receive entries from requester (bidirectional)
let (entries_applied, _) = protocol::receive_entries(&mut stream, store).await?; let (entries_applied, _) = protocol::receive_entries(&mut stream, &store).await?;
sink.finish().await?; sink.finish().await?;
+14 -37
View File
@@ -52,11 +52,8 @@ pub async fn join_mesh(
let store_uuid = lattice_core::Uuid::from_slice(&resp.store_uuid) let store_uuid = lattice_core::Uuid::from_slice(&resp.store_uuid)
.map_err(|_| NodeError::Actor("Invalid UUID from peer".to_string()))?; .map_err(|_| NodeError::Actor("Invalid UUID from peer".to_string()))?;
// Create local store with that UUID // Complete join - creates store, sets as root, caches handle
node.create_store_with_uuid(store_uuid)?; let handle = node.complete_join(store_uuid).await?;
node.set_root_store(store_uuid)?;
let (handle, _) = node.open_store(store_uuid)?;
// Immediately sync with the peer to get initial data // Immediately sync with the peer to get initial data
println!("[Join] Syncing with peer to get initial data..."); println!("[Join] Syncing with peer to get initial data...");
@@ -66,18 +63,9 @@ pub async fn join_mesh(
} }
Err(e) => { Err(e) => {
eprintln!("[Join] Warning: Initial sync failed: {}", e); eprintln!("[Join] Warning: Initial sync failed: {}", e);
// Don't fail join, just warn - peer might not have data yet
} }
} }
// Write our name to the store (separate key, not JSON)
// Note: inviter sets our status to 'active' via server.rs
let pubkey_hex = hex::encode(node.node_id());
if let Some(name) = node.name() {
let name_key = format!("/nodes/{}/name", pubkey_hex);
let _ = handle.put(name_key.as_bytes(), name.as_bytes()).await;
}
Ok(handle) Ok(handle)
} }
_ => Err(NodeError::Actor("Unexpected response message type".to_string())), _ => Err(NodeError::Actor("Unexpected response message type".to_string())),
@@ -109,6 +97,7 @@ pub async fn sync_with_peer(
// 1. Send SyncRequest with our state (don't finish yet - we'll send entries later) // 1. Send SyncRequest with our state (don't finish yet - we'll send entries later)
let req = PeerMessage { let req = PeerMessage {
message: Some(peer_message::Message::SyncRequest(lattice_core::proto::SyncRequest { message: Some(peer_message::Message::SyncRequest(lattice_core::proto::SyncRequest {
store_id: store.id().as_bytes().to_vec(),
state: Some(my_state.to_proto()), state: Some(my_state.to_proto()),
full_sync: false, full_sync: false,
})), })),
@@ -163,7 +152,7 @@ pub async fn sync_with_peer(
}) })
} }
/// Sync with all active peers from the store. /// Sync with all active peers from the node.
pub async fn sync_all( pub async fn sync_all(
node: &Node, node: &Node,
endpoint: &LatticeEndpoint, endpoint: &LatticeEndpoint,
@@ -171,34 +160,22 @@ pub async fn sync_all(
) -> Result<Vec<SyncResult>, NodeError> { ) -> Result<Vec<SyncResult>, NodeError> {
let my_pubkey = hex::encode(node.node_id()); let my_pubkey = hex::encode(node.node_id());
// Get all active peers (invited peers haven't joined yet) // Get all active peers using node.list_peers()
let all_entries = store.list().await?; let peers = node.list_peers().await?;
let mut peer_ids = Vec::new(); let mut results = Vec::new();
for (key, value) in &all_entries { for peer in peers {
let key_str = String::from_utf8_lossy(key); if peer.status == PeerStatus::Active && peer.pubkey != my_pubkey {
if key_str.ends_with("/status") && value == PeerStatus::Active.as_str().as_bytes() { if let Ok(peer_id) = parse_node_id(&peer.pubkey) {
if let Some(pubkey) = key_str.strip_prefix("/nodes/").and_then(|s| s.strip_suffix("/status")) { match sync_with_peer(endpoint, store, peer_id).await {
if pubkey != my_pubkey { Ok(result) => results.push(result),
if let Ok(id) = parse_node_id(pubkey) { Err(e) => {
peer_ids.push(id); eprintln!("Sync with {} failed: {}", peer_id.fmt_short(), e);
} }
} }
} }
} }
} }
// Sync with each peer
let mut results = Vec::new();
for peer_id in peer_ids {
match sync_with_peer(endpoint, store, peer_id).await {
Ok(result) => results.push(result),
Err(e) => {
// Log error but continue with other peers
eprintln!("Sync with {} failed: {}", peer_id.fmt_short(), e);
}
}
}
Ok(results) Ok(results)
} }
+5 -3
View File
@@ -111,12 +111,14 @@ message JoinResponse {
// 7. Sync Protocol Messages (bidirectional sync after join) // 7. Sync Protocol Messages (bidirectional sync after join)
message SyncRequest { message SyncRequest {
SyncState state = 1; // Sender's sync state (for incremental sync) bytes store_id = 1; // Store UUID to sync (16 bytes)
bool full_sync = 2; // If true, request all entries (for join) SyncState state = 2; // Sender's sync state (for incremental sync)
bool full_sync = 3; // If true, request all entries (for join)
} }
message SyncResponse { message SyncResponse {
SyncState state = 1; // Responder's sync state bytes store_id = 1; // Store UUID being synced (16 bytes)
SyncState state = 2; // Responder's sync state
} }
message SyncEntry { message SyncEntry {