feat: introduce NodeIdentity and store_actor in lattice-core, and implement mesh networking in lattice-net while removing unicast.
This commit is contained in:
@@ -16,6 +16,7 @@ tracing = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
//! - **Gossip**: Broadcasting changes across the mesh
|
||||
//! - **Unicast**: Point-to-point communication for reconciliation
|
||||
//! - **Framing**: Length-delimited message framing for QUIC streams
|
||||
//! - **Mesh**: Peer-to-peer join and sync operations
|
||||
|
||||
pub mod endpoint;
|
||||
pub mod gossip;
|
||||
pub mod unicast;
|
||||
pub mod framing;
|
||||
pub mod mesh;
|
||||
|
||||
pub use endpoint::{LatticeEndpoint, PublicKey};
|
||||
pub use framing::{MessageSink, MessageStream};
|
||||
pub use lattice_core::proto::{SyncRequest, SyncResponse, SyncEntry, SyncDone, SyncState, Frontier};
|
||||
pub use mesh::{spawn_accept_loop, join_mesh, sync_with_peer, sync_all, SyncResult};
|
||||
|
||||
/// Parse a PublicKey (NodeId) from hex or base32 string
|
||||
pub fn parse_node_id(s: &str) -> Result<PublicKey, String> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Mesh networking - peer-to-peer join and sync operations
|
||||
//!
|
||||
//! - **server**: Accept incoming connections and handle join/sync requests
|
||||
//! - **sync**: Outgoing join and sync operations
|
||||
//! - **protocol**: Shared send/receive entry logic
|
||||
|
||||
mod server;
|
||||
mod sync;
|
||||
mod protocol;
|
||||
|
||||
pub use server::spawn_accept_loop;
|
||||
pub use sync::{join_mesh, sync_with_peer, sync_all, SyncResult};
|
||||
pub use protocol::{send_missing_entries, receive_entries};
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Protocol - shared logic for bidirectional sync entry exchange
|
||||
|
||||
use crate::{MessageSink, MessageStream};
|
||||
use lattice_core::{StoreHandle, CausalEntryIter};
|
||||
use lattice_core::proto::{peer_message, PeerMessage, SignedEntry};
|
||||
use lattice_core::sync_state::SyncState;
|
||||
use prost::Message;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Send entries that peer is missing based on state diff.
|
||||
/// Returns (entries_sent, optional_error).
|
||||
pub async fn send_missing_entries(
|
||||
sink: &mut MessageSink,
|
||||
store: &StoreHandle,
|
||||
my_state: &SyncState,
|
||||
peer_state: &SyncState,
|
||||
) -> Result<u64, String> {
|
||||
let missing = peer_state.diff(my_state);
|
||||
|
||||
// Build queues for each author's entries
|
||||
let mut author_entries: Vec<VecDeque<SignedEntry>> = Vec::new();
|
||||
for range in missing {
|
||||
let from_hash = if range.from_hash == [0u8; 32] { None } else { Some(range.from_hash) };
|
||||
let entries = store.read_entries_after(&range.author, from_hash).await
|
||||
.map_err(|e| format!("Failed to read entries: {}", e))?;
|
||||
if !entries.is_empty() {
|
||||
author_entries.push(entries.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Stream entries in HLC (causal) order
|
||||
let mut entries_sent = 0u64;
|
||||
for entry in CausalEntryIter::new(author_entries) {
|
||||
let sync_msg = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncEntry(lattice_core::proto::SyncEntry {
|
||||
signed_entry: entry.encode_to_vec(),
|
||||
hash: vec![],
|
||||
})),
|
||||
};
|
||||
sink.send(&sync_msg).await?;
|
||||
entries_sent += 1;
|
||||
}
|
||||
|
||||
// Send SyncDone
|
||||
let done = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncDone(lattice_core::proto::SyncDone {
|
||||
entries_sent,
|
||||
})),
|
||||
};
|
||||
sink.send(&done).await?;
|
||||
|
||||
Ok(entries_sent)
|
||||
}
|
||||
|
||||
/// Receive and apply entries until SyncDone is received.
|
||||
/// Returns (entries_applied, entries_reported_by_peer).
|
||||
pub async fn receive_entries(
|
||||
stream: &mut MessageStream,
|
||||
store: &StoreHandle,
|
||||
) -> Result<(u64, u64), String> {
|
||||
let mut entries_applied = 0u64;
|
||||
let mut entries_reported = 0u64;
|
||||
|
||||
loop {
|
||||
match stream.recv().await {
|
||||
Ok(Some(msg)) => match msg.message {
|
||||
Some(peer_message::Message::SyncEntry(entry)) => {
|
||||
if let Ok(signed) = SignedEntry::decode(&entry.signed_entry[..]) {
|
||||
if store.apply_entry(signed).await.is_ok() {
|
||||
entries_applied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(peer_message::Message::SyncDone(done)) => {
|
||||
entries_reported = done.entries_sent;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok((entries_applied, entries_reported))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Server - handle incoming peer connections for join and sync
|
||||
|
||||
use crate::{MessageSink, MessageStream};
|
||||
use lattice_core::{StoreHandle, PeerStatus};
|
||||
use iroh::Endpoint;
|
||||
use iroh::endpoint::Connection;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use lattice_core::proto::{PeerMessage, peer_message, JoinResponse};
|
||||
use super::protocol;
|
||||
|
||||
/// Spawn the accept loop for incoming connections.
|
||||
pub fn spawn_accept_loop(
|
||||
endpoint: Endpoint,
|
||||
shared_store: Arc<RwLock<Option<StoreHandle>>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Some(incoming) = endpoint.accept().await {
|
||||
match incoming.await {
|
||||
Ok(conn) => {
|
||||
let store = shared_store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(conn, store).await {
|
||||
eprintln!("[Accept] Error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => eprintln!("[Accept] Handshake error: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle a single incoming connection
|
||||
async fn handle_connection(
|
||||
conn: Connection,
|
||||
shared_store: Arc<RwLock<Option<StoreHandle>>>,
|
||||
) -> Result<(), String> {
|
||||
let remote_id = conn.remote_id();
|
||||
let remote_hex = hex::encode(remote_id.as_bytes());
|
||||
println!("\n[Incoming] {} (ALPN: {})", remote_id.fmt_short(), String::from_utf8_lossy(conn.alpn()));
|
||||
|
||||
let store = {
|
||||
let guard = shared_store.read().await;
|
||||
match &*guard {
|
||||
Some(s) => s.clone(),
|
||||
None => return Err("No store available".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let (send, recv) = conn.accept_bi().await
|
||||
.map_err(|e| format!("Accept stream error: {}", e))?;
|
||||
|
||||
// Wrap in framed message streams
|
||||
let mut sink = MessageSink::new(send);
|
||||
let mut stream = MessageStream::new(recv);
|
||||
|
||||
// Read first message
|
||||
let msg = stream.recv().await?
|
||||
.ok_or_else(|| "Peer closed stream".to_string())?;
|
||||
|
||||
match msg.message {
|
||||
Some(peer_message::Message::JoinRequest(req)) => {
|
||||
// For join: verify peer is invited
|
||||
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)) => {
|
||||
// For sync: verify peer is active (or invited for first sync after join)
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Expected peer status check mode
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum PeerStatusCheck {
|
||||
Exactly(PeerStatus),
|
||||
ActiveOrInvited,
|
||||
}
|
||||
|
||||
/// Verify a peer has the expected status
|
||||
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 {
|
||||
PeerStatusCheck::Exactly(ps) => status == ps.as_str(),
|
||||
PeerStatusCheck::ActiveOrInvited => status == PeerStatus::Active.as_str() || status == PeerStatus::Invited.as_str(),
|
||||
};
|
||||
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Peer status is '{}', expected {:?}", status, expected))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a sync request - bidirectional exchange of entries
|
||||
async fn handle_sync_request(
|
||||
mut sink: MessageSink,
|
||||
mut stream: MessageStream,
|
||||
peer_request: lattice_core::proto::SyncRequest,
|
||||
store: &StoreHandle,
|
||||
) -> Result<(), String> {
|
||||
println!("[Sync] Received SyncRequest");
|
||||
|
||||
// Get our sync state
|
||||
let my_state = store.sync_state().await
|
||||
.map_err(|e| format!("Failed to get sync state: {}", e))?;
|
||||
|
||||
// 1. Send our sync state as response
|
||||
let resp = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncResponse(lattice_core::proto::SyncResponse {
|
||||
state: Some(my_state.to_proto()),
|
||||
})),
|
||||
};
|
||||
sink.send(&resp).await?;
|
||||
|
||||
// 2. Send entries peer is missing
|
||||
let peer_state = peer_request.state
|
||||
.map(|s| lattice_core::sync_state::SyncState::from_proto(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
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);
|
||||
|
||||
// 3. Receive entries from requester (bidirectional)
|
||||
let (entries_applied, _) = protocol::receive_entries(&mut stream, store).await?;
|
||||
|
||||
sink.finish().await?;
|
||||
|
||||
println!("[Sync] Applied {} entries from peer", entries_applied);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Sync - outgoing mesh join and sync operations
|
||||
|
||||
use crate::{MessageSink, MessageStream, LatticeEndpoint, parse_node_id};
|
||||
use lattice_core::{Node, NodeError, StoreHandle, PeerStatus};
|
||||
use lattice_core::proto::{peer_message, PeerMessage, JoinRequest, SignedEntry};
|
||||
use prost::Message;
|
||||
use super::protocol;
|
||||
|
||||
/// Result of a sync operation with a peer
|
||||
pub struct SyncResult {
|
||||
pub entries_applied: u64,
|
||||
pub entries_sent_by_peer: u64,
|
||||
}
|
||||
|
||||
/// Join an existing mesh by connecting to a peer.
|
||||
/// Returns the new StoreHandle on success.
|
||||
/// After joining, automatically syncs with the peer to get initial data.
|
||||
pub async fn join_mesh(
|
||||
node: &Node,
|
||||
endpoint: &LatticeEndpoint,
|
||||
peer_id: iroh::PublicKey,
|
||||
) -> Result<StoreHandle, NodeError> {
|
||||
// Connect to peer
|
||||
let conn = endpoint.connect(peer_id).await
|
||||
.map_err(|e| NodeError::Actor(format!("Connection failed: {}", e)))?;
|
||||
|
||||
// Open stream
|
||||
let (send, recv) = conn.open_bi().await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to open stream: {}", e)))?;
|
||||
|
||||
let mut sink = MessageSink::new(send);
|
||||
let mut stream = MessageStream::new(recv);
|
||||
|
||||
// Send JoinRequest
|
||||
let req = PeerMessage {
|
||||
message: Some(peer_message::Message::JoinRequest(JoinRequest {
|
||||
node_pubkey: node.node_id().to_vec(),
|
||||
})),
|
||||
};
|
||||
sink.send(&req).await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to send: {}", e)))?;
|
||||
sink.finish().await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to finish: {}", e)))?;
|
||||
|
||||
// Receive JoinResponse
|
||||
let msg = stream.recv().await
|
||||
.map_err(|e| NodeError::Actor(format!("Recv error: {}", e)))?
|
||||
.ok_or_else(|| NodeError::Actor("Peer closed stream".to_string()))?;
|
||||
|
||||
match msg.message {
|
||||
Some(peer_message::Message::JoinResponse(resp)) => {
|
||||
let store_uuid = lattice_core::Uuid::from_slice(&resp.store_uuid)
|
||||
.map_err(|_| NodeError::Actor("Invalid UUID from peer".to_string()))?;
|
||||
|
||||
// Create local store with that UUID
|
||||
node.create_store_with_uuid(store_uuid)?;
|
||||
node.set_root_store(store_uuid)?;
|
||||
|
||||
let (handle, _) = node.open_store(store_uuid)?;
|
||||
|
||||
// Immediately sync with the peer to get initial data
|
||||
println!("[Join] Syncing with peer to get initial data...");
|
||||
match sync_with_peer(node, endpoint, &handle, peer_id).await {
|
||||
Ok(result) => {
|
||||
println!("[Join] Initial sync complete: applied {} entries", result.entries_applied);
|
||||
}
|
||||
Err(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)
|
||||
}
|
||||
_ => Err(NodeError::Actor("Unexpected response message type".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync with a specific peer (bidirectional).
|
||||
/// Both sides exchange states and send missing entries to each other.
|
||||
pub async fn sync_with_peer(
|
||||
node: &Node,
|
||||
endpoint: &LatticeEndpoint,
|
||||
store: &StoreHandle,
|
||||
peer_id: iroh::PublicKey,
|
||||
) -> Result<SyncResult, NodeError> {
|
||||
|
||||
// Connect
|
||||
let conn = endpoint.connect(peer_id).await
|
||||
.map_err(|e| NodeError::Actor(format!("Connection failed: {}", e)))?;
|
||||
|
||||
// Open stream
|
||||
let (send, recv) = conn.open_bi().await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to open stream: {}", e)))?;
|
||||
|
||||
let mut sink = MessageSink::new(send);
|
||||
let mut stream = MessageStream::new(recv);
|
||||
|
||||
// Get our sync state
|
||||
let my_state = store.sync_state().await?;
|
||||
|
||||
// 1. Send SyncRequest with our state (don't finish yet - we'll send entries later)
|
||||
let req = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncRequest(lattice_core::proto::SyncRequest {
|
||||
state: Some(my_state.to_proto()),
|
||||
full_sync: false,
|
||||
})),
|
||||
};
|
||||
sink.send(&req).await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to send: {}", e)))?;
|
||||
|
||||
// 2. Receive SyncResponse (peer's state) and entries until SyncDone
|
||||
let mut entries_applied = 0u64;
|
||||
let mut entries_sent_by_peer = 0u64;
|
||||
let mut peer_state = lattice_core::sync_state::SyncState::default();
|
||||
|
||||
loop {
|
||||
match stream.recv().await {
|
||||
Ok(Some(msg)) => match msg.message {
|
||||
Some(peer_message::Message::SyncResponse(resp)) => {
|
||||
// Peer's sync state - we'll use this to compute what to send
|
||||
if let Some(s) = resp.state {
|
||||
peer_state = lattice_core::sync_state::SyncState::from_proto(&s);
|
||||
}
|
||||
}
|
||||
Some(peer_message::Message::SyncEntry(entry)) => {
|
||||
if let Ok(signed) = SignedEntry::decode(&entry.signed_entry[..]) {
|
||||
if store.apply_entry(signed).await.is_ok() {
|
||||
entries_applied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(peer_message::Message::SyncDone(done)) => {
|
||||
entries_sent_by_peer = done.entries_sent;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send entries peer is missing (using shared protocol)
|
||||
let entries_sent = protocol::send_missing_entries(&mut sink, store, &my_state, &peer_state).await
|
||||
.map_err(|e| NodeError::Actor(e))?;
|
||||
|
||||
sink.finish().await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to finish: {}", e)))?;
|
||||
|
||||
println!("[Sync] Applied {} entries, sent {} entries", entries_applied, entries_sent);
|
||||
|
||||
Ok(SyncResult {
|
||||
entries_applied,
|
||||
entries_sent_by_peer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sync with all active peers from the store.
|
||||
pub async fn sync_all(
|
||||
node: &Node,
|
||||
endpoint: &LatticeEndpoint,
|
||||
store: &StoreHandle,
|
||||
) -> Result<Vec<SyncResult>, NodeError> {
|
||||
let my_pubkey = hex::encode(node.node_id());
|
||||
|
||||
// Get all active peers (invited peers haven't joined yet)
|
||||
let all_entries = store.list().await?;
|
||||
let mut peer_ids = Vec::new();
|
||||
|
||||
for (key, value) in &all_entries {
|
||||
let key_str = String::from_utf8_lossy(key);
|
||||
if key_str.ends_with("/status") && value == PeerStatus::Active.as_str().as_bytes() {
|
||||
if let Some(pubkey) = key_str.strip_prefix("/nodes/").and_then(|s| s.strip_suffix("/status")) {
|
||||
if pubkey != my_pubkey {
|
||||
if let Ok(id) = parse_node_id(pubkey) {
|
||||
peer_ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with each peer
|
||||
let mut results = Vec::new();
|
||||
for peer_id in peer_ids {
|
||||
match sync_with_peer(node, 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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
//! Unicast communication for direct peer-to-peer messaging
|
||||
|
||||
// TODO: Implement unicast using iroh
|
||||
Reference in New Issue
Block a user