feat: implement store actor pattern for CLI store operations and update roadmap
This commit is contained in:
@@ -57,6 +57,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Milestone 1.9: Async Refactor
|
||||||
|
|
||||||
|
**Goal:** Prepare codebase for concurrent CLI + network operation.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
**Phase 1: Store Actor (sync)** ✓
|
||||||
|
- [x] Store actor pattern: dedicated thread owns Store, receives commands via `std::sync::mpsc`
|
||||||
|
- [x] StoreHandle wraps channel sender, keeps current API
|
||||||
|
- [x] Validate: CLI works as before with actor
|
||||||
|
|
||||||
|
**Phase 2: Async Runtime**
|
||||||
|
- [ ] Add tokio runtime (`#[tokio::main]`)
|
||||||
|
- [ ] Migrate `std::sync::mpsc` → `tokio::sync::mpsc`
|
||||||
|
- [ ] Async CLI using `tokio::io::stdin()` or `rustyline` async
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
- [x] CLI still works as before
|
||||||
|
- [x] Store operations serialized (no data races)
|
||||||
|
- [ ] Ready for concurrent network tasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Milestone 2: Two-Node Sync
|
## Milestone 2: Two-Node Sync
|
||||||
|
|
||||||
**Goal:** Two nodes can sync their logs over the network.
|
**Goal:** Two nodes can sync their logs over the network.
|
||||||
@@ -68,6 +92,7 @@
|
|||||||
- [ ] Iroh integration (peer discovery, connection)
|
- [ ] Iroh integration (peer discovery, connection)
|
||||||
- [ ] Multi-author log merging
|
- [ ] Multi-author log merging
|
||||||
- [ ] CLI: `peers`, `connect`/`join` commands
|
- [ ] CLI: `peers`, `connect`/`join` commands
|
||||||
|
- [ ] Background sync task (tokio::spawn)
|
||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod node;
|
mod node;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod store_actor;
|
||||||
|
|
||||||
use commands::CommandResult;
|
use commands::CommandResult;
|
||||||
use node::{LatticeNodeBuilder, StoreHandle};
|
use node::{LatticeNodeBuilder, StoreHandle};
|
||||||
|
|||||||
+68
-48
@@ -1,8 +1,7 @@
|
|||||||
//! Local Lattice node API with multi-store support
|
//! Local Lattice node API with multi-store support
|
||||||
|
|
||||||
use lattice_core::{
|
use lattice_core::{
|
||||||
DataDir, EntryBuilder, MetaStore, Node, SigChain, Store, Uuid,
|
DataDir, MetaStore, Node, SigChain, Store, Uuid,
|
||||||
hlc::HLC,
|
|
||||||
log::LogError,
|
log::LogError,
|
||||||
meta_store::MetaStoreError,
|
meta_store::MetaStoreError,
|
||||||
sigchain::SigChainError,
|
sigchain::SigChainError,
|
||||||
@@ -10,7 +9,6 @@ use lattice_core::{
|
|||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::cell::RefCell;
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
@@ -35,6 +33,12 @@ pub enum NodeError {
|
|||||||
|
|
||||||
#[error("Already initialized")]
|
#[error("Already initialized")]
|
||||||
AlreadyInitialized,
|
AlreadyInitialized,
|
||||||
|
|
||||||
|
#[error("Channel closed")]
|
||||||
|
ChannelClosed,
|
||||||
|
|
||||||
|
#[error("Actor error: {0}")]
|
||||||
|
Actor(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NodeInfo {
|
pub struct NodeInfo {
|
||||||
@@ -167,85 +171,101 @@ impl LatticeNode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let info = StoreInfo { store_id, entries_replayed };
|
let info = StoreInfo { store_id, entries_replayed };
|
||||||
|
|
||||||
|
// Spawn actor thread - actor owns store, sigchain, and node copy
|
||||||
|
let (tx, actor_handle) = crate::store_actor::spawn_store_actor(
|
||||||
|
store_id,
|
||||||
|
store,
|
||||||
|
sigchain,
|
||||||
|
(*self.node).clone(),
|
||||||
|
);
|
||||||
|
|
||||||
let handle = StoreHandle {
|
let handle = StoreHandle {
|
||||||
store_id,
|
store_id,
|
||||||
node: Rc::clone(&self.node),
|
tx,
|
||||||
sigchain: RefCell::new(sigchain),
|
actor_handle,
|
||||||
store,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((handle, info))
|
Ok((handle, info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A handle to a specific store with KV operations
|
/// A handle to a specific store - wraps channel to actor thread
|
||||||
pub struct StoreHandle {
|
pub struct StoreHandle {
|
||||||
store_id: Uuid,
|
store_id: Uuid,
|
||||||
node: Rc<Node>,
|
tx: std::sync::mpsc::Sender<crate::store_actor::StoreCmd>,
|
||||||
sigchain: RefCell<SigChain>,
|
#[allow(dead_code)]
|
||||||
store: Store,
|
actor_handle: std::thread::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StoreHandle {
|
impl StoreHandle {
|
||||||
pub fn id(&self) -> Uuid { self.store_id }
|
pub fn id(&self) -> Uuid { self.store_id }
|
||||||
|
|
||||||
pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, NodeError> {
|
pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, NodeError> {
|
||||||
Ok(self.store.get(key)?)
|
use crate::store_actor::StoreCmd;
|
||||||
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
|
self.tx.send(StoreCmd::Get { key: key.to_vec(), resp: resp_tx })
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?;
|
||||||
|
resp_rx.recv()
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?
|
||||||
|
.map_err(NodeError::Store)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_heads(&self, key: &[u8]) -> Result<Vec<lattice_core::HeadInfo>, NodeError> {
|
pub fn get_heads(&self, key: &[u8]) -> Result<Vec<lattice_core::HeadInfo>, NodeError> {
|
||||||
Ok(self.store.get_heads(key)?)
|
use crate::store_actor::StoreCmd;
|
||||||
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
|
self.tx.send(StoreCmd::GetHeads { key: key.to_vec(), resp: resp_tx })
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?;
|
||||||
|
resp_rx.recv()
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?
|
||||||
|
.map_err(NodeError::Store)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list(&self) -> Result<Vec<(Vec<u8>, Vec<u8>)>, NodeError> {
|
pub fn list(&self) -> Result<Vec<(Vec<u8>, Vec<u8>)>, NodeError> {
|
||||||
Ok(self.store.list_all()?)
|
use crate::store_actor::StoreCmd;
|
||||||
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
|
self.tx.send(StoreCmd::List { resp: resp_tx })
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?;
|
||||||
|
resp_rx.recv()
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?
|
||||||
|
.map_err(NodeError::Store)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn log_seq(&self) -> u64 {
|
pub fn log_seq(&self) -> u64 {
|
||||||
self.sigchain.borrow().len()
|
use crate::store_actor::StoreCmd;
|
||||||
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
|
let _ = self.tx.send(StoreCmd::LogSeq { resp: resp_tx });
|
||||||
|
resp_rx.recv().unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn applied_seq(&self) -> Result<u64, NodeError> {
|
pub fn applied_seq(&self) -> Result<u64, NodeError> {
|
||||||
let author = self.node.public_key_bytes();
|
use crate::store_actor::StoreCmd;
|
||||||
Ok(self.store.author_state(&author)?
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
.map(|s| s.seq)
|
self.tx.send(StoreCmd::AppliedSeq { resp: resp_tx })
|
||||||
.unwrap_or(0))
|
.map_err(|_| NodeError::ChannelClosed)?;
|
||||||
|
resp_rx.recv()
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?
|
||||||
|
.map_err(NodeError::Store)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn put(&self, key: &[u8], value: &[u8]) -> Result<u64, NodeError> {
|
pub fn put(&self, key: &[u8], value: &[u8]) -> Result<u64, NodeError> {
|
||||||
// Get current heads for this key to cite as parents
|
use crate::store_actor::StoreCmd;
|
||||||
let heads = self.store.get_heads(key)?;
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
|
self.tx.send(StoreCmd::Put { key: key.to_vec(), value: value.to_vec(), resp: resp_tx })
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?;
|
||||||
self.commit_entry(parent_hashes, |b| b.put(key.to_vec(), value.to_vec()))
|
resp_rx.recv()
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?
|
||||||
|
.map_err(|e| NodeError::Actor(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, key: &[u8]) -> Result<u64, NodeError> {
|
pub fn delete(&self, key: &[u8]) -> Result<u64, NodeError> {
|
||||||
// Get current heads for this key to cite as parents
|
use crate::store_actor::StoreCmd;
|
||||||
let heads = self.store.get_heads(key)?;
|
let (resp_tx, resp_rx) = std::sync::mpsc::channel();
|
||||||
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
|
self.tx.send(StoreCmd::Delete { key: key.to_vec(), resp: resp_tx })
|
||||||
|
.map_err(|_| NodeError::ChannelClosed)?;
|
||||||
self.commit_entry(parent_hashes, |b| b.delete(key.to_vec()))
|
resp_rx.recv()
|
||||||
}
|
.map_err(|_| NodeError::ChannelClosed)?
|
||||||
|
.map_err(|e| NodeError::Actor(e.to_string()))
|
||||||
fn commit_entry<F>(&self, parent_hashes: Vec<Vec<u8>>, build: F) -> Result<u64, NodeError>
|
|
||||||
where
|
|
||||||
F: FnOnce(EntryBuilder) -> EntryBuilder,
|
|
||||||
{
|
|
||||||
let mut sigchain = self.sigchain.borrow_mut();
|
|
||||||
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);
|
|
||||||
|
|
||||||
sigchain.append(&entry)?;
|
|
||||||
self.store.apply_entry(&entry)?;
|
|
||||||
|
|
||||||
Ok(seq)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! Store Actor - dedicated thread that owns Store and processes commands via channel
|
||||||
|
|
||||||
|
use lattice_core::{
|
||||||
|
EntryBuilder, HeadInfo, Node, SigChain, Store, Uuid,
|
||||||
|
hlc::HLC,
|
||||||
|
proto::AuthorState,
|
||||||
|
sigchain::SigChainError,
|
||||||
|
store::StoreError,
|
||||||
|
};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
|
/// Commands sent to the store actor
|
||||||
|
pub enum StoreCmd {
|
||||||
|
Get {
|
||||||
|
key: Vec<u8>,
|
||||||
|
resp: std::sync::mpsc::Sender<Result<Option<Vec<u8>>, StoreError>>,
|
||||||
|
},
|
||||||
|
GetHeads {
|
||||||
|
key: Vec<u8>,
|
||||||
|
resp: std::sync::mpsc::Sender<Result<Vec<HeadInfo>, StoreError>>,
|
||||||
|
},
|
||||||
|
List {
|
||||||
|
resp: std::sync::mpsc::Sender<Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError>>,
|
||||||
|
},
|
||||||
|
Put {
|
||||||
|
key: Vec<u8>,
|
||||||
|
value: Vec<u8>,
|
||||||
|
resp: std::sync::mpsc::Sender<Result<u64, StoreActorError>>,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
key: Vec<u8>,
|
||||||
|
resp: std::sync::mpsc::Sender<Result<u64, StoreActorError>>,
|
||||||
|
},
|
||||||
|
LogSeq {
|
||||||
|
resp: std::sync::mpsc::Sender<u64>,
|
||||||
|
},
|
||||||
|
AppliedSeq {
|
||||||
|
resp: std::sync::mpsc::Sender<Result<u64, StoreError>>,
|
||||||
|
},
|
||||||
|
AuthorState {
|
||||||
|
author: [u8; 32],
|
||||||
|
resp: std::sync::mpsc::Sender<Result<Option<AuthorState>, StoreError>>,
|
||||||
|
},
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum StoreActorError {
|
||||||
|
Store(StoreError),
|
||||||
|
SigChain(SigChainError),
|
||||||
|
ChannelClosed,
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
StoreActorError::ChannelClosed => write!(f, "Channel closed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StoreActorError {}
|
||||||
|
|
||||||
|
/// The store actor - runs in its own thread, owns Store and SigChain
|
||||||
|
pub struct StoreActor {
|
||||||
|
store_id: Uuid,
|
||||||
|
store: Store,
|
||||||
|
sigchain: SigChain,
|
||||||
|
node: Node,
|
||||||
|
rx: 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: Node,
|
||||||
|
rx: Receiver<StoreCmd>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
store_id,
|
||||||
|
store,
|
||||||
|
sigchain,
|
||||||
|
node,
|
||||||
|
rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the actor loop - processes commands until Shutdown received
|
||||||
|
pub fn run(mut self) {
|
||||||
|
while let Ok(cmd) = self.rx.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 _ = resp.send(self.sigchain.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::Shutdown => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_put(&mut self, key: &[u8], value: &[u8]) -> Result<u64, StoreActorError> {
|
||||||
|
let heads = self.store.get_heads(key)?;
|
||||||
|
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)?;
|
||||||
|
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 seq = self.sigchain.len() + 1;
|
||||||
|
let prev_hash = self.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);
|
||||||
|
|
||||||
|
self.sigchain.append(&entry)?;
|
||||||
|
self.store.apply_entry(&entry)?;
|
||||||
|
|
||||||
|
Ok(seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a store actor in a new thread, returns (sender, join_handle)
|
||||||
|
pub fn spawn_store_actor(
|
||||||
|
store_id: Uuid,
|
||||||
|
store: Store,
|
||||||
|
sigchain: SigChain,
|
||||||
|
node: Node,
|
||||||
|
) -> (Sender<StoreCmd>, JoinHandle<()>) {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let actor = StoreActor::new(store_id, store, sigchain, node, rx);
|
||||||
|
let handle = thread::spawn(move || actor.run());
|
||||||
|
(tx, handle)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ pub enum NodeError {
|
|||||||
///
|
///
|
||||||
/// Each node has an Ed25519 keypair used for signing sigchain entries
|
/// Each node has an Ed25519 keypair used for signing sigchain entries
|
||||||
/// and establishing trust within the network.
|
/// and establishing trust within the network.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Node {
|
pub struct Node {
|
||||||
signing_key: SigningKey,
|
signing_key: SigningKey,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user