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:
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user