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
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod store_actor;
pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024;
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 entry::Entry;
pub use sync_state::{SyncState, AuthorInfo, MissingRange};
+118 -46
View File
@@ -10,7 +10,6 @@ use crate::{
node_identity::NodeError as IdentityError,
};
use std::path::Path;
use std::rc::Rc;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -54,6 +53,11 @@ pub struct StoreInfo {
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
pub struct PeerInfo {
pub pubkey: String,
@@ -97,9 +101,9 @@ impl NodeBuilder {
Ok(Node {
data_dir: self.data_dir,
node: Rc::new(node),
node: std::sync::Arc::new(node),
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)
pub struct Node {
data_dir: DataDir,
node: Rc<NodeIdentity>,
node: std::sync::Arc<NodeIdentity>,
meta: MetaStore,
root_store: std::cell::RefCell<Option<StoreHandle>>,
root_store: tokio::sync::RwLock<Option<StoreHandle>>,
}
impl Node {
@@ -146,16 +150,21 @@ impl Node {
/// Set the node's display 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> {
// Update meta.db
self.meta.set_name(name)?;
// 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());
let name_key = format!("/nodes/{}/name", pubkey_hex);
handle.put(name_key.as_bytes(), name.as_bytes()).await?;
self.publish_name().await
}
/// Publish this node's name from meta.db to the root store.
/// Used after joining a mesh to announce ourselves.
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(())
}
@@ -165,17 +174,17 @@ impl Node {
}
/// Get reference to the cached root store handle (if open)
pub fn root_store(&self) -> std::cell::Ref<'_, Option<StoreHandle>> {
self.root_store.borrow()
pub async fn root_store(&self) -> tokio::sync::RwLockReadGuard<'_, Option<StoreHandle>> {
self.root_store.read().await
}
/// Open the root store if set. Node owns the handle internally.
/// 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()? {
Some(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))
}
None => Ok(None),
@@ -213,17 +222,34 @@ impl Node {
handle.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await?;
// Store the handle - node owns it
*self.root_store.borrow_mut() = Some(handle);
*self.root_store.write().await = Some(handle);
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 ---
/// Invite a peer to the mesh. Writes their info with status = invited.
pub async fn invite_peer(&self, pubkey: &[u8; 32]) -> Result<(), NodeError> {
let store = self.root_store.borrow();
let store = store.as_ref()
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);
@@ -251,11 +277,11 @@ impl Node {
/// List all peers in the mesh with their info
pub async fn list_peers(&self) -> Result<Vec<PeerInfo>, NodeError> {
let store = self.root_store.borrow();
let store = store.as_ref()
let guard = self.root_store.read().await;
let store = guard.as_ref()
.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
let mut peers_map: std::collections::HashMap<String, PeerStatus> = std::collections::HashMap::new();
@@ -299,10 +325,10 @@ impl Node {
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> {
let store = self.root_store.borrow();
let store = store.as_ref()
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);
@@ -312,28 +338,26 @@ impl Node {
return Err(NodeError::Actor("Cannot remove yourself".to_string()));
}
// Check if peer exists
let status_key = format!("/nodes/{}/status", pubkey_hex);
match store.get(status_key.as_bytes()).await? {
Some(status) if status == PeerStatus::Removed.as_str().as_bytes() => {
return Err(NodeError::Actor("Peer already removed".to_string()));
}
None => {
return Err(NodeError::Actor("Peer not found".to_string()));
}
_ => {}
// Find all keys for this peer using prefix search
let prefix = format!("/nodes/{}/", pubkey_hex);
let keys = store.list_by_prefix(prefix.as_bytes(), false).await?;
if keys.is_empty() {
return Err(NodeError::Actor("Peer not found".to_string()));
}
// Set status to removed
store.put(status_key.as_bytes(), PeerStatus::Removed.as_str().as_bytes()).await?;
// Delete all found keys
for (key, _) in keys {
store.delete(&key).await?;
}
Ok(())
}
/// Get a peer's status
pub async fn get_peer_status(&self, pubkey: &[u8; 32]) -> Result<Option<PeerStatus>, NodeError> {
let store = self.root_store.borrow();
let store = store.as_ref()
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);
@@ -347,6 +371,44 @@ impl Node {
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> {
Ok(self.meta.list_stores()?)
@@ -454,10 +516,20 @@ impl StoreHandle {
.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;
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)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
@@ -627,7 +699,7 @@ mod tests {
.expect("create node");
// Initially no root store
assert!(node.root_store().is_none());
assert!(node.root_store().await.is_none());
// Init creates root store
let root_id = node.init().await.expect("init failed");
@@ -687,7 +759,7 @@ mod tests {
.build()
.expect("create node");
node.init().await.expect("init");
let store = node.root_store();
let store = node.root_store().await;
let store = store.as_ref().unwrap();
// Get baseline seq after init
@@ -733,7 +805,7 @@ mod tests {
let pubkey_hex = hex::encode(node.node_id());
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 stored_name = store.get(name_key.as_bytes()).await.unwrap();
assert_eq!(stored_name, Some(initial_name.as_bytes().to_vec()));
@@ -748,7 +820,7 @@ mod tests {
// Verify store updated
{
let store = node.root_store();
let store = node.root_store().await;
let store = store.as_ref().unwrap();
let stored_name = store.get(name_key.as_bytes()).await.unwrap();
assert_eq!(stored_name, Some(new_name.as_bytes().to_vec()));
+4 -4
View File
@@ -138,8 +138,8 @@ pub enum PeerStatus {
Invited,
/// Peer is active and can sync
Active,
/// Peer has been removed from the mesh
Removed,
/// Peer is temporarily inactive
Dormant,
}
impl PeerStatus {
@@ -147,7 +147,7 @@ impl PeerStatus {
match self {
PeerStatus::Invited => "invited",
PeerStatus::Active => "active",
PeerStatus::Removed => "removed",
PeerStatus::Dormant => "dormant",
}
}
@@ -155,7 +155,7 @@ impl PeerStatus {
match s {
"invited" => Some(PeerStatus::Invited),
"active" => Some(PeerStatus::Active),
"removed" => Some(PeerStatus::Removed),
"dormant" => Some(PeerStatus::Dormant),
_ => None,
}
}
+78 -3
View File
@@ -247,16 +247,36 @@ impl Store {
}
/// 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 table = read_txn.open_table(KV_TABLE)?;
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_bytes = key.value();
// Stop when we've passed the prefix
if !key_bytes.starts_with(prefix) {
break;
}
let heads = HeadList::decode(value.value())?.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)
@@ -1672,4 +1692,59 @@ mod tests {
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>>,
},
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>>,
},
Put {
@@ -142,8 +148,11 @@ impl StoreActor {
StoreCmd::GetHeads { key, resp } => {
let _ = resp.send(self.store.get_heads(&key));
}
StoreCmd::List { resp } => {
let _ = resp.send(self.store.list_all());
StoreCmd::List { include_deleted, resp } => {
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 } => {
let result = self.do_put(&key, &value);