feat: introduce NodeIdentity and store_actor in lattice-core, and implement mesh networking in lattice-net while removing unicast.

This commit is contained in:
2025-12-22 22:11:20 +01:00
parent 665114036b
commit 57ecbffaed
22 changed files with 984 additions and 884 deletions
+3
View File
@@ -16,6 +16,9 @@ blake3 = { workspace = true }
hex = { workspace = true }
redb = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
hostname = "0.4"
serde_json = "1"
[build-dependencies]
prost-build = { workspace = true }
+6 -6
View File
@@ -102,10 +102,10 @@ mod tests {
use super::*;
use crate::hlc::HLC;
use crate::clock::MockClock;
use crate::node::Node;
use crate::node_identity::NodeIdentity;
use crate::signed_entry::EntryBuilder;
fn make_entry(node: &Node, seq: u64, clock_ms: u64) -> SignedEntry {
fn make_entry(node: &NodeIdentity, seq: u64, clock_ms: u64) -> SignedEntry {
let clock = MockClock::new(clock_ms);
EntryBuilder::new(seq, HLC::now_with_clock(&clock))
.store_id(vec![0u8; 16])
@@ -122,7 +122,7 @@ mod tests {
#[test]
fn test_single_queue() {
let node = Node::generate();
let node = NodeIdentity::generate();
let entries: VecDeque<_> = vec![
make_entry(&node, 1, 1000),
make_entry(&node, 2, 2000),
@@ -135,8 +135,8 @@ mod tests {
#[test]
fn test_merge_multiple_queues() {
let node_a = Node::generate();
let node_b = Node::generate();
let node_a = NodeIdentity::generate();
let node_b = NodeIdentity::generate();
// Author A: entries at time 1000, 3000
let queue_a: VecDeque<_> = vec![
@@ -167,7 +167,7 @@ mod tests {
#[test]
fn test_many_authors() {
// Test with 10 authors to verify heap behavior
let nodes: Vec<_> = (0..10).map(|_| Node::generate()).collect();
let nodes: Vec<_> = (0..10).map(|_| NodeIdentity::generate()).collect();
let queues: Vec<VecDeque<_>> = nodes.iter().enumerate().map(|(i, node)| {
vec![make_entry(node, 1, (i * 100 + 50) as u64)].into()
}).collect();
+6 -3
View File
@@ -1,7 +1,7 @@
//! Lattice Core
//!
//! Core types for the Lattice distributed mesh:
//! - **Node**: Identity with Ed25519 keypair
//! - **NodeIdentity**: Cryptographic identity with Ed25519 keypair
//! - **SigChain**: Append-only cryptographically signed log
//! - **Entry**: Atomic operations in the log
//! - **SyncState**: Per-author sequence tracking for reconciliation
@@ -14,6 +14,7 @@
//! - **Store**: Persistent KV state from log replay
//! - **CausalIter**: Merge-sort iterator for HLC-ordered sync
pub mod node_identity;
pub mod node;
pub mod sigchain;
pub mod entry;
@@ -27,12 +28,14 @@ pub mod log;
pub mod store;
pub mod meta_store;
pub mod causal_iter;
pub mod store_actor;
// Constants
/// Maximum size of a serialized SignedEntry (16 MB)
pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024;
pub use node::Node;
pub use node_identity::{NodeIdentity, PeerStatus};
pub use node::{Node, NodeBuilder, NodeInfo, StoreInfo, StoreHandle, NodeError};
pub use sigchain::{SigChain, SigChainManager};
pub use entry::Entry;
pub use sync_state::{SyncState, AuthorInfo, MissingRange};
@@ -46,4 +49,4 @@ pub use meta_store::MetaStore;
pub use proto::HeadInfo;
pub use uuid::Uuid;
pub use causal_iter::CausalEntryIter;
pub use store_actor::{StoreActor, StoreCmd, StoreActorError, spawn_store_actor};
+11 -11
View File
@@ -207,7 +207,7 @@ mod tests {
use super::*;
use crate::clock::MockClock;
use crate::hlc::HLC;
use crate::node::Node;
use crate::node_identity::NodeIdentity;
use crate::signed_entry::EntryBuilder;
use std::env::temp_dir;
@@ -226,7 +226,7 @@ mod tests {
let path = temp_log_path("single_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let hlc = HLC::now_with_clock(&clock);
@@ -248,7 +248,7 @@ mod tests {
let path = temp_log_path("multiple_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
for i in 1..=5 {
@@ -269,7 +269,7 @@ mod tests {
let path = temp_log_path("after_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let mut entries = Vec::new();
@@ -301,7 +301,7 @@ mod tests {
let path = temp_log_path("not_found_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
@@ -321,7 +321,7 @@ mod tests {
let path = temp_log_path("reader_hash_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
@@ -368,7 +368,7 @@ mod tests {
let path = temp_log_path("corrupted_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
@@ -399,7 +399,7 @@ mod tests {
let path = temp_log_path("truncated_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
@@ -430,7 +430,7 @@ mod tests {
let path = temp_log_path("too_large_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
// Create payload larger than MAX_ENTRY_SIZE
@@ -455,7 +455,7 @@ mod tests {
let path = temp_log_path("boundary_last_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
@@ -506,7 +506,7 @@ mod tests {
let path = temp_log_path("corruption_middle_v6");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
// Write 3 entries
+23
View File
@@ -13,6 +13,7 @@ const STORES_TABLE: TableDefinition<&[u8], u64> = TableDefinition::new("stores")
const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
const META_ROOT_STORE: &str = "root_store";
const META_NAME: &str = "name";
#[derive(Error, Debug)]
pub enum MetaStoreError {
@@ -106,6 +107,28 @@ impl MetaStore {
write_txn.commit()?;
Ok(())
}
/// Get the node's display name
pub fn name(&self) -> Result<Option<String>, MetaStoreError> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(META_TABLE)?;
match table.get(META_NAME)? {
Some(value) => Ok(Some(String::from_utf8_lossy(value.value()).to_string())),
None => Ok(None),
}
}
/// Set the node's display name
pub fn set_name(&self, name: &str) -> Result<(), MetaStoreError> {
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(META_TABLE)?;
table.insert(META_NAME, name.as_bytes())?;
}
write_txn.commit()?;
Ok(())
}
}
#[cfg(test)]
+513 -149
View File
@@ -1,133 +1,401 @@
//! Node identity and cryptographic keys
//!
//! Each node has an Ed25519 keypair:
//! - Private key: stored locally in `identity.key` (never replicated)
//! - Public key: serves as the node's identity (32 bytes)
//! Local Lattice node API with multi-store support
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::rngs::OsRng;
use std::fs;
use std::io::{self, Read, Write};
use crate::{
DataDir, MetaStore, NodeIdentity, PeerStatus, SigChain, Store, Uuid,
log::LogError,
meta_store::MetaStoreError,
sigchain::SigChainError,
store::StoreError,
spawn_store_actor, StoreCmd,
node_identity::NodeError as IdentityError,
};
use std::path::Path;
use std::rc::Rc;
use thiserror::Error;
/// Errors that can occur during node operations
#[derive(Error, Debug)]
pub enum NodeError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
Io(#[from] std::io::Error),
#[error("Invalid key length: expected 32 bytes, got {0}")]
InvalidKeyLength(usize),
#[error("Store error: {0}")]
Store(#[from] StoreError),
#[error("Invalid signature")]
InvalidSignature,
#[error("MetaStore error: {0}")]
MetaStore(#[from] MetaStoreError),
#[error("SigChain error: {0}")]
SigChain(#[from] SigChainError),
#[error("Log error: {0}")]
Log(#[from] LogError),
#[error("Node error: {0}")]
Node(#[from] IdentityError),
#[error("Already initialized")]
AlreadyInitialized,
#[error("Channel closed")]
ChannelClosed,
#[error("Actor error: {0}")]
Actor(String),
}
/// A node in the Lattice mesh.
///
/// Each node has an Ed25519 keypair used for signing sigchain entries
/// and establishing trust within the network.
#[derive(Clone)]
pub struct NodeInfo {
pub node_id: String,
pub data_path: String,
pub stores: Vec<Uuid>,
}
pub struct StoreInfo {
pub store_id: Uuid,
pub entries_replayed: u64,
}
pub struct NodeBuilder {
pub data_dir: DataDir,
}
impl NodeBuilder {
pub fn new() -> Self {
Self { data_dir: DataDir::default() }
}
pub fn build(self) -> Result<Node, NodeError> {
self.data_dir.ensure_dirs()?;
let key_path = self.data_dir.identity_key();
let is_new = !key_path.exists();
let node = if key_path.exists() {
NodeIdentity::load(&key_path)?
} else {
let node = NodeIdentity::generate();
node.save(&key_path)?;
node
};
let meta = MetaStore::open(self.data_dir.meta_db())?;
// Set hostname on first creation
if is_new {
let hostname = hostname::get()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let _ = meta.set_name(&hostname);
}
Ok(Node {
data_dir: self.data_dir,
node: Rc::new(node),
meta,
})
}
}
impl Default for NodeBuilder {
fn default() -> Self { Self::new() }
}
/// A local Lattice node (manages identity and store registry)
pub struct Node {
signing_key: SigningKey,
data_dir: DataDir,
node: Rc<NodeIdentity>,
meta: MetaStore,
}
impl Node {
/// Generate a new node with a random keypair.
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
Self { signing_key }
}
/// Create a node from an existing signing key.
pub fn from_signing_key(signing_key: SigningKey) -> Self {
Self { signing_key }
}
/// Load a node's identity from a key file, or generate and save if it doesn't exist.
pub fn load_or_generate(path: impl AsRef<Path>) -> Result<Self, NodeError> {
let path = path.as_ref();
if path.exists() {
Self::load(path)
} else {
let node = Self::generate();
node.save(path)?;
Ok(node)
pub fn info(&self) -> NodeInfo {
NodeInfo {
node_id: hex::encode(self.node.public_key_bytes()),
data_path: self.data_dir.base().display().to_string(),
stores: self.meta.list_stores().unwrap_or_default(),
}
}
/// Load a node's identity from a key file.
pub fn load(path: impl AsRef<Path>) -> Result<Self, NodeError> {
let mut file = fs::File::open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
if bytes.len() != 32 {
return Err(NodeError::InvalidKeyLength(bytes.len()));
}
let key_bytes: [u8; 32] = bytes.try_into().unwrap();
let signing_key = SigningKey::from_bytes(&key_bytes);
Ok(Self { signing_key })
pub fn node_id(&self) -> [u8; 32] {
self.node.public_key_bytes()
}
/// Save the node's private key to a file.
pub fn save(&self, path: impl AsRef<Path>) -> Result<(), NodeError> {
let path = path.as_ref();
/// Get the secret key bytes for Iroh integration (same Ed25519 key)
pub fn secret_key_bytes(&self) -> [u8; 32] {
self.node.secret_key_bytes()
}
pub fn data_path(&self) -> &Path {
self.data_dir.base()
}
/// Get the node's display name (from meta.db, set on creation)
pub fn name(&self) -> Option<String> {
self.meta.name().ok().flatten()
}
/// Set the node's display name.
/// Updates meta.db and if a store handle is provided, also updates /nodes/{pubkey}/name
pub async fn set_name(&self, name: &str, store: Option<&StoreHandle>) -> Result<(), NodeError> {
// Update meta.db
self.meta.set_name(name)?;
// Create parent directories if they don't exist
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
// If store provided, update there too
if let Some(handle) = store {
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?;
}
let mut file = fs::File::create(path)?;
file.write_all(self.signing_key.as_bytes())?;
Ok(())
}
/// Get the node's public key (identity).
pub fn public_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
/// Get the root store ID
pub fn root_store(&self) -> Result<Option<Uuid>, NodeError> {
Ok(self.meta.root_store()?)
}
/// Open the root store if set
pub fn open_root_store(&self) -> Result<Option<(StoreHandle, StoreInfo)>, NodeError> {
match self.meta.root_store()? {
Some(id) => Ok(Some(self.open_store(id)?)),
None => Ok(None),
}
}
/// Get the node's public key as bytes (32 bytes).
pub fn public_key_bytes(&self) -> [u8; 32] {
self.signing_key.verifying_key().to_bytes()
/// Initialize the node with a root store (fails if already initialized).
/// Writes the node's pubkey to `/nodes/{pubkey}/info` in the root store.
pub async fn init(&self) -> Result<(Uuid, StoreHandle), NodeError> {
if self.meta.root_store()?.is_some() {
return Err(NodeError::AlreadyInitialized);
}
let store_id = self.create_store()?;
self.meta.set_root_store(store_id)?;
// Open the store and write our node info as separate keys
let (handle, _) = self.open_store(store_id)?;
let pubkey_hex = hex::encode(self.node.public_key_bytes());
// Store node metadata as separate keys
if let Some(name) = self.name() {
let name_key = format!("/nodes/{}/name", pubkey_hex);
handle.put(name_key.as_bytes(), name.as_bytes()).await?;
}
let added_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let added_at_key = format!("/nodes/{}/added_at", pubkey_hex);
handle.put(added_at_key.as_bytes(), added_at.to_string().as_bytes()).await?;
// Write status = active
let status_key = format!("/nodes/{}/status", pubkey_hex);
handle.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await?;
Ok((store_id, handle))
}
/// Get the signing key for creating signatures.
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
pub fn list_stores(&self) -> Result<Vec<Uuid>, NodeError> {
Ok(self.meta.list_stores()?)
}
/// Get the secret key bytes (32 bytes) for Iroh integration.
/// WARNING: Handle with care - this exposes the private key material.
pub fn secret_key_bytes(&self) -> [u8; 32] {
self.signing_key.to_bytes()
pub fn create_store(&self) -> Result<Uuid, NodeError> {
let store_id = Uuid::new_v4();
self.create_store_internal(store_id)
}
/// Create a store with a specific UUID (for joining existing mesh)
pub fn create_store_with_uuid(&self, store_id: Uuid) -> Result<Uuid, NodeError> {
self.create_store_internal(store_id)
}
/// Set a store as the root store
pub fn set_root_store(&self, store_id: Uuid) -> Result<(), NodeError> {
self.meta.set_root_store(store_id)?;
Ok(())
}
fn create_store_internal(&self, store_id: Uuid) -> Result<Uuid, NodeError> {
self.data_dir.ensure_store_dirs(store_id)?;
let _ = Store::open(self.data_dir.store_state_db(store_id))?;
self.meta.add_store(store_id)?;
Ok(store_id)
}
/// Sign a message.
pub fn sign(&self, message: &[u8]) -> Signature {
self.signing_key.sign(message)
pub fn open_store(&self, store_id: Uuid) -> Result<(StoreHandle, StoreInfo), NodeError> {
self.data_dir.ensure_store_dirs(store_id)?;
let author_id_hex = hex::encode(self.node.public_key_bytes());
let log_path = self.data_dir.store_log_file(store_id, &author_id_hex);
let sigchain = if log_path.exists() {
SigChain::from_log(&log_path, *store_id.as_bytes(), self.node.public_key_bytes())?
} else {
SigChain::new(&log_path, *store_id.as_bytes(), self.node.public_key_bytes())
};
let store = Store::open(self.data_dir.store_state_db(store_id))?;
let entries_replayed = if log_path.exists() {
store.replay_log(&log_path)?
} else {
0
};
let info = StoreInfo { store_id, entries_replayed };
// Spawn actor thread - actor owns store, sigchain, and node copy
let (tx, actor_handle) = spawn_store_actor(
store_id,
store,
sigchain,
(*self.node).clone(),
);
let handle = StoreHandle {
store_id,
tx,
actor_handle: Some(actor_handle),
};
Ok((handle, info))
}
}
/// A handle to a specific store - wraps channel to actor thread
pub struct StoreHandle {
store_id: Uuid,
tx: tokio::sync::mpsc::Sender<StoreCmd>,
actor_handle: Option<std::thread::JoinHandle<()>>,
}
impl Clone for StoreHandle {
fn clone(&self) -> Self {
Self {
store_id: self.store_id,
tx: self.tx.clone(),
actor_handle: None, // Clones don't own the actor thread
}
}
}
impl StoreHandle {
pub fn id(&self) -> Uuid { self.store_id }
pub async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::Get { key: key.to_vec(), resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
/// Verify a signature against this node's public key.
pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<(), NodeError> {
self.public_key()
.verify(message, signature)
.map_err(|_| NodeError::InvalidSignature)
pub async fn get_heads(&self, key: &[u8]) -> Result<Vec<crate::HeadInfo>, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::GetHeads { key: key.to_vec(), resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
/// Verify a signature using a raw public key.
pub fn verify_with_key(
public_key: &VerifyingKey,
message: &[u8],
signature: &Signature,
) -> Result<(), NodeError> {
public_key
.verify(message, signature)
.map_err(|_| NodeError::InvalidSignature)
pub async fn list(&self) -> 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
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn log_seq(&self) -> u64 {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
let _ = self.tx.send(StoreCmd::LogSeq { resp: resp_tx }).await;
resp_rx.await.unwrap_or(0)
}
pub async fn applied_seq(&self) -> Result<u64, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::AppliedSeq { resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn author_state(&self, author: &[u8; 32]) -> Result<Option<crate::proto::AuthorState>, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::AuthorState { author: *author, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn sync_state(&self) -> Result<crate::sync_state::SyncState, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::SyncState { resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn read_entries_after(&self, author: &[u8; 32], from_hash: Option<[u8; 32]>) -> Result<Vec<crate::proto::SignedEntry>, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::ReadEntriesAfter { author: *author, from_hash, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn apply_entry(&self, entry: crate::proto::SignedEntry) -> Result<(), NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::ApplyEntry { entry, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn put(&self, key: &[u8], value: &[u8]) -> Result<u64, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::Put { key: key.to_vec(), value: value.to_vec(), resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(|e| NodeError::Actor(e.to_string()))
}
pub async fn delete(&self, key: &[u8]) -> Result<u64, NodeError> {
use StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::Delete { key: key.to_vec(), resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(|e| NodeError::Actor(e.to_string()))
}
}
impl Drop for StoreHandle {
fn drop(&mut self) {
// Only send shutdown if we own the actor (non-cloned handle)
if let Some(handle) = self.actor_handle.take() {
let _ = self.tx.try_send(StoreCmd::Shutdown);
let _ = handle.join();
}
// Clones (actor_handle = None) don't send shutdown - actor keeps running
}
}
@@ -136,87 +404,183 @@ mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn test_generate() {
let node = Node::generate();
let pk = node.public_key_bytes();
assert_eq!(pk.len(), 32);
fn temp_data_dir(name: &str) -> DataDir {
let path = temp_dir().join(format!("lattice_node_test_{}", name));
let _ = std::fs::remove_dir_all(&path);
DataDir::new(path)
}
#[test]
fn test_sign_and_verify() {
let node = Node::generate();
let message = b"hello lattice";
#[tokio::test]
async fn test_create_and_open_store() {
let data_dir = temp_data_dir("meta_store");
let signature = node.sign(message);
assert!(node.verify(message, &signature).is_ok());
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("Failed to create node");
assert!(node.info().stores.is_empty());
let store_id = node.create_store().expect("Failed to create store");
// Verify it's in the list
let stores = node.list_stores().expect("list failed");
assert!(stores.contains(&store_id));
let (handle, _) = node.open_store(store_id).expect("Failed to open store");
handle.put(b"/key", b"value").await.expect("put failed");
assert_eq!(handle.get(b"/key").await.unwrap(), Some(b"value".to_vec()));
let _ = std::fs::remove_dir_all(data_dir.base());
}
#[test]
fn test_verify_wrong_message() {
let node = Node::generate();
let signature = node.sign(b"original");
#[tokio::test]
async fn test_store_isolation() {
let data_dir = temp_data_dir("meta_isolation");
assert!(node.verify(b"tampered", &signature).is_err());
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("Failed to create node");
let store_a = node.create_store().expect("create A");
let store_b = node.create_store().expect("create B");
let (handle_a, _) = node.open_store(store_a).expect("open A");
handle_a.put(b"/key", b"from A").await.expect("put A");
let (handle_b, _) = node.open_store(store_b).expect("open B");
assert_eq!(handle_b.get(b"/key").await.unwrap(), None);
assert_eq!(handle_a.get(b"/key").await.unwrap(), Some(b"from A".to_vec()));
let _ = std::fs::remove_dir_all(data_dir.base());
}
#[test]
fn test_verify_with_different_key() {
let node1 = Node::generate();
let node2 = Node::generate();
#[tokio::test]
async fn test_init_creates_root_store() {
let data_dir = temp_data_dir("init_root");
let signature = node1.sign(b"message");
assert!(node2.verify(b"message", &signature).is_err());
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("create node");
// Initially no root store
assert!(node.root_store().unwrap().is_none());
// Init creates root store
let (root_id, _handle) = node.init().await.expect("init failed");
assert_eq!(node.root_store().unwrap(), Some(root_id));
let _ = std::fs::remove_dir_all(data_dir.base());
}
#[test]
fn test_save_and_load() {
let temp_path = temp_dir().join("lattice_test_identity.key");
#[tokio::test]
async fn test_duplicate_init_fails() {
let data_dir = temp_data_dir("init_dup");
// Generate and save
let node1 = Node::generate();
let pk1 = node1.public_key_bytes();
node1.save(&temp_path).unwrap();
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("create node");
// Load and verify same key
let node2 = Node::load(&temp_path).unwrap();
let pk2 = node2.public_key_bytes();
node.init().await.expect("first init");
assert_eq!(pk1, pk2);
// Second init should fail
match node.init().await {
Ok(_) => panic!("Expected AlreadyInitialized error"),
Err(e) => match e {
NodeError::AlreadyInitialized => (),
_ => panic!("Expected AlreadyInitialized, got {:?}", e),
},
}
// Cleanup
fs::remove_file(&temp_path).ok();
let _ = std::fs::remove_dir_all(data_dir.base());
}
#[test]
fn test_load_or_generate() {
let temp_path = temp_dir().join("lattice_test_identity2.key");
#[tokio::test]
async fn test_root_store_in_info_after_init() {
let data_dir = temp_data_dir("init_info");
// Remove if exists
fs::remove_file(&temp_path).ok();
// First session: init
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("create node");
let (root_id, _) = node.init().await.expect("init");
drop(node); // End first session
// First call: generates
let node1 = Node::load_or_generate(&temp_path).unwrap();
let pk1 = node1.public_key_bytes();
// Second session: root_store should persist
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("reload node");
// Second call: loads existing
let node2 = Node::load_or_generate(&temp_path).unwrap();
let pk2 = node2.public_key_bytes();
assert_eq!(node.root_store().unwrap(), Some(root_id));
assert_eq!(pk1, pk2);
// Cleanup
fs::remove_file(&temp_path).ok();
let _ = std::fs::remove_dir_all(data_dir.base());
}
#[test]
fn test_verify_with_key_static() {
let node = Node::generate();
let pk = node.public_key();
let message = b"test message";
#[tokio::test]
async fn test_idempotent_put_and_delete() {
let data_dir = temp_data_dir("idempotent");
let signature = node.sign(message);
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("create node");
let (_, store) = node.init().await.expect("init");
assert!(Node::verify_with_key(&pk, message, &signature).is_ok());
// Get baseline seq after init
let baseline = store.log_seq().await;
// Put twice with same value - second should be idempotent
let seq1 = store.put(b"/key", b"value").await.expect("put 1");
assert_eq!(seq1, baseline + 1);
let seq2 = store.put(b"/key", b"value").await.expect("put 2");
assert_eq!(seq2, baseline + 1, "Second put should be idempotent (no new entry)");
assert_eq!(store.log_seq().await, baseline + 1);
// Delete twice - second should be idempotent
let seq3 = store.delete(b"/key").await.expect("delete 1");
assert_eq!(seq3, baseline + 2);
let seq4 = store.delete(b"/key").await.expect("delete 2");
assert_eq!(seq4, baseline + 2, "Second delete should be idempotent (no new entry)");
assert_eq!(store.log_seq().await, baseline + 2);
let _ = std::fs::remove_dir_all(data_dir.base());
}
#[tokio::test]
async fn test_set_name_updates_store() {
let data_dir = temp_data_dir("set_name");
let node = NodeBuilder { data_dir: data_dir.clone() }
.build()
.expect("create node");
// Set initial name
assert!(node.name().is_some());
let initial_name = node.name().unwrap();
// Init creates root store
let (_, store) = node.init().await.expect("init");
// Verify initial name is in store
let pubkey_hex = hex::encode(node.node_id());
let name_key = format!("/nodes/{}/name", pubkey_hex);
let stored_name = store.get(name_key.as_bytes()).await.unwrap();
assert_eq!(stored_name, Some(initial_name.as_bytes().to_vec()));
// Change name
let new_name = "my-custom-name";
node.set_name(new_name, Some(&store)).await.expect("set_name");
// Verify meta.db updated
assert_eq!(node.name(), Some(new_name.to_string()));
// Verify store updated
let stored_name = store.get(name_key.as_bytes()).await.unwrap();
assert_eq!(stored_name, Some(new_name.as_bytes().to_vec()));
let _ = std::fs::remove_dir_all(data_dir.base());
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Node identity and cryptographic keys
//!
//! Each node has an Ed25519 keypair:
//! - Private key: stored locally in `identity.key` (never replicated)
//! - Public key: serves as the node's identity (32 bytes)
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::rngs::OsRng;
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
use thiserror::Error;
/// Errors that can occur during node operations
#[derive(Error, Debug)]
pub enum NodeError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Invalid key length: expected 32 bytes, got {0}")]
InvalidKeyLength(usize),
#[error("Invalid signature")]
InvalidSignature,
}
/// A node in the Lattice mesh.
///
/// Each node has an Ed25519 keypair used for signing sigchain entries
/// and establishing trust within the network.
#[derive(Clone)]
pub struct NodeIdentity {
signing_key: SigningKey,
}
impl NodeIdentity {
/// Generate a new node with a random keypair.
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
Self { signing_key }
}
/// Create a node from an existing signing key.
pub fn from_signing_key(signing_key: SigningKey) -> Self {
Self { signing_key }
}
/// Load a node's identity from a key file, or generate and save if it doesn't exist.
pub fn load_or_generate(path: impl AsRef<Path>) -> Result<Self, NodeError> {
let path = path.as_ref();
if path.exists() {
Self::load(path)
} else {
let node = Self::generate();
node.save(path)?;
Ok(node)
}
}
/// Load a node's identity from a key file.
pub fn load(path: impl AsRef<Path>) -> Result<Self, NodeError> {
let mut file = fs::File::open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
if bytes.len() != 32 {
return Err(NodeError::InvalidKeyLength(bytes.len()));
}
let key_bytes: [u8; 32] = bytes.try_into().unwrap();
let signing_key = SigningKey::from_bytes(&key_bytes);
Ok(Self { signing_key })
}
/// Save the node's private key to a file.
pub fn save(&self, path: impl AsRef<Path>) -> Result<(), NodeError> {
let path = path.as_ref();
// Create parent directories if they don't exist
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::File::create(path)?;
file.write_all(self.signing_key.as_bytes())?;
Ok(())
}
/// Get the node's public key (identity).
pub fn public_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
}
/// Get the node's public key as bytes (32 bytes).
pub fn public_key_bytes(&self) -> [u8; 32] {
self.signing_key.verifying_key().to_bytes()
}
/// Get the signing key for creating signatures.
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
/// Get the secret key bytes (32 bytes) for Iroh integration.
/// WARNING: Handle with care - this exposes the private key material.
pub fn secret_key_bytes(&self) -> [u8; 32] {
self.signing_key.to_bytes()
}
/// Sign a message.
pub fn sign(&self, message: &[u8]) -> Signature {
self.signing_key.sign(message)
}
/// Verify a signature against this node's public key.
pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<(), NodeError> {
self.public_key()
.verify(message, signature)
.map_err(|_| NodeError::InvalidSignature)
}
/// Verify a signature using a raw public key.
pub fn verify_with_key(
public_key: &VerifyingKey,
message: &[u8],
signature: &Signature,
) -> Result<(), NodeError> {
public_key
.verify(message, signature)
.map_err(|_| NodeError::InvalidSignature)
}
}
/// Peer status values used across the system
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PeerStatus {
/// Peer has been invited but hasn't joined yet
Invited,
/// Peer is active and can sync
Active,
/// Peer has been removed from the mesh
Removed,
}
impl PeerStatus {
pub fn as_str(&self) -> &'static str {
match self {
PeerStatus::Invited => "invited",
PeerStatus::Active => "active",
PeerStatus::Removed => "removed",
}
}
pub fn from_str(s: &str) -> Option<PeerStatus> {
match s {
"invited" => Some(PeerStatus::Invited),
"active" => Some(PeerStatus::Active),
"removed" => Some(PeerStatus::Removed),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn test_generate() {
let node = NodeIdentity::generate();
let pk = node.public_key_bytes();
assert_eq!(pk.len(), 32);
}
#[test]
fn test_sign_and_verify() {
let node = NodeIdentity::generate();
let message = b"hello lattice";
let signature = node.sign(message);
assert!(node.verify(message, &signature).is_ok());
}
#[test]
fn test_verify_wrong_message() {
let node = NodeIdentity::generate();
let signature = node.sign(b"original");
assert!(node.verify(b"tampered", &signature).is_err());
}
#[test]
fn test_verify_with_different_key() {
let node1 = NodeIdentity::generate();
let node2 = NodeIdentity::generate();
let signature = node1.sign(b"message");
assert!(node2.verify(b"message", &signature).is_err());
}
#[test]
fn test_save_and_load() {
let temp_path = temp_dir().join("lattice_test_identity.key");
// Generate and save
let node1 = NodeIdentity::generate();
let pk1 = node1.public_key_bytes();
node1.save(&temp_path).unwrap();
// Load and verify same key
let node2 = NodeIdentity::load(&temp_path).unwrap();
let pk2 = node2.public_key_bytes();
assert_eq!(pk1, pk2);
// Cleanup
fs::remove_file(&temp_path).ok();
}
#[test]
fn test_load_or_generate() {
let temp_path = temp_dir().join("lattice_test_identity2.key");
// Remove if exists
fs::remove_file(&temp_path).ok();
// First call: generates
let node1 = NodeIdentity::load_or_generate(&temp_path).unwrap();
let pk1 = node1.public_key_bytes();
// Second call: loads existing
let node2 = NodeIdentity::load_or_generate(&temp_path).unwrap();
let pk2 = node2.public_key_bytes();
assert_eq!(pk1, pk2);
// Cleanup
fs::remove_file(&temp_path).ok();
}
#[test]
fn test_verify_with_key_static() {
let node = NodeIdentity::generate();
let pk = node.public_key();
let message = b"test message";
let signature = node.sign(message);
assert!(NodeIdentity::verify_with_key(&pk, message, &signature).is_ok());
}
}
+11 -11
View File
@@ -4,7 +4,7 @@
//! before appending (correct seq, prev_hash, valid signature) and persists to disk.
use crate::log::{append_entry, read_entries, LogError};
use crate::node::Node;
use crate::node_identity::NodeIdentity;
use crate::proto::{Entry, SignedEntry};
use crate::signed_entry::{hash_signed_entry, verify_signed_entry};
use prost::Message;
@@ -229,7 +229,7 @@ impl SigChain {
}
/// Create and append a new entry using the node's key
pub fn create_entry(&mut self, node: &Node, ops: Vec<crate::proto::Operation>) -> Result<SignedEntry, SigChainError> {
pub fn create_entry(&mut self, node: &NodeIdentity, ops: Vec<crate::proto::Operation>) -> Result<SignedEntry, SigChainError> {
use crate::clock::SystemClock;
use crate::hlc::HLC;
use crate::signed_entry::EntryBuilder;
@@ -322,7 +322,7 @@ mod tests {
use super::*;
use crate::clock::MockClock;
use crate::hlc::HLC;
use crate::node::Node;
use crate::node_identity::NodeIdentity;
use crate::proto::{operation, Operation, PutOp};
use crate::signed_entry::EntryBuilder;
use std::env::temp_dir;
@@ -352,7 +352,7 @@ mod tests {
let path = temp_log_path("append");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, TEST_STORE, author);
@@ -377,7 +377,7 @@ mod tests {
let path = temp_log_path("multiple");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
@@ -402,7 +402,7 @@ mod tests {
let path = temp_log_path("from_log");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let clock = MockClock::new(1000);
@@ -433,7 +433,7 @@ mod tests {
let path = temp_log_path("wrong_seq");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
@@ -457,7 +457,7 @@ mod tests {
let path = temp_log_path("wrong_prev");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
@@ -489,7 +489,7 @@ mod tests {
let path = temp_log_path("wrong_author");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let other_author = [99u8; 32]; // Different author
let mut chain = SigChain::new(&path, TEST_STORE, other_author);
let clock = MockClock::new(1000);
@@ -513,7 +513,7 @@ mod tests {
let path = temp_log_path("create");
std::fs::remove_file(&path).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, TEST_STORE, author);
@@ -545,7 +545,7 @@ mod tests {
std::fs::remove_file(&path_a).ok();
std::fs::remove_file(&path_b).ok();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let clock = MockClock::new(1000);
+10 -10
View File
@@ -7,7 +7,7 @@
//! - Computing entry hashes for prev_hash linking
use crate::hlc::HLC;
use crate::node::{Node, NodeError};
use crate::node_identity::{NodeIdentity, NodeError};
use crate::proto::{Entry, Hlc, Operation, PutOp, DeleteOp, SignedEntry, operation};
use ed25519_dalek::{Signature, VerifyingKey};
use prost::Message;
@@ -116,14 +116,14 @@ impl EntryBuilder {
}
/// Build and sign the entry, returning a SignedEntry
pub fn sign(self, node: &Node) -> SignedEntry {
pub fn sign(self, node: &NodeIdentity) -> SignedEntry {
let entry = self.build();
sign_entry(&entry, node)
}
}
/// Sign an Entry to create a SignedEntry
pub fn sign_entry(entry: &Entry, node: &Node) -> SignedEntry {
pub fn sign_entry(entry: &Entry, node: &NodeIdentity) -> SignedEntry {
let entry_bytes = entry.encode_to_vec();
let signature = node.sign(&entry_bytes);
@@ -152,7 +152,7 @@ pub fn verify_signed_entry(signed: &SignedEntry) -> Result<Entry, EntryError> {
let signature = Signature::from_bytes(&sig_bytes);
// Verify
Node::verify_with_key(&public_key, &signed.entry_bytes, &signature)?;
NodeIdentity::verify_with_key(&public_key, &signed.entry_bytes, &signature)?;
// Decode entry
let entry = Entry::decode(&signed.entry_bytes[..])?;
@@ -192,7 +192,7 @@ mod tests {
#[test]
fn test_sign_and_verify() {
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let hlc = HLC::now_with_clock(&clock);
@@ -211,7 +211,7 @@ mod tests {
#[test]
fn test_verify_tampered_fails() {
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let hlc = HLC::now_with_clock(&clock);
@@ -227,8 +227,8 @@ mod tests {
#[test]
fn test_verify_wrong_key_fails() {
let node1 = Node::generate();
let node2 = Node::generate();
let node1 = NodeIdentity::generate();
let node2 = NodeIdentity::generate();
let clock = MockClock::new(1000);
let hlc = HLC::now_with_clock(&clock);
@@ -244,7 +244,7 @@ mod tests {
#[test]
fn test_hash_signed_entry() {
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let hlc = HLC::now_with_clock(&clock);
@@ -262,7 +262,7 @@ mod tests {
#[test]
fn test_prev_hash_chaining() {
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
// First entry
+33 -33
View File
@@ -324,7 +324,7 @@ mod tests {
use super::*;
use crate::clock::MockClock;
use crate::hlc::HLC;
use crate::node::Node;
use crate::node_identity::NodeIdentity;
use crate::signed_entry::EntryBuilder;
use std::env::temp_dir;
@@ -341,7 +341,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
@@ -391,7 +391,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock = MockClock::new(1000);
// First write
@@ -426,7 +426,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
// Create two heads
let clock1 = MockClock::new(1000);
@@ -473,7 +473,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
// Create two concurrent heads
let clock1 = MockClock::new(1000);
@@ -525,7 +525,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
// Create a single head
let clock1 = MockClock::new(1000);
@@ -573,8 +573,8 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let alice = Node::generate();
let bob = Node::generate();
let alice = NodeIdentity::generate();
let bob = NodeIdentity::generate();
// Initial state: K = v1
let clock1 = MockClock::new(1000);
@@ -632,9 +632,9 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let alice = Node::generate();
let bob = Node::generate();
let charlie = Node::generate();
let alice = NodeIdentity::generate();
let bob = NodeIdentity::generate();
let charlie = NodeIdentity::generate();
// Alice creates K = v1
let clock1 = MockClock::new(1000);
@@ -692,7 +692,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let clock1 = MockClock::new(1000);
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock1))
@@ -725,7 +725,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
// First write: a = 1
let clock1 = MockClock::new(1000);
@@ -788,7 +788,7 @@ mod tests {
let _ = std::fs::remove_file(&log_path);
let store = Store::open(&state_path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
// First write: a = 1
@@ -850,7 +850,7 @@ mod tests {
let _ = std::fs::remove_file(&log_path);
let store = Store::open(&state_path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
@@ -898,7 +898,7 @@ mod tests {
let _ = std::fs::remove_file(&log_path);
let store = Store::open(&state_path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
@@ -957,7 +957,7 @@ mod tests {
let _ = std::fs::remove_file(&log_path);
let store = Store::open(&state_path).unwrap();
let node = Node::generate();
let node = NodeIdentity::generate();
let author = node.public_key_bytes();
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
@@ -1125,7 +1125,7 @@ mod tests {
// Node A writes some entries
let store_a = Store::open(&path_a).unwrap();
let node_a = Node::generate();
let node_a = NodeIdentity::generate();
// Write 3 entries on node A
for i in 1u64..=3 {
@@ -1196,8 +1196,8 @@ mod tests {
let store_a = Store::open(&path_a).unwrap();
let store_b = Store::open(&path_b).unwrap();
let node_a = Node::generate();
let node_b = Node::generate();
let node_a = NodeIdentity::generate();
let node_b = NodeIdentity::generate();
// Node A writes entries
for i in 1u64..=2 {
@@ -1283,9 +1283,9 @@ mod tests {
let store_a = Store::open(&path_a).unwrap();
let store_b = Store::open(&path_b).unwrap();
let store_c = Store::open(&path_c).unwrap();
let node_a = Node::generate();
let node_b = Node::generate();
let node_c = Node::generate();
let node_a = NodeIdentity::generate();
let node_b = NodeIdentity::generate();
let node_c = NodeIdentity::generate();
// Each node writes one entry
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&MockClock::new(1000)))
@@ -1369,8 +1369,8 @@ mod tests {
let store_a = Store::open(&path_a).unwrap();
let store_b = Store::open(&path_b).unwrap();
let node_a = Node::generate();
let node_b = Node::generate();
let node_a = NodeIdentity::generate();
let node_b = NodeIdentity::generate();
// Both nodes write to the SAME key with different values
// Use same HLC to force conflict (tie-break on author)
@@ -1435,8 +1435,8 @@ mod tests {
let _ = std::fs::remove_file(&path);
let store = Store::open(&path).unwrap();
let node_low = Node::generate();
let node_high = Node::generate();
let node_low = NodeIdentity::generate();
let node_high = NodeIdentity::generate();
// Determine which node has "higher" author bytes
let (high_node, low_node) = if node_high.public_key_bytes() > node_low.public_key_bytes() {
@@ -1494,9 +1494,9 @@ mod tests {
let store_d = Store::open(&path_d).unwrap();
// Create 3 nodes (virtual peers)
let node_a = Node::generate();
let node_b = Node::generate();
let node_c = Node::generate();
let node_a = NodeIdentity::generate();
let node_b = NodeIdentity::generate();
let node_c = NodeIdentity::generate();
let clock = MockClock::new(1000);
@@ -1609,9 +1609,9 @@ mod tests {
let store = Store::open(&path).unwrap();
// Create 3 nodes
let node_a = Node::generate();
let node_b = Node::generate();
let node_c = Node::generate();
let node_a = NodeIdentity::generate();
let node_b = NodeIdentity::generate();
let node_c = NodeIdentity::generate();
let clock = MockClock::new(1000);
+276
View File
@@ -0,0 +1,276 @@
//! Store Actor - dedicated thread that owns Store and processes commands via channel
use crate::{
EntryBuilder, HeadInfo, NodeIdentity, SigChain, SigChainManager, Store, Uuid,
hlc::HLC,
proto::AuthorState,
sigchain::SigChainError,
store::StoreError,
sync_state::SyncState,
proto::SignedEntry,
log,
};
use tokio::sync::{mpsc, oneshot};
use std::thread::{self, JoinHandle};
/// Commands sent to the store actor
pub enum StoreCmd {
Get {
key: Vec<u8>,
resp: oneshot::Sender<Result<Option<Vec<u8>>, StoreError>>,
},
GetHeads {
key: Vec<u8>,
resp: oneshot::Sender<Result<Vec<HeadInfo>, StoreError>>,
},
List {
resp: oneshot::Sender<Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError>>,
},
Put {
key: Vec<u8>,
value: Vec<u8>,
resp: oneshot::Sender<Result<u64, StoreActorError>>,
},
Delete {
key: Vec<u8>,
resp: oneshot::Sender<Result<u64, StoreActorError>>,
},
LogSeq {
resp: oneshot::Sender<u64>,
},
AppliedSeq {
resp: oneshot::Sender<Result<u64, StoreError>>,
},
AuthorState {
author: [u8; 32],
resp: oneshot::Sender<Result<Option<AuthorState>, StoreError>>,
},
// Sync-related commands
SyncState {
resp: oneshot::Sender<Result<SyncState, StoreError>>,
},
ReadEntriesAfter {
author: [u8; 32],
from_hash: Option<[u8; 32]>,
resp: oneshot::Sender<Result<Vec<SignedEntry>, StoreError>>,
},
ApplyEntry {
entry: SignedEntry,
resp: oneshot::Sender<Result<(), StoreError>>,
},
Shutdown,
}
#[derive(Debug)]
pub enum StoreActorError {
Store(StoreError),
SigChain(SigChainError),
}
impl From<StoreError> for StoreActorError {
fn from(e: StoreError) -> Self {
StoreActorError::Store(e)
}
}
impl From<SigChainError> for StoreActorError {
fn from(e: SigChainError) -> Self {
StoreActorError::SigChain(e)
}
}
impl std::fmt::Display for StoreActorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StoreActorError::Store(e) => write!(f, "Store error: {}", e),
StoreActorError::SigChain(e) => write!(f, "SigChain error: {}", e),
}
}
}
impl std::error::Error for StoreActorError {}
/// The store actor - runs in its own thread, owns Store and SigChainManager
pub struct StoreActor {
store_id: Uuid,
store: Store,
chain_manager: SigChainManager, // Manages all authors' sigchains
node: NodeIdentity,
rx: mpsc::Receiver<StoreCmd>,
}
impl StoreActor {
/// Create a new store actor (but don't start the thread yet)
pub fn new(
store_id: Uuid,
store: Store,
sigchain: SigChain,
node: NodeIdentity,
rx: mpsc::Receiver<StoreCmd>,
) -> Self {
// Derive logs_dir from sigchain's log file path
let logs_dir = sigchain.log_path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
// Create chain manager and register the local node's sigchain
let mut chain_manager = SigChainManager::new(&logs_dir, *store_id.as_bytes());
let local_author = node.public_key_bytes();
chain_manager.get_or_create(local_author); // Pre-initialize local chain
Self {
store_id,
store,
chain_manager,
node,
rx,
}
}
/// Run the actor loop - processes commands until Shutdown received
/// Uses blocking_recv since redb is sync and we run in spawn_blocking
pub fn run(mut self) {
while let Some(cmd) = self.rx.blocking_recv() {
match cmd {
StoreCmd::Get { key, resp } => {
let _ = resp.send(self.store.get(&key));
}
StoreCmd::GetHeads { key, resp } => {
let _ = resp.send(self.store.get_heads(&key));
}
StoreCmd::List { resp } => {
let _ = resp.send(self.store.list_all());
}
StoreCmd::Put { key, value, resp } => {
let result = self.do_put(&key, &value);
let _ = resp.send(result);
}
StoreCmd::Delete { key, resp } => {
let result = self.do_delete(&key);
let _ = resp.send(result);
}
StoreCmd::LogSeq { resp } => {
let local_author = self.node.public_key_bytes();
let len = self.chain_manager.get(&local_author)
.map(|c| c.len())
.unwrap_or(0);
let _ = resp.send(len);
}
StoreCmd::AppliedSeq { resp } => {
let author = self.node.public_key_bytes();
let result = self.store.author_state(&author)
.map(|s| s.map(|a| a.seq).unwrap_or(0));
let _ = resp.send(result);
}
StoreCmd::AuthorState { author, resp } => {
let _ = resp.send(self.store.author_state(&author));
}
StoreCmd::SyncState { resp } => {
let _ = resp.send(self.store.sync_state());
}
StoreCmd::ReadEntriesAfter { author, from_hash, resp } => {
// Read entries from the log file for this author
let result = self.do_read_entries_after(&author, from_hash);
let _ = resp.send(result);
}
StoreCmd::ApplyEntry { entry, resp } => {
// Use SigChainManager to append to the correct author's log
if let Err(e) = self.chain_manager.append_entry(&entry) {
let _ = resp.send(Err(StoreError::from(e)));
continue;
}
// Then apply to store
let result = self.store.apply_entry(&entry);
let _ = resp.send(result);
}
StoreCmd::Shutdown => {
break;
}
}
}
}
fn do_put(&mut self, key: &[u8], value: &[u8]) -> Result<u64, StoreActorError> {
let heads = self.store.get_heads(key)?;
// Idempotency check (pure function)
if !Store::needs_put(&heads, value) {
let local_author = self.node.public_key_bytes();
return Ok(self.chain_manager.get(&local_author).map(|c| c.len()).unwrap_or(0));
}
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
self.commit_entry(parent_hashes, |b| b.put(key.to_vec(), value.to_vec()))
}
fn do_delete(&mut self, key: &[u8]) -> Result<u64, StoreActorError> {
let heads = self.store.get_heads(key)?;
// Idempotency check (pure function)
if !Store::needs_delete(&heads) {
let local_author = self.node.public_key_bytes();
return Ok(self.chain_manager.get(&local_author).map(|c| c.len()).unwrap_or(0));
}
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
self.commit_entry(parent_hashes, |b| b.delete(key.to_vec()))
}
fn commit_entry<F>(&mut self, parent_hashes: Vec<Vec<u8>>, build: F) -> Result<u64, StoreActorError>
where
F: FnOnce(EntryBuilder) -> EntryBuilder,
{
let local_author = self.node.public_key_bytes();
let sigchain = self.chain_manager.get_or_create(local_author);
let seq = sigchain.len() + 1;
let prev_hash = *sigchain.last_hash();
let builder = EntryBuilder::new(seq, HLC::now())
.store_id(self.store_id.as_bytes().to_vec())
.prev_hash(prev_hash.to_vec())
.parent_hashes(parent_hashes);
let entry = build(builder).sign(&self.node);
// Append to local sigchain
let sigchain = self.chain_manager.get_or_create(local_author);
sigchain.append(&entry)?;
self.store.apply_entry(&entry)?;
Ok(seq)
}
fn do_read_entries_after(
&self,
author: &[u8; 32],
from_hash: Option<[u8; 32]>,
) -> Result<Vec<SignedEntry>, StoreError> {
// Build log path for this author
let author_hex = hex::encode(author);
let log_path = self.chain_manager.logs_dir().join(format!("{}.log", author_hex));
if !log_path.exists() {
return Ok(Vec::new()); // No log file for this author
}
// Use lattice_core's read_entries_after
log::read_entries_after(&log_path, from_hash)
.map_err(StoreError::from)
}
}
/// Spawn a store actor in a new thread, returns (sender, join_handle)
/// Uses std::thread since redb is blocking
pub fn spawn_store_actor(
store_id: Uuid,
store: Store,
sigchain: SigChain,
node: NodeIdentity,
) -> (mpsc::Sender<StoreCmd>, JoinHandle<()>) {
let (tx, rx) = mpsc::channel(32);
let actor = StoreActor::new(store_id, store, sigchain, node, rx);
let handle = thread::spawn(move || actor.run());
(tx, handle)
}