Compare commits
25
Commits
114e58fc7b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e39f34383 | ||
|
|
4ccdbc97f5 | ||
|
|
2de54d0033 | ||
|
|
9d4495b3d7 | ||
|
|
76810f8d8e | ||
|
|
57ecbffaed | ||
|
|
665114036b | ||
|
|
e942da49ff | ||
|
|
7c8e5cfa3d | ||
|
|
1943e06509 | ||
|
|
4761501ec9 | ||
|
|
c2d4219320 | ||
|
|
a1f134eb02 | ||
|
|
ce852da25e | ||
|
|
57c2906b10 | ||
|
|
346ebccee7 | ||
|
|
f45c6ccfcf | ||
|
|
15dd1b337f | ||
|
|
1f750bdae0 | ||
|
|
a699c0b1a6 | ||
|
|
b16f9a0b39 | ||
|
|
b64afa063f | ||
|
|
f153cba267 | ||
|
|
2f48483d0e | ||
|
|
e1405bb910 |
+17
@@ -0,0 +1,17 @@
|
||||
# Rust / Cargo
|
||||
/target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"lattice-core",
|
||||
"lattice-net",
|
||||
"lattice-cli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Workspace crates
|
||||
lattice-core = { path = "lattice-core" }
|
||||
lattice-net = { path = "lattice-net" }
|
||||
lattice-cli = { path = "lattice-cli" }
|
||||
|
||||
# CLI
|
||||
rustyline = "17"
|
||||
|
||||
# Networking (Iroh)
|
||||
iroh = { version = "0.95", features = ["discovery-local-network"] }
|
||||
iroh-gossip = "0.95"
|
||||
|
||||
# Cryptography
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
|
||||
# Serialization
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
prost-build = "0.13"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Utilities
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
bytes = "1"
|
||||
dirs = "5"
|
||||
blake3 = "1"
|
||||
hex = "0.4"
|
||||
redb = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = "0.4"
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = "warn"
|
||||
@@ -1,3 +0,0 @@
|
||||
target/
|
||||
blobs/
|
||||
identity.key
|
||||
Generated
-4443
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "lattice-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
blake3 = "1.5"
|
||||
bytes = "1.5"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||
futures = "0.3"
|
||||
hex = "0.4"
|
||||
iroh = { version = "0.95.1", features = ["discovery-local-network"] }
|
||||
iroh-gossip = "0.95.0"
|
||||
iroh-tickets = "0.2.0"
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.36", features = ["full"] }
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
||||
@@ -1,434 +0,0 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{mpsc, Notify};
|
||||
use ed25519_dalek::{Signer, Verifier, Signature};
|
||||
use iroh::{Endpoint, SecretKey, PublicKey};
|
||||
use iroh::discovery::mdns::MdnsDiscovery;
|
||||
use iroh_gossip::net::Gossip;
|
||||
use iroh_tickets::endpoint::EndpointTicket;
|
||||
use iroh_gossip::proto::TopicId;
|
||||
use iroh::protocol::Router;
|
||||
use futures::StreamExt;
|
||||
use std::io::Write;
|
||||
|
||||
// --- Data Structures ---
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Entry {
|
||||
pub path: String,
|
||||
pub content_hash: String,
|
||||
pub author: String,
|
||||
pub timestamp: u64,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum Message {
|
||||
Update(Entry),
|
||||
WantBlob(String),
|
||||
BlobData(String, Vec<u8>),
|
||||
}
|
||||
|
||||
// --- Logic ---
|
||||
|
||||
type Db = Arc<Mutex<HashMap<String, Entry>>>;
|
||||
|
||||
pub struct Node {
|
||||
pub keypair: ed25519_dalek::SigningKey,
|
||||
pub db: Db,
|
||||
pub blobs_dir: std::path::PathBuf,
|
||||
pub gossip_sender: Option<mpsc::Sender<Message>>,
|
||||
pub neighbors: Arc<Mutex<std::collections::HashSet<PublicKey>>>,
|
||||
pub pending_requests: Arc<Mutex<HashMap<String, Arc<Notify>>>>,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn new(blobs_dir: std::path::PathBuf) -> Self {
|
||||
let key_path = std::path::Path::new("identity.key");
|
||||
let keypair = if key_path.exists() {
|
||||
let bytes = std::fs::read(key_path).expect("Failed to read key");
|
||||
ed25519_dalek::SigningKey::from_bytes(bytes.as_slice().try_into().unwrap())
|
||||
} else {
|
||||
let pk = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
|
||||
std::fs::write(key_path, pk.to_bytes()).expect("Failed to write key");
|
||||
pk
|
||||
};
|
||||
|
||||
if !blobs_dir.exists() {
|
||||
std::fs::create_dir_all(&blobs_dir).expect("Failed to create blobs dir");
|
||||
}
|
||||
|
||||
Self {
|
||||
keypair,
|
||||
db: Arc::new(Mutex::new(HashMap::new())),
|
||||
blobs_dir,
|
||||
gossip_sender: None,
|
||||
neighbors: Arc::new(Mutex::new(std::collections::HashSet::new())),
|
||||
pending_requests: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_peers(&self) -> Vec<PublicKey> {
|
||||
let peers_file = self.blobs_dir.parent().unwrap().join("peers.txt");
|
||||
if !peers_file.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let content = std::fs::read_to_string(&peers_file).unwrap_or_default();
|
||||
content.lines()
|
||||
.filter_map(|line| std::str::FromStr::from_str(line).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn save_peer(&self, peer: PublicKey) {
|
||||
let peers_file = self.blobs_dir.parent().unwrap().join("peers.txt");
|
||||
// Avoid duplicates in file trivially by checking if already known in memory?
|
||||
// But restart clears memory.
|
||||
// Just append. Ideally we read all and check, but performance.
|
||||
// Let's just append.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(peers_file)
|
||||
.expect("Failed to open peers file");
|
||||
writeln!(file, "{}", peer).expect("Failed to write peer");
|
||||
}
|
||||
|
||||
pub fn pubkey_hex(&self) -> String {
|
||||
hex::encode(self.keypair.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
pub fn sign_entry(&self, path: &str, hash: &str, timestamp: u64) -> Vec<u8> {
|
||||
let mut msg = Vec::new();
|
||||
msg.extend_from_slice(path.as_bytes());
|
||||
msg.extend_from_slice(hash.as_bytes());
|
||||
msg.extend_from_slice(×tamp.to_le_bytes());
|
||||
self.keypair.sign(&msg).to_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn verify_entry(entry: &Entry) -> bool {
|
||||
let pubkey_bytes = match hex::decode(&entry.author) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let pubkey_arr: [u8; 32] = match pubkey_bytes.as_slice().try_into() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let pubkey = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_arr) {
|
||||
Ok(pk) => pk,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let mut msg = Vec::new();
|
||||
msg.extend_from_slice(entry.path.as_bytes());
|
||||
msg.extend_from_slice(entry.content_hash.as_bytes());
|
||||
msg.extend_from_slice(&entry.timestamp.to_le_bytes());
|
||||
|
||||
let sig_arr: [u8; 64] = match entry.signature.as_slice().try_into() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let sig = Signature::from_bytes(&sig_arr);
|
||||
|
||||
pubkey.verify(&msg, &sig).is_ok()
|
||||
}
|
||||
|
||||
pub fn put_local(&self, path: String, content: String) -> Entry {
|
||||
let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
|
||||
let blob_path = self.blobs_dir.join(&hash);
|
||||
std::fs::write(blob_path, content).expect("Failed to write blob");
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_micros() as u64;
|
||||
|
||||
let signature = self.sign_entry(&path, &hash, timestamp);
|
||||
|
||||
let entry = Entry {
|
||||
path: path.clone(),
|
||||
content_hash: hash.clone(),
|
||||
author: self.pubkey_hex(),
|
||||
timestamp,
|
||||
signature,
|
||||
};
|
||||
|
||||
println!("[LOCAL] Writing {} -> {}", path, hash);
|
||||
let mut db = self.db.lock().unwrap();
|
||||
db.insert(path, entry.clone());
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
pub fn process_update(&self, entry: Entry) -> bool {
|
||||
if !Self::verify_entry(&entry) {
|
||||
println!("[WARN] Invalid Signature for {}", entry.path);
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut db = self.db.lock().unwrap();
|
||||
if let Some(existing) = db.get(&entry.path) {
|
||||
if entry.timestamp <= existing.timestamp {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
println!("[SYNC] Received Update: {} -> {}", entry.path, entry.content_hash);
|
||||
db.insert(entry.path.clone(), entry);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let blobs_dir = std::path::PathBuf::from("blobs");
|
||||
let node = Arc::new(Node::new(blobs_dir));
|
||||
println!("Lattice Node Started. Identity: {}", node.pubkey_hex());
|
||||
|
||||
// Convert Node key to Iroh SecretKey
|
||||
let secret_key = SecretKey::from_bytes(&node.keypair.to_bytes());
|
||||
let node_id = secret_key.public();
|
||||
|
||||
// Configure MDNS
|
||||
// Note: If build() returns Result, use ?
|
||||
let mdns = MdnsDiscovery::builder()
|
||||
.build(node_id)?;
|
||||
|
||||
// Bind endpoint
|
||||
let endpoint = Endpoint::builder()
|
||||
.secret_key(secret_key)
|
||||
.discovery(mdns)
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
println!("Iroh Node ID: {}", endpoint.secret_key().public());
|
||||
|
||||
// Spawn Gossip
|
||||
let gossip = Gossip::builder().spawn(endpoint.clone());
|
||||
|
||||
// Spawn Router (The Accept Loop handling Gossip ALPN)
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_gossip::ALPN, gossip.clone())
|
||||
.spawn(); // CORRECTED: No await.
|
||||
|
||||
|
||||
// Create and print Ticket
|
||||
let my_addr = endpoint.addr(); // CORRECTED: No ?
|
||||
|
||||
let ticket = EndpointTicket::new(my_addr);
|
||||
println!("My Ticket: {}", ticket);
|
||||
|
||||
let topic_bytes = blake3::hash(b"lattice-test-net").as_bytes().to_owned();
|
||||
let topic_id = TopicId::from_bytes(topic_bytes);
|
||||
|
||||
// Join
|
||||
println!("Joining Gossip Topic: lattice-test-net...");
|
||||
|
||||
// Load persisted peers
|
||||
let initial_peers = node.load_peers();
|
||||
if !initial_peers.is_empty() {
|
||||
println!("Loaded {} persisted peers.", initial_peers.len());
|
||||
// Populate neighbors set specifically for display if desired,
|
||||
// though NeighborUp will fire when connection is actually established.
|
||||
}
|
||||
|
||||
let (sink, mut stream) = gossip.subscribe(topic_id, initial_peers).await?.split();
|
||||
println!("Joined Gossip Topic: lattice-test-net");
|
||||
|
||||
// Command Loop
|
||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<String>(100);
|
||||
|
||||
// Stdin Task
|
||||
tokio::spawn(async move {
|
||||
let stdin = std::io::stdin();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stdin.read_line(&mut line).is_ok() {
|
||||
if tx_cmd.send(line).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
print!("> ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
// Event Loop
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(line) = rx_cmd.recv() => {
|
||||
let node = node.clone();
|
||||
let sink = sink.clone();
|
||||
let endpoint = endpoint.clone();
|
||||
let gossip = gossip.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let parts: Vec<&str> = line.trim().split_whitespace().collect();
|
||||
match parts.as_slice() {
|
||||
["put", path, content] => {
|
||||
let entry = node.put_local(path.to_string(), content.to_string());
|
||||
let msg = Message::Update(entry);
|
||||
if let Ok(msg_bytes) = serde_json::to_vec(&msg) {
|
||||
sink.broadcast(msg_bytes.into()).await.ok();
|
||||
println!("[GOSSIP] Broadcasted update.");
|
||||
}
|
||||
},
|
||||
["get", path] => {
|
||||
let entry_opt = {
|
||||
let db = node.db.lock().unwrap();
|
||||
db.get(*path).cloned()
|
||||
};
|
||||
|
||||
if let Some(entry) = entry_opt {
|
||||
let blob_path = node.blobs_dir.join(&entry.content_hash);
|
||||
if blob_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(blob_path) {
|
||||
println!("Content: {}", content);
|
||||
}
|
||||
} else {
|
||||
println!("[MISSING BLOB] Need hash: {}", entry.content_hash);
|
||||
|
||||
let notify = Arc::new(Notify::new());
|
||||
node.pending_requests.lock().unwrap().insert(entry.content_hash.clone(), notify.clone());
|
||||
|
||||
let msg = Message::WantBlob(entry.content_hash.clone());
|
||||
if let Ok(msg_bytes) = serde_json::to_vec(&msg) {
|
||||
sink.broadcast(msg_bytes.into()).await.ok();
|
||||
println!("Requesting blob... waiting...");
|
||||
|
||||
// Wait for notification with timeout
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), notify.notified()).await {
|
||||
Ok(_) => {
|
||||
// check again
|
||||
let blob_path = node.blobs_dir.join(&entry.content_hash);
|
||||
if let Ok(content) = std::fs::read_to_string(blob_path) {
|
||||
println!("Content: {}", content);
|
||||
} else {
|
||||
println!("Error reading received blob.");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Timeout waiting for blob.");
|
||||
// Clean up
|
||||
node.pending_requests.lock().unwrap().remove(&entry.content_hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Path not found.");
|
||||
}
|
||||
},
|
||||
["list"] => {
|
||||
let db = node.db.lock().unwrap();
|
||||
for (k, v) in db.iter() {
|
||||
println!("{} -> {} (ts: {})", k, v.content_hash, v.timestamp);
|
||||
}
|
||||
},
|
||||
["connect", arg] => {
|
||||
use std::str::FromStr;
|
||||
let topic_bytes = blake3::hash(b"lattice-test-net").as_bytes().to_owned();
|
||||
let topic_id = TopicId::from_bytes(topic_bytes);
|
||||
|
||||
if let Ok(ticket) = EndpointTicket::from_str(arg) {
|
||||
println!("Connecting via Ticket...");
|
||||
let addr = iroh::EndpointAddr::from(ticket);
|
||||
let peer_id = addr.id;
|
||||
|
||||
// Connect first (ensure transport)
|
||||
match endpoint.connect(addr, iroh_gossip::ALPN).await {
|
||||
Ok(_) => {
|
||||
println!("Connected! Adding to Gossip...");
|
||||
match gossip.subscribe(topic_id, vec![peer_id]).await {
|
||||
Ok(_) => println!("Subscribed peer to gossip topic!"),
|
||||
Err(e) => println!("Gossip subscribe error: {}", e),
|
||||
}
|
||||
},
|
||||
Err(e) => println!("Connection failed: {}", e),
|
||||
}
|
||||
} else if let Ok(peer_id) = PublicKey::from_str(arg) {
|
||||
println!("Connecting via Node ID (Discovery) to: {}", peer_id);
|
||||
match endpoint.connect(peer_id, iroh_gossip::ALPN).await {
|
||||
Ok(_) => {
|
||||
println!("Connected! Adding to Gossip...");
|
||||
match gossip.subscribe(topic_id, vec![peer_id]).await {
|
||||
Ok(_) => println!("Subscribed peer to gossip topic!"),
|
||||
Err(e) => println!("Gossip subscribe error: {}", e),
|
||||
}
|
||||
},
|
||||
Err(e) => println!("Connection failed: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Invalid ID or Ticket");
|
||||
}
|
||||
},
|
||||
["peers"] => {
|
||||
let neighbors = node.neighbors.lock().unwrap();
|
||||
println!("Connected Gossip Peers: {}", neighbors.len());
|
||||
for peer in neighbors.iter() {
|
||||
println!("- {}", peer);
|
||||
}
|
||||
},
|
||||
["quit"] => std::process::exit(0), // Can't break loop from spawn
|
||||
_ => println!("Unknown command. Usage: put <path> <content> | get <path> | list | connect <ticket_or_node_id> | peers"),
|
||||
}
|
||||
print!("> ");
|
||||
std::io::stdout().flush().ok();
|
||||
});
|
||||
},
|
||||
Some(res) = stream.next() => {
|
||||
let event = res?;
|
||||
match event {
|
||||
iroh_gossip::api::Event::Received(msg) => {
|
||||
if let Ok(message) = serde_json::from_slice::<Message>(&msg.content) {
|
||||
match message {
|
||||
Message::Update(entry) => { node.process_update(entry); },
|
||||
Message::WantBlob(hash) => {
|
||||
let blob_path = node.blobs_dir.join(&hash);
|
||||
if blob_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&blob_path) {
|
||||
let reply = Message::BlobData(hash, content.into_bytes());
|
||||
if let Ok(reply_bytes) = serde_json::to_vec(&reply) {
|
||||
sink.broadcast(reply_bytes.into()).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Message::BlobData(hash, data) => {
|
||||
let blob_path = node.blobs_dir.join(&hash);
|
||||
if !blob_path.exists() {
|
||||
std::fs::write(blob_path, data).expect("Failed to write blob");
|
||||
println!("[SYNC] Received Blob: {}", hash);
|
||||
|
||||
// Notify waiters
|
||||
if let Some(notify) = node.pending_requests.lock().unwrap().remove(&hash) {
|
||||
notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
iroh_gossip::api::Event::NeighborUp(node_id) => {
|
||||
println!("[GOSSIP] Neighbor Up: {}", node_id);
|
||||
if node.neighbors.lock().unwrap().insert(node_id) {
|
||||
node.save_peer(node_id);
|
||||
}
|
||||
},
|
||||
iroh_gossip::api::Event::NeighborDown(node_id) => {
|
||||
println!("[GOSSIP] Neighbor Down: {}", node_id);
|
||||
node.neighbors.lock().unwrap().remove(&node_id);
|
||||
},
|
||||
_ => {} // Handle Lagged or other future variants
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
# Architecture
|
||||
|
||||
## Ideas
|
||||
|
||||
**Core:**
|
||||
- SigChains: Ed25519-signed, hash-chained append-only logs per node.
|
||||
- Offline-First: Iroh for networking. Vector clocks identify missing entries on reconnect.
|
||||
- Full Replication: All nodes keep all logs until watermark consensus, then prune.
|
||||
|
||||
**State:**
|
||||
- Log-Based State: KV store derived from entries. Watermarks enable pruning + snapshots.
|
||||
- Merkle-ized State: state.db as Merkle tree. O(1) sync checks, efficient diffing, light clients.
|
||||
- DAG Conflict Resolution: Entries track ancestry. Forks merge on next write. Tips only in state.db.
|
||||
- KV Snapshots: Point-in-time snapshots for log pruning, fast bootstrap, time travel.
|
||||
|
||||
**Operations:**
|
||||
- Atomic Batch Writes: Multiple key updates as single entry.
|
||||
- Conditional Updates (CAS): Update only if current value matches expected hash.
|
||||
|
||||
**CRDTs:**
|
||||
- LWW-Register: Last-writer-wins for single values.
|
||||
- LWW-Element-Set: Set with add/remove, element present if add > remove timestamp.
|
||||
|
||||
## Concepts
|
||||
|
||||
- Transitive Pairing: Nodes can introduce new nodes to the mesh.
|
||||
- Multi-Mesh: A node can participate in multiple meshes (clusters). Each mesh is a group of nodes sharing data.
|
||||
- Manifest Store: Joining a mesh means joining a special KV store of type "manifest" that defines the mesh membership. The manifest contains node info (`/nodes/{pubkey}/...`).
|
||||
|
||||
## Stack
|
||||
|
||||
- rust
|
||||
- iroh
|
||||
- prost protocol buffers
|
||||
- redb (embedded KV store)
|
||||
- rustyline (interactive CLI)
|
||||
|
||||
### Bootstrap
|
||||
|
||||
- New peers request a full state snapshot from their first connection.
|
||||
- The snapshot allows them to skip replaying the entire log history.
|
||||
- After bootstrap, the node receives incremental updates via gossip.
|
||||
|
||||
### Networking
|
||||
|
||||
- Designed for mobile clients that may only sync a few times per day.
|
||||
- When peers connect, they exchange vector clocks to identify missing entries.
|
||||
- Missing entries are fetched via unicast.
|
||||
- MAX_DRIFT should be generous (e.g., hours) to accommodate sleeping devices.
|
||||
|
||||
Networking modes:
|
||||
- Active (servers/laptops on power): Frequent gossip broadcasts, proactive sync.
|
||||
- Low-power (mobile/battery): Pull-based sync on wake. Query peers instead of relying on push gossip.
|
||||
|
||||
## Parts
|
||||
|
||||
### Nodes
|
||||
|
||||
- Identified by their Ed25519 public key.
|
||||
- Private key stored locally in `identity.key` (not replicated).
|
||||
- Node data stored in KV:
|
||||
- `/nodes/{pubkey}/name` = display name
|
||||
- `/nodes/{pubkey}/added_at` = timestamp when added
|
||||
- `/nodes/{pubkey}/status` = `invited` | `active` | `dormant` (removal deletes keys)
|
||||
- `/nodes/{pubkey}/role` = `server` | `device` (optional, hints sync priority)
|
||||
- Peer invitation flow:
|
||||
1. Inviter runs `invite <peer_pubkey>` → writes `/nodes/{peer}/info` + `/status`
|
||||
2. Inviter shares their Iroh NodeId out-of-band (QR code, link, text)
|
||||
3. Invited peer runs `join <inviter_nodeid>` → syncs with inviter
|
||||
4. Sync pulls `/nodes/{self}/info` + `/status` → peer is authorized
|
||||
5. `connect` implicitly adds inviter to peer's `/nodes/*` (mutual awareness)
|
||||
- Accepting = syncing. The invited peer discovers authorization by receiving the entries.
|
||||
- Liveness: Each node tracks `last_seen` locally (from watermark gossip). UI alerts if a peer hasn't been seen for threshold (e.g., 30 days). User decides to mark dormant/disabled.
|
||||
- Status effects:
|
||||
- `active`: Normal sync participant, blocks watermark until acknowledged.
|
||||
- `dormant`: Excluded from watermark consensus, can be reactivated.
|
||||
- `disabled`: Permanently removed from mesh.
|
||||
- Sync priority: Low-power clients prefer peers marked as `server` or recently active.
|
||||
|
||||
Future:
|
||||
- Key rotation: Allow nodes to rotate their keypair. Old key signs a "rotation" entry pointing to new key.
|
||||
- Secure storage: Support platform keystores (macOS Keychain, Linux Secret Service, TPM) for private key protection.
|
||||
|
||||
### Data Model
|
||||
|
||||
- Multiple KV stores supported, identified by `store_id` (UUID).
|
||||
- Keys: Arbitrary byte arrays (`Vec<u8>`), sorted lexicographically.
|
||||
- Values: Arbitrary byte arrays (`Vec<u8>`).
|
||||
- Each store defines its own key/value format — applications know their schema.
|
||||
- Logs are per `(store_id, author_id)` tuple.
|
||||
- State is maintained by tracking the "frontier" (tips) of the causal graph for each key.
|
||||
- Entry ordering: by HLC timestamp, then by author ID as tiebreaker.
|
||||
|
||||
**Sync vs Causality:**
|
||||
- Vector Clocks track log coverage ("I have entries from Node A up to seq 50") — syncing files.
|
||||
- DAG Parents track data causality ("This value replaces that value") — resolving key conflicts.
|
||||
|
||||
#### DAG Conflict Resolution
|
||||
|
||||
Instead of simple LWW where newest timestamp blindly overwrites, every entry tracks its ancestry:
|
||||
|
||||
**Data Model:**
|
||||
- Each entry includes `parent_hashes` — references to the entries it supersedes
|
||||
- History forms a DAG (directed acyclic graph), not a linear chain
|
||||
- state.db stores only "tips" (heads) of the graph per key
|
||||
|
||||
**Life Cycle:**
|
||||
|
||||
1. **Write (normal):** New entry points to previous entry's hash as parent. History is a straight line.
|
||||
|
||||
2. **Write (concurrent/offline):** Two nodes edit same key independently, both pointing to same old parent. History forks into two branches.
|
||||
|
||||
3. **Read (forked):** System sees multiple valid values. Uses deterministic rule (highest HLC, then author_id tiebreaker) to return one "winner". No error thrown.
|
||||
|
||||
4. **Merge (healing):** Next write to that key cites both existing branches as parents. Fork merges back to single tip.
|
||||
|
||||
**Example: Partial Write (Branch Extension)**
|
||||
|
||||
```
|
||||
Initial: Heads = {A, B} where A(ts:100), B(ts:105). Read winner = B.
|
||||
|
||||
Offline node C wakes up, only knows A (hasn't seen B).
|
||||
C writes "v3" with parent = [A].
|
||||
|
||||
Result: Heads = {C, B}. Conflict shifted, not resolved.
|
||||
C(ts:110) > B(ts:105), so C wins reads.
|
||||
|
||||
┌──> [A] ──> [C:110]
|
||||
[Root]─┤
|
||||
└──> [B:105]
|
||||
|
||||
Later: A synced node writes D with parents = [C, B].
|
||||
Result: Heads = {D}. Fork merged.
|
||||
```
|
||||
|
||||
This preserves B's work even though C never saw it. Naive LWW would lose B forever.
|
||||
|
||||
#### Store Consistency Modes
|
||||
|
||||
- **Eventually consistent**: Default. Writes accepted locally, sync happens async. Fast, offline-capable.
|
||||
- **Strictly consistent**: Writes require quorum acknowledgment before commit. Slower, requires connectivity.
|
||||
|
||||
### Timestamps (Hybrid Logical Clocks)
|
||||
|
||||
Timestamps use HLC `<wall_time, counter>` with Causal Clamping:
|
||||
|
||||
- Each entry includes an HLC and a reference to its parent (prev_hash).
|
||||
- Standard HLC: `new_hlc = max(local_wall_clock, max_seen_hlc + 1)`.
|
||||
- On receive: if `entry.hlc > local_wall_clock + MAX_DRIFT`, clamp to `parent.hlc + 1`.
|
||||
- All nodes compute the same clamped time from the parent (deterministic).
|
||||
- Genesis entries (no parent) with future timestamps are dropped.
|
||||
|
||||
Pre-flight check (before signing):
|
||||
- Compare local_clock to max_peer_hlc (from recent gossip/entries).
|
||||
- If `local_clock > max_peer_hlc + MAX_DRIFT`, use `max_peer_hlc + 1` instead.
|
||||
- This catches future-clock nodes before they poison the log.
|
||||
|
||||
Authors apply their own entries through the standard receive path to ensure consistent clamping.
|
||||
|
||||
### Storage
|
||||
|
||||
Each node stores logs as one file per author:
|
||||
|
||||
```
|
||||
~/.local/share/lattice/
|
||||
├── identity.key # Ed25519 private key
|
||||
├── stores/
|
||||
│ └── {store_uuid}/
|
||||
│ ├── logs/
|
||||
│ │ └── {author_id_hex}.log # Append-only SignedEntry stream
|
||||
│ └── state.db # redb: KV snapshot + frontiers
|
||||
└── meta.db # redb: global metadata (known stores, peers)
|
||||
```
|
||||
|
||||
- Logs: Append-only binary files per `(store, author)`, containing serialized `SignedEntry` messages.
|
||||
- State DB (redb): Per-store KV state and frontiers. Updated as entries are applied.
|
||||
|
||||
#### state.db Tables (per store, redb)
|
||||
|
||||
```
|
||||
Table Key Value Purpose
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
kv Vec<u8> (key) Vec<HeadInfo> Current tips for each key
|
||||
AUTHOR_TABLE [u8; 32] (author_id) (u64 seq, [u8; 32] hash) Per-author frontier tracking
|
||||
meta Vec<u8> Vec<u8> Store metadata (incl. merkle_root)
|
||||
```
|
||||
|
||||
`HeadInfo: { value: Vec<u8>, hlc: u64, author: [u8;32], hash: [u8;32] }`
|
||||
|
||||
Note: KV stores multiple heads per key to support DAG conflict resolution. Reads pick winner deterministically.
|
||||
|
||||
#### meta.db Tables (global, redb)
|
||||
|
||||
```
|
||||
Table Key Value Purpose
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
stores [u8; 16] (UUID) u64 (created_at_ms) Known stores
|
||||
meta "root_store" [u8; 16] (UUID) Root store ID (opened on startup)
|
||||
```
|
||||
|
||||
- **Root Store**: The primary/manifest store for this node, auto-opened on CLI startup
|
||||
- **Stores Table**: Tracks all stores this node participates in
|
||||
- Manifest stores define mesh membership via `/nodes/{pubkey}/...` entries
|
||||
- Data stores hold application data
|
||||
|
||||
#### In-Memory Structures
|
||||
|
||||
- log_frontiers: `HashMap<AuthorId, (seq, hash)>` — rebuilt from log files on startup
|
||||
|
||||
### Operation Flow (put/delete)
|
||||
|
||||
```
|
||||
1. User calls put("/key", value)
|
||||
│
|
||||
▼
|
||||
2. SigChain.create_entry()
|
||||
- Build Entry with parent_hashes (current tips for key)
|
||||
- Sign it → SignedEntry
|
||||
│
|
||||
▼
|
||||
3. Append to log + Gossip (critical path)
|
||||
- Write to author's log file
|
||||
- Update log_frontiers (in-memory)
|
||||
- Broadcast to peers
|
||||
│
|
||||
▼
|
||||
4. Apply to state.db (background)
|
||||
- Update kv heads (merge parent tips into new tip)
|
||||
- Update applied_frontiers
|
||||
- Update merkle_root hash
|
||||
```
|
||||
|
||||
Fast path (1-3): durable + distributed. Background (4): queryable state.
|
||||
|
||||
### Read Flow (get)
|
||||
|
||||
`get(key)` reads directly from local state.db. Reads are eventually consistent — if state.db lags behind the log, the read may return slightly stale data.
|
||||
|
||||
### Watermarks
|
||||
|
||||
- Nodes gossip their watermarks periodically (throttled).
|
||||
- A watermark is a vector clock: how much of each author's log the node has seen.
|
||||
- All nodes keep all logs (own + others) for redundancy until watermark consensus.
|
||||
- Once all peers have acknowledged entries, they can be pruned and replaced by the snapshot.
|
||||
- If a node is offline too long, it re-bootstraps with a fresh snapshot when it reconnects.
|
||||
- Note: Consider preserving logs longer than required for redundancy — enables time travel (view state at any point in history).
|
||||
|
||||
**Pruning and DAG Parents:**
|
||||
- If a new entry references a parent that was pruned, accept it only if strictly newer than snapshot timestamp.
|
||||
- Snapshots act as the base; entries referencing parents older than snapshot are roots relative to that snapshot.
|
||||
|
||||
### Rich CRDTs (Future)
|
||||
|
||||
Instead of a generic scripting language, use specific data types that merge better than LWW.
|
||||
|
||||
Extend value types in redb:
|
||||
|
||||
```rust
|
||||
enum ReplicatedValue {
|
||||
LWW(Vec<u8>), // Standard Last-Write-Wins (current model)
|
||||
Counter(i64), // PN-Counter (Increment/Decrement)
|
||||
Set(HashSet<Vec<u8>>), // OR-Set (Observed-Remove Set)
|
||||
}
|
||||
```
|
||||
|
||||
**Counter** (for "storage used" etc.):
|
||||
- State is `{node_id: value}` map. Merge = sum all nodes. No conflicts possible.
|
||||
|
||||
**OR-Set** (for group membership etc.):
|
||||
- Merge = union. Element present if add timestamp > remove timestamp.
|
||||
|
||||
**Op Code Compromise**: Use commutative operations instead of a VM:
|
||||
|
||||
```protobuf
|
||||
message Entry {
|
||||
oneof operation {
|
||||
PutOp put = 1;
|
||||
DeleteOp delete = 2;
|
||||
MergeOp merge = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message MergeOp {
|
||||
string key = 1;
|
||||
oneof payload {
|
||||
int64 counter_delta = 2;
|
||||
bytes set_add_member = 3;
|
||||
bytes set_remove_member = 4;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommendation: Use Put/Delete for 90% of data. Add CRDT primitives only when needed (concurrent counters, lists) rather than a scripting language.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Permissions
|
||||
|
||||
Write permissions are enforceable cryptographically:
|
||||
- Every entry is signed by author
|
||||
- Nodes verify signature before accepting
|
||||
- Manifest defines allowed writers: `/nodes/{pubkey}/role` = `writer` | `reader`
|
||||
- Entries from non-writers are rejected
|
||||
|
||||
Read permissions are not enforceable:
|
||||
- Sharing a store = granting read access
|
||||
- Encryption adds a layer but doesn't solve revocation (once you have the key, you can read past data)
|
||||
- True revocation is impossible — you can't "unread" data
|
||||
|
||||
Practical model:
|
||||
- Share store = grant read
|
||||
- Write access defined in manifest
|
||||
- Read-only nodes replicate and verify but can't contribute entries
|
||||
|
||||
Future:
|
||||
- Capability-based permissions: Explore finer-grained write access (e.g., per-key or per-prefix permissions) via capabilities. Exact mechanism TBD.
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# Lattice Roadmap
|
||||
|
||||
## Milestone 1: Single-Node Append-Only Log
|
||||
|
||||
**Goal:** A single node can create, sign, and persist entries to its own log. No networking yet.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- [x] HLC timestamps
|
||||
- [x] Node identity (Ed25519 keypair, save/load)
|
||||
- [x] Entry signing & verification
|
||||
- [x] Log file I/O (append, read, hash verification)
|
||||
- [x] SigChain (validate entries before appending)
|
||||
- [x] Store (redb) — `kv` + `meta` tables, log replay
|
||||
- [x] Interactive CLI: `init`, `put`, `get`, `delete`, `status`, `quit`
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Can create a new identity
|
||||
- Can append entries to local log
|
||||
- Can replay log to reconstruct KV state
|
||||
- All operations survive restart
|
||||
|
||||
### Multi-KV Refactoring (before M2) ✓
|
||||
|
||||
- [x] DataDir → `stores/{uuid}/` subdirectories
|
||||
- [x] Store → per-store state.db
|
||||
- [x] Log paths → `stores/{uuid}/logs/{author}.log`
|
||||
- [x] Proto: Entry has store_id (UUID)
|
||||
- [x] CLI → `init`, `create-store`, `list-stores`, `use`
|
||||
- [x] meta.db stores table (MetaStore)
|
||||
- [x] SigChain → validate entry.store_id
|
||||
|
||||
---
|
||||
|
||||
## Milestone 1.5: DAG Conflict Resolution
|
||||
|
||||
**Goal:** Upgrade store from simple LWW to DAG-based conflict resolution per architecture.md.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- [x] Proto: Add `repeated bytes parent_hashes` to Entry (for DAG causality)
|
||||
- [x] Proto: Add `HeadInfo` message for multi-head storage
|
||||
- [x] Store: KV table schema → `Vec<u8> → Vec<HeadInfo>`
|
||||
- [x] Store: `apply_entry` → track multiple heads, merge parent tips
|
||||
- [x] Store: `get` → deterministic winner (highest HLC, author tiebreaker)
|
||||
- [x] Store: `get_heads` → inspect all heads for a key
|
||||
- [x] EntryBuilder: `.parent_hashes(...)` method for DAG ancestry
|
||||
- [x] CLI: Show conflict indicator when multiple heads
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- [x] Concurrent writes to same key create multiple heads
|
||||
- [x] Reads return deterministic winner
|
||||
- [x] Next write citing both heads merges fork to single tip
|
||||
- [x] All existing tests still pass (71 tests)
|
||||
|
||||
---
|
||||
|
||||
## 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** ✓
|
||||
- [x] Add tokio runtime (`#[tokio::main]`)
|
||||
- [x] Migrate `std::sync::mpsc` → `tokio::sync::mpsc`
|
||||
- [x] Async CLI using `block_in_place` for sync handlers
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- [x] CLI still works as before
|
||||
- [x] Store operations serialized (no data races)
|
||||
- [x] Ready for concurrent network tasks
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2: Two-Node Sync
|
||||
|
||||
**Goal:** Two nodes can sync their logs over the network.
|
||||
|
||||
### Deliverables
|
||||
|
||||
**Phase 1: Sync Logic (no network)** ✓
|
||||
- [x] SyncState with AuthorInfo (seq + hash) for hash-based log resumption
|
||||
- [x] `Store::sync_state()` → author-to-seq+hash map from AUTHOR_TABLE
|
||||
- [x] `SyncState::diff()` → `Vec<MissingRange>` with from_hash for `read_entries_after`
|
||||
- [x] Multi-store sync test: compute diff, fetch entries, apply, verify same state
|
||||
|
||||
**Phase 2: Iroh Integration**
|
||||
|
||||
*Completed:*
|
||||
- [x] Node info in root store on init: `/nodes/{pubkey}/info` + `/status`
|
||||
- [x] CLI: `invite <pubkey>` to authorize peers
|
||||
- [x] CLI: `peers` to list known nodes (with name/added_at info, sorted)
|
||||
- [x] CLI: `remove <pubkey>` to remove a peer
|
||||
- [x] Iroh endpoint on startup (same Ed25519 key, mDNS + DNS discovery)
|
||||
- [x] CLI: `join <nodeid>` - connects to peer, verifies invited
|
||||
- [x] Peer verification via `/nodes/{pubkey}/status` check
|
||||
|
||||
*Join Protocol (new→existing):* ✓
|
||||
- [x] Proto: `JoinRequest` / `JoinResponse` with store UUID
|
||||
- [x] Accept handler sends root store UUID in response
|
||||
- [x] Join command creates empty store with received UUID (no writes until sync)
|
||||
|
||||
*Sync Protocol (bidirectional):* ✓
|
||||
- [x] Proto: `PeerMessage` wrapper with `oneof` for message type discrimination
|
||||
- [x] `framing.rs` with `MessageSink`/`MessageStream` using `LengthDelimitedCodec`
|
||||
- [x] Proto: `SyncRequest`/`SyncResponse` using `SyncState`
|
||||
- [x] `Store::read_entries_after(hash)` to fetch log chunks
|
||||
- [x] Accept handler: receive SyncState, compute diff, send missing entries
|
||||
- [x] Sync command: receive entries, apply to store via `apply_entry`
|
||||
- [x] CLI: `sync [nodeid]` command (syncs with all active peers if no nodeid)
|
||||
- [x] After sync: node updates own `/nodes/{pubkey}/info` with hostname
|
||||
|
||||
*Cleanup*:
|
||||
- [x] Move core logic from cmd_join and cmd_sync out of commands.rs (now in `sync.rs`)
|
||||
- [x] Add 'invited' state: invite sets 'invited', peer sets 'active' after sync
|
||||
|
||||
*Regressions:*
|
||||
- [x] Entry ordering: Per-author streaming is correct (hash chain per author, HLC for cross-author).
|
||||
- [x] Multi-head sync fixed: SyncState now tracks HashSet of head hashes per author.
|
||||
- [x] Sync entry ordering: Entries sent in HLC order (merge-sort across authors) to ensure causal order.
|
||||
- [x] `join_mesh` doesn't populate `node.root_store`: Fixed with `complete_join` method.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Node A writes, Node B syncs, both have same state
|
||||
- Works offline-first (sync when connected)
|
||||
|
||||
**Post-M2 Refactoring:**
|
||||
- [x] Unify `node.rs` from `lattice-cli` and `lattice-core`
|
||||
- [x] Move network code to `lattice-net`
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3: Multi-Node Mesh
|
||||
|
||||
**Goal:** N nodes form a gossip mesh for real-time sync.
|
||||
|
||||
### Deliverables
|
||||
|
||||
**Phase 1: LatticeServer Refactor** ✓
|
||||
- [x] `LatticeServer` struct in `lattice-net` wrapping `Arc<Node>` + `Endpoint`
|
||||
- [x] Move `join_mesh`, `sync_with_peer`, `sync_all` to `LatticeServer` methods
|
||||
- [x] Encapsulate accept loop inside `LatticeServer` (via Router + ProtocolHandler)
|
||||
- [x] CLI uses `LatticeServer` instead of raw `Node` + `Endpoint`
|
||||
- [ ] Integration test: invite → join → sync end-to-end
|
||||
- [ ] Periodic background sync with known peers
|
||||
- [ ] Track last sync time per peer
|
||||
|
||||
**Phase 2: Gossip Protocol** ✓ (iroh-gossip)
|
||||
- [x] Router handles both `lattice-sync/1` and `/iroh-gossip/1` ALPNs
|
||||
- [x] `NodeEvent::RootStoreActivated` emitted when root store opens
|
||||
- [x] Auto-join gossip topic on root store activation
|
||||
- [x] Broadcast local entries to gossip topic on commit
|
||||
- [x] Receive gossip entries and apply to store
|
||||
- [x] Topic ID via `blake3::hash("lattice/{store_id}")`
|
||||
- [ ] Gossip bootstrap peers from `/peers/` (needs Prefix Watch)
|
||||
|
||||
**Next: Prefix Watch (reactive store updates)**
|
||||
- [ ] `store.watch_prefix(prefix) -> Receiver<WatchEvent>`
|
||||
- [ ] `WatchEvent::Put { key, value }` / `WatchEvent::Delete { key }`
|
||||
- [ ] StoreActor tracks watchers per prefix, emits on matching put/delete
|
||||
- [ ] LatticeServer uses `/peers/` watch to update gossip bootstrap peers dynamically
|
||||
- [ ] Enables reactive patterns: config changes, presence, app-level subscriptions
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt
|
||||
|
||||
**Logging**
|
||||
- [ ] Replace `println!`/`eprintln!` with `tracing` crate (`tracing::info!`, `tracing::error!`)
|
||||
- Standard in Rust async ecosystem, used by Iroh internally
|
||||
|
||||
**Lifecycle Management (Zombie Tasks)**
|
||||
- [ ] Spawned infinite loops (`spawn_node_event_listener`, `spawn_entry_forward_loop`, gossip receive loop) keep running if `LatticeServer` is dropped
|
||||
- [ ] Use `tokio_util::sync::CancellationToken` or keep `JoinHandle`s for graceful shutdown
|
||||
|
||||
**Error Handling**
|
||||
- [ ] Replace `Result<..., String>` with `anyhow::Result` or define `LatticeNetError` enum
|
||||
- String errors make it hard to handle specific failure cases
|
||||
|
||||
---
|
||||
|
||||
## Future
|
||||
|
||||
- offline nodes should not delay sync
|
||||
- sync command should transitive sync all peers
|
||||
- Gossip:
|
||||
- gossip new entries to peers
|
||||
- backfill missing entries from peers (how do peers notice missing entries?)
|
||||
- snapshots for kv store
|
||||
- prune using consensus watermark
|
||||
- remove_peer should be a transactional operation on store
|
||||
- Watermark tracking & log pruning
|
||||
- Track minimum confirmed seq per author across all peers
|
||||
- Log pruning: remove entries below watermark
|
||||
- Multi-KV-Store sync
|
||||
- Optimized sync on join. Only transfer current watermark state, then sync missing entries. This would allow pruning. Might need snapshot support in KV store.
|
||||
- Mobile (iOS/Android) clients
|
||||
- Key rotation
|
||||
- Secure storage (Keychain, TPM)
|
||||
- Snapshots for fast bootstrap
|
||||
- FUSE filesystem mount
|
||||
- Note: FUSE requires u64 inode numbers → maintain `BiMap<u64, Hash>` in redb
|
||||
- Merkle-ized State
|
||||
- state.db as Merkle tree with signed root hash
|
||||
- O(1) sync checks (compare root), efficient binary-search diffing
|
||||
- Light clients: fetch value + Merkle proof, verify without full state
|
||||
- Trade-off: write amplification, requires deterministic tree (Patricia Trie / Merkle Search Tree)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Storage Format
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
~/.local/share/lattice/
|
||||
├── identity.key # Ed25519 private key (not replicated)
|
||||
├── meta.db # Global metadata (redb)
|
||||
└── stores/{uuid}/
|
||||
├── logs/{author}.log # Append-only SignedEntry stream
|
||||
└── state.db # Per-store KV state (redb)
|
||||
```
|
||||
|
||||
## meta.db (redb)
|
||||
|
||||
| Table | Key | Value | Purpose |
|
||||
|---------|---------------|--------------------|------------------------------|
|
||||
| stores | UUID (16B) | created_at (u64) | Known stores |
|
||||
| meta | "root_store" | UUID (16B) | Auto-opened on CLI startup |
|
||||
|
||||
## state.db (redb, per store)
|
||||
|
||||
| Table | Key | Value | Purpose |
|
||||
|---------|----------|-------------|------------------------|
|
||||
| kv | String | Vec<u8> | Key-value data |
|
||||
| meta | String | Vec<u8> | last_seq, last_hash |
|
||||
|
||||
## Log Files
|
||||
|
||||
Each `{author}.log` contains length-delimited `LogRecord` messages:
|
||||
|
||||
```protobuf
|
||||
message LogRecord {
|
||||
bytes hash = 1; // BLAKE3 hash of entry_bytes
|
||||
bytes entry_bytes = 2; // Serialized SignedEntry
|
||||
}
|
||||
```
|
||||
|
||||
Hashes are verified on read; corruption causes `LogError::HashMismatch`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Test Cases
|
||||
|
||||
## Timestamp / HLC
|
||||
|
||||
### Time-traveling node applies own entry
|
||||
- Node X has clock at year 2050
|
||||
- Node X creates entry, signs, broadcasts
|
||||
- All nodes (including X) should clamp to parent.hlc + 1
|
||||
- Verify: X's state.db matches other nodes' state.db
|
||||
- Failure mode: X applies using 2050, others use 101 → divergence
|
||||
|
||||
### Clamping with no parent (genesis entry)
|
||||
- Node X creates first-ever entry with future timestamp
|
||||
- All nodes should DROP the entry (no parent to anchor to)
|
||||
- Verify: entry is not applied anywhere
|
||||
|
||||
### Out-of-order entry arrival
|
||||
- Entry B (hlc=91) arrives after Entry A (hlc=100)
|
||||
- Both write to same key
|
||||
- Verify: A's value wins (LWW with timestamp tracking)
|
||||
- Verify: no rollback needed, just comparison on apply
|
||||
|
||||
### Clock drift detection
|
||||
- Node consistently sees its entries clamped
|
||||
- Verify: UI alerts user about clock being ahead
|
||||
|
||||
### Clock in past (Pi without RTC, boots at 1970)
|
||||
- Node X has clock at 1970
|
||||
- Node X receives entries from peers with HLC around 2024
|
||||
- Standard HLC: X uses max(1970, peer_hlc + 1) = peer_hlc + 1
|
||||
- Verify: X's entries slot in correctly (no special handling needed)
|
||||
|
||||
### Pre-flight peer sanity check (future clock)
|
||||
- Node X has clock at 2050
|
||||
- Before creating entry, X compares local_clock to max_peer_hlc
|
||||
- If local_clock > max_peer_hlc + MAX_DRIFT, use max_peer_hlc + 1
|
||||
- Verify: X's entry uses sane timestamp, all nodes agree
|
||||
@@ -0,0 +1,74 @@
|
||||
# Testing Scenarios (Validation Apps)
|
||||
|
||||
These apps test HLC ordering, gossip convergence, and conflict resolution.
|
||||
|
||||
## Level 1: Pixel Board (Visual Convergence)
|
||||
|
||||
50x50 collaborative grid where users paint pixels.
|
||||
|
||||
**Data Model:** `/canvas/{x}_{y}` → `{hex_color}`
|
||||
|
||||
**Tests:**
|
||||
- Visualize sync disagreements immediately
|
||||
- High write volume (log performance)
|
||||
- Simultaneous writes (HLC tiebreaker)
|
||||
|
||||
**Scenario:** Node A paints all red (offline), Node B paints all blue (offline), connect. Board must be identical on both.
|
||||
|
||||
---
|
||||
|
||||
## Level 2: Shared Grocery List (LWW Trap)
|
||||
|
||||
List with add/check/delete operations.
|
||||
|
||||
**Data Model:** `/list/{item_uuid}` → `{ name, status: "needed"|"bought" }`
|
||||
|
||||
**Tests:** Exposes LWW weakness (resurrection bug)
|
||||
|
||||
**Scenario:**
|
||||
1. Alice syncs, sees "Milk", goes offline, marks "bought"
|
||||
2. Bob syncs, sees "Milk", deletes it
|
||||
3. Reconnect
|
||||
|
||||
**Result:** Item either resurrects or vanishes based on timestamp. Forces tombstone pattern.
|
||||
|
||||
---
|
||||
|
||||
## Level 3: Chat Room (Causal Ordering)
|
||||
|
||||
Group chat application.
|
||||
|
||||
**Data Model:** `/chat/{channel}/{timestamp}_{node_id}` → `{ msg }`
|
||||
|
||||
**Tests:**
|
||||
- HLC causal ordering
|
||||
- Prefix queries (redb range scans)
|
||||
- Gap detection via vector clocks
|
||||
|
||||
**Scenario:**
|
||||
1. Node A sends "Msg 1"
|
||||
2. Node B sees it, replies "Msg 2"
|
||||
3. Node C comes online, connects only to B
|
||||
|
||||
**Success:** Node C receives "Msg 1" before/with "Msg 2" (transitive sync).
|
||||
|
||||
---
|
||||
|
||||
## Level 4: Chaos Monkey (Automated Simulation)
|
||||
|
||||
Tokio-based simulation harness with in-memory networking.
|
||||
|
||||
**Setup:**
|
||||
- 5 node threads in one process
|
||||
- In-memory network (tokio channels)
|
||||
- Chaos monkey randomly: cuts connections, writes random keys, sleeps threads
|
||||
|
||||
**Assertion:**
|
||||
```rust
|
||||
let state_0 = nodes[0].dump_state_hash();
|
||||
for i in 1..5 {
|
||||
assert_eq!(state_0, nodes[i].dump_state_hash());
|
||||
}
|
||||
```
|
||||
|
||||
Catches HLC clamping edge cases that manual testing misses.
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "lattice-cli"
|
||||
description = "Interactive CLI for Lattice"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "lattice"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
lattice-core = { workspace = true }
|
||||
lattice-net = { workspace = true }
|
||||
rustyline = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
shlex = "1"
|
||||
serde_json = "1"
|
||||
iroh = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
futures-util = "0.3"
|
||||
@@ -0,0 +1,79 @@
|
||||
//! CLI command handlers
|
||||
|
||||
use lattice_core::{Node, StoreHandle};
|
||||
use lattice_net::LatticeServer;
|
||||
|
||||
/// Result of a command that may switch stores or exit
|
||||
pub enum CommandResult {
|
||||
/// No store change
|
||||
Ok,
|
||||
/// Switch to this store
|
||||
SwitchTo(StoreHandle),
|
||||
/// Exit the CLI
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Helper to call async code from sync command handlers
|
||||
pub fn block_async<F: std::future::Future>(f: F) -> F::Output {
|
||||
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
|
||||
}
|
||||
|
||||
pub type Handler = fn(&Node, Option<&StoreHandle>, Option<&LatticeServer>, &[String]) -> CommandResult;
|
||||
|
||||
pub struct Command {
|
||||
pub name: &'static str,
|
||||
pub args: &'static str,
|
||||
pub desc: &'static str,
|
||||
pub group: &'static str,
|
||||
pub min_args: usize,
|
||||
pub max_args: usize,
|
||||
pub handler: Handler,
|
||||
}
|
||||
|
||||
/// Get all available commands
|
||||
pub fn commands() -> Vec<Command> {
|
||||
let mut cmds = Vec::new();
|
||||
|
||||
// General CLI commands
|
||||
cmds.push(Command {
|
||||
name: "help", args: "", desc: "Show this help",
|
||||
group: "general", min_args: 0, max_args: 0, handler: cmd_help as Handler
|
||||
});
|
||||
cmds.push(Command {
|
||||
name: "quit", args: "", desc: "Exit",
|
||||
group: "general", min_args: 0, max_args: 0, handler: cmd_quit as Handler
|
||||
});
|
||||
|
||||
// Node commands (operations on the node)
|
||||
cmds.extend(crate::node_commands::node_commands());
|
||||
|
||||
// Store commands (raw KV operations)
|
||||
cmds.extend(crate::store_commands::store_commands());
|
||||
|
||||
cmds
|
||||
}
|
||||
|
||||
fn cmd_help(_node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
let cmds = commands();
|
||||
let mut last_group = "";
|
||||
for cmd in &cmds {
|
||||
if cmd.group != last_group {
|
||||
println!();
|
||||
println!("[{}]", cmd.group);
|
||||
last_group = cmd.group;
|
||||
}
|
||||
let usage = if cmd.args.is_empty() {
|
||||
cmd.name.to_string()
|
||||
} else {
|
||||
format!("{} {}", cmd.name, cmd.args)
|
||||
};
|
||||
println!(" {:18} {}", usage, cmd.desc);
|
||||
}
|
||||
println!();
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_quit(_node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
println!("Goodbye!");
|
||||
CommandResult::Quit
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Lattice Interactive CLI
|
||||
|
||||
mod commands;
|
||||
mod node_commands;
|
||||
mod store_commands;
|
||||
|
||||
use lattice_net::LatticeServer;
|
||||
use commands::CommandResult;
|
||||
use lattice_core::{NodeBuilder, StoreHandle};
|
||||
use rustyline::error::ReadlineError;
|
||||
use rustyline::DefaultEditor;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("Lattice CLI v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!("Type 'help' for commands, 'quit' to exit.\n");
|
||||
|
||||
let node = match NodeBuilder::new().build() {
|
||||
Ok(n) => Arc::new(n),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to initialize: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create LatticeServer (creates endpoint and spawns accept loop internally)
|
||||
let server = match LatticeServer::new_from_node(node.clone()).await {
|
||||
Ok(s) => {
|
||||
println!("Iroh: {} (listening)", s.endpoint().public_key().fmt_short());
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Iroh failed to start: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let info = node.info();
|
||||
println!("Node ID: {}", info.node_id);
|
||||
println!("Data: {}", info.data_path);
|
||||
|
||||
if !info.stores.is_empty() {
|
||||
println!("Stores: {}", info.stores.len());
|
||||
}
|
||||
|
||||
let mut current_store: Option<StoreHandle> = match node.open_root_store().await {
|
||||
Ok(Some(open_info)) => {
|
||||
if open_info.entries_replayed > 0 {
|
||||
println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed);
|
||||
} else {
|
||||
println!("Root: {}", open_info.store_id);
|
||||
}
|
||||
|
||||
node.root_store().await.as_ref().cloned()
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("Status: Not initialized (use 'init')");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
println!();
|
||||
|
||||
let mut rl = DefaultEditor::new().expect("Failed to create editor");
|
||||
let cmds = commands::commands();
|
||||
|
||||
loop {
|
||||
let prompt = match ¤t_store {
|
||||
Some(h) => format!("lattice:{}> ", &h.id().to_string()[..8]),
|
||||
None => "lattice:no-store> ".to_string(),
|
||||
};
|
||||
|
||||
match rl.readline(&prompt) {
|
||||
Ok(line) => {
|
||||
let line = line.trim();
|
||||
if line.is_empty() { continue; }
|
||||
let _ = rl.add_history_entry(line);
|
||||
|
||||
let args = match shlex::split(line) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
println!("Error: mismatched quotes");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let cmd_name = args.first().map(|s| s.as_str()).unwrap_or("");
|
||||
|
||||
match cmds.iter().find(|c| c.name == cmd_name || (cmd_name == "exit" && c.name == "quit")) {
|
||||
Some(cmd) => {
|
||||
let cmd_args = &args[1..];
|
||||
if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args {
|
||||
println!("Usage: {} {}", cmd.name, cmd.args);
|
||||
} else {
|
||||
match (cmd.handler)(&node, current_store.as_ref(), server.as_ref(), cmd_args) {
|
||||
CommandResult::Ok => {}
|
||||
CommandResult::SwitchTo(h) => {
|
||||
current_store = Some(h);
|
||||
}
|
||||
CommandResult::Quit => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => println!("Unknown: '{}'. Type 'help'.", cmd_name),
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
|
||||
println!("Goodbye!");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Node commands - operations on the node (mesh, peers, status)
|
||||
|
||||
use crate::commands::{block_async, Command, CommandResult, Handler};
|
||||
use lattice_core::{Node, StoreHandle, PeerStatus, Uuid};
|
||||
use lattice_net::LatticeServer;
|
||||
use chrono::DateTime;
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn node_commands() -> Vec<Command> {
|
||||
vec![
|
||||
// Store management
|
||||
Command { name: "init", args: "", desc: "Initialize root store", group: "node", min_args: 0, max_args: 0, handler: cmd_init as Handler },
|
||||
Command { name: "create-store", args: "", desc: "Create a new store", group: "node", min_args: 0, max_args: 0, handler: cmd_create_store as Handler },
|
||||
Command { name: "use", args: "<uuid>", desc: "Switch to a store", group: "node", min_args: 1, max_args: 1, handler: cmd_use_store as Handler },
|
||||
Command { name: "list-stores", args: "", desc: "List all stores", group: "node", min_args: 0, max_args: 0, handler: cmd_list_stores as Handler },
|
||||
Command { name: "node-status", args: "", desc: "Show node info", group: "node", min_args: 0, max_args: 0, handler: cmd_node_status as Handler },
|
||||
// Peer management
|
||||
Command { name: "invite", args: "<pubkey>", desc: "Invite a peer", group: "peers", min_args: 1, max_args: 1, handler: cmd_invite as Handler },
|
||||
Command { name: "peers", args: "", desc: "List all peers", group: "peers", min_args: 0, max_args: 0, handler: cmd_peers as Handler },
|
||||
Command { name: "remove", args: "<pubkey>", desc: "Remove a peer", group: "peers", min_args: 1, max_args: 1, handler: cmd_remove as Handler },
|
||||
// Networking
|
||||
Command { name: "join", args: "<node_id>", desc: "Join an existing mesh", group: "network", min_args: 1, max_args: 1, handler: cmd_join as Handler },
|
||||
Command { name: "sync", args: "[node_id]", desc: "Sync with peers", group: "network", min_args: 0, max_args: 1, handler: cmd_sync as Handler },
|
||||
]
|
||||
}
|
||||
|
||||
// --- Store management ---
|
||||
|
||||
fn cmd_init(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
match block_async(node.init()) {
|
||||
Ok(store_id) => {
|
||||
println!("Initialized with root store: {}", store_id);
|
||||
println!("Node info stored in /nodes/{}/*", hex::encode(node.node_id()));
|
||||
match block_async(node.root_store()).as_ref() {
|
||||
Some(h) => CommandResult::SwitchTo(h.clone()),
|
||||
None => CommandResult::Ok,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
CommandResult::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_create_store(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
match node.create_store() {
|
||||
Ok(store_id) => {
|
||||
println!("Created store: {}", store_id);
|
||||
match block_async(node.open_store(store_id)) {
|
||||
Ok((handle, _)) => {
|
||||
println!("Switched to new store");
|
||||
CommandResult::SwitchTo(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: {}", e);
|
||||
CommandResult::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
CommandResult::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_use_store(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let store_id = match Uuid::parse_str(&args[0]) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
eprintln!("Error: invalid UUID '{}'", args[0]);
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
match block_async(node.open_store(store_id)) {
|
||||
Ok((handle, info)) => {
|
||||
if info.entries_replayed > 0 {
|
||||
println!("Replayed {} entries ({:.2?})", info.entries_replayed, start.elapsed());
|
||||
} else {
|
||||
println!("Switched to store {}", store_id);
|
||||
}
|
||||
CommandResult::SwitchTo(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
CommandResult::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list_stores(node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
let stores = match node.list_stores() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
let current_id = store.map(|s| s.id());
|
||||
|
||||
if stores.is_empty() {
|
||||
println!("No stores. Use 'init' or 'create-store'.");
|
||||
} else {
|
||||
for store_id in stores {
|
||||
let marker = if Some(store_id) == current_id { " *" } else { "" };
|
||||
println!("{}{}", store_id, marker);
|
||||
}
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
// --- Info ---
|
||||
|
||||
fn cmd_node_status(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
println!("Node ID: {}", hex::encode(node.node_id()));
|
||||
if let Some(name) = node.name() {
|
||||
println!("Name: {}", name);
|
||||
}
|
||||
println!("Data: {}", node.data_path().display());
|
||||
match node.root_store_id() {
|
||||
Ok(Some(id)) => println!("Root: {}", id),
|
||||
Ok(None) => println!("Root: (not set)"),
|
||||
Err(_) => println!("Root: (error)"),
|
||||
}
|
||||
|
||||
// Count peers using node.list_peers()
|
||||
if let Ok(peers) = block_async(node.list_peers()) {
|
||||
let active = peers.iter().filter(|p| p.status == PeerStatus::Active).count();
|
||||
let invited = peers.iter().filter(|p| p.status == PeerStatus::Invited).count();
|
||||
println!("Peers: {} active, {} invited", active, invited);
|
||||
}
|
||||
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
// --- Peer management ---
|
||||
|
||||
fn cmd_invite(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let pubkey_hex = &args[0];
|
||||
let pubkey: [u8; 32] = match hex::decode(pubkey_hex) {
|
||||
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
|
||||
_ => {
|
||||
eprintln!("Invalid pubkey: expected 64 hex chars (32 bytes)");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
match block_async(node.invite_peer(&pubkey)) {
|
||||
Ok(()) => {
|
||||
println!("Invited peer: {}", pubkey_hex);
|
||||
println!(" Status: {} (will become active after sync)", PeerStatus::Invited.as_str());
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_peers(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
let peers = match block_async(node.list_peers()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
if peers.is_empty() {
|
||||
println!("No peers found.");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
|
||||
// Group peers by status
|
||||
let mut by_status: std::collections::HashMap<PeerStatus, Vec<&lattice_core::PeerInfo>> =
|
||||
std::collections::HashMap::new();
|
||||
for peer in &peers {
|
||||
by_status.entry(peer.status).or_default().push(peer);
|
||||
}
|
||||
|
||||
// Print grouped by status in order: active, invited, dormant
|
||||
let status_order = [PeerStatus::Active, PeerStatus::Invited, PeerStatus::Dormant];
|
||||
for status in &status_order {
|
||||
if let Some(peer_list) = by_status.get(status) {
|
||||
println!("\n[{}] ({}):", status.as_str(), peer_list.len());
|
||||
let mut sorted: Vec<_> = peer_list.iter().collect();
|
||||
sorted.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
|
||||
for peer in sorted {
|
||||
let added_str = peer.added_at
|
||||
.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_default();
|
||||
let info_str = match (peer.name.as_ref(), added_str.is_empty()) {
|
||||
(Some(name), false) => format!(" {} ({})", name, added_str),
|
||||
(Some(name), true) => format!(" {}", name),
|
||||
(None, false) => format!(" ({})", added_str),
|
||||
(None, true) => String::new(),
|
||||
};
|
||||
println!(" {}{}", peer.pubkey, info_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_remove(node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let pubkey_hex = &args[0];
|
||||
let pubkey: [u8; 32] = match hex::decode(pubkey_hex) {
|
||||
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
|
||||
_ => {
|
||||
eprintln!("Invalid pubkey: expected 64 hex characters");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
match block_async(node.remove_peer(&pubkey)) {
|
||||
Ok(()) => println!("Removed peer: {}...", &pubkey_hex[..10]),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
// --- Networking ---
|
||||
|
||||
fn cmd_join(_node: &Node, store: Option<&StoreHandle>, server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let server = match server {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Iroh endpoint not started.");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
if store.is_some() {
|
||||
eprintln!("Already initialized. Use 'sync' to sync with peers.");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
|
||||
let peer_id = match lattice_net::parse_node_id(&args[0]) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!("Invalid node ID: {}", e);
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
println!("Joining mesh via {}...", peer_id.fmt_short());
|
||||
|
||||
match block_async(server.join_mesh(peer_id)) {
|
||||
Ok(handle) => {
|
||||
println!("Joined mesh! Use 'sync' command to sync entries.");
|
||||
CommandResult::SwitchTo(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Join failed: {}", e);
|
||||
CommandResult::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_sync(_node: &Node, store: Option<&StoreHandle>, server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let server = match server {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Iroh endpoint not started.");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
let store = match store {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("No store open. Use 'init' or 'join' first.");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
if args.is_empty() {
|
||||
// Sync with all active peers
|
||||
match block_async(server.sync_all(store)) {
|
||||
Ok(results) => {
|
||||
if results.is_empty() {
|
||||
println!("No peers to sync with.");
|
||||
} else {
|
||||
let total: u64 = results.iter().map(|r| r.entries_applied).sum();
|
||||
println!("\nSync complete! Applied {} entries from {} peer(s).", total, results.len());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Sync failed: {}", e),
|
||||
}
|
||||
} else {
|
||||
// Sync with specific peer
|
||||
let peer_id = match lattice_net::parse_node_id(&args[0]) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!("Invalid node ID: {}", e);
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
println!("Syncing with {}...", peer_id.fmt_short());
|
||||
match block_async(server.sync_with_peer(store, peer_id)) {
|
||||
Ok(result) => {
|
||||
println!("Sync complete! Applied {} entries (peer sent {})",
|
||||
result.entries_applied, result.entries_sent_by_peer);
|
||||
}
|
||||
Err(e) => eprintln!("Sync failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult::Ok
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Store commands - direct KV operations
|
||||
|
||||
use crate::commands::{block_async, Command, CommandResult, Handler};
|
||||
use lattice_core::{Node, StoreHandle};
|
||||
use lattice_net::LatticeServer;
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn store_commands() -> Vec<Command> {
|
||||
vec![
|
||||
Command { name: "store-status", args: "", desc: "Show store info", group: "store", min_args: 0, max_args: 0, handler: cmd_store_status as Handler },
|
||||
Command { name: "put", args: "<key> <value>", desc: "Store a key-value pair", group: "store", min_args: 2, max_args: 2, handler: cmd_put as Handler },
|
||||
Command { name: "get", args: "<key> [-v]", desc: "Get value for key", group: "store", min_args: 1, max_args: 2, handler: cmd_get as Handler },
|
||||
Command { name: "delete", args: "<key>", desc: "Delete a key", group: "store", min_args: 1, max_args: 1, handler: cmd_delete as Handler },
|
||||
Command { name: "list", args: "[prefix] [-v]", desc: "List keys (optionally filtered by prefix)", group: "store", min_args: 0, max_args: 2, handler: cmd_list as Handler },
|
||||
Command { name: "author-state", args: "[pubkey]", desc: "Show author sync state", group: "store", min_args: 0, max_args: 1, handler: cmd_author_state as Handler },
|
||||
]
|
||||
}
|
||||
|
||||
fn cmd_store_status(_node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||
let Some(h) = store else {
|
||||
println!("No store selected. Use 'init' or 'use <uuid>'");
|
||||
return CommandResult::Ok;
|
||||
};
|
||||
|
||||
println!("Store ID: {}", h.id());
|
||||
println!("Log Seq: {}", block_async(h.log_seq()));
|
||||
println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0));
|
||||
|
||||
let all = block_async(h.list(false)).unwrap_or_default();
|
||||
println!("Keys: {}", all.len());
|
||||
|
||||
// Show log directory size
|
||||
let (file_count, total_size) = block_async(h.log_stats());
|
||||
if file_count > 0 {
|
||||
println!("Logs: {} files, {} bytes", file_count, total_size);
|
||||
}
|
||||
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_put(_node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let Some(h) = store else {
|
||||
println!("No store selected. Use 'init' or 'use <uuid>'");
|
||||
return CommandResult::Ok;
|
||||
};
|
||||
let start = Instant::now();
|
||||
match block_async(h.put(args[0].as_bytes(), args[1].as_bytes())) {
|
||||
Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_get(_node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let Some(h) = store else {
|
||||
println!("No store selected. Use 'init' or 'use <uuid>'");
|
||||
return CommandResult::Ok;
|
||||
};
|
||||
let verbose = args.get(1).map(|a| a == "-v").unwrap_or(false);
|
||||
let start = Instant::now();
|
||||
let key = args[0].as_bytes();
|
||||
|
||||
if verbose {
|
||||
// Show all heads
|
||||
match block_async(h.get_heads(key)) {
|
||||
Ok(heads) if heads.is_empty() => println!("(nil)"),
|
||||
Ok(heads) => {
|
||||
for (i, head) in heads.iter().enumerate() {
|
||||
let winner = if i == 0 { "→" } else { " " };
|
||||
let tombstone = if head.tombstone { "⊗" } else { "" };
|
||||
let author_short = hex::encode(&head.author).chars().take(8).collect::<String>();
|
||||
if head.tombstone {
|
||||
println!("{} {} (deleted) (hlc:{}, author:{})",
|
||||
winner, tombstone, head.hlc, author_short);
|
||||
} else {
|
||||
println!("{} {} (hlc:{}, author:{})",
|
||||
winner, format_value(&head.value), head.hlc, author_short);
|
||||
}
|
||||
}
|
||||
if heads.len() > 1 {
|
||||
println!("⚠ {} heads (conflict)", heads.len());
|
||||
}
|
||||
println!("({:.2?})", start.elapsed());
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
} else {
|
||||
match block_async(h.get(key)) {
|
||||
Ok(Some(v)) => {
|
||||
let heads = block_async(h.get_heads(key)).unwrap_or_default();
|
||||
if heads.len() > 1 {
|
||||
println!("{} (⚠ {} heads)", format_value(&v), heads.len());
|
||||
} else {
|
||||
println!("{}", format_value(&v));
|
||||
}
|
||||
println!("({:.2?})", start.elapsed());
|
||||
}
|
||||
Ok(None) => println!("(nil)"),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_delete(_node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let Some(h) = store else {
|
||||
println!("No store selected. Use 'init' or 'use <uuid>'");
|
||||
return CommandResult::Ok;
|
||||
};
|
||||
let start = Instant::now();
|
||||
match block_async(h.delete(args[0].as_bytes())) {
|
||||
Ok(seq) => println!("OK (seq: {}, {:.2?})", seq, start.elapsed()),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_list(_node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let Some(h) = store else {
|
||||
println!("No store selected. Use 'init' or 'use <uuid>'");
|
||||
return CommandResult::Ok;
|
||||
};
|
||||
|
||||
// Parse args: [prefix] [-v]
|
||||
let verbose = args.iter().any(|a| a == "-v");
|
||||
let prefix = args.iter().find(|a| *a != "-v").cloned();
|
||||
|
||||
let start = Instant::now();
|
||||
let result = if let Some(p) = &prefix {
|
||||
block_async(h.list_by_prefix(p.as_bytes(), verbose))
|
||||
} else {
|
||||
block_async(h.list(verbose))
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(entries) => {
|
||||
if entries.is_empty() {
|
||||
println!("(empty)");
|
||||
} else {
|
||||
for (k, v) in &entries {
|
||||
let key_str = format_value(k);
|
||||
if verbose {
|
||||
// Show all heads for this key
|
||||
let heads = block_async(h.get_heads(k)).unwrap_or_default();
|
||||
println!("{}:", key_str);
|
||||
for (i, head) in heads.iter().enumerate() {
|
||||
let winner = if i == 0 { "→" } else { " " };
|
||||
let author_short = hex::encode(&head.author).chars().take(8).collect::<String>();
|
||||
if head.tombstone {
|
||||
println!(" {} ⊗ (deleted) (hlc:{}, author:{})",
|
||||
winner, head.hlc, author_short);
|
||||
} else {
|
||||
println!(" {} {} (hlc:{}, author:{})",
|
||||
winner, format_value(&head.value), head.hlc, author_short);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check for multiple heads
|
||||
let heads = block_async(h.get_heads(k)).unwrap_or_default();
|
||||
if heads.len() > 1 {
|
||||
println!("{} = {} (⚠ {} heads)", key_str, format_value(v), heads.len());
|
||||
} else {
|
||||
println!("{} = {}", key_str, format_value(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
let prefix_str = prefix.as_ref().map(|p| format!(" (prefix: {})", p)).unwrap_or_default();
|
||||
println!("({} keys{}, {:.2?})", entries.len(), prefix_str, start.elapsed());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn cmd_author_state(node: &Node, store: Option<&StoreHandle>, _server: Option<&LatticeServer>, args: &[String]) -> CommandResult {
|
||||
let store = match store {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Error: no store selected");
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
};
|
||||
|
||||
// Get author: from arg or default to self
|
||||
let author_bytes: [u8; 32] = if args.is_empty() {
|
||||
node.node_id()
|
||||
} else {
|
||||
let hex_str = args[0].trim_start_matches("0x");
|
||||
match hex::decode(hex_str) {
|
||||
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
|
||||
Ok(bytes) => {
|
||||
eprintln!("Error: author must be 32 bytes, got {}", bytes.len());
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: invalid hex: {}", e);
|
||||
return CommandResult::Ok;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match block_async(store.author_state(&author_bytes)) {
|
||||
Ok(Some(state)) => {
|
||||
println!("Author: {}", hex::encode(&author_bytes));
|
||||
println!(" seq: {}", state.seq);
|
||||
println!(" hash: {}", hex::encode(&state.hash));
|
||||
println!(" log_offset: {}", state.log_offset);
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("No state for author: {}", hex::encode(&author_bytes));
|
||||
}
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
CommandResult::Ok
|
||||
}
|
||||
|
||||
fn format_value(v: &[u8]) -> String {
|
||||
std::str::from_utf8(v).map(String::from).unwrap_or_else(|_| format!("0x{}", hex::encode(v)))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "lattice-core"
|
||||
description = "Core types for Lattice: nodes, sigchains, entries, and vector clocks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ed25519-dalek = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
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 }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::io::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
prost_build::compile_protos(
|
||||
&["../proto/lattice.proto"],
|
||||
&["../proto/"],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Causal Entry Iterator - yields entries in HLC (causal) order
|
||||
//!
|
||||
//! Implements merge-sort streaming across multiple author queues using a min-heap,
|
||||
//! ensuring entries are returned in correct causal order for sync.
|
||||
//! Complexity: O(N log K) where N = total entries, K = number of authors.
|
||||
|
||||
use crate::proto::{Entry, SignedEntry};
|
||||
use prost::Message;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BinaryHeap, VecDeque};
|
||||
|
||||
/// A heap entry that wraps an author queue index and the HLC of its front entry.
|
||||
/// Uses Reverse for min-heap behavior (lowest HLC first).
|
||||
struct HeapEntry {
|
||||
hlc: (u64, u32),
|
||||
queue_idx: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for HeapEntry {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.hlc == other.hlc
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for HeapEntry {}
|
||||
|
||||
impl PartialOrd for HeapEntry {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for HeapEntry {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse order for min-heap (BinaryHeap is max-heap by default)
|
||||
other.hlc.cmp(&self.hlc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator that yields SignedEntry in HLC (causal) order.
|
||||
///
|
||||
/// Takes multiple VecDeques (one per author) and yields entries
|
||||
/// from lowest to highest HLC, ensuring causal ordering for sync.
|
||||
/// Uses a min-heap for O(log K) per-entry overhead instead of O(K) linear scan.
|
||||
pub struct CausalEntryIter {
|
||||
queues: Vec<VecDeque<SignedEntry>>,
|
||||
heap: BinaryHeap<HeapEntry>,
|
||||
}
|
||||
|
||||
impl CausalEntryIter {
|
||||
/// Create a new iterator from a list of entry queues (one per author)
|
||||
pub fn new(queues: Vec<VecDeque<SignedEntry>>) -> Self {
|
||||
let mut heap = BinaryHeap::with_capacity(queues.len());
|
||||
|
||||
// Initialize heap with the front entry from each non-empty queue
|
||||
for (idx, queue) in queues.iter().enumerate() {
|
||||
if let Some(entry) = queue.front() {
|
||||
heap.push(HeapEntry {
|
||||
hlc: Self::get_hlc(entry),
|
||||
queue_idx: idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Self { queues, heap }
|
||||
}
|
||||
|
||||
/// Extract HLC (wall_time, counter) from a SignedEntry
|
||||
fn get_hlc(entry: &SignedEntry) -> (u64, u32) {
|
||||
Entry::decode(&entry.entry_bytes[..])
|
||||
.ok()
|
||||
.and_then(|e| e.timestamp)
|
||||
.map(|t| (t.wall_time, t.counter))
|
||||
.unwrap_or((0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CausalEntryIter {
|
||||
type Item = SignedEntry;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// Pop the queue with lowest HLC
|
||||
let HeapEntry { queue_idx, .. } = self.heap.pop()?;
|
||||
|
||||
// Remove entry from that queue
|
||||
let entry = self.queues[queue_idx].pop_front()?;
|
||||
|
||||
// If queue still has entries, push its new front back to heap
|
||||
if let Some(next_entry) = self.queues[queue_idx].front() {
|
||||
self.heap.push(HeapEntry {
|
||||
hlc: Self::get_hlc(next_entry),
|
||||
queue_idx,
|
||||
});
|
||||
}
|
||||
|
||||
Some(entry)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hlc::HLC;
|
||||
use crate::clock::MockClock;
|
||||
use crate::node_identity::NodeIdentity;
|
||||
use crate::signed_entry::EntryBuilder;
|
||||
|
||||
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])
|
||||
.prev_hash(vec![0u8; 32])
|
||||
.put(b"/test".to_vec(), format!("seq{}", seq).into_bytes())
|
||||
.sign(node)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_iter() {
|
||||
let iter = CausalEntryIter::new(vec![]);
|
||||
assert_eq!(iter.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_queue() {
|
||||
let node = NodeIdentity::generate();
|
||||
let entries: VecDeque<_> = vec![
|
||||
make_entry(&node, 1, 1000),
|
||||
make_entry(&node, 2, 2000),
|
||||
].into();
|
||||
|
||||
let iter = CausalEntryIter::new(vec![entries]);
|
||||
let result: Vec<_> = iter.collect();
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_multiple_queues() {
|
||||
let node_a = NodeIdentity::generate();
|
||||
let node_b = NodeIdentity::generate();
|
||||
|
||||
// Author A: entries at time 1000, 3000
|
||||
let queue_a: VecDeque<_> = vec![
|
||||
make_entry(&node_a, 1, 1000),
|
||||
make_entry(&node_a, 2, 3000),
|
||||
].into();
|
||||
|
||||
// Author B: entries at time 2000
|
||||
let queue_b: VecDeque<_> = vec![
|
||||
make_entry(&node_b, 1, 2000),
|
||||
].into();
|
||||
|
||||
let iter = CausalEntryIter::new(vec![queue_a, queue_b]);
|
||||
let result: Vec<_> = iter.collect();
|
||||
|
||||
// Should be in HLC order: 1000, 2000, 3000
|
||||
assert_eq!(result.len(), 3);
|
||||
|
||||
// Verify order by checking HLC values
|
||||
let hlcs: Vec<_> = result.iter()
|
||||
.map(|e| CausalEntryIter::get_hlc(e))
|
||||
.collect();
|
||||
assert_eq!(hlcs[0].0, 1000);
|
||||
assert_eq!(hlcs[1].0, 2000);
|
||||
assert_eq!(hlcs[2].0, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_many_authors() {
|
||||
// Test with 10 authors to verify heap behavior
|
||||
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();
|
||||
|
||||
let iter = CausalEntryIter::new(queues);
|
||||
let result: Vec<_> = iter.collect();
|
||||
|
||||
assert_eq!(result.len(), 10);
|
||||
|
||||
// Verify strictly increasing HLC order
|
||||
let hlcs: Vec<_> = result.iter()
|
||||
.map(|e| CausalEntryIter::get_hlc(e).0)
|
||||
.collect();
|
||||
for window in hlcs.windows(2) {
|
||||
assert!(window[0] < window[1], "HLCs should be strictly increasing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Clock abstraction for testable time
|
||||
//!
|
||||
//! Provides a trait for getting the current time, with implementations
|
||||
//! for real system time and mock time for testing.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Trait for getting the current wall clock time in milliseconds
|
||||
pub trait Clock: Send + Sync {
|
||||
/// Get the current time in milliseconds since Unix epoch
|
||||
fn now_ms(&self) -> u64;
|
||||
}
|
||||
|
||||
/// Real system clock implementation
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now_ms(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock clock for testing - returns a fixed time
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MockClock {
|
||||
pub time_ms: u64,
|
||||
}
|
||||
|
||||
impl MockClock {
|
||||
pub fn new(time_ms: u64) -> Self {
|
||||
Self { time_ms }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for MockClock {
|
||||
fn now_ms(&self) -> u64 {
|
||||
self.time_ms
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_system_clock_returns_reasonable_time() {
|
||||
let clock = SystemClock;
|
||||
let now = clock.now_ms();
|
||||
// Should be after 2025-01-01
|
||||
assert!(now > 1_735_689_600_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_clock_returns_fixed_time() {
|
||||
let clock = MockClock::new(12345);
|
||||
assert_eq!(clock.now_ms(), 12345);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Data directory management
|
||||
//!
|
||||
//! Provides platform-specific paths for Lattice data storage:
|
||||
//! - `identity.key` — Ed25519 private key
|
||||
//! - `meta.db` — Global metadata (stores table)
|
||||
//! - `stores/{uuid}/logs/{author}.log` — Per-store, per-author logs
|
||||
//! - `stores/{uuid}/state.db` — Per-store KV state
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
const APP_NAME: &str = "lattice";
|
||||
|
||||
/// Data directory configuration.
|
||||
///
|
||||
/// Multi-store layout:
|
||||
/// ```text
|
||||
/// base/
|
||||
/// identity.key
|
||||
/// meta.db
|
||||
/// stores/{uuid}/
|
||||
/// logs/{author}.log
|
||||
/// state.db
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataDir {
|
||||
base: PathBuf,
|
||||
}
|
||||
|
||||
impl DataDir {
|
||||
/// Create a DataDir with a custom base path.
|
||||
pub fn new(base: impl Into<PathBuf>) -> Self {
|
||||
Self { base: base.into() }
|
||||
}
|
||||
|
||||
/// Create a DataDir using the platform-specific data directory.
|
||||
pub fn default_location() -> Option<Self> {
|
||||
dirs::data_dir().map(|d| Self::new(d.join(APP_NAME)))
|
||||
}
|
||||
|
||||
/// Get the base directory path.
|
||||
pub fn base(&self) -> &Path {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Get the path to the identity key file.
|
||||
pub fn identity_key(&self) -> PathBuf {
|
||||
self.base.join("identity.key")
|
||||
}
|
||||
|
||||
/// Get the path to the global metadata database.
|
||||
pub fn meta_db(&self) -> PathBuf {
|
||||
self.base.join("meta.db")
|
||||
}
|
||||
|
||||
/// Get the path to the stores directory.
|
||||
pub fn stores_dir(&self) -> PathBuf {
|
||||
self.base.join("stores")
|
||||
}
|
||||
|
||||
/// Get the path to a specific store's directory.
|
||||
pub fn store_dir(&self, store_id: Uuid) -> PathBuf {
|
||||
self.stores_dir().join(store_id.to_string())
|
||||
}
|
||||
|
||||
/// Get the path to a store's logs directory.
|
||||
pub fn store_logs_dir(&self, store_id: Uuid) -> PathBuf {
|
||||
self.store_dir(store_id).join("logs")
|
||||
}
|
||||
|
||||
/// Get the path to a specific author's log file within a store.
|
||||
pub fn store_log_file(&self, store_id: Uuid, author_id_hex: &str) -> PathBuf {
|
||||
self.store_logs_dir(store_id).join(format!("{}.log", author_id_hex))
|
||||
}
|
||||
|
||||
/// Get the path to a store's state database.
|
||||
pub fn store_state_db(&self, store_id: Uuid) -> PathBuf {
|
||||
self.store_dir(store_id).join("state.db")
|
||||
}
|
||||
|
||||
/// Ensure base directory exists.
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.base)?;
|
||||
std::fs::create_dir_all(self.stores_dir())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure directories for a specific store exist.
|
||||
pub fn ensure_store_dirs(&self, store_id: Uuid) -> std::io::Result<()> {
|
||||
self.ensure_dirs()?;
|
||||
std::fs::create_dir_all(self.store_logs_dir(store_id))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DataDir {
|
||||
fn default() -> Self {
|
||||
Self::default_location().unwrap_or_else(|| Self::new("./data"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_custom_path() {
|
||||
let dd = DataDir::new("/custom/path");
|
||||
assert_eq!(dd.base(), Path::new("/custom/path"));
|
||||
assert_eq!(dd.identity_key(), PathBuf::from("/custom/path/identity.key"));
|
||||
assert_eq!(dd.meta_db(), PathBuf::from("/custom/path/meta.db"));
|
||||
assert_eq!(dd.stores_dir(), PathBuf::from("/custom/path/stores"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_paths() {
|
||||
let dd = DataDir::new("/data");
|
||||
let store_id = Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap();
|
||||
|
||||
assert_eq!(dd.store_dir(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
|
||||
assert_eq!(dd.store_logs_dir(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs"));
|
||||
assert_eq!(dd.store_log_file(store_id, "abc123"), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs/abc123.log"));
|
||||
assert_eq!(dd.store_state_db(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/state.db"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_location_exists() {
|
||||
// On most systems, default_location should return Some
|
||||
let location = DataDir::default_location();
|
||||
assert!(location.is_some() || true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_impl() {
|
||||
let dd = DataDir::default();
|
||||
assert!(dd.base().to_str().is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Log entries (atomic operations)
|
||||
|
||||
/// An atomic, batched operation in the sigchain.
|
||||
///
|
||||
/// Entries are the fundamental unit of change in Lattice.
|
||||
/// The KV store is a "view" generated by replaying these entries.
|
||||
pub struct Entry {
|
||||
// TODO: operation data, signature, prev_hash
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Hybrid Logical Clock (HLC) implementation
|
||||
//!
|
||||
//! HLCs combine wall clock time with a logical counter to provide
|
||||
//! causally consistent ordering even with clock drift.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use crate::clock::{Clock, SystemClock};
|
||||
|
||||
/// Default maximum drift allowed before clamping (1 hour in ms)
|
||||
pub const DEFAULT_MAX_DRIFT_MS: u64 = 60 * 60 * 1000;
|
||||
|
||||
/// Hybrid Logical Clock
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HLC {
|
||||
/// Wall clock time in milliseconds since Unix epoch
|
||||
pub wall_time: u64,
|
||||
/// Logical counter for ordering events at same wall_time
|
||||
pub counter: u32,
|
||||
}
|
||||
|
||||
impl HLC {
|
||||
/// Create a new HLC with the given wall_time and counter
|
||||
pub fn new(wall_time: u64, counter: u32) -> Self {
|
||||
Self { wall_time, counter }
|
||||
}
|
||||
|
||||
/// Create an HLC from the current system time
|
||||
pub fn now() -> Self {
|
||||
Self::now_with_clock(&SystemClock)
|
||||
}
|
||||
|
||||
/// Create an HLC from the given clock (for testing)
|
||||
pub fn now_with_clock(clock: &impl Clock) -> Self {
|
||||
Self {
|
||||
wall_time: clock.now_ms(),
|
||||
counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update this clock upon receiving a message with the given HLC.
|
||||
/// Uses the system clock.
|
||||
pub fn update(&self, received: &HLC) -> HLC {
|
||||
self.update_with_clock(received, &SystemClock)
|
||||
}
|
||||
|
||||
/// Update this clock with an explicit clock source (for testing)
|
||||
pub fn update_with_clock(&self, received: &HLC, clock: &impl Clock) -> HLC {
|
||||
let local_wall_time = clock.now_ms();
|
||||
|
||||
if local_wall_time > self.wall_time && local_wall_time > received.wall_time {
|
||||
// Local wall clock is ahead of everything, use it
|
||||
HLC::new(local_wall_time, 0)
|
||||
} else if self.wall_time > received.wall_time {
|
||||
// Our last HLC is ahead, increment counter
|
||||
HLC::new(self.wall_time, self.counter + 1)
|
||||
} else if received.wall_time > self.wall_time {
|
||||
// Received HLC is ahead, use it and increment
|
||||
HLC::new(received.wall_time, received.counter + 1)
|
||||
} else {
|
||||
// Same wall_time, take max counter and increment
|
||||
HLC::new(self.wall_time, self.counter.max(received.counter) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a potentially-future HLC to be at most parent + 1.
|
||||
/// Returns the clamped HLC if the original exceeds max_drift,
|
||||
/// otherwise returns the original.
|
||||
pub fn clamp_future(&self, parent: &HLC, local_wall_time: u64, max_drift_ms: u64) -> HLC {
|
||||
if self.wall_time > local_wall_time + max_drift_ms {
|
||||
// Clamp to parent + 1 (deterministic across all nodes)
|
||||
HLC::new(parent.wall_time, parent.counter + 1)
|
||||
} else {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp with clock source (convenience method)
|
||||
pub fn clamp_future_with_clock(&self, parent: &HLC, clock: &impl Clock, max_drift_ms: u64) -> HLC {
|
||||
self.clamp_future(parent, clock.now_ms(), max_drift_ms)
|
||||
}
|
||||
|
||||
/// Check if this HLC exceeds the given wall time by more than max_drift
|
||||
pub fn is_future(&self, local_wall_time: u64, max_drift_ms: u64) -> bool {
|
||||
self.wall_time > local_wall_time + max_drift_ms
|
||||
}
|
||||
|
||||
/// Increment this HLC for a new local event
|
||||
pub fn tick(&self) -> HLC {
|
||||
self.tick_with_clock(&SystemClock)
|
||||
}
|
||||
|
||||
/// Increment with explicit clock (for testing)
|
||||
pub fn tick_with_clock(&self, clock: &impl Clock) -> HLC {
|
||||
let now = clock.now_ms();
|
||||
if now > self.wall_time {
|
||||
HLC::new(now, 0)
|
||||
} else {
|
||||
HLC::new(self.wall_time, self.counter + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for HLC {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match self.wall_time.cmp(&other.wall_time) {
|
||||
Ordering::Equal => self.counter.cmp(&other.counter),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for HLC {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HLC {
|
||||
fn default() -> Self {
|
||||
Self::now()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::MockClock;
|
||||
|
||||
#[test]
|
||||
fn test_hlc_ordering() {
|
||||
let a = HLC::new(100, 0);
|
||||
let b = HLC::new(100, 1);
|
||||
let c = HLC::new(101, 0);
|
||||
|
||||
assert!(a < b);
|
||||
assert!(b < c);
|
||||
assert!(a < c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_update_received_ahead() {
|
||||
let local = HLC::new(100, 5);
|
||||
let received = HLC::new(200, 3);
|
||||
let clock = MockClock::new(50); // Wall clock behind both
|
||||
|
||||
let updated = local.update_with_clock(&received, &clock);
|
||||
|
||||
assert!(updated > received);
|
||||
assert_eq!(updated.wall_time, 200);
|
||||
assert_eq!(updated.counter, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_update_local_ahead() {
|
||||
let local = HLC::new(200, 5);
|
||||
let received = HLC::new(100, 3);
|
||||
let clock = MockClock::new(50); // Wall clock behind both
|
||||
|
||||
let updated = local.update_with_clock(&received, &clock);
|
||||
|
||||
assert!(updated > local);
|
||||
assert_eq!(updated.wall_time, 200);
|
||||
assert_eq!(updated.counter, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_update_wall_clock_ahead() {
|
||||
let local = HLC::new(100, 5);
|
||||
let received = HLC::new(150, 3);
|
||||
let clock = MockClock::new(500); // Wall clock ahead of both
|
||||
|
||||
let updated = local.update_with_clock(&received, &clock);
|
||||
|
||||
assert_eq!(updated.wall_time, 500);
|
||||
assert_eq!(updated.counter, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_clamp_future() {
|
||||
let future = HLC::new(2050_000_000_000, 0);
|
||||
let parent = HLC::new(100, 5);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let clamped = future.clamp_future_with_clock(&parent, &clock, DEFAULT_MAX_DRIFT_MS);
|
||||
|
||||
assert_eq!(clamped.wall_time, 100);
|
||||
assert_eq!(clamped.counter, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_clamp_within_drift() {
|
||||
let normal = HLC::new(1000, 3);
|
||||
let parent = HLC::new(100, 5);
|
||||
let clock = MockClock::new(900);
|
||||
|
||||
let clamped = normal.clamp_future_with_clock(&parent, &clock, DEFAULT_MAX_DRIFT_MS);
|
||||
|
||||
assert_eq!(clamped, normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clock_in_past_uses_received() {
|
||||
// Simulates Pi booting with clock at 1970
|
||||
let old_clock = HLC::new(0, 0);
|
||||
let received = HLC::new(1_700_000_000_000, 5);
|
||||
let clock = MockClock::new(0); // Clock also at 1970
|
||||
|
||||
let updated = old_clock.update_with_clock(&received, &clock);
|
||||
|
||||
assert_eq!(updated.wall_time, 1_700_000_000_000);
|
||||
assert_eq!(updated.counter, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tick_with_mock_clock() {
|
||||
let hlc = HLC::new(100, 5);
|
||||
|
||||
// Clock behind: counter increments
|
||||
let clock = MockClock::new(50);
|
||||
let ticked = hlc.tick_with_clock(&clock);
|
||||
assert_eq!(ticked.wall_time, 100);
|
||||
assert_eq!(ticked.counter, 6);
|
||||
|
||||
// Clock ahead: use new wall time
|
||||
let clock = MockClock::new(200);
|
||||
let ticked = hlc.tick_with_clock(&clock);
|
||||
assert_eq!(ticked.wall_time, 200);
|
||||
assert_eq!(ticked.counter, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_now_with_mock_clock() {
|
||||
let clock = MockClock::new(12345);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
assert_eq!(hlc.wall_time, 12345);
|
||||
assert_eq!(hlc.counter, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_update_same_wall_time_collision() {
|
||||
// Both local and received have the same wall_time (collision branch)
|
||||
let local = HLC::new(100, 5);
|
||||
let received = HLC::new(100, 8);
|
||||
let clock = MockClock::new(50); // Wall clock behind both
|
||||
|
||||
let updated = local.update_with_clock(&received, &clock);
|
||||
|
||||
// Should take max(5, 8) + 1 = 9
|
||||
assert_eq!(updated.wall_time, 100);
|
||||
assert_eq!(updated.counter, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hlc_update_same_wall_time_local_counter_higher() {
|
||||
// Same wall_time, but local has higher counter
|
||||
let local = HLC::new(100, 10);
|
||||
let received = HLC::new(100, 3);
|
||||
let clock = MockClock::new(50);
|
||||
|
||||
let updated = local.update_with_clock(&received, &clock);
|
||||
|
||||
// Should take max(10, 3) + 1 = 11
|
||||
assert_eq!(updated.wall_time, 100);
|
||||
assert_eq!(updated.counter, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_future() {
|
||||
let hlc = HLC::new(1000, 0);
|
||||
|
||||
// Within drift: not future
|
||||
assert!(!hlc.is_future(500, 600));
|
||||
|
||||
// Exactly at drift boundary: not future (>= vs >)
|
||||
assert!(!hlc.is_future(500, 500));
|
||||
|
||||
// Beyond drift: is future
|
||||
assert!(hlc.is_future(500, 400));
|
||||
|
||||
// Way in the future
|
||||
let future = HLC::new(2050_000_000_000, 0);
|
||||
assert!(future.is_future(1_700_000_000_000, DEFAULT_MAX_DRIFT_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_clock_smoke() {
|
||||
// Ensure SystemClock compiles and returns reasonable values
|
||||
let hlc = HLC::now();
|
||||
// Should be after 2025-01-01 (1735689600000 ms)
|
||||
assert!(hlc.wall_time > 1_735_689_600_000);
|
||||
assert_eq!(hlc.counter, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_uses_system_clock() {
|
||||
let hlc = HLC::default();
|
||||
// Should be after 2025-01-01
|
||||
assert!(hlc.wall_time > 1_735_689_600_000);
|
||||
assert_eq!(hlc.counter, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_with_system_clock_smoke() {
|
||||
let local = HLC::new(100, 5);
|
||||
let received = HLC::new(200, 3);
|
||||
|
||||
// This should use the real system clock internally
|
||||
let updated = local.update(&received);
|
||||
|
||||
// Updated should be greater than both
|
||||
assert!(updated > local);
|
||||
assert!(updated > received);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tick_with_system_clock_smoke() {
|
||||
let hlc = HLC::new(100, 5);
|
||||
let ticked = hlc.tick();
|
||||
|
||||
// Should be greater than original
|
||||
assert!(ticked > hlc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Lattice Core
|
||||
//!
|
||||
//! Core types for the Lattice distributed mesh:
|
||||
//! - **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
|
||||
//! - **HLC**: Hybrid Logical Clock for ordering
|
||||
//! - **Clock**: Time abstraction for testability
|
||||
//! - **Proto**: Generated protobuf types from lattice.proto
|
||||
//! - **DataDir**: Platform-specific data directory paths
|
||||
//! - **SignedEntry**: Entry creation, signing, and verification
|
||||
//! - **Log**: Append-only log file I/O
|
||||
//! - **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;
|
||||
pub mod sync_state;
|
||||
pub mod hlc;
|
||||
pub mod clock;
|
||||
pub mod proto;
|
||||
pub mod data_dir;
|
||||
pub mod signed_entry;
|
||||
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_identity::{NodeIdentity, PeerStatus};
|
||||
pub use node::{Node, NodeBuilder, NodeInfo, StoreInfo, StoreHandle, NodeError, NodeEvent, PeerInfo, JoinAcceptance};
|
||||
pub use sigchain::{SigChain, SigChainManager};
|
||||
pub use entry::Entry;
|
||||
pub use sync_state::{SyncState, AuthorInfo, MissingRange};
|
||||
pub use hlc::HLC;
|
||||
pub use clock::{Clock, SystemClock, MockClock};
|
||||
pub use data_dir::DataDir;
|
||||
pub use signed_entry::{EntryBuilder, sign_entry, verify_signed_entry, hash_signed_entry};
|
||||
pub use log::{append_entry, read_entries, read_entries_after, LogReader};
|
||||
pub use store::Store;
|
||||
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};
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Log file I/O for append-only entry storage
|
||||
//!
|
||||
//! Each author has a log file containing length-delimited LogRecord messages.
|
||||
//! LogRecord = { hash: [u8; 32], entry_bytes: SignedEntry }
|
||||
|
||||
use crate::proto::{LogRecord, SignedEntry};
|
||||
use crate::MAX_ENTRY_SIZE;
|
||||
use prost::Message;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufReader, BufWriter, Read, Write};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during log operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LogError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Proto decode error: {0}")]
|
||||
Decode(#[from] prost::DecodeError),
|
||||
|
||||
#[error("Entry too large: {0} bytes (max {MAX_ENTRY_SIZE})")]
|
||||
EntryTooLarge(usize),
|
||||
|
||||
#[error("Unexpected EOF while reading entry")]
|
||||
UnexpectedEof,
|
||||
|
||||
#[error("Hash mismatch: stored hash does not match computed hash")]
|
||||
HashMismatch,
|
||||
}
|
||||
|
||||
/// Append a SignedEntry to a log file as a LogRecord
|
||||
pub fn append_entry(path: impl AsRef<Path>, entry: &SignedEntry) -> Result<u64, LogError> {
|
||||
let path = path.as_ref();
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
// Serialize the SignedEntry
|
||||
let entry_bytes = entry.encode_to_vec();
|
||||
|
||||
if entry_bytes.len() > MAX_ENTRY_SIZE {
|
||||
return Err(LogError::EntryTooLarge(entry_bytes.len()));
|
||||
}
|
||||
|
||||
// Compute hash
|
||||
let hash: [u8; 32] = blake3::hash(&entry_bytes).into();
|
||||
|
||||
// Create LogRecord
|
||||
let record = LogRecord {
|
||||
hash: hash.to_vec(),
|
||||
entry_bytes,
|
||||
};
|
||||
|
||||
// Write length-delimited LogRecord
|
||||
let mut buf = Vec::new();
|
||||
record.encode_length_delimited(&mut buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
writer.write_all(&buf)?;
|
||||
writer.flush()?;
|
||||
|
||||
// Ensure data is physically written to disk
|
||||
writer.get_ref().sync_all()?;
|
||||
|
||||
// Return new file size
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
Ok(metadata.len())
|
||||
}
|
||||
|
||||
/// Read all SignedEntry messages from a log file (with hash verification)
|
||||
pub fn read_entries(path: impl AsRef<Path>) -> Result<Vec<SignedEntry>, LogError> {
|
||||
read_entries_after(path, None)
|
||||
}
|
||||
|
||||
/// Read all entries that come AFTER the given hash.
|
||||
/// If `last_hash` is None, reads all entries.
|
||||
pub fn read_entries_after(path: impl AsRef<Path>, last_hash: Option<[u8; 32]>) -> Result<Vec<SignedEntry>, LogError> {
|
||||
let reader = match LogReader::open(&path) {
|
||||
Ok(r) => r,
|
||||
Err(LogError::Io(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut found_start = last_hash.is_none();
|
||||
|
||||
for result in reader {
|
||||
let (hash, entry) = result?;
|
||||
|
||||
if found_start {
|
||||
entries.push(entry);
|
||||
} else if let Some(target) = last_hash {
|
||||
if hash == target {
|
||||
found_start = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Iterator over entries in a log file
|
||||
/// Returns (hash, SignedEntry) pairs
|
||||
pub struct LogReader {
|
||||
reader: BufReader<File>,
|
||||
}
|
||||
|
||||
impl LogReader {
|
||||
/// Open a log file for reading
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, LogError> {
|
||||
let file = File::open(path)?;
|
||||
Ok(Self {
|
||||
reader: BufReader::new(file),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for LogReader {
|
||||
type Item = Result<([u8; 32], SignedEntry), LogError>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match read_one_record(&mut self.reader) {
|
||||
Ok(Some(pair)) => Some(Ok(pair)),
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single LogRecord, returning (hash, SignedEntry)
|
||||
fn read_one_record<R: Read>(reader: &mut R) -> Result<Option<([u8; 32], SignedEntry)>, LogError> {
|
||||
// Read length-delimited bytes
|
||||
let record_bytes = match read_length_delimited_bytes(reader) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(LogError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Decode LogRecord
|
||||
let record = LogRecord::decode(&record_bytes[..])?;
|
||||
|
||||
// Verify hash
|
||||
let computed_hash: [u8; 32] = blake3::hash(&record.entry_bytes).into();
|
||||
let stored_hash: [u8; 32] = record.hash.try_into()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid hash length"))?;
|
||||
|
||||
if computed_hash != stored_hash {
|
||||
return Err(LogError::HashMismatch);
|
||||
}
|
||||
|
||||
// Decode SignedEntry
|
||||
let entry = SignedEntry::decode(&record.entry_bytes[..])?;
|
||||
Ok(Some((stored_hash, entry)))
|
||||
}
|
||||
|
||||
/// Read length-delimited bytes from a reader
|
||||
fn read_length_delimited_bytes<R: Read>(reader: &mut R) -> Result<Vec<u8>, LogError> {
|
||||
// Read varint length prefix
|
||||
let mut prefix_buf = Vec::with_capacity(10);
|
||||
let mut byte = [0u8; 1];
|
||||
|
||||
loop {
|
||||
match reader.read_exact(&mut byte) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Err(e.into()),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
prefix_buf.push(byte[0]);
|
||||
|
||||
if byte[0] & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
if prefix_buf.len() > 10 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "varint too long").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the length
|
||||
let len = prost::decode_length_delimiter(&prefix_buf[..])
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
if len > MAX_ENTRY_SIZE {
|
||||
return Err(LogError::EntryTooLarge(len));
|
||||
}
|
||||
|
||||
// Read the data
|
||||
let mut data_buf = vec![0u8; len];
|
||||
reader.read_exact(&mut data_buf).map_err(|e| {
|
||||
if e.kind() == io::ErrorKind::UnexpectedEof {
|
||||
LogError::UnexpectedEof
|
||||
} else {
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(data_buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::MockClock;
|
||||
use crate::hlc::HLC;
|
||||
use crate::node_identity::NodeIdentity;
|
||||
use crate::signed_entry::EntryBuilder;
|
||||
use std::env::temp_dir;
|
||||
|
||||
fn temp_log_path(name: &str) -> std::path::PathBuf {
|
||||
temp_dir().join(format!("lattice_test_{}.log", name))
|
||||
}
|
||||
|
||||
/// Compute hash the same way append_entry does
|
||||
fn compute_entry_hash(entry: &SignedEntry) -> [u8; 32] {
|
||||
let entry_bytes = entry.encode_to_vec();
|
||||
blake3::hash(&entry_bytes).into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_and_read_single() {
|
||||
let path = temp_log_path("single_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
let entry = EntryBuilder::new(1, hlc)
|
||||
.put("/test/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
append_entry(&path, &entry).unwrap();
|
||||
|
||||
let entries = read_entries(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].entry_bytes, entry.entry_bytes);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_multiple() {
|
||||
let path = temp_log_path("multiple_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
for i in 1..=5 {
|
||||
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
||||
.put(format!("/key/{}", i), format!("value{}", i).into_bytes())
|
||||
.sign(&node);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
}
|
||||
|
||||
let entries = read_entries(&path).unwrap();
|
||||
assert_eq!(entries.len(), 5);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_entries_after() {
|
||||
let path = temp_log_path("after_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut hashes = Vec::new();
|
||||
|
||||
for i in 1..=5 {
|
||||
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
||||
.put(format!("/key/{}", i), format!("value{}", i).into_bytes())
|
||||
.sign(&node);
|
||||
hashes.push(compute_entry_hash(&entry));
|
||||
append_entry(&path, &entry).unwrap();
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
// Read entries after hash[1] (second entry) -> should get entries 3, 4, 5
|
||||
let result = read_entries_after(&path, Some(hashes[1])).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].entry_bytes, entries[2].entry_bytes);
|
||||
|
||||
// Read all entries (no hash)
|
||||
let all = read_entries_after(&path, None).unwrap();
|
||||
assert_eq!(all.len(), 5);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_not_found_returns_empty() {
|
||||
let path = temp_log_path("not_found_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
|
||||
let fake_hash = [0u8; 32];
|
||||
let result = read_entries_after(&path, Some(fake_hash)).unwrap();
|
||||
assert_eq!(result.len(), 0);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_reader_returns_hash() {
|
||||
let path = temp_log_path("reader_hash_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
let expected_hash = compute_entry_hash(&entry);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
|
||||
let mut reader = LogReader::open(&path).unwrap();
|
||||
let (hash, _) = reader.next().unwrap().unwrap();
|
||||
assert_eq!(hash, expected_hash);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_empty_file() {
|
||||
let path = temp_log_path("empty_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
File::create(&path).unwrap();
|
||||
|
||||
let entries = read_entries(&path).unwrap();
|
||||
assert_eq!(entries.len(), 0);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_nonexistent() {
|
||||
let path = temp_log_path("nonexistent_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let entries = read_entries(&path).unwrap();
|
||||
assert_eq!(entries.len(), 0);
|
||||
}
|
||||
|
||||
// --- Negative Tests ---
|
||||
|
||||
#[test]
|
||||
fn test_corrupted_entry_detected() {
|
||||
use std::io::Seek;
|
||||
|
||||
let path = temp_log_path("corrupted_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/key", b"original".to_vec())
|
||||
.sign(&node);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
|
||||
// Corrupt the file: change a byte in the middle
|
||||
let mut file = OpenOptions::new().write(true).open(&path).unwrap();
|
||||
file.seek(io::SeekFrom::Start(20)).unwrap();
|
||||
file.write_all(&[0xFF]).unwrap();
|
||||
drop(file);
|
||||
|
||||
let result = read_entries(&path);
|
||||
|
||||
match result {
|
||||
Err(LogError::HashMismatch) => (),
|
||||
Err(LogError::Decode(_)) => (),
|
||||
Err(e) => panic!("Expected HashMismatch or Decode error, got: {:?}", e),
|
||||
Ok(_) => panic!("Corrupted entry was accepted!"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncated_file() {
|
||||
let path = temp_log_path("truncated_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/key", b"data".to_vec())
|
||||
.sign(&node);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
|
||||
// Truncate file by 1 byte
|
||||
let file = OpenOptions::new().write(true).open(&path).unwrap();
|
||||
let len = file.metadata().unwrap().len();
|
||||
file.set_len(len - 1).unwrap();
|
||||
drop(file);
|
||||
|
||||
let result = read_entries(&path);
|
||||
|
||||
match result {
|
||||
Err(LogError::UnexpectedEof) => (),
|
||||
Err(LogError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => (),
|
||||
Err(LogError::Decode(_)) => (), // Also acceptable
|
||||
res => panic!("Expected UnexpectedEof, got: {:?}", res),
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_too_large() {
|
||||
let path = temp_log_path("too_large_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Create payload larger than MAX_ENTRY_SIZE
|
||||
let huge_payload = vec![0u8; crate::MAX_ENTRY_SIZE + 100];
|
||||
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/huge", huge_payload)
|
||||
.sign(&node);
|
||||
|
||||
let result = append_entry(&path, &entry);
|
||||
|
||||
match result {
|
||||
Err(LogError::EntryTooLarge(size)) => assert!(size > crate::MAX_ENTRY_SIZE),
|
||||
_ => panic!("Expected EntryTooLarge error"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_after_last_element() {
|
||||
let path = temp_log_path("boundary_last_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
|
||||
let hash = compute_entry_hash(&entry);
|
||||
|
||||
// Ask for everything AFTER the only entry
|
||||
let result = read_entries_after(&path, Some(hash)).unwrap();
|
||||
|
||||
// Result must be empty
|
||||
assert_eq!(result.len(), 0);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_entry_exceeding_limit() {
|
||||
let path = temp_log_path("huge_read_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
// Write a length prefix claiming the entry is > MAX_ENTRY_SIZE
|
||||
let mut file = File::create(&path).unwrap();
|
||||
|
||||
let too_big = (crate::MAX_ENTRY_SIZE + 1) as usize;
|
||||
let mut buf = Vec::new();
|
||||
prost::encode_length_delimiter(too_big, &mut buf).unwrap();
|
||||
|
||||
file.write_all(&buf).unwrap();
|
||||
// Write some dummy bytes (reader should reject before reading these)
|
||||
file.write_all(&[0u8; 10]).unwrap();
|
||||
drop(file);
|
||||
|
||||
let result = read_entries(&path);
|
||||
|
||||
match result {
|
||||
Err(LogError::EntryTooLarge(size)) => assert_eq!(size, too_big),
|
||||
_ => panic!("Expected EntryTooLarge before allocating RAM"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_corruption_in_middle_of_stream() {
|
||||
let path = temp_log_path("corruption_middle_v6");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Write 3 entries
|
||||
for i in 0..3 {
|
||||
let entry = EntryBuilder::new(i + 1, HLC::now_with_clock(&clock))
|
||||
.put(format!("/key/{}", i), b"val".to_vec())
|
||||
.sign(&node);
|
||||
append_entry(&path, &entry).unwrap();
|
||||
}
|
||||
|
||||
// Corrupt a byte in the middle of the file
|
||||
let mut file_bytes = std::fs::read(&path).unwrap();
|
||||
let mid_idx = file_bytes.len() / 2;
|
||||
file_bytes[mid_idx] = !file_bytes[mid_idx]; // Bitflip
|
||||
std::fs::write(&path, file_bytes).unwrap();
|
||||
|
||||
// Reading should fail at some point
|
||||
let result = read_entries(&path);
|
||||
|
||||
// Must fail with HashMismatch or DecodeError
|
||||
assert!(result.is_err());
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! MetaStore - global node metadata in meta.db
|
||||
//!
|
||||
//! Tables:
|
||||
//! - stores: UUID → created_at (Unix ms)
|
||||
//! - meta: "root_store" → UUID (auto-opened on startup)
|
||||
|
||||
use redb::{Database, ReadableTable, TableDefinition};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
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 {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] redb::DatabaseError),
|
||||
|
||||
#[error("Table error: {0}")]
|
||||
Table(#[from] redb::TableError),
|
||||
|
||||
#[error("Transaction error: {0}")]
|
||||
Transaction(#[from] redb::TransactionError),
|
||||
|
||||
#[error("Commit error: {0}")]
|
||||
Commit(#[from] redb::CommitError),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(#[from] redb::StorageError),
|
||||
}
|
||||
|
||||
/// Global metadata store
|
||||
pub struct MetaStore {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl MetaStore {
|
||||
/// Open or create meta.db at the given path
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, MetaStoreError> {
|
||||
let db = Database::create(path)?;
|
||||
|
||||
// Ensure tables exist
|
||||
let write_txn = db.begin_write()?;
|
||||
{
|
||||
let _ = write_txn.open_table(STORES_TABLE)?;
|
||||
let _ = write_txn.open_table(META_TABLE)?;
|
||||
}
|
||||
write_txn.commit()?;
|
||||
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
/// Register a new store
|
||||
pub fn add_store(&self, store_id: Uuid) -> Result<(), MetaStoreError> {
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let mut table = write_txn.open_table(STORES_TABLE)?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
table.insert(store_id.as_bytes().as_slice(), now)?;
|
||||
}
|
||||
write_txn.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered stores
|
||||
pub fn list_stores(&self) -> Result<Vec<Uuid>, MetaStoreError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let table = read_txn.open_table(STORES_TABLE)?;
|
||||
|
||||
let mut stores = Vec::new();
|
||||
for result in table.iter()? {
|
||||
let (key, _created_at) = result?;
|
||||
let bytes: [u8; 16] = key.value().try_into().unwrap_or([0; 16]);
|
||||
stores.push(Uuid::from_bytes(bytes));
|
||||
}
|
||||
Ok(stores)
|
||||
}
|
||||
|
||||
/// Get the root store ID (auto-opened on startup)
|
||||
pub fn root_store(&self) -> Result<Option<Uuid>, MetaStoreError> {
|
||||
let read_txn = self.db.begin_read()?;
|
||||
let table = read_txn.open_table(META_TABLE)?;
|
||||
|
||||
match table.get(META_ROOT_STORE)? {
|
||||
Some(value) => {
|
||||
let bytes: [u8; 16] = value.value().try_into().unwrap_or([0; 16]);
|
||||
Ok(Some(Uuid::from_bytes(bytes)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the root store ID
|
||||
pub fn set_root_store(&self, store_id: Uuid) -> Result<(), MetaStoreError> {
|
||||
let write_txn = self.db.begin_write()?;
|
||||
{
|
||||
let mut table = write_txn.open_table(META_TABLE)?;
|
||||
table.insert(META_ROOT_STORE, store_id.as_bytes().as_slice())?;
|
||||
}
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env::temp_dir;
|
||||
|
||||
#[test]
|
||||
fn test_add_and_list_stores() {
|
||||
let path = temp_dir().join("meta_store_test.db");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let meta = MetaStore::open(&path).unwrap();
|
||||
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
meta.add_store(id1).unwrap();
|
||||
meta.add_store(id2).unwrap();
|
||||
|
||||
let stores = meta.list_stores().unwrap();
|
||||
assert_eq!(stores.len(), 2);
|
||||
assert!(stores.contains(&id1));
|
||||
assert!(stores.contains(&id2));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_root_store() {
|
||||
let path = temp_dir().join("meta_store_root.db");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let meta = MetaStore::open(&path).unwrap();
|
||||
|
||||
// Initially no root store
|
||||
assert_eq!(meta.root_store().unwrap(), None);
|
||||
|
||||
let root = Uuid::new_v4();
|
||||
meta.set_root_store(root).unwrap();
|
||||
|
||||
assert_eq!(meta.root_store().unwrap(), Some(root));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,985 @@
|
||||
//! Local Lattice node API with multi-store support
|
||||
|
||||
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,
|
||||
proto::SignedEntry,
|
||||
};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum NodeError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Store error: {0}")]
|
||||
Store(#[from] StoreError),
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub name: Option<String>,
|
||||
pub added_at: Option<u64>,
|
||||
pub added_by: Option<String>,
|
||||
pub status: PeerStatus,
|
||||
}
|
||||
|
||||
/// Events emitted by Node for interested listeners (e.g., LatticeServer)
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum NodeEvent {
|
||||
/// Root store was activated (opened or set)
|
||||
RootStoreActivated(StoreHandle),
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Create event channel
|
||||
let (event_tx, _) = broadcast::channel(16);
|
||||
|
||||
Ok(Node {
|
||||
data_dir: self.data_dir,
|
||||
node: std::sync::Arc::new(node),
|
||||
meta,
|
||||
root_store: tokio::sync::RwLock::new(None),
|
||||
event_tx,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NodeBuilder {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
/// A local Lattice node (manages identity and store registry)
|
||||
pub struct Node {
|
||||
data_dir: DataDir,
|
||||
node: std::sync::Arc<NodeIdentity>,
|
||||
meta: MetaStore,
|
||||
root_store: tokio::sync::RwLock<Option<StoreHandle>>,
|
||||
event_tx: broadcast::Sender<NodeEvent>,
|
||||
}
|
||||
|
||||
impl 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node_id(&self) -> [u8; 32] {
|
||||
self.node.public_key_bytes()
|
||||
}
|
||||
|
||||
/// Subscribe to node events (e.g., root store activation)
|
||||
pub fn subscribe_events(&self) -> broadcast::Receiver<NodeEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// 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 root store is open, also updates /nodes/{pubkey}/name
|
||||
pub async fn set_name(&self, name: &str) -> Result<(), NodeError> {
|
||||
self.meta.set_name(name)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Get the root store ID
|
||||
pub fn root_store_id(&self) -> Result<Option<Uuid>, NodeError> {
|
||||
Ok(self.meta.root_store()?)
|
||||
}
|
||||
|
||||
/// Get reference to the cached root store handle (if open)
|
||||
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 async fn open_root_store(&self) -> Result<Option<StoreInfo>, NodeError> {
|
||||
match self.meta.root_store()? {
|
||||
Some(id) => {
|
||||
let (handle, info) = self.open_store(id).await?;
|
||||
|
||||
// Emit event for listeners (send clone, keep original)
|
||||
let _ = self.event_tx.send(NodeEvent::RootStoreActivated(handle.clone()));
|
||||
|
||||
// Store original handle (owns actor thread)
|
||||
*self.root_store.write().await = Some(handle);
|
||||
|
||||
Ok(Some(info))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the node with a root store (fails if already initialized).
|
||||
/// Node owns the store handle internally. Access via root_store().
|
||||
pub async fn init(&self) -> Result<Uuid, 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).await?;
|
||||
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?;
|
||||
|
||||
// Store the handle - node owns it
|
||||
*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 (original stays in cache)
|
||||
let (handle, _) = self.open_store(store_id).await?;
|
||||
let handle_clone = handle.clone();
|
||||
*self.root_store.write().await = Some(handle);
|
||||
|
||||
// Emit event for listeners
|
||||
let _ = self.event_tx.send(NodeEvent::RootStoreActivated(handle_clone.clone()));
|
||||
|
||||
// Publish our name to the store
|
||||
let _ = self.publish_name().await;
|
||||
|
||||
Ok(handle_clone)
|
||||
}
|
||||
|
||||
// --- 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 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 my_pubkey_hex = hex::encode(self.node.public_key_bytes());
|
||||
|
||||
let added_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Write added_by
|
||||
let added_by_key = format!("/nodes/{}/added_by", pubkey_hex);
|
||||
store.put(added_by_key.as_bytes(), my_pubkey_hex.as_bytes()).await?;
|
||||
|
||||
// Write added_at
|
||||
let added_at_key = format!("/nodes/{}/added_at", pubkey_hex);
|
||||
store.put(added_at_key.as_bytes(), added_at.to_string().as_bytes()).await?;
|
||||
|
||||
// Write status = invited
|
||||
let status_key = format!("/nodes/{}/status", pubkey_hex);
|
||||
store.put(status_key.as_bytes(), PeerStatus::Invited.as_str().as_bytes()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all peers in the mesh with their info
|
||||
pub async fn list_peers(&self) -> Result<Vec<PeerInfo>, 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 all = store.list(false).await?;
|
||||
|
||||
// Collect unique pubkeys with status
|
||||
let mut peers_map: std::collections::HashMap<String, PeerStatus> = std::collections::HashMap::new();
|
||||
for (key, value) in &all {
|
||||
let key_str = String::from_utf8_lossy(key);
|
||||
if key_str.ends_with("/status") {
|
||||
if let Some(pubkey) = key_str.strip_prefix("/nodes/").and_then(|s| s.strip_suffix("/status")) {
|
||||
let status_str = String::from_utf8_lossy(value);
|
||||
if let Some(status) = PeerStatus::from_str(&status_str) {
|
||||
peers_map.insert(pubkey.to_string(), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build PeerInfo for each peer
|
||||
let mut peers = Vec::new();
|
||||
for (pubkey, status) in peers_map {
|
||||
let name_key = format!("/nodes/{}/name", pubkey);
|
||||
let added_at_key = format!("/nodes/{}/added_at", pubkey);
|
||||
let added_by_key = format!("/nodes/{}/added_by", pubkey);
|
||||
|
||||
let name = store.get(name_key.as_bytes()).await?
|
||||
.map(|b| String::from_utf8_lossy(&b).to_string());
|
||||
|
||||
let added_at = store.get(added_at_key.as_bytes()).await?
|
||||
.and_then(|b| String::from_utf8_lossy(&b).parse().ok());
|
||||
|
||||
let added_by = store.get(added_by_key.as_bytes()).await?
|
||||
.map(|b| String::from_utf8_lossy(&b).to_string());
|
||||
|
||||
peers.push(PeerInfo {
|
||||
pubkey,
|
||||
name,
|
||||
added_at,
|
||||
added_by,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
/// Remove a peer from the mesh (deletes all their /nodes/{pubkey}/* keys)
|
||||
pub async fn remove_peer(&self, pubkey: &[u8; 32]) -> 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);
|
||||
|
||||
// Prevent self-removal
|
||||
if pubkey == &self.node.public_key_bytes() {
|
||||
return Err(NodeError::Actor("Cannot remove yourself".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()));
|
||||
}
|
||||
|
||||
// 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 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);
|
||||
|
||||
match store.get(status_key.as_bytes()).await? {
|
||||
Some(bytes) => {
|
||||
let status_str = String::from_utf8_lossy(&bytes);
|
||||
Ok(PeerStatus::from_str(&status_str))
|
||||
}
|
||||
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()?)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn open_store(&self, store_id: Uuid) -> Result<(StoreHandle, StoreInfo), NodeError> {
|
||||
// Check if this store is already cached as root_store
|
||||
{
|
||||
let guard = self.root_store.read().await;
|
||||
if let Some(ref handle) = *guard {
|
||||
if handle.id() == store_id {
|
||||
let info = StoreInfo { store_id, entries_replayed: 0 };
|
||||
return Ok((handle.clone(), info));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not cached, open it fresh
|
||||
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, entry_tx, actor_handle) = spawn_store_actor(
|
||||
store_id,
|
||||
store,
|
||||
sigchain,
|
||||
(*self.node).clone(),
|
||||
);
|
||||
|
||||
// Store the entry sender for gossip
|
||||
let handle = StoreHandle {
|
||||
store_id,
|
||||
tx,
|
||||
actor_handle: Some(actor_handle),
|
||||
entry_tx,
|
||||
};
|
||||
|
||||
Ok((handle, info))
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a specific store - wraps channel to actor thread
|
||||
#[derive(Debug)]
|
||||
pub struct StoreHandle {
|
||||
store_id: Uuid,
|
||||
tx: tokio::sync::mpsc::Sender<StoreCmd>,
|
||||
actor_handle: Option<std::thread::JoinHandle<()>>,
|
||||
entry_tx: broadcast::Sender<SignedEntry>,
|
||||
}
|
||||
|
||||
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
|
||||
entry_tx: self.entry_tx.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreHandle {
|
||||
pub fn id(&self) -> Uuid { self.store_id }
|
||||
|
||||
/// Subscribe to receive entries as they're committed locally
|
||||
pub fn subscribe_entries(&self) -> broadcast::Receiver<SignedEntry> {
|
||||
self.entry_tx.subscribe()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 { 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)?
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Get log directory statistics (file count, total bytes)
|
||||
pub async fn log_stats(&self) -> (usize, u64) {
|
||||
use StoreCmd;
|
||||
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
|
||||
let _ = self.tx.send(StoreCmd::LogStats { resp: resp_tx }).await;
|
||||
resp_rx.await.unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env::temp_dir;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_open_store() {
|
||||
let data_dir = temp_data_dir("meta_store");
|
||||
|
||||
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).await.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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_isolation() {
|
||||
let data_dir = temp_data_dir("meta_isolation");
|
||||
|
||||
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).await.expect("open A");
|
||||
handle_a.put(b"/key", b"from A").await.expect("put A");
|
||||
|
||||
let (handle_b, _) = node.open_store(store_b).await.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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_creates_root_store() {
|
||||
let data_dir = temp_data_dir("init_root");
|
||||
|
||||
let node = NodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("create node");
|
||||
|
||||
// Initially no root store
|
||||
assert!(node.root_store().await.is_none());
|
||||
|
||||
// Init creates root store
|
||||
let root_id = node.init().await.expect("init failed");
|
||||
assert_eq!(node.root_store_id().unwrap(), Some(root_id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_duplicate_init_fails() {
|
||||
let data_dir = temp_data_dir("init_dup");
|
||||
|
||||
let node = NodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("create node");
|
||||
|
||||
node.init().await.expect("first init");
|
||||
|
||||
// Second init should fail
|
||||
match node.init().await {
|
||||
Ok(_) => panic!("Expected AlreadyInitialized error"),
|
||||
Err(e) => match e {
|
||||
NodeError::AlreadyInitialized => (),
|
||||
_ => panic!("Expected AlreadyInitialized, got {:?}", e),
|
||||
},
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_root_store_in_info_after_init() {
|
||||
let data_dir = temp_data_dir("init_info");
|
||||
|
||||
// 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
|
||||
|
||||
// Second session: root_store should persist
|
||||
let node = NodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("reload node");
|
||||
|
||||
assert_eq!(node.root_store_id().unwrap(), Some(root_id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_idempotent_put_and_delete() {
|
||||
let data_dir = temp_data_dir("idempotent");
|
||||
|
||||
let node = NodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("create node");
|
||||
node.init().await.expect("init");
|
||||
let store = node.root_store().await;
|
||||
let store = store.as_ref().unwrap();
|
||||
|
||||
// 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
|
||||
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 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()));
|
||||
}
|
||||
|
||||
// Change name
|
||||
let new_name = "my-custom-name";
|
||||
node.set_name(new_name).await.expect("set_name");
|
||||
|
||||
// Verify meta.db updated
|
||||
assert_eq!(node.name(), Some(new_name.to_string()));
|
||||
|
||||
// Verify store updated
|
||||
{
|
||||
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()));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invite_peer() {
|
||||
let data_dir = temp_data_dir("invite_peer");
|
||||
|
||||
let node = NodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("create node");
|
||||
|
||||
// Init first
|
||||
node.init().await.expect("init");
|
||||
|
||||
// Invite a peer
|
||||
let peer_pubkey = [0u8; 32]; // Dummy pubkey
|
||||
node.invite_peer(&peer_pubkey).await.expect("invite");
|
||||
|
||||
// Verify peer is Invited
|
||||
let peers = node.list_peers().await.expect("list_peers");
|
||||
let invited = peers.iter().find(|p| p.pubkey == hex::encode(peer_pubkey));
|
||||
assert!(invited.is_some(), "Should find invited peer");
|
||||
assert_eq!(invited.unwrap().status, PeerStatus::Invited);
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_join() {
|
||||
let data_dir = temp_data_dir("accept_join");
|
||||
|
||||
let node = NodeBuilder { data_dir: data_dir.clone() }
|
||||
.build()
|
||||
.expect("create node");
|
||||
|
||||
// Init first
|
||||
let store_id = node.init().await.expect("init");
|
||||
|
||||
// Invite a peer
|
||||
let peer_pubkey = [1u8; 32]; // Dummy pubkey
|
||||
node.invite_peer(&peer_pubkey).await.expect("invite");
|
||||
|
||||
// Accept the join
|
||||
let acceptance = node.accept_join(&peer_pubkey).await.expect("accept_join");
|
||||
assert_eq!(acceptance.store_id, store_id);
|
||||
|
||||
// Peer should now be Active
|
||||
let peers = node.list_peers().await.expect("list_peers");
|
||||
let peer = peers.iter().find(|p| p.pubkey == hex::encode(peer_pubkey));
|
||||
assert!(peer.is_some(), "Should find peer");
|
||||
assert_eq!(peer.unwrap().status, PeerStatus::Active);
|
||||
|
||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invite_join_sync_flow() {
|
||||
// Node A: creator, Node B: joiner
|
||||
let data_dir_a = temp_data_dir("flow_a");
|
||||
let data_dir_b = temp_data_dir("flow_b");
|
||||
|
||||
let node_a = NodeBuilder { data_dir: data_dir_a.clone() }
|
||||
.build()
|
||||
.expect("create node A");
|
||||
let node_b = NodeBuilder { data_dir: data_dir_b.clone() }
|
||||
.build()
|
||||
.expect("create node B");
|
||||
|
||||
// Step 1: Node A initializes
|
||||
let store_id = node_a.init().await.expect("A init");
|
||||
let store_a = node_a.root_store().await;
|
||||
let store_a = store_a.as_ref().expect("A has root store");
|
||||
|
||||
// Step 2: A invites B
|
||||
let b_pubkey: [u8; 32] = node_b.node_id().try_into().unwrap();
|
||||
node_a.invite_peer(&b_pubkey).await.expect("invite B");
|
||||
|
||||
// Verify B is invited
|
||||
let peers = node_a.list_peers().await.expect("list peers");
|
||||
assert!(peers.iter().any(|p| p.status == PeerStatus::Invited));
|
||||
|
||||
// Step 3: B "joins" (complete_join simulates receiving JoinResponse)
|
||||
let store_b = node_b.complete_join(store_id).await.expect("B join");
|
||||
|
||||
// Verify B has the same store ID
|
||||
assert_eq!(store_b.id(), store_id);
|
||||
|
||||
// Step 4: A writes data
|
||||
store_a.put(b"/key", b"from A").await.expect("A put");
|
||||
|
||||
// Step 5: B writes data independently
|
||||
store_b.put(b"/key", b"from B").await.expect("B put");
|
||||
|
||||
// Each store has its own local state (not synced yet)
|
||||
let a_val = store_a.get(b"/key").await.expect("A get").unwrap();
|
||||
let b_val = store_b.get(b"/key").await.expect("B get").unwrap();
|
||||
|
||||
// A sees "from A" (its own write wins locally)
|
||||
assert_eq!(a_val, b"from A".to_vec());
|
||||
// B sees "from B" (its own write wins locally)
|
||||
assert_eq!(b_val, b"from B".to_vec());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(data_dir_a.base());
|
||||
let _ = std::fs::remove_dir_all(data_dir_b.base());
|
||||
}
|
||||
}
|
||||
@@ -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 is temporarily inactive
|
||||
Dormant,
|
||||
}
|
||||
|
||||
impl PeerStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PeerStatus::Invited => "invited",
|
||||
PeerStatus::Active => "active",
|
||||
PeerStatus::Dormant => "dormant",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<PeerStatus> {
|
||||
match s {
|
||||
"invited" => Some(PeerStatus::Invited),
|
||||
"active" => Some(PeerStatus::Active),
|
||||
"dormant" => Some(PeerStatus::Dormant),
|
||||
_ => 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Generated protobuf types for Lattice
|
||||
//!
|
||||
//! This module re-exports types generated from `proto/lattice.proto`
|
||||
|
||||
// Include the generated code from prost-build
|
||||
include!(concat!(env!("OUT_DIR"), "/lattice.rs"));
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hlc_roundtrip() {
|
||||
let hlc = Hlc {
|
||||
wall_time: 1234567890,
|
||||
counter: 42,
|
||||
};
|
||||
|
||||
// Encode
|
||||
let mut buf = Vec::new();
|
||||
prost::Message::encode(&hlc, &mut buf).unwrap();
|
||||
|
||||
// Decode
|
||||
let decoded: Hlc = prost::Message::decode(&buf[..]).unwrap();
|
||||
|
||||
assert_eq!(decoded.wall_time, 1234567890);
|
||||
assert_eq!(decoded.counter, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_with_ops() {
|
||||
let entry = Entry {
|
||||
version: 1,
|
||||
store_id: vec![1u8; 16],
|
||||
prev_hash: vec![0u8; 32],
|
||||
parent_hashes: vec![],
|
||||
seq: 5,
|
||||
timestamp: Some(Hlc {
|
||||
wall_time: 1000,
|
||||
counter: 0,
|
||||
}),
|
||||
ops: vec![
|
||||
Operation {
|
||||
op_type: Some(operation::OpType::Put(PutOp {
|
||||
key: b"/nodes/abc".to_vec(),
|
||||
value: b"hello".to_vec(),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Encode
|
||||
let mut buf = Vec::new();
|
||||
prost::Message::encode(&entry, &mut buf).unwrap();
|
||||
|
||||
// Decode
|
||||
let decoded: Entry = prost::Message::decode(&buf[..]).unwrap();
|
||||
|
||||
assert_eq!(decoded.version, 1);
|
||||
assert_eq!(decoded.seq, 5);
|
||||
assert_eq!(decoded.ops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signed_entry() {
|
||||
let signed = SignedEntry {
|
||||
entry_bytes: vec![1, 2, 3, 4],
|
||||
signature: vec![0u8; 64],
|
||||
author_id: vec![0u8; 32],
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
prost::Message::encode(&signed, &mut buf).unwrap();
|
||||
|
||||
let decoded: SignedEntry = prost::Message::decode(&buf[..]).unwrap();
|
||||
assert_eq!(decoded.entry_bytes, vec![1, 2, 3, 4]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Cryptographic SigChain (append-only signed log)
|
||||
//!
|
||||
//! A SigChain manages a single author's append-only log. It validates entries
|
||||
//! before appending (correct seq, prev_hash, valid signature) and persists to disk.
|
||||
|
||||
use crate::log::{append_entry, read_entries, LogError};
|
||||
use crate::node_identity::NodeIdentity;
|
||||
use crate::proto::{Entry, SignedEntry};
|
||||
use crate::signed_entry::{hash_signed_entry, verify_signed_entry};
|
||||
use prost::Message;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during sigchain operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SigChainError {
|
||||
#[error("Log error: {0}")]
|
||||
Log(#[from] LogError),
|
||||
|
||||
#[error("Invalid signature")]
|
||||
InvalidSignature,
|
||||
|
||||
#[error("Wrong author: expected {expected}, got {got}")]
|
||||
WrongAuthor { expected: String, got: String },
|
||||
|
||||
#[error("Wrong store_id: expected {expected}, got {got}")]
|
||||
WrongStoreId { expected: String, got: String },
|
||||
|
||||
#[error("Invalid sequence: expected {expected}, got {got}")]
|
||||
InvalidSequence { expected: u64, got: u64 },
|
||||
|
||||
#[error("Invalid prev_hash: expected {expected}, got {got}")]
|
||||
InvalidPrevHash { expected: String, got: String },
|
||||
|
||||
#[error("Decode error: {0}")]
|
||||
Decode(#[from] prost::DecodeError),
|
||||
}
|
||||
|
||||
/// An append-only log where each entry is cryptographically signed
|
||||
/// and hash-linked to the previous entry, scoped to a specific store.
|
||||
pub struct SigChain {
|
||||
/// Path to the log file
|
||||
log_path: PathBuf,
|
||||
|
||||
/// Store UUID (16 bytes)
|
||||
store_id: [u8; 16],
|
||||
|
||||
/// Author's public key (32 bytes)
|
||||
author_id: [u8; 32],
|
||||
|
||||
/// Next expected sequence number
|
||||
next_seq: u64,
|
||||
|
||||
/// Hash of the last entry (zeroes if empty)
|
||||
last_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl SigChain {
|
||||
/// Create a new empty sigchain for a (store, author) pair
|
||||
pub fn new(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Self {
|
||||
Self {
|
||||
log_path: log_path.as_ref().to_path_buf(),
|
||||
store_id,
|
||||
author_id,
|
||||
next_seq: 1,
|
||||
last_hash: [0u8; 32],
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a sigchain from an existing log file
|
||||
pub fn from_log(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Result<Self, SigChainError> {
|
||||
let log_path = log_path.as_ref().to_path_buf();
|
||||
let entries = read_entries(&log_path)?;
|
||||
|
||||
let mut chain = Self::new(&log_path, store_id, author_id);
|
||||
|
||||
for signed_entry in entries {
|
||||
// Verify signature
|
||||
verify_signed_entry(&signed_entry)
|
||||
.map_err(|_| SigChainError::InvalidSignature)?;
|
||||
|
||||
// Validate author (author_id is in SignedEntry)
|
||||
let entry_author: [u8; 32] = signed_entry.author_id.clone().try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if entry_author != author_id {
|
||||
return Err(SigChainError::WrongAuthor {
|
||||
expected: hex::encode(author_id),
|
||||
got: hex::encode(&entry_author),
|
||||
});
|
||||
}
|
||||
|
||||
// Decode Entry
|
||||
let entry = Entry::decode(&signed_entry.entry_bytes[..])?;
|
||||
|
||||
// Validate store_id
|
||||
// Note: Empty/malformed store_id becomes [0u8;16], which fails validation
|
||||
// against any real UUID store. This intentionally rejects legacy entries.
|
||||
let entry_store: [u8; 16] = entry.store_id.clone().try_into()
|
||||
.unwrap_or([0u8; 16]);
|
||||
if entry_store != store_id {
|
||||
return Err(SigChainError::WrongStoreId {
|
||||
expected: hex::encode(store_id),
|
||||
got: hex::encode(entry_store),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate sequence
|
||||
if entry.seq != chain.next_seq {
|
||||
return Err(SigChainError::InvalidSequence {
|
||||
expected: chain.next_seq,
|
||||
got: entry.seq,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate prev_hash
|
||||
let expected_prev: [u8; 32] = chain.last_hash;
|
||||
let got_prev: [u8; 32] = entry.prev_hash.try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if got_prev != expected_prev {
|
||||
return Err(SigChainError::InvalidPrevHash {
|
||||
expected: hex::encode(expected_prev),
|
||||
got: hex::encode(got_prev),
|
||||
});
|
||||
}
|
||||
|
||||
// Update state
|
||||
chain.last_hash = hash_signed_entry(&signed_entry);
|
||||
chain.next_seq += 1;
|
||||
}
|
||||
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Get the author's public key
|
||||
pub fn author_id(&self) -> &[u8; 32] {
|
||||
&self.author_id
|
||||
}
|
||||
|
||||
/// Get the next expected sequence number
|
||||
pub fn next_seq(&self) -> u64 {
|
||||
self.next_seq
|
||||
}
|
||||
|
||||
/// Get the hash of the last entry
|
||||
pub fn last_hash(&self) -> &[u8; 32] {
|
||||
&self.last_hash
|
||||
}
|
||||
|
||||
/// Get the log file path
|
||||
pub fn log_path(&self) -> &std::path::Path {
|
||||
&self.log_path
|
||||
}
|
||||
|
||||
/// Get the current length of the chain
|
||||
pub fn len(&self) -> u64 {
|
||||
self.next_seq - 1
|
||||
}
|
||||
|
||||
/// Check if the chain is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.next_seq == 1
|
||||
}
|
||||
|
||||
/// Validate a signed entry without appending
|
||||
pub fn validate(&self, signed_entry: &SignedEntry) -> Result<(), SigChainError> {
|
||||
// Verify signature
|
||||
verify_signed_entry(signed_entry)
|
||||
.map_err(|_| SigChainError::InvalidSignature)?;
|
||||
|
||||
// Validate author (author_id is in SignedEntry)
|
||||
let author: [u8; 32] = signed_entry.author_id.clone().try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if author != self.author_id {
|
||||
return Err(SigChainError::WrongAuthor {
|
||||
expected: hex::encode(self.author_id),
|
||||
got: hex::encode(author),
|
||||
});
|
||||
}
|
||||
|
||||
// Decode entry
|
||||
let entry = Entry::decode(&signed_entry.entry_bytes[..])?;
|
||||
|
||||
// Validate store_id
|
||||
// Note: Empty/malformed store_id becomes [0u8;16], which fails validation
|
||||
// against any real UUID store. This intentionally rejects legacy entries.
|
||||
let entry_store: [u8; 16] = entry.store_id.clone().try_into()
|
||||
.unwrap_or([0u8; 16]);
|
||||
if entry_store != self.store_id {
|
||||
return Err(SigChainError::WrongStoreId {
|
||||
expected: hex::encode(self.store_id),
|
||||
got: hex::encode(entry_store),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate sequence
|
||||
if entry.seq != self.next_seq {
|
||||
return Err(SigChainError::InvalidSequence {
|
||||
expected: self.next_seq,
|
||||
got: entry.seq,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate prev_hash
|
||||
let prev: [u8; 32] = entry.prev_hash.try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if prev != self.last_hash {
|
||||
return Err(SigChainError::InvalidPrevHash {
|
||||
expected: hex::encode(self.last_hash),
|
||||
got: hex::encode(prev),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a signed entry to the chain (validates first)
|
||||
pub fn append(&mut self, signed_entry: &SignedEntry) -> Result<(), SigChainError> {
|
||||
// Validate
|
||||
self.validate(signed_entry)?;
|
||||
|
||||
// Write to log
|
||||
append_entry(&self.log_path, signed_entry)?;
|
||||
|
||||
// Update state
|
||||
self.last_hash = hash_signed_entry(signed_entry);
|
||||
self.next_seq += 1;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create and append a new entry using the node's key
|
||||
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;
|
||||
|
||||
let hlc = HLC::now_with_clock(&SystemClock);
|
||||
|
||||
let mut builder = EntryBuilder::new(self.next_seq, hlc)
|
||||
.store_id(self.store_id.to_vec())
|
||||
.prev_hash(self.last_hash.to_vec());
|
||||
|
||||
// Add operations
|
||||
for op in ops {
|
||||
builder = builder.operation(op);
|
||||
}
|
||||
|
||||
let signed = builder.sign(node);
|
||||
|
||||
self.append(&signed)?;
|
||||
|
||||
Ok(signed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages multiple SigChains (one per author) for a store.
|
||||
/// Provides unified interface for appending entries from any author.
|
||||
pub struct SigChainManager {
|
||||
/// Directory containing log files (one per author)
|
||||
logs_dir: PathBuf,
|
||||
|
||||
/// Store UUID (16 bytes)
|
||||
store_id: [u8; 16],
|
||||
|
||||
/// Cache of loaded SigChains by author
|
||||
chains: std::collections::HashMap<[u8; 32], SigChain>,
|
||||
}
|
||||
|
||||
impl SigChainManager {
|
||||
/// Create a new manager for a store's logs directory
|
||||
pub fn new(logs_dir: impl AsRef<Path>, store_id: [u8; 16]) -> Self {
|
||||
Self {
|
||||
logs_dir: logs_dir.as_ref().to_path_buf(),
|
||||
store_id,
|
||||
chains: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a SigChain for an author
|
||||
pub fn get_or_create(&mut self, author: [u8; 32]) -> &mut SigChain {
|
||||
self.chains.entry(author).or_insert_with(|| {
|
||||
let author_hex = hex::encode(author);
|
||||
let log_path = self.logs_dir.join(format!("{}.log", author_hex));
|
||||
|
||||
// Try to load existing log, or create new
|
||||
SigChain::from_log(&log_path, self.store_id, author)
|
||||
.unwrap_or_else(|_| SigChain::new(&log_path, self.store_id, author))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the local node's sigchain (for creating new entries)
|
||||
pub fn get(&self, author: &[u8; 32]) -> Option<&SigChain> {
|
||||
self.chains.get(author)
|
||||
}
|
||||
|
||||
/// Append an entry to the appropriate author's log
|
||||
/// This is the main entry point for all entry writes (from put, sync, etc.)
|
||||
pub fn append_entry(&mut self, entry: &SignedEntry) -> Result<(), SigChainError> {
|
||||
let author: [u8; 32] = entry.author_id.clone()
|
||||
.try_into()
|
||||
.map_err(|_| SigChainError::WrongAuthor {
|
||||
expected: "32 bytes".to_string(),
|
||||
got: format!("{} bytes", entry.author_id.len()),
|
||||
})?;
|
||||
|
||||
// For synced entries, we can't validate seq/prev_hash since they may arrive
|
||||
// out of order. Just append to the log file directly.
|
||||
let chain = self.get_or_create(author);
|
||||
append_entry(chain.log_path(), entry)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the logs directory path
|
||||
pub fn logs_dir(&self) -> &Path {
|
||||
&self.logs_dir
|
||||
}
|
||||
|
||||
/// Get log directory statistics (file count, total bytes)
|
||||
pub fn log_stats(&self) -> (usize, u64) {
|
||||
if !self.logs_dir.exists() {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut total_size = 0u64;
|
||||
let mut file_count = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(&self.logs_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if meta.is_file() {
|
||||
total_size += meta.len();
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(file_count, total_size)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::MockClock;
|
||||
use crate::hlc::HLC;
|
||||
use crate::node_identity::NodeIdentity;
|
||||
use crate::proto::{operation, Operation, PutOp};
|
||||
use crate::signed_entry::EntryBuilder;
|
||||
use std::env::temp_dir;
|
||||
|
||||
fn temp_log_path(name: &str) -> PathBuf {
|
||||
temp_dir().join(format!("lattice_sigchain_test_{}.log", name))
|
||||
}
|
||||
|
||||
const TEST_STORE: [u8; 16] = [1u8; 16];
|
||||
|
||||
#[test]
|
||||
fn test_new_sigchain() {
|
||||
let path = temp_log_path("new");
|
||||
let author = [1u8; 32];
|
||||
|
||||
let chain = SigChain::new(&path, TEST_STORE, author);
|
||||
|
||||
assert_eq!(chain.author_id(), &author);
|
||||
assert_eq!(chain.next_seq(), 1);
|
||||
assert_eq!(chain.last_hash(), &[0u8; 32]);
|
||||
assert!(chain.is_empty());
|
||||
assert_eq!(chain.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_entry() {
|
||||
let path = temp_log_path("append");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||
|
||||
let clock = MockClock::new(1000);
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
chain.append(&entry).unwrap();
|
||||
|
||||
assert_eq!(chain.next_seq(), 2);
|
||||
assert_eq!(chain.len(), 1);
|
||||
assert!(!chain.is_empty());
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_multiple() {
|
||||
let path = temp_log_path("multiple");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
for i in 1..=3 {
|
||||
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash(chain.last_hash.to_vec())
|
||||
.put(format!("/key/{}", i), format!("value{}", i).into_bytes())
|
||||
.sign(&node);
|
||||
chain.append(&entry).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(chain.len(), 3);
|
||||
assert_eq!(chain.next_seq(), 4);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_log() {
|
||||
let path = temp_log_path("from_log");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Write some entries
|
||||
{
|
||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||
for i in 1..=3 {
|
||||
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash(chain.last_hash.to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
chain.append(&entry).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload from log
|
||||
let chain = SigChain::from_log(&path, TEST_STORE, author).unwrap();
|
||||
|
||||
assert_eq!(chain.len(), 3);
|
||||
assert_eq!(chain.next_seq(), 4);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_sequence() {
|
||||
let path = temp_log_path("wrong_seq");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Try to append with wrong seq (2 instead of 1)
|
||||
let entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let result = chain.append(&entry);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::InvalidSequence { .. })));
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_prev_hash() {
|
||||
let path = temp_log_path("wrong_prev");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// First entry
|
||||
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"v1".to_vec())
|
||||
.sign(&node);
|
||||
chain.append(&entry1).unwrap();
|
||||
|
||||
// Second entry with wrong prev_hash
|
||||
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash([99u8; 32].to_vec()) // Wrong!
|
||||
.put("/key", b"v2".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let result = chain.append(&entry2);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::InvalidPrevHash { .. })));
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_author() {
|
||||
let path = temp_log_path("wrong_author");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
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);
|
||||
|
||||
// Entry signed by node but chain expects other_author
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.store_id(TEST_STORE.to_vec())
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let result = chain.append(&entry);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::WrongAuthor { .. })));
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_entry() {
|
||||
let path = temp_log_path("create");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||
|
||||
let ops = vec![
|
||||
Operation {
|
||||
op_type: Some(operation::OpType::Put(PutOp {
|
||||
key: b"/test".to_vec(),
|
||||
value: b"hello".to_vec(),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
let signed = chain.create_entry(&node, ops).unwrap();
|
||||
|
||||
assert_eq!(chain.len(), 1);
|
||||
|
||||
// Verify it was written
|
||||
let entries = read_entries(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].entry_bytes, signed.entry_bytes);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_store_id() {
|
||||
let path_a = temp_log_path("storeid_a");
|
||||
let path_b = temp_log_path("storeid_b");
|
||||
std::fs::remove_file(&path_a).ok();
|
||||
std::fs::remove_file(&path_b).ok();
|
||||
|
||||
let node = NodeIdentity::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
let store_a = [0xAAu8; 16];
|
||||
let store_b = [0xBBu8; 16];
|
||||
|
||||
// Create valid entry for store A
|
||||
let mut chain_a = SigChain::new(&path_a, store_a, author);
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.store_id(store_a.to_vec())
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
chain_a.append(&entry).unwrap();
|
||||
|
||||
// Try to replay that entry into store B's chain
|
||||
let mut chain_b = SigChain::new(&path_b, store_b, author);
|
||||
let result = chain_b.append(&entry);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::WrongStoreId { .. })));
|
||||
|
||||
std::fs::remove_file(&path_a).ok();
|
||||
std::fs::remove_file(&path_b).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Signed entry creation and verification
|
||||
//!
|
||||
//! Provides utilities for:
|
||||
//! - Building Entry messages with operations
|
||||
//! - Signing entries to create SignedEntry
|
||||
//! - Verifying signatures
|
||||
//! - Computing entry hashes for prev_hash linking
|
||||
|
||||
use crate::hlc::HLC;
|
||||
use crate::node_identity::{NodeIdentity, NodeError};
|
||||
use crate::proto::{Entry, Hlc, Operation, PutOp, DeleteOp, SignedEntry, operation};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use prost::Message;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during entry operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum EntryError {
|
||||
#[error("Signature verification failed: {0}")]
|
||||
Signature(#[from] NodeError),
|
||||
|
||||
#[error("Proto decode error: {0}")]
|
||||
Decode(#[from] prost::DecodeError),
|
||||
|
||||
#[error("Invalid signature length: expected 64 bytes, got {0}")]
|
||||
InvalidSignatureLength(usize),
|
||||
|
||||
#[error("Invalid public key length: expected 32 bytes, got {0}")]
|
||||
InvalidPublicKeyLength(usize),
|
||||
}
|
||||
|
||||
/// Builder for creating Entry messages
|
||||
pub struct EntryBuilder {
|
||||
version: u32,
|
||||
store_id: Vec<u8>,
|
||||
prev_hash: Vec<u8>,
|
||||
parent_hashes: Vec<Vec<u8>>,
|
||||
seq: u64,
|
||||
timestamp: HLC,
|
||||
ops: Vec<Operation>,
|
||||
}
|
||||
|
||||
impl EntryBuilder {
|
||||
/// Create a new entry builder with the given sequence number and timestamp
|
||||
pub fn new(seq: u64, timestamp: HLC) -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
store_id: Vec::new(),
|
||||
prev_hash: vec![0u8; 32],
|
||||
parent_hashes: Vec::new(),
|
||||
seq,
|
||||
timestamp,
|
||||
ops: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the store ID (16-byte UUID)
|
||||
pub fn store_id(mut self, id: impl Into<Vec<u8>>) -> Self {
|
||||
self.store_id = id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the previous entry hash (for sigchain linking)
|
||||
pub fn prev_hash(mut self, hash: impl Into<Vec<u8>>) -> Self {
|
||||
self.prev_hash = hash.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the parent hashes (for DAG ancestry)
|
||||
pub fn parent_hashes(mut self, hashes: Vec<Vec<u8>>) -> Self {
|
||||
self.parent_hashes = hashes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a Put operation
|
||||
pub fn put(mut self, key: impl Into<Vec<u8>>, value: impl Into<Vec<u8>>) -> Self {
|
||||
self.ops.push(Operation {
|
||||
op_type: Some(operation::OpType::Put(PutOp {
|
||||
key: key.into(),
|
||||
value: value.into(),
|
||||
})),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a Delete operation
|
||||
pub fn delete(mut self, key: impl Into<Vec<u8>>) -> Self {
|
||||
self.ops.push(Operation {
|
||||
op_type: Some(operation::OpType::Delete(DeleteOp {
|
||||
key: key.into(),
|
||||
})),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a raw operation
|
||||
pub fn operation(mut self, op: Operation) -> Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the Entry proto message
|
||||
pub fn build(self) -> Entry {
|
||||
Entry {
|
||||
version: self.version,
|
||||
store_id: self.store_id,
|
||||
prev_hash: self.prev_hash,
|
||||
parent_hashes: self.parent_hashes,
|
||||
seq: self.seq,
|
||||
timestamp: Some(Hlc {
|
||||
wall_time: self.timestamp.wall_time,
|
||||
counter: self.timestamp.counter,
|
||||
}),
|
||||
ops: self.ops,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and sign the entry, returning a 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: &NodeIdentity) -> SignedEntry {
|
||||
let entry_bytes = entry.encode_to_vec();
|
||||
let signature = node.sign(&entry_bytes);
|
||||
|
||||
SignedEntry {
|
||||
entry_bytes,
|
||||
signature: signature.to_bytes().to_vec(),
|
||||
author_id: node.public_key_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a SignedEntry's signature
|
||||
pub fn verify_signed_entry(signed: &SignedEntry) -> Result<Entry, EntryError> {
|
||||
// Parse public key
|
||||
if signed.author_id.len() != 32 {
|
||||
return Err(EntryError::InvalidPublicKeyLength(signed.author_id.len()));
|
||||
}
|
||||
let pk_bytes: [u8; 32] = signed.author_id.clone().try_into().unwrap();
|
||||
let public_key = VerifyingKey::from_bytes(&pk_bytes)
|
||||
.map_err(|_| NodeError::InvalidSignature)?;
|
||||
|
||||
// Parse signature
|
||||
if signed.signature.len() != 64 {
|
||||
return Err(EntryError::InvalidSignatureLength(signed.signature.len()));
|
||||
}
|
||||
let sig_bytes: [u8; 64] = signed.signature.clone().try_into().unwrap();
|
||||
let signature = Signature::from_bytes(&sig_bytes);
|
||||
|
||||
// Verify
|
||||
NodeIdentity::verify_with_key(&public_key, &signed.entry_bytes, &signature)?;
|
||||
|
||||
// Decode entry
|
||||
let entry = Entry::decode(&signed.entry_bytes[..])?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Compute the BLAKE3 hash of a SignedEntry (for prev_hash linking)
|
||||
pub fn hash_signed_entry(signed: &SignedEntry) -> [u8; 32] {
|
||||
let bytes = signed.encode_to_vec();
|
||||
blake3::hash(&bytes).into()
|
||||
}
|
||||
|
||||
/// Compute the BLAKE3 hash of entry_bytes (alternative for lighter hashing)
|
||||
pub fn hash_entry_bytes(entry_bytes: &[u8]) -> [u8; 32] {
|
||||
blake3::hash(entry_bytes).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::MockClock;
|
||||
|
||||
#[test]
|
||||
fn test_entry_builder() {
|
||||
let clock = MockClock::new(1000);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
let entry = EntryBuilder::new(1, hlc)
|
||||
.put("/test/key", b"value".to_vec())
|
||||
.delete("/test/old")
|
||||
.build();
|
||||
|
||||
assert_eq!(entry.version, 1);
|
||||
assert_eq!(entry.seq, 1);
|
||||
assert_eq!(entry.ops.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_and_verify() {
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
let signed = EntryBuilder::new(1, hlc)
|
||||
.put("/nodes/abc", b"test".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
assert_eq!(signed.author_id.len(), 32);
|
||||
assert_eq!(signed.signature.len(), 64);
|
||||
|
||||
// Verify
|
||||
let entry = verify_signed_entry(&signed).unwrap();
|
||||
assert_eq!(entry.seq, 1);
|
||||
assert_eq!(entry.ops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_tampered_fails() {
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
let mut signed = EntryBuilder::new(1, hlc)
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
// Tamper with entry bytes
|
||||
signed.entry_bytes[0] ^= 0xFF;
|
||||
|
||||
assert!(verify_signed_entry(&signed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_wrong_key_fails() {
|
||||
let node1 = NodeIdentity::generate();
|
||||
let node2 = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
let mut signed = EntryBuilder::new(1, hlc)
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node1);
|
||||
|
||||
// Replace author with different key
|
||||
signed.author_id = node2.public_key_bytes().to_vec();
|
||||
|
||||
assert!(verify_signed_entry(&signed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_signed_entry() {
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
let hlc = HLC::now_with_clock(&clock);
|
||||
|
||||
let signed = EntryBuilder::new(1, hlc)
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let hash = hash_signed_entry(&signed);
|
||||
assert_eq!(hash.len(), 32);
|
||||
|
||||
// Same entry should produce same hash
|
||||
let hash2 = hash_signed_entry(&signed);
|
||||
assert_eq!(hash, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prev_hash_chaining() {
|
||||
let node = NodeIdentity::generate();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// First entry
|
||||
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.put("/key", b"v1".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let hash1 = hash_signed_entry(&entry1);
|
||||
|
||||
// Second entry links to first
|
||||
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||
.prev_hash(hash1)
|
||||
.put("/key", b"v2".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let decoded = verify_signed_entry(&entry2).unwrap();
|
||||
assert_eq!(decoded.prev_hash, hash1.to_vec());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
//! 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, broadcast};
|
||||
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 {
|
||||
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 {
|
||||
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>>,
|
||||
},
|
||||
LogStats {
|
||||
resp: oneshot::Sender<(usize, u64)>,
|
||||
},
|
||||
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,
|
||||
node: NodeIdentity,
|
||||
rx: mpsc::Receiver<StoreCmd>,
|
||||
/// Broadcast sender for emitting entries after they're committed locally
|
||||
entry_tx: broadcast::Sender<SignedEntry>,
|
||||
}
|
||||
|
||||
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>,
|
||||
entry_tx: broadcast::Sender<SignedEntry>,
|
||||
) -> 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,
|
||||
entry_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 { 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);
|
||||
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::LogStats { resp } => {
|
||||
let _ = resp.send(self.chain_manager.log_stats());
|
||||
}
|
||||
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)?;
|
||||
|
||||
// Broadcast the entry to listeners (for gossip)
|
||||
let _ = self.entry_tx.send(entry.clone());
|
||||
|
||||
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 (cmd_tx, entry_tx, 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>, broadcast::Sender<SignedEntry>, JoinHandle<()>) {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let (entry_tx, _entry_rx) = broadcast::channel(64);
|
||||
let actor = StoreActor::new(store_id, store, sigchain, node, rx, entry_tx.clone());
|
||||
let handle = thread::spawn(move || actor.run());
|
||||
(tx, entry_tx, handle)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Sync state for causality tracking and reconciliation
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Author ID type (32-byte Ed25519 public key)
|
||||
pub type Author = [u8; 32];
|
||||
|
||||
/// Per-author sync information: seq + all head hashes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorInfo {
|
||||
pub seq: u64,
|
||||
pub heads: HashSet<[u8; 32]>, // All head hashes for this author
|
||||
}
|
||||
|
||||
impl AuthorInfo {
|
||||
pub fn new(seq: u64, hash: [u8; 32]) -> Self {
|
||||
let mut heads = HashSet::new();
|
||||
heads.insert(hash);
|
||||
Self { seq, heads }
|
||||
}
|
||||
|
||||
pub fn with_heads(seq: u64, heads: HashSet<[u8; 32]>) -> Self {
|
||||
Self { seq, heads }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync state tracking per-author sequence numbers and head hashes.
|
||||
///
|
||||
/// Used during reconciliation to identify missing entries between peers.
|
||||
/// Tracks all head hashes per author to handle forks correctly.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SyncState {
|
||||
authors: HashMap<Author, AuthorInfo>,
|
||||
}
|
||||
|
||||
/// Describes entries needed from a peer for a specific author.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MissingRange {
|
||||
pub author: Author,
|
||||
pub from_seq: u64, // exclusive - we have up to this
|
||||
pub from_hash: [u8; 32], // hash to resume reading after (zero = start)
|
||||
pub to_seq: u64, // inclusive - peer has up to this
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
/// Create a new empty sync state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
authors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the info for an author (returns None if not present).
|
||||
pub fn get(&self, author: &Author) -> Option<&AuthorInfo> {
|
||||
self.authors.get(author)
|
||||
}
|
||||
|
||||
/// Get the sequence number for an author (returns 0 if not present).
|
||||
pub fn seq(&self, author: &Author) -> u64 {
|
||||
self.authors.get(author).map(|i| i.seq).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get head hashes for an author (returns empty set if not present).
|
||||
pub fn heads(&self, author: &Author) -> HashSet<[u8; 32]> {
|
||||
self.authors.get(author).map(|i| i.heads.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set the info for an author (single hash convenience method).
|
||||
pub fn set(&mut self, author: Author, seq: u64, hash: [u8; 32]) {
|
||||
self.authors.insert(author, AuthorInfo::new(seq, hash));
|
||||
}
|
||||
|
||||
/// Set the info for an author with multiple heads.
|
||||
pub fn set_heads(&mut self, author: Author, seq: u64, heads: HashSet<[u8; 32]>) {
|
||||
self.authors.insert(author, AuthorInfo::with_heads(seq, heads));
|
||||
}
|
||||
|
||||
/// Add a head hash for an author (updates seq if higher).
|
||||
pub fn add_head(&mut self, author: Author, seq: u64, hash: [u8; 32]) {
|
||||
if let Some(info) = self.authors.get_mut(&author) {
|
||||
info.heads.insert(hash);
|
||||
if seq > info.seq {
|
||||
info.seq = seq;
|
||||
}
|
||||
} else {
|
||||
self.set(author, seq, hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all authors and their info.
|
||||
pub fn authors(&self) -> &HashMap<Author, AuthorInfo> {
|
||||
&self.authors
|
||||
}
|
||||
|
||||
/// Compute what entries we're missing compared to a peer's state.
|
||||
///
|
||||
/// Returns ranges of entries we need from the peer.
|
||||
/// Compares hash sets when seq matches to detect forks.
|
||||
pub fn diff(&self, peer: &SyncState) -> Vec<MissingRange> {
|
||||
let mut missing = Vec::new();
|
||||
|
||||
for (author, peer_info) in peer.authors() {
|
||||
let my_seq = self.seq(author);
|
||||
let my_heads = self.heads(author);
|
||||
|
||||
// We need entries if:
|
||||
// 1. Peer's seq is higher than ours, OR
|
||||
// 2. Peer's seq equals ours but they have heads we don't (fork)
|
||||
let need_entries = if peer_info.seq > my_seq {
|
||||
true
|
||||
} else if peer_info.seq == my_seq && my_seq > 0 {
|
||||
// Same seq - check for forks (different hashes at same seq)
|
||||
peer_info.heads.iter().any(|h| !my_heads.contains(h))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if need_entries {
|
||||
// Request from our common ancestor (or start if we have nothing)
|
||||
let from_hash = if my_heads.is_empty() {
|
||||
[0u8; 32]
|
||||
} else {
|
||||
*my_heads.iter().next().unwrap()
|
||||
};
|
||||
|
||||
missing.push(MissingRange {
|
||||
author: *author,
|
||||
from_seq: my_seq,
|
||||
from_hash,
|
||||
to_seq: peer_info.seq,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
missing
|
||||
}
|
||||
|
||||
/// Merge another sync state into this one (union of heads, max seq).
|
||||
pub fn merge(&mut self, other: &SyncState) {
|
||||
for (author, info) in other.authors() {
|
||||
if let Some(my_info) = self.authors.get_mut(author) {
|
||||
// Union heads
|
||||
for h in &info.heads {
|
||||
my_info.heads.insert(*h);
|
||||
}
|
||||
// Take max seq
|
||||
if info.seq > my_info.seq {
|
||||
my_info.seq = info.seq;
|
||||
}
|
||||
} else {
|
||||
self.authors.insert(*author, info.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to proto message for network transmission
|
||||
pub fn to_proto(&self) -> crate::proto::SyncState {
|
||||
let frontiers = self.authors.iter().map(|(author, info)| {
|
||||
crate::proto::Frontier {
|
||||
author_id: author.to_vec(),
|
||||
max_seq: info.seq,
|
||||
head_hashes: info.heads.iter().map(|h| h.to_vec()).collect(),
|
||||
}
|
||||
}).collect();
|
||||
crate::proto::SyncState {
|
||||
frontiers,
|
||||
sender_hlc: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from proto message
|
||||
pub fn from_proto(proto: &crate::proto::SyncState) -> Self {
|
||||
let mut state = Self::new();
|
||||
for frontier in &proto.frontiers {
|
||||
if frontier.author_id.len() == 32 {
|
||||
let mut author = [0u8; 32];
|
||||
author.copy_from_slice(&frontier.author_id);
|
||||
|
||||
let mut heads = HashSet::new();
|
||||
for hash_bytes in &frontier.head_hashes {
|
||||
if hash_bytes.len() == 32 {
|
||||
let mut hash = [0u8; 32];
|
||||
hash.copy_from_slice(hash_bytes);
|
||||
heads.insert(hash);
|
||||
}
|
||||
}
|
||||
|
||||
if heads.is_empty() {
|
||||
// Fallback: empty hash if no heads provided
|
||||
heads.insert([0u8; 32]);
|
||||
}
|
||||
|
||||
state.set_heads(author, frontier.max_seq, heads);
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diff_empty() {
|
||||
let a = SyncState::new();
|
||||
let b = SyncState::new();
|
||||
assert!(a.diff(&b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_peer_ahead() {
|
||||
let mut a = SyncState::new();
|
||||
let mut b = SyncState::new();
|
||||
|
||||
let author = [1u8; 32];
|
||||
let hash_a = [0xAA; 32];
|
||||
let hash_b = [0xBB; 32];
|
||||
|
||||
a.set(author, 5, hash_a);
|
||||
b.set(author, 10, hash_b);
|
||||
|
||||
let missing = a.diff(&b);
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0].author, author);
|
||||
assert_eq!(missing[0].from_seq, 5);
|
||||
assert_eq!(missing[0].from_hash, hash_a); // Resume after our hash
|
||||
assert_eq!(missing[0].to_seq, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_i_am_ahead() {
|
||||
let mut a = SyncState::new();
|
||||
let mut b = SyncState::new();
|
||||
|
||||
let author = [1u8; 32];
|
||||
a.set(author, 10, [0xAA; 32]);
|
||||
b.set(author, 5, [0xBB; 32]);
|
||||
|
||||
// I'm ahead, so I don't need anything from peer
|
||||
let missing = a.diff(&b);
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_new_author() {
|
||||
let a = SyncState::new();
|
||||
let mut b = SyncState::new();
|
||||
|
||||
let author = [2u8; 32];
|
||||
b.set(author, 3, [0xBB; 32]);
|
||||
|
||||
// Peer has author I don't have
|
||||
let missing = a.diff(&b);
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0].from_seq, 0);
|
||||
assert_eq!(missing[0].from_hash, [0u8; 32]); // Zero hash = read from start
|
||||
assert_eq!(missing[0].to_seq, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge() {
|
||||
let mut a = SyncState::new();
|
||||
let mut b = SyncState::new();
|
||||
|
||||
let author1 = [1u8; 32];
|
||||
let author2 = [2u8; 32];
|
||||
|
||||
a.set(author1, 10, [0xA1; 32]);
|
||||
a.set(author2, 5, [0xA2; 32]);
|
||||
|
||||
b.set(author1, 5, [0xB1; 32]); // a is ahead
|
||||
b.set(author2, 8, [0xB2; 32]); // b is ahead
|
||||
|
||||
a.merge(&b);
|
||||
assert_eq!(a.seq(&author1), 10); // kept a's value
|
||||
assert_eq!(a.seq(&author2), 8); // took b's value
|
||||
}
|
||||
|
||||
/// This test documents a known issue: SyncState tracks only ONE hash per author,
|
||||
/// but with forks/multi-heads, there could be multiple branches.
|
||||
///
|
||||
/// Scenario:
|
||||
/// - Author writes entry1 (hash=A)
|
||||
/// - Two peers independently write entry2 and entry3 (both have prev=A)
|
||||
/// - Peer1 has: entry1 -> entry2 (seq=2, hash=B)
|
||||
/// - Peer2 has: entry1 -> entry3 (seq=2, hash=C)
|
||||
/// - When Peer3 syncs with Peer1, SyncState says "I need entries after hash=B"
|
||||
/// - But Peer2 only has entries after hash=A, so Peer3 never gets entry3!
|
||||
///
|
||||
#[test]
|
||||
fn test_multihead_sync_inconsistency() {
|
||||
// This is a conceptual test showing the problem
|
||||
// In reality, both forks would have seq=2 but different hashes
|
||||
// SyncState can only track one, so the other branch gets lost
|
||||
|
||||
let mut peer1_state = SyncState::new();
|
||||
let mut peer2_state = SyncState::new();
|
||||
let new_peer_state = SyncState::new();
|
||||
|
||||
let author = [1u8; 32];
|
||||
|
||||
// Both peers have seq=2, but different hashes (different forks)
|
||||
peer1_state.set(author, 2, [0xBB; 32]); // entry1 -> entry2
|
||||
peer2_state.set(author, 2, [0xCC; 32]); // entry1 -> entry3
|
||||
|
||||
// New peer syncs with peer1 first
|
||||
let missing_from_peer1 = new_peer_state.diff(&peer1_state);
|
||||
assert_eq!(missing_from_peer1.len(), 1);
|
||||
assert_eq!(missing_from_peer1[0].to_seq, 2);
|
||||
|
||||
// After applying peer1's entries, new peer has seq=2, hash=BB
|
||||
let mut after_peer1 = new_peer_state.clone();
|
||||
after_peer1.set(author, 2, [0xBB; 32]);
|
||||
|
||||
// Now sync with peer2 - BUG: new peer thinks it's up to date!
|
||||
let missing_from_peer2 = after_peer1.diff(&peer2_state);
|
||||
|
||||
// This assertion FAILS - we get empty missing even though peer2 has entry3!
|
||||
// The bug: peer2's seq=2 equals our seq=2, so we think we're in sync
|
||||
// But peer2's hash=0xCC != our hash=0xBB - they have different entries!
|
||||
assert!(!missing_from_peer2.is_empty(),
|
||||
"BUG: SyncState misses peer2's fork because seq numbers match");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "lattice-net"
|
||||
description = "Networking layer for Lattice using Iroh"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
lattice-core = { workspace = true }
|
||||
iroh = { workspace = true }
|
||||
iroh-gossip = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
blake3.workspace = true
|
||||
anyhow = "1.0.100"
|
||||
futures-lite = "2.6.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Iroh endpoint for network connectivity
|
||||
//!
|
||||
//! Creates an Iroh endpoint from the node's Ed25519 secret key,
|
||||
//! ensuring the same identity is used for both Lattice and Iroh.
|
||||
//!
|
||||
//! Discovery: Uses both DNS (default) and mDNS (local network)
|
||||
|
||||
use iroh::{Endpoint, endpoint::{BindError, Connection, ConnectError}};
|
||||
use iroh::discovery::mdns::MdnsDiscovery;
|
||||
pub use iroh::PublicKey;
|
||||
|
||||
/// ALPN protocol identifier for Lattice sync
|
||||
pub const LATTICE_ALPN: &[u8] = b"lattice-sync/1";
|
||||
|
||||
/// Wrapper around Iroh endpoint with Lattice integration
|
||||
pub struct LatticeEndpoint {
|
||||
endpoint: Endpoint,
|
||||
}
|
||||
|
||||
impl LatticeEndpoint {
|
||||
/// Create a new endpoint from Ed25519 secret key bytes (from identity.key)
|
||||
/// Enables both DNS discovery (internet) and mDNS discovery (local network)
|
||||
pub async fn new(secret_key_bytes: [u8; 32]) -> Result<Self, BindError> {
|
||||
let secret_key = iroh::SecretKey::from_bytes(&secret_key_bytes);
|
||||
|
||||
// mDNS for local network discovery
|
||||
let mdns = MdnsDiscovery::builder();
|
||||
|
||||
let endpoint = Endpoint::builder()
|
||||
.secret_key(secret_key)
|
||||
.alpns(vec![
|
||||
LATTICE_ALPN.to_vec(),
|
||||
iroh_gossip::ALPN.to_vec(), // Also accept gossip protocol
|
||||
])
|
||||
.discovery(mdns) // Add mDNS on top of default DNS
|
||||
.bind()
|
||||
.await?;
|
||||
Ok(Self { endpoint })
|
||||
}
|
||||
|
||||
/// Get the public key (same as Lattice pubkey, can be shared with peers)
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.endpoint.secret_key().public()
|
||||
}
|
||||
|
||||
/// Connect to a peer by their public key
|
||||
pub async fn connect(&self, peer: PublicKey) -> Result<Connection, ConnectError> {
|
||||
self.endpoint.connect(peer, LATTICE_ALPN).await
|
||||
}
|
||||
|
||||
/// Accept an incoming connection
|
||||
pub async fn accept(&self) -> Option<iroh::endpoint::Incoming> {
|
||||
self.endpoint.accept().await
|
||||
}
|
||||
|
||||
/// Get the underlying endpoint
|
||||
pub fn endpoint(&self) -> &Endpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Message framing for Iroh streams using tokio-util LengthDelimitedCodec
|
||||
//!
|
||||
//! Provides a clean interface for sending/receiving length-prefixed PeerMessage
|
||||
//! over QUIC streams without manual buffer management.
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use lattice_core::proto::PeerMessage;
|
||||
use prost::Message;
|
||||
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||
|
||||
/// Framed writer for sending PeerMessage over an Iroh SendStream
|
||||
pub struct MessageSink {
|
||||
inner: FramedWrite<iroh::endpoint::SendStream, LengthDelimitedCodec>,
|
||||
}
|
||||
|
||||
impl MessageSink {
|
||||
pub fn new(stream: iroh::endpoint::SendStream) -> Self {
|
||||
Self {
|
||||
inner: FramedWrite::new(stream, LengthDelimitedCodec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a PeerMessage (length-prefixed)
|
||||
pub async fn send(&mut self, msg: &PeerMessage) -> Result<(), String> {
|
||||
let bytes = msg.encode_to_vec();
|
||||
self.inner.send(bytes.into()).await
|
||||
.map_err(|e| format!("Send error: {}", e))
|
||||
}
|
||||
|
||||
/// Finish the stream (signal we're done sending)
|
||||
pub async fn finish(self) -> Result<(), String> {
|
||||
let mut stream = self.inner.into_inner();
|
||||
let _ = stream.finish();
|
||||
stream.stopped().await.ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Framed reader for receiving PeerMessage from an Iroh RecvStream
|
||||
pub struct MessageStream {
|
||||
inner: FramedRead<iroh::endpoint::RecvStream, LengthDelimitedCodec>,
|
||||
}
|
||||
|
||||
impl MessageStream {
|
||||
pub fn new(stream: iroh::endpoint::RecvStream) -> Self {
|
||||
Self {
|
||||
inner: FramedRead::new(stream, LengthDelimitedCodec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive next PeerMessage (or None if stream closed)
|
||||
pub async fn recv(&mut self) -> Result<Option<PeerMessage>, String> {
|
||||
match self.inner.next().await {
|
||||
Some(Ok(bytes)) => {
|
||||
PeerMessage::decode(&bytes[..])
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Decode error: {}", e))
|
||||
}
|
||||
Some(Err(e)) => Err(format!("Read error: {}", e)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Gossip protocol for broadcasting changes
|
||||
|
||||
// TODO: Implement gossip using iroh-gossip
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Lattice Networking
|
||||
//!
|
||||
//! Networking layer using Iroh:
|
||||
//! - **Endpoint**: Network identity and connection management
|
||||
//! - **Gossip**: Broadcasting changes across the mesh
|
||||
//! - **Unicast**: Point-to-point communication for reconciliation
|
||||
//! - **Framing**: Length-delimited message framing for QUIC streams
|
||||
//! - **Mesh**: Peer-to-peer join and sync operations
|
||||
|
||||
pub mod endpoint;
|
||||
pub mod gossip;
|
||||
pub mod framing;
|
||||
pub mod mesh;
|
||||
|
||||
pub use endpoint::{LatticeEndpoint, PublicKey, LATTICE_ALPN};
|
||||
pub use framing::{MessageSink, MessageStream};
|
||||
pub use lattice_core::proto::{SyncRequest, SyncResponse, SyncEntry, SyncDone, SyncState, Frontier};
|
||||
pub use mesh::{LatticeServer, SyncResult};
|
||||
|
||||
/// Parse a PublicKey (NodeId) from hex or base32 string
|
||||
pub fn parse_node_id(s: &str) -> Result<PublicKey, String> {
|
||||
s.parse().map_err(|e| format!("{}", e))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Mesh networking - peer-to-peer join and sync operations
|
||||
//!
|
||||
//! - **server**: LatticeServer for mesh networking (join, sync, accept loop)
|
||||
//! - **protocol**: Shared send/receive entry logic
|
||||
|
||||
mod server;
|
||||
mod protocol;
|
||||
|
||||
pub use server::{LatticeServer, SyncResult};
|
||||
pub use protocol::{send_missing_entries, receive_entries};
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Protocol - shared logic for bidirectional sync entry exchange
|
||||
|
||||
use crate::{MessageSink, MessageStream};
|
||||
use lattice_core::{StoreHandle, CausalEntryIter};
|
||||
use lattice_core::proto::{peer_message, PeerMessage, SignedEntry};
|
||||
use lattice_core::sync_state::SyncState;
|
||||
use prost::Message;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Send entries that peer is missing based on state diff.
|
||||
/// Returns (entries_sent, optional_error).
|
||||
pub async fn send_missing_entries(
|
||||
sink: &mut MessageSink,
|
||||
store: &StoreHandle,
|
||||
my_state: &SyncState,
|
||||
peer_state: &SyncState,
|
||||
) -> Result<u64, String> {
|
||||
let missing = peer_state.diff(my_state);
|
||||
|
||||
// Build queues for each author's entries
|
||||
let mut author_entries: Vec<VecDeque<SignedEntry>> = Vec::new();
|
||||
for range in missing {
|
||||
let from_hash = if range.from_hash == [0u8; 32] { None } else { Some(range.from_hash) };
|
||||
let entries = store.read_entries_after(&range.author, from_hash).await
|
||||
.map_err(|e| format!("Failed to read entries: {}", e))?;
|
||||
if !entries.is_empty() {
|
||||
author_entries.push(entries.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Stream entries in HLC (causal) order
|
||||
let mut entries_sent = 0u64;
|
||||
for entry in CausalEntryIter::new(author_entries) {
|
||||
let sync_msg = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncEntry(lattice_core::proto::SyncEntry {
|
||||
signed_entry: entry.encode_to_vec(),
|
||||
hash: vec![],
|
||||
})),
|
||||
};
|
||||
sink.send(&sync_msg).await?;
|
||||
entries_sent += 1;
|
||||
}
|
||||
|
||||
// Send SyncDone
|
||||
let done = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncDone(lattice_core::proto::SyncDone {
|
||||
entries_sent,
|
||||
})),
|
||||
};
|
||||
sink.send(&done).await?;
|
||||
|
||||
Ok(entries_sent)
|
||||
}
|
||||
|
||||
/// Receive and apply entries until SyncDone is received.
|
||||
/// Returns (entries_applied, entries_reported_by_peer).
|
||||
pub async fn receive_entries(
|
||||
stream: &mut MessageStream,
|
||||
store: &StoreHandle,
|
||||
) -> Result<(u64, u64), String> {
|
||||
let mut entries_applied = 0u64;
|
||||
let mut entries_reported = 0u64;
|
||||
|
||||
loop {
|
||||
match stream.recv().await {
|
||||
Ok(Some(msg)) => match msg.message {
|
||||
Some(peer_message::Message::SyncEntry(entry)) => {
|
||||
if let Ok(signed) = SignedEntry::decode(&entry.signed_entry[..]) {
|
||||
if store.apply_entry(signed).await.is_ok() {
|
||||
entries_applied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(peer_message::Message::SyncDone(done)) => {
|
||||
entries_reported = done.entries_sent;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok((entries_applied, entries_reported))
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Server - LatticeServer for mesh networking
|
||||
|
||||
use crate::{MessageSink, MessageStream, LatticeEndpoint, parse_node_id, LATTICE_ALPN};
|
||||
use lattice_core::{Node, NodeError, NodeEvent, PeerStatus, Uuid, StoreHandle};
|
||||
use iroh::endpoint::Connection;
|
||||
use iroh::protocol::{Router, ProtocolHandler, AcceptError};
|
||||
use iroh_gossip::Gossip;
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use futures_util::StreamExt;
|
||||
use lattice_core::proto::{PeerMessage, peer_message, JoinRequest, JoinResponse, SignedEntry};
|
||||
use prost::Message;
|
||||
use super::protocol;
|
||||
|
||||
/// Result of a sync operation with a peer
|
||||
pub struct SyncResult {
|
||||
pub entries_applied: u64,
|
||||
pub entries_sent_by_peer: u64,
|
||||
}
|
||||
|
||||
/// LatticeServer wraps Node + Endpoint + Gossip and provides mesh networking methods.
|
||||
/// Uses Router to handle incoming connections for both sync and gossip protocols.
|
||||
pub struct LatticeServer {
|
||||
node: Arc<Node>,
|
||||
endpoint: LatticeEndpoint,
|
||||
gossip: Gossip,
|
||||
#[allow(dead_code)]
|
||||
router: Router,
|
||||
/// Gossip senders per store topic
|
||||
gossip_senders: Arc<RwLock<HashMap<Uuid, iroh_gossip::api::GossipSender>>>,
|
||||
}
|
||||
|
||||
/// Protocol handler for lattice sync connections
|
||||
struct SyncProtocol {
|
||||
node: Arc<Node>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SyncProtocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SyncProtocol").finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl ProtocolHandler for SyncProtocol {
|
||||
fn accept(&self, conn: Connection) -> impl std::future::Future<Output = Result<(), AcceptError>> + Send {
|
||||
let node = self.node.clone();
|
||||
Box::pin(async move {
|
||||
if let Err(e) = handle_connection(node, conn).await {
|
||||
eprintln!("[Accept] Error: {}", e);
|
||||
// Log error but return Ok - protocol handled the connection
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LatticeServer {
|
||||
/// Create a new LatticeServer from just a Node (creates endpoint internally).
|
||||
pub async fn new_from_node(node: Arc<Node>) -> Result<Self, String> {
|
||||
let endpoint = LatticeEndpoint::new(node.secret_key_bytes()).await
|
||||
.map_err(|e| format!("Failed to create endpoint: {}", e))?;
|
||||
Self::new(node, endpoint).await
|
||||
}
|
||||
|
||||
/// Create a new LatticeServer with existing endpoint.
|
||||
pub async fn new(node: Arc<Node>, endpoint: LatticeEndpoint) -> Result<Self, String> {
|
||||
// Create gossip instance
|
||||
let gossip = Gossip::builder().spawn(endpoint.endpoint().clone());
|
||||
|
||||
// Create sync protocol handler
|
||||
let sync_protocol = SyncProtocol { node: node.clone() };
|
||||
|
||||
// Create router to handle both protocols
|
||||
let router = Router::builder(endpoint.endpoint().clone())
|
||||
.accept(LATTICE_ALPN, sync_protocol)
|
||||
.accept(iroh_gossip::ALPN, gossip.clone())
|
||||
.spawn();
|
||||
|
||||
let server = Self {
|
||||
node,
|
||||
endpoint,
|
||||
gossip,
|
||||
router,
|
||||
gossip_senders: Arc::new(RwLock::new(HashMap::new())),
|
||||
};
|
||||
server.spawn_node_event_listener();
|
||||
|
||||
// If root store is already open, start gossip for it
|
||||
if let Some(store) = (*server.node.root_store().await).clone() {
|
||||
println!("[Gossip] Root store already open, starting gossip...");
|
||||
server.join_gossip_topic(store.id()).await?;
|
||||
server.spawn_entry_forward_loop(store);
|
||||
}
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Spawn a listener for Node events (auto-starts gossip when root store is activated)
|
||||
fn spawn_node_event_listener(&self) {
|
||||
let mut event_rx = self.node.subscribe_events();
|
||||
let gossip_senders = self.gossip_senders.clone();
|
||||
let gossip = self.gossip.clone();
|
||||
let node = self.node.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = event_rx.recv().await {
|
||||
match event {
|
||||
NodeEvent::RootStoreActivated(store) => {
|
||||
println!("[Gossip] Root store activated: {}, starting gossip...", store.id());
|
||||
|
||||
let store_id = store.id();
|
||||
|
||||
// Get bootstrap peers from node's peer list
|
||||
let bootstrap_peers: Vec<iroh::PublicKey> = match node.list_peers().await {
|
||||
Ok(peers) => {
|
||||
peers.iter()
|
||||
.filter(|p| p.status == PeerStatus::Active)
|
||||
.filter_map(|p| parse_node_id(&p.pubkey).ok())
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Gossip] Failed to list peers: {}, using empty list", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
println!("[Gossip] Bootstrap peers: {}", bootstrap_peers.len());
|
||||
|
||||
// Topic ID from hash of "lattice/{store_id}" for namespacing
|
||||
let topic_bytes = blake3::hash(format!("lattice/{}", store_id).as_bytes());
|
||||
let topic_id = iroh_gossip::TopicId::from_bytes(*topic_bytes.as_bytes());
|
||||
|
||||
// Use subscribe (non-blocking) - peers will connect when they sync
|
||||
// subscribe_and_join would block waiting for peers we can't reach yet
|
||||
match gossip.subscribe(topic_id, bootstrap_peers).await {
|
||||
Ok(sub) => {
|
||||
let (sender, receiver) = sub.split();
|
||||
|
||||
// Store sender
|
||||
gossip_senders.write().await.insert(store_id, sender);
|
||||
|
||||
// Spawn receive loop
|
||||
let store_recv = store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut receiver = receiver;
|
||||
println!("[Gossip] Receive loop started for topic {:?}", topic_id);
|
||||
|
||||
while let Some(event) = futures_util::StreamExt::next(&mut receiver).await {
|
||||
match event {
|
||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||
println!("[Gossip] Received {} bytes", msg.content.len());
|
||||
if let Ok(entry) = SignedEntry::decode(&msg.content[..]) {
|
||||
if let Err(e) = store_recv.apply_entry(entry).await {
|
||||
eprintln!("[Gossip] Failed to apply entry: {}", e);
|
||||
} else {
|
||||
println!("[Gossip] Applied entry successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
println!("[Gossip] Event: {:?}", other);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Gossip] Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn entry forward loop
|
||||
let gossip_senders = gossip_senders.clone();
|
||||
let mut entry_rx = store.subscribe_entries();
|
||||
tokio::spawn(async move {
|
||||
println!("[Gossip] Entry forward loop started for store {}", store_id);
|
||||
while let Ok(entry) = entry_rx.recv().await {
|
||||
let senders = gossip_senders.read().await;
|
||||
if let Some(sender) = senders.get(&store_id) {
|
||||
let bytes = entry.encode_to_vec();
|
||||
println!("[Gossip] Broadcasting {} bytes", bytes.len());
|
||||
let _ = sender.broadcast(bytes.into()).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
println!("[Gossip] Gossip started for store {}", store_id);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Gossip] Failed to subscribe to topic: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start gossip for a store (call after store is opened)
|
||||
pub async fn start_gossip_for_store(&self, store: StoreHandle) -> Result<(), String> {
|
||||
println!("[Gossip] Starting gossip for store {}", store.id());
|
||||
self.join_gossip_topic(store.id()).await?;
|
||||
self.spawn_entry_forward_loop(store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a loop that forwards local store entry broadcasts to gossip
|
||||
fn spawn_entry_forward_loop(&self, store: StoreHandle) {
|
||||
let store_id = store.id();
|
||||
let gossip_senders = self.gossip_senders.clone();
|
||||
let mut entry_rx = store.subscribe_entries();
|
||||
|
||||
println!("[Gossip] Starting entry forward loop for store {}", store_id);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok(entry) = entry_rx.recv().await {
|
||||
println!("[Gossip] Received local entry, forwarding to gossip...");
|
||||
// Forward to gossip sender
|
||||
let senders = gossip_senders.read().await;
|
||||
if let Some(sender) = senders.get(&store_id) {
|
||||
let bytes = entry.encode_to_vec();
|
||||
println!("[Gossip] Broadcasting {} bytes to topic {}", bytes.len(), store_id);
|
||||
if let Err(e) = sender.broadcast(bytes.into()).await {
|
||||
eprintln!("[Gossip] Failed to broadcast entry: {}", e);
|
||||
} else {
|
||||
println!("[Gossip] Broadcast successful");
|
||||
}
|
||||
} else {
|
||||
eprintln!("[Gossip] No gossip sender for store {}", store_id);
|
||||
}
|
||||
}
|
||||
println!("[Gossip] Entry forward loop ended for store {}", store_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Access the underlying node
|
||||
pub fn node(&self) -> &Node {
|
||||
&self.node
|
||||
}
|
||||
|
||||
/// Access the underlying endpoint
|
||||
pub fn endpoint(&self) -> &LatticeEndpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
|
||||
/// Join gossip topic for a store (subscribes and spawns receive loop)
|
||||
pub async fn join_gossip_topic(&self, store_id: Uuid) -> Result<(), String> {
|
||||
// Get active peers to bootstrap gossip
|
||||
let peers = self.node.list_peers().await
|
||||
.map_err(|e| format!("Failed to list peers: {}", e))?;
|
||||
|
||||
let bootstrap_peers: Vec<iroh::PublicKey> = peers.iter()
|
||||
.filter(|p| p.status == PeerStatus::Active)
|
||||
.filter_map(|p| parse_node_id(&p.pubkey).ok())
|
||||
.collect();
|
||||
|
||||
println!("[Gossip] Joining topic {} with {} bootstrap peers", store_id, bootstrap_peers.len());
|
||||
|
||||
// Topic ID from store UUID bytes (padded to 32 bytes)
|
||||
// Topic ID from hash of "lattice/{store_id}" for namespacing
|
||||
let topic_bytes = blake3::hash(format!("lattice/{}", store_id).as_bytes());
|
||||
let topic_id = iroh_gossip::TopicId::from_bytes(*topic_bytes.as_bytes());
|
||||
|
||||
// Subscribe to topic
|
||||
let (sender, mut receiver) = self.gossip.subscribe(topic_id, bootstrap_peers).await
|
||||
.map_err(|e| format!("Failed to subscribe to gossip topic: {}", e))?
|
||||
.split();
|
||||
|
||||
// Store sender for broadcasting
|
||||
{
|
||||
let mut senders = self.gossip_senders.write().await;
|
||||
senders.insert(store_id, sender);
|
||||
}
|
||||
|
||||
// Spawn receive loop
|
||||
let node = self.node.clone();
|
||||
let topic = topic_id;
|
||||
tokio::spawn(async move {
|
||||
// StreamExt imported at module level
|
||||
println!("[Gossip] Receive loop started for topic {:?}", topic);
|
||||
|
||||
while let Some(event) = receiver.next().await {
|
||||
match event {
|
||||
Ok(iroh_gossip::api::Event::Received(message)) => {
|
||||
println!("[Gossip] Received gossip message: {} bytes", message.content.len());
|
||||
// Decode SignedEntry and apply
|
||||
match SignedEntry::decode(&message.content[..]) {
|
||||
Ok(entry) => {
|
||||
println!("[Gossip] Decoded entry, applying...");
|
||||
// Find store and apply entry
|
||||
if let Some(store) = (*node.root_store().await).clone() {
|
||||
if let Err(e) = store.apply_entry(entry.into()).await {
|
||||
eprintln!("[Gossip] Failed to apply entry: {}", e);
|
||||
} else {
|
||||
println!("[Gossip] Entry applied successfully");
|
||||
}
|
||||
} else {
|
||||
eprintln!("[Gossip] No root store to apply entry to");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[Gossip] Failed to decode entry: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
println!("[Gossip] Other event: {:?}", other);
|
||||
}
|
||||
Err(e) => eprintln!("[Gossip] Receive error: {}", e),
|
||||
}
|
||||
}
|
||||
println!("[Gossip] Receive loop ended for topic");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast an entry to all gossip subscribers for a store
|
||||
pub async fn broadcast_entry(&self, store_id: Uuid, entry: &SignedEntry) -> Result<(), String> {
|
||||
let senders = self.gossip_senders.read().await;
|
||||
if let Some(sender) = senders.get(&store_id) {
|
||||
let bytes = entry.encode_to_vec();
|
||||
sender.broadcast(bytes.into()).await
|
||||
.map_err(|e| format!("Gossip broadcast failed: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join an existing mesh by connecting to a peer.
|
||||
pub async fn join_mesh(&self, peer_id: iroh::PublicKey) -> Result<StoreHandle, NodeError> {
|
||||
let conn = self.endpoint.connect(peer_id).await
|
||||
.map_err(|e| NodeError::Actor(format!("Connection failed: {}", e)))?;
|
||||
|
||||
let (send, recv) = conn.open_bi().await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to open stream: {}", e)))?;
|
||||
|
||||
let mut sink = MessageSink::new(send);
|
||||
let mut stream = MessageStream::new(recv);
|
||||
|
||||
// Send JoinRequest
|
||||
let req = PeerMessage {
|
||||
message: Some(peer_message::Message::JoinRequest(JoinRequest {
|
||||
node_pubkey: self.node.node_id().to_vec(),
|
||||
})),
|
||||
};
|
||||
sink.send(&req).await.map_err(|e| NodeError::Actor(e))?;
|
||||
sink.finish().await.map_err(|e| NodeError::Actor(e))?;
|
||||
|
||||
// Receive JoinResponse
|
||||
let msg = stream.recv().await
|
||||
.map_err(|e| NodeError::Actor(e))?
|
||||
.ok_or_else(|| NodeError::Actor("Peer closed stream".to_string()))?;
|
||||
|
||||
match msg.message {
|
||||
Some(peer_message::Message::JoinResponse(resp)) => {
|
||||
let store_uuid = lattice_core::Uuid::from_slice(&resp.store_uuid)
|
||||
.map_err(|_| NodeError::Actor("Invalid UUID from peer".to_string()))?;
|
||||
|
||||
let handle = self.node.complete_join(store_uuid).await?;
|
||||
|
||||
// Sync with peer to get initial data
|
||||
println!("[Join] Syncing with peer to get initial data...");
|
||||
if let Ok(result) = self.sync_with_peer(&handle, peer_id).await {
|
||||
println!("[Join] Initial sync complete: {} entries", result.entries_applied);
|
||||
}
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
_ => Err(NodeError::Actor("Unexpected response".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync with a specific peer.
|
||||
pub async fn sync_with_peer(&self, store: &StoreHandle, peer_id: iroh::PublicKey) -> Result<SyncResult, NodeError> {
|
||||
let conn = self.endpoint.connect(peer_id).await
|
||||
.map_err(|e| NodeError::Actor(format!("Connection failed: {}", e)))?;
|
||||
|
||||
let (send, recv) = conn.open_bi().await
|
||||
.map_err(|e| NodeError::Actor(format!("Failed to open stream: {}", e)))?;
|
||||
|
||||
let mut sink = MessageSink::new(send);
|
||||
let mut stream = MessageStream::new(recv);
|
||||
|
||||
let my_state = store.sync_state().await?;
|
||||
|
||||
// Send SyncRequest
|
||||
let req = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncRequest(lattice_core::proto::SyncRequest {
|
||||
store_id: store.id().as_bytes().to_vec(),
|
||||
state: Some(my_state.to_proto()),
|
||||
full_sync: false,
|
||||
})),
|
||||
};
|
||||
sink.send(&req).await.map_err(|e| NodeError::Actor(e))?;
|
||||
|
||||
// Receive SyncResponse
|
||||
let resp_msg = stream.recv().await.map_err(|e| NodeError::Actor(e))?
|
||||
.ok_or_else(|| NodeError::Actor("Peer closed stream".to_string()))?;
|
||||
|
||||
let peer_state = match resp_msg.message {
|
||||
Some(peer_message::Message::SyncResponse(resp)) => {
|
||||
resp.state.map(|s| lattice_core::sync_state::SyncState::from_proto(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => return Err(NodeError::Actor("Expected SyncResponse".to_string())),
|
||||
};
|
||||
|
||||
// Exchange entries
|
||||
let _entries_sent = protocol::send_missing_entries(&mut sink, store, &my_state, &peer_state).await
|
||||
.map_err(|e| NodeError::Actor(e))?;
|
||||
|
||||
let (entries_applied, entries_sent_by_peer) = protocol::receive_entries(&mut stream, store).await
|
||||
.map_err(|e| NodeError::Actor(e))?;
|
||||
|
||||
sink.finish().await.map_err(|e| NodeError::Actor(e))?;
|
||||
|
||||
Ok(SyncResult { entries_applied, entries_sent_by_peer })
|
||||
}
|
||||
|
||||
/// Sync with all active peers.
|
||||
pub async fn sync_all(&self, store: &StoreHandle) -> Result<Vec<SyncResult>, NodeError> {
|
||||
let peers = self.node.list_peers().await?;
|
||||
let mut results = Vec::new();
|
||||
let my_pubkey = self.endpoint.public_key();
|
||||
|
||||
for peer in peers {
|
||||
if peer.status != PeerStatus::Active {
|
||||
continue;
|
||||
}
|
||||
|
||||
let peer_id = match parse_node_id(&peer.pubkey) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!("[Sync] Failed to parse peer {}: {}", peer.pubkey, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip self
|
||||
if peer_id == my_pubkey {
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("[Sync] Syncing with {}...", peer_id.fmt_short());
|
||||
match self.sync_with_peer(store, peer_id).await {
|
||||
Ok(result) => {
|
||||
println!("[Sync] Applied {} entries", result.entries_applied);
|
||||
results.push(result);
|
||||
}
|
||||
Err(e) => eprintln!("[Sync] Failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Connection handling ---
|
||||
// --- Connection handling ---
|
||||
|
||||
/// Handle a single incoming connection
|
||||
async fn handle_connection(
|
||||
node: Arc<Node>,
|
||||
conn: Connection,
|
||||
) -> Result<(), String> {
|
||||
let remote_id = conn.remote_id();
|
||||
let remote_hex = hex::encode(remote_id.as_bytes());
|
||||
println!("\n[Incoming] {} (ALPN: {})", remote_id.fmt_short(), String::from_utf8_lossy(conn.alpn()));
|
||||
|
||||
// Parse remote pubkey
|
||||
let remote_pubkey: [u8; 32] = hex::decode(&remote_hex)
|
||||
.map_err(|_| "Invalid pubkey hex")?
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid pubkey length")?;
|
||||
|
||||
let (send, recv) = conn.accept_bi().await
|
||||
.map_err(|e| format!("Accept stream error: {}", e))?;
|
||||
|
||||
let sink = MessageSink::new(send);
|
||||
let mut stream = MessageStream::new(recv);
|
||||
|
||||
// Read first message to determine request type
|
||||
let msg = stream.recv().await?
|
||||
.ok_or_else(|| "Peer closed stream".to_string())?;
|
||||
|
||||
match msg.message {
|
||||
Some(peer_message::Message::JoinRequest(req)) => {
|
||||
handle_join_request(&node, &remote_pubkey, req, sink).await
|
||||
}
|
||||
Some(peer_message::Message::SyncRequest(req)) => {
|
||||
handle_sync_request(&node, &remote_pubkey, req, sink, stream).await
|
||||
}
|
||||
_ => Err("Unexpected message type".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a join request from an invited peer
|
||||
async fn handle_join_request(
|
||||
node: &Node,
|
||||
remote_pubkey: &[u8; 32],
|
||||
req: lattice_core::proto::JoinRequest,
|
||||
mut sink: MessageSink,
|
||||
) -> Result<(), String> {
|
||||
println!("[Join] Got JoinRequest from {}", hex::encode(&req.node_pubkey));
|
||||
|
||||
// Accept the join - verifies invited, sets active, returns store ID
|
||||
let acceptance = node.accept_join(remote_pubkey).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = PeerMessage {
|
||||
message: Some(peer_message::Message::JoinResponse(JoinResponse {
|
||||
store_uuid: acceptance.store_id.as_bytes().to_vec(),
|
||||
inviter_pubkey: vec![],
|
||||
})),
|
||||
};
|
||||
sink.send(&resp).await?;
|
||||
sink.finish().await?;
|
||||
|
||||
println!("[Join] Sent JoinResponse, peer now active");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a sync request - bidirectional exchange of entries
|
||||
async fn handle_sync_request(
|
||||
node: &Node,
|
||||
remote_pubkey: &[u8; 32],
|
||||
peer_request: lattice_core::proto::SyncRequest,
|
||||
mut sink: MessageSink,
|
||||
mut stream: MessageStream,
|
||||
) -> Result<(), String> {
|
||||
// Verify peer is active (allowed to sync)
|
||||
node.verify_peer_status(remote_pubkey, &[PeerStatus::Active]).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("[Sync] Verified peer as active");
|
||||
|
||||
// Parse store_id from request
|
||||
let store_id = Uuid::from_slice(&peer_request.store_id)
|
||||
.map_err(|_| format!("Invalid store_id in SyncRequest: {} bytes, expected 16", peer_request.store_id.len()))?;
|
||||
|
||||
println!("[Sync] Received SyncRequest for store {}", store_id);
|
||||
|
||||
// Open the requested store (uses cache if available)
|
||||
let (store, _info) = node.open_store(store_id).await
|
||||
.map_err(|e| format!("Failed to open store {}: {}", store_id, e))?;
|
||||
|
||||
println!("[Sync] Received SyncRequest");
|
||||
|
||||
// Get our sync state
|
||||
let my_state = store.sync_state().await
|
||||
.map_err(|e| format!("Failed to get sync state: {}", e))?;
|
||||
|
||||
// 1. Send our sync state as response
|
||||
let resp = PeerMessage {
|
||||
message: Some(peer_message::Message::SyncResponse(lattice_core::proto::SyncResponse {
|
||||
store_id: store.id().as_bytes().to_vec(),
|
||||
state: Some(my_state.to_proto()),
|
||||
})),
|
||||
};
|
||||
sink.send(&resp).await?;
|
||||
|
||||
// 2. Send entries peer is missing
|
||||
let peer_state = peer_request.state
|
||||
.map(|s| lattice_core::sync_state::SyncState::from_proto(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
let entries_sent = protocol::send_missing_entries(&mut sink, &store, &my_state, &peer_state).await?;
|
||||
println!("[Sync] Sent {} entries, now receiving from peer...", entries_sent);
|
||||
|
||||
// 3. Receive entries from requester (bidirectional)
|
||||
let (entries_applied, _) = protocol::receive_entries(&mut stream, &store).await?;
|
||||
|
||||
sink.finish().await?;
|
||||
|
||||
println!("[Sync] Applied {} entries from peer", entries_applied);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package lattice;
|
||||
|
||||
// 1. The Wrapper (What flies over the wire)
|
||||
message SignedEntry {
|
||||
// The serialized bytes of the 'Entry' message.
|
||||
// We keep this as raw bytes so the signature verification is stable.
|
||||
bytes entry_bytes = 1;
|
||||
|
||||
// Ed25519 Signature of 'entry_bytes'
|
||||
bytes signature = 2;
|
||||
|
||||
// Public Key of the author (32 bytes)
|
||||
bytes author_id = 3;
|
||||
}
|
||||
|
||||
// 2. The Log Entry (The Atomic Unit)
|
||||
message Entry {
|
||||
// Versioning allows us to change the format radically later if needed
|
||||
uint32 version = 1;
|
||||
|
||||
// Store this entry belongs to (16-byte UUID)
|
||||
bytes store_id = 6;
|
||||
|
||||
// Ordering Metadata
|
||||
bytes prev_hash = 2; // Link to previous sigchain entry (32 bytes)
|
||||
uint64 seq = 3; // Monotonic sequence number
|
||||
HLC timestamp = 4; // Hybrid Logical Clock
|
||||
|
||||
// DAG ancestry: hashes of entries this supersedes (separate from sigchain)
|
||||
repeated bytes parent_hashes = 7;
|
||||
|
||||
// The Batch of Operations
|
||||
repeated Operation ops = 5;
|
||||
}
|
||||
|
||||
// HeadInfo: a tip/head in the DAG for a key
|
||||
message HeadInfo {
|
||||
bytes value = 1; // The value at this head
|
||||
uint64 hlc = 2; // Combined HLC for ordering (wall_time_ms << 16 | counter)
|
||||
bytes author = 3; // Author's public key (32 bytes)
|
||||
bytes hash = 4; // Hash of the SignedEntry that created this head
|
||||
bool tombstone = 5; // True if this head represents a delete
|
||||
}
|
||||
|
||||
// HeadList: wrapper for storing multiple heads per key in state.db
|
||||
message HeadList {
|
||||
repeated HeadInfo heads = 1;
|
||||
}
|
||||
|
||||
// AuthorState: tracks last applied entry per author for replay optimization
|
||||
message AuthorState {
|
||||
uint64 seq = 1; // Last applied seq for this author's sigchain
|
||||
bytes hash = 2; // Hash of last applied entry
|
||||
uint64 log_offset = 3; // Byte offset in log file for fast resume
|
||||
}
|
||||
|
||||
// Hybrid Logical Clock
|
||||
message HLC {
|
||||
uint64 wall_time = 1; // Unix timestamp (ms)
|
||||
uint32 counter = 2; // Logical counter for same-ms ordering
|
||||
}
|
||||
|
||||
// 3. The Operation (The Change)
|
||||
message Operation {
|
||||
// "oneof" is how Protobuf handles Rust Enums
|
||||
oneof op_type {
|
||||
PutOp put = 1;
|
||||
DeleteOp delete = 2;
|
||||
// Future: MergeOp merge = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message PutOp {
|
||||
bytes key = 1;
|
||||
bytes value = 2;
|
||||
}
|
||||
|
||||
message DeleteOp {
|
||||
bytes key = 1;
|
||||
}
|
||||
|
||||
// 4. The Sync Handshake (Vector Clocks)
|
||||
message SyncState {
|
||||
repeated Frontier frontiers = 1;
|
||||
HLC sender_hlc = 2; // Sender's current clock (for peer time awareness)
|
||||
}
|
||||
|
||||
message Frontier {
|
||||
bytes author_id = 1; // Ed25519 public key (32 bytes)
|
||||
uint64 max_seq = 2; // Highest sequence number seen from this author
|
||||
repeated bytes head_hashes = 3; // All head hashes for this author
|
||||
}
|
||||
|
||||
// 5. Log File Record (wrapper for storage)
|
||||
message LogRecord {
|
||||
bytes hash = 1; // BLAKE3 hash of entry_bytes (32 bytes)
|
||||
bytes entry_bytes = 2; // Serialized SignedEntry
|
||||
}
|
||||
|
||||
// 6. Join Protocol Messages (new node joining existing mesh)
|
||||
message JoinRequest {
|
||||
bytes node_pubkey = 1; // Joining node's public key (32 bytes)
|
||||
}
|
||||
|
||||
message JoinResponse {
|
||||
bytes store_uuid = 1; // Root store UUID (16 bytes) for new node to create
|
||||
bytes inviter_pubkey = 2; // Inviter's public key for verification
|
||||
}
|
||||
|
||||
// 7. Sync Protocol Messages (bidirectional sync after join)
|
||||
message SyncRequest {
|
||||
bytes store_id = 1; // Store UUID to sync (16 bytes)
|
||||
SyncState state = 2; // Sender's sync state (for incremental sync)
|
||||
bool full_sync = 3; // If true, request all entries (for join)
|
||||
}
|
||||
|
||||
message SyncResponse {
|
||||
bytes store_id = 1; // Store UUID being synced (16 bytes)
|
||||
SyncState state = 2; // Responder's sync state
|
||||
}
|
||||
|
||||
message SyncEntry {
|
||||
bytes signed_entry = 1; // Serialized SignedEntry
|
||||
bytes hash = 2; // Hash for verification
|
||||
}
|
||||
|
||||
message SyncDone {
|
||||
uint64 entries_sent = 1;
|
||||
}
|
||||
|
||||
// 8. Peer Message Wrapper (for proper message type discrimination)
|
||||
message PeerMessage {
|
||||
oneof message {
|
||||
JoinRequest join_request = 1;
|
||||
JoinResponse join_response = 2;
|
||||
SyncRequest sync_request = 3;
|
||||
SyncResponse sync_response = 4;
|
||||
SyncEntry sync_entry = 5;
|
||||
SyncDone sync_done = 6;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user