Compare commits
9
Commits
1943e06509
...
3e39f34383
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e39f34383 | ||
|
|
4ccdbc97f5 | ||
|
|
2de54d0033 | ||
|
|
9d4495b3d7 | ||
|
|
76810f8d8e | ||
|
|
57ecbffaed | ||
|
|
665114036b | ||
|
|
e942da49ff | ||
|
|
7c8e5cfa3d |
+4
-3
@@ -3,7 +3,6 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"lattice-core",
|
"lattice-core",
|
||||||
"lattice-net",
|
"lattice-net",
|
||||||
"lattice-store",
|
|
||||||
"lattice-cli",
|
"lattice-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -16,14 +15,13 @@ license = "MIT"
|
|||||||
# Workspace crates
|
# Workspace crates
|
||||||
lattice-core = { path = "lattice-core" }
|
lattice-core = { path = "lattice-core" }
|
||||||
lattice-net = { path = "lattice-net" }
|
lattice-net = { path = "lattice-net" }
|
||||||
lattice-store = { path = "lattice-store" }
|
|
||||||
lattice-cli = { path = "lattice-cli" }
|
lattice-cli = { path = "lattice-cli" }
|
||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
rustyline = "17"
|
rustyline = "17"
|
||||||
|
|
||||||
# Networking (Iroh)
|
# Networking (Iroh)
|
||||||
iroh = "0.95"
|
iroh = { version = "0.95", features = ["discovery-local-network"] }
|
||||||
iroh-gossip = "0.95"
|
iroh-gossip = "0.95"
|
||||||
|
|
||||||
# Cryptography
|
# Cryptography
|
||||||
@@ -37,6 +35,8 @@ prost-build = "0.13"
|
|||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -47,6 +47,7 @@ blake3 = "1"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
redb = "2"
|
redb = "2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|||||||
+11
-4
@@ -59,10 +59,17 @@ Networking modes:
|
|||||||
- Identified by their Ed25519 public key.
|
- Identified by their Ed25519 public key.
|
||||||
- Private key stored locally in `identity.key` (not replicated).
|
- Private key stored locally in `identity.key` (not replicated).
|
||||||
- Node data stored in KV:
|
- Node data stored in KV:
|
||||||
- `/nodes/{pubkey}/info` = static metadata (name, added_by, added_at)
|
- `/nodes/{pubkey}/name` = display name
|
||||||
- `/nodes/{pubkey}/status` = `active` | `dormant` | `disabled`
|
- `/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)
|
- `/nodes/{pubkey}/role` = `server` | `device` (optional, hints sync priority)
|
||||||
- Inviting a node = writing entries to `/nodes/{pubkey}/...`.
|
- 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.
|
- 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:
|
- Status effects:
|
||||||
- `active`: Normal sync participant, blocks watermark until acknowledged.
|
- `active`: Normal sync participant, blocks watermark until acknowledged.
|
||||||
@@ -174,7 +181,7 @@ Each node stores logs as one file per author:
|
|||||||
Table Key Value Purpose
|
Table Key Value Purpose
|
||||||
─────────────────────────────────────────────────────────────────────────────
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
kv Vec<u8> (key) Vec<HeadInfo> Current tips for each key
|
kv Vec<u8> (key) Vec<HeadInfo> Current tips for each key
|
||||||
applied_frontiers [u8; 32] (author_id) (u64 seq, [u8; 32] hash) What's applied to this store
|
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)
|
meta Vec<u8> Vec<u8> Store metadata (incl. merkle_root)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+92
-9
@@ -94,33 +94,116 @@
|
|||||||
- [x] Multi-store sync test: compute diff, fetch entries, apply, verify same state
|
- [x] Multi-store sync test: compute diff, fetch entries, apply, verify same state
|
||||||
|
|
||||||
**Phase 2: Iroh Integration**
|
**Phase 2: Iroh Integration**
|
||||||
- [ ] Iroh integration (peer discovery, connection)
|
|
||||||
- [ ] Sync protocol (push missing entries over network)
|
*Completed:*
|
||||||
- [ ] CLI: `peers`, `connect`/`join` commands
|
- [x] Node info in root store on init: `/nodes/{pubkey}/info` + `/status`
|
||||||
- [ ] Background sync task (tokio::spawn)
|
- [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
|
### Success Criteria
|
||||||
|
|
||||||
- Node A writes, Node B syncs, both have same state
|
- Node A writes, Node B syncs, both have same state
|
||||||
- Works offline-first (sync when connected)
|
- 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
|
## Milestone 3: Multi-Node Mesh
|
||||||
|
|
||||||
**Goal:** N nodes form a gossip mesh with watermark consensus.
|
**Goal:** N nodes form a gossip mesh for real-time sync.
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
- [ ] Gossip protocol
|
**Phase 1: LatticeServer Refactor** ✓
|
||||||
- [ ] Watermark tracking & log pruning
|
- [x] `LatticeServer` struct in `lattice-net` wrapping `Arc<Node>` + `Endpoint`
|
||||||
- [ ] Node invitation (sigchain membership)
|
- [x] Move `join_mesh`, `sync_with_peer`, `sync_all` to `LatticeServer` methods
|
||||||
- [ ] Conflict detection (LWW resolution)
|
- [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
|
## 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
|
- Mobile (iOS/Android) clients
|
||||||
- Key rotation
|
- Key rotation
|
||||||
- Secure storage (Keychain, TPM)
|
- Secure storage (Keychain, TPM)
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lattice-core = { workspace = true }
|
lattice-core = { workspace = true }
|
||||||
|
lattice-net = { workspace = true }
|
||||||
rustyline = { workspace = true }
|
rustyline = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
shlex = "1"
|
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"
|
||||||
|
|||||||
+45
-380
@@ -1,414 +1,79 @@
|
|||||||
//! CLI command handlers
|
//! CLI command handlers
|
||||||
|
|
||||||
use crate::node::{LatticeNode, StoreHandle};
|
use lattice_core::{Node, StoreHandle};
|
||||||
use lattice_core::Uuid;
|
use lattice_net::LatticeServer;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
/// Result of a command that may switch stores
|
/// Result of a command that may switch stores or exit
|
||||||
pub enum CommandResult {
|
pub enum CommandResult {
|
||||||
/// No store change
|
/// No store change
|
||||||
Ok,
|
Ok,
|
||||||
/// Switch to this store
|
/// Switch to this store
|
||||||
SwitchTo(StoreHandle),
|
SwitchTo(StoreHandle),
|
||||||
|
/// Exit the CLI
|
||||||
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to call async code from sync command handlers
|
/// Helper to call async code from sync command handlers
|
||||||
fn block_async<F: std::future::Future>(f: F) -> F::Output {
|
pub fn block_async<F: std::future::Future>(f: F) -> F::Output {
|
||||||
tokio::task::block_in_place(|| {
|
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
|
||||||
tokio::runtime::Handle::current().block_on(f)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Handler = fn(&LatticeNode, Option<&StoreHandle>, &[String]) -> CommandResult;
|
pub type Handler = fn(&Node, Option<&StoreHandle>, Option<&LatticeServer>, &[String]) -> CommandResult;
|
||||||
|
|
||||||
pub struct Command {
|
pub struct Command {
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
pub args: &'static str,
|
pub args: &'static str,
|
||||||
pub description: &'static str,
|
pub desc: &'static str,
|
||||||
|
pub group: &'static str,
|
||||||
pub min_args: usize,
|
pub min_args: usize,
|
||||||
pub max_args: usize,
|
pub max_args: usize,
|
||||||
pub handler: Handler,
|
pub handler: Handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all available commands
|
||||||
pub fn commands() -> Vec<Command> {
|
pub fn commands() -> Vec<Command> {
|
||||||
vec![
|
let mut cmds = Vec::new();
|
||||||
Command {
|
|
||||||
name: "init",
|
|
||||||
args: "",
|
|
||||||
description: "Initialize node with root store",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 0,
|
|
||||||
handler: cmd_init,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "create-store",
|
|
||||||
args: "",
|
|
||||||
description: "Create a new store",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 0,
|
|
||||||
handler: cmd_create_store,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "use",
|
|
||||||
args: "<uuid>",
|
|
||||||
description: "Switch to a store",
|
|
||||||
min_args: 1,
|
|
||||||
max_args: 1,
|
|
||||||
handler: cmd_use_store,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "list-stores",
|
|
||||||
args: "",
|
|
||||||
description: "List all stores",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 0,
|
|
||||||
handler: cmd_list_stores,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "put",
|
|
||||||
args: "<key> <value>",
|
|
||||||
description: "Store a key-value pair",
|
|
||||||
min_args: 2,
|
|
||||||
max_args: 2,
|
|
||||||
handler: cmd_put,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "get",
|
|
||||||
args: "<key> [-v]",
|
|
||||||
description: "Retrieve a value by key",
|
|
||||||
min_args: 1,
|
|
||||||
max_args: 2,
|
|
||||||
handler: cmd_get,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "delete",
|
|
||||||
args: "<key>",
|
|
||||||
description: "Delete a key",
|
|
||||||
min_args: 1,
|
|
||||||
max_args: 1,
|
|
||||||
handler: cmd_delete,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "list",
|
|
||||||
args: "[-v]",
|
|
||||||
description: "List all key-value pairs (-v for verbose)",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 1,
|
|
||||||
handler: cmd_list,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "status",
|
|
||||||
args: "",
|
|
||||||
description: "Show node/store info",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 0,
|
|
||||||
handler: cmd_status,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "author-state",
|
|
||||||
args: "[author-hex]",
|
|
||||||
description: "Show author state (default: self)",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 1,
|
|
||||||
handler: cmd_author_state,
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
name: "help",
|
|
||||||
args: "",
|
|
||||||
description: "Show this help message",
|
|
||||||
min_args: 0,
|
|
||||||
max_args: 0,
|
|
||||||
handler: cmd_help,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Store management ---
|
|
||||||
|
|
||||||
fn cmd_init(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
|
|
||||||
match node.init() {
|
|
||||||
Ok(store_id) => {
|
|
||||||
println!("Initialized with root store: {}", store_id);
|
|
||||||
match node.open_store(store_id) {
|
|
||||||
Ok((handle, _)) => CommandResult::SwitchTo(handle),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Warning: {}", e);
|
|
||||||
CommandResult::Ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error: {}", e);
|
|
||||||
CommandResult::Ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_create_store(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
|
|
||||||
match node.create_store() {
|
|
||||||
Ok(store_id) => {
|
|
||||||
println!("Created store: {}", store_id);
|
|
||||||
match 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: &LatticeNode, _store: Option<&StoreHandle>, 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();
|
// General CLI commands
|
||||||
match node.open_store(store_id) {
|
cmds.push(Command {
|
||||||
Ok((handle, info)) => {
|
name: "help", args: "", desc: "Show this help",
|
||||||
if info.entries_replayed > 0 {
|
group: "general", min_args: 0, max_args: 0, handler: cmd_help as Handler
|
||||||
println!("Replayed {} entries ({:.2?})", info.entries_replayed, start.elapsed());
|
});
|
||||||
} else {
|
cmds.push(Command {
|
||||||
println!("Switched to store {}", store_id);
|
name: "quit", args: "", desc: "Exit",
|
||||||
}
|
group: "general", min_args: 0, max_args: 0, handler: cmd_quit as Handler
|
||||||
CommandResult::SwitchTo(handle)
|
});
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error: {}", e);
|
|
||||||
CommandResult::Ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_list_stores(node: &LatticeNode, store: Option<&StoreHandle>, _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() {
|
// Node commands (operations on the node)
|
||||||
println!("No stores. Use 'init' or 'create-store'.");
|
cmds.extend(crate::node_commands::node_commands());
|
||||||
} else {
|
|
||||||
for store_id in stores {
|
// Store commands (raw KV operations)
|
||||||
let marker = if Some(store_id) == current_id { " *" } else { "" };
|
cmds.extend(crate::store_commands::store_commands());
|
||||||
println!("{}{}", store_id, marker);
|
|
||||||
}
|
cmds
|
||||||
}
|
|
||||||
CommandResult::Ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Info ---
|
fn cmd_help(_node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||||
|
let cmds = commands();
|
||||||
fn cmd_help(_node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
|
let mut last_group = "";
|
||||||
println!("\nCommands:");
|
for cmd in &cmds {
|
||||||
for cmd in commands() {
|
if cmd.group != last_group {
|
||||||
if cmd.args.is_empty() {
|
println!();
|
||||||
println!(" {:<16} {}", cmd.name, cmd.description);
|
println!("[{}]", cmd.group);
|
||||||
|
last_group = cmd.group;
|
||||||
|
}
|
||||||
|
let usage = if cmd.args.is_empty() {
|
||||||
|
cmd.name.to_string()
|
||||||
} else {
|
} else {
|
||||||
println!(" {} {:<8} {}", cmd.name, cmd.args, cmd.description);
|
format!("{} {}", cmd.name, cmd.args)
|
||||||
}
|
};
|
||||||
|
println!(" {:18} {}", usage, cmd.desc);
|
||||||
}
|
}
|
||||||
println!(" quit Exit");
|
|
||||||
println!();
|
println!();
|
||||||
CommandResult::Ok
|
CommandResult::Ok
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_status(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
|
fn cmd_quit(_node: &Node, _store: Option<&StoreHandle>, _server: Option<&LatticeServer>, _args: &[String]) -> CommandResult {
|
||||||
println!("Node ID: {}", hex::encode(node.node_id()));
|
println!("Goodbye!");
|
||||||
println!("Data: {}", node.data_path().display());
|
CommandResult::Quit
|
||||||
match node.root_store() {
|
|
||||||
Ok(Some(id)) => println!("Root: {}", id),
|
|
||||||
Ok(None) => println!("Root: (not set)"),
|
|
||||||
Err(_) => println!("Root: (error)"),
|
|
||||||
}
|
|
||||||
if let Some(h) = store {
|
|
||||||
println!("Store: {}", h.id());
|
|
||||||
println!("Log Seq: {}", block_async(h.log_seq()));
|
|
||||||
println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0));
|
|
||||||
} else {
|
|
||||||
println!("Store: (none)");
|
|
||||||
}
|
|
||||||
CommandResult::Ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- KV ---
|
|
||||||
|
|
||||||
fn cmd_put(_node: &LatticeNode, store: Option<&StoreHandle>, 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: &LatticeNode, store: Option<&StoreHandle>, 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: &LatticeNode, store: Option<&StoreHandle>, 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: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
|
|
||||||
let Some(h) = store else {
|
|
||||||
println!("No store selected. Use 'init' or 'use <uuid>'");
|
|
||||||
return CommandResult::Ok;
|
|
||||||
};
|
|
||||||
let verbose = args.first().map(|a| a == "-v").unwrap_or(false);
|
|
||||||
let start = Instant::now();
|
|
||||||
match block_async(h.list()) {
|
|
||||||
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 {
|
|
||||||
println!("{} = {}", key_str, format_value(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("({} keys, {:.2?})", entries.len(), start.elapsed());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_author_state(node: &LatticeNode, store: Option<&StoreHandle>, 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-16
@@ -1,27 +1,41 @@
|
|||||||
//! Lattice Interactive CLI
|
//! Lattice Interactive CLI
|
||||||
|
|
||||||
mod node;
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod store_actor;
|
mod node_commands;
|
||||||
|
mod store_commands;
|
||||||
|
|
||||||
|
use lattice_net::LatticeServer;
|
||||||
use commands::CommandResult;
|
use commands::CommandResult;
|
||||||
use node::{LatticeNodeBuilder, StoreHandle};
|
use lattice_core::{NodeBuilder, StoreHandle};
|
||||||
use rustyline::error::ReadlineError;
|
use rustyline::error::ReadlineError;
|
||||||
use rustyline::DefaultEditor;
|
use rustyline::DefaultEditor;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
println!("Lattice CLI v{}", env!("CARGO_PKG_VERSION"));
|
println!("Lattice CLI v{}", env!("CARGO_PKG_VERSION"));
|
||||||
println!("Type 'help' for commands, 'quit' to exit.\n");
|
println!("Type 'help' for commands, 'quit' to exit.\n");
|
||||||
|
|
||||||
let node = match LatticeNodeBuilder::new().build() {
|
let node = match NodeBuilder::new().build() {
|
||||||
Ok(n) => n,
|
Ok(n) => Arc::new(n),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to initialize: {}", e);
|
eprintln!("Failed to initialize: {}", e);
|
||||||
return;
|
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();
|
let info = node.info();
|
||||||
println!("Node ID: {}", info.node_id);
|
println!("Node ID: {}", info.node_id);
|
||||||
println!("Data: {}", info.data_path);
|
println!("Data: {}", info.data_path);
|
||||||
@@ -30,14 +44,15 @@ async fn main() {
|
|||||||
println!("Stores: {}", info.stores.len());
|
println!("Stores: {}", info.stores.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut current_store: Option<StoreHandle> = match node.open_root_store() {
|
let mut current_store: Option<StoreHandle> = match node.open_root_store().await {
|
||||||
Ok(Some((h, open_info))) => {
|
Ok(Some(open_info)) => {
|
||||||
if open_info.entries_replayed > 0 {
|
if open_info.entries_replayed > 0 {
|
||||||
println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed);
|
println!("Root: {} (replayed {})", open_info.store_id, open_info.entries_replayed);
|
||||||
} else {
|
} else {
|
||||||
println!("Root: {}", open_info.store_id);
|
println!("Root: {}", open_info.store_id);
|
||||||
}
|
}
|
||||||
Some(h)
|
|
||||||
|
node.root_store().await.as_ref().cloned()
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
println!("Status: Not initialized (use 'init')");
|
println!("Status: Not initialized (use 'init')");
|
||||||
@@ -74,21 +89,19 @@ async fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cmd_name = args.first().map(|s| s.as_str()).unwrap_or("");
|
let cmd_name = args.first().map(|s| s.as_str()).unwrap_or("");
|
||||||
|
|
||||||
if cmd_name == "quit" || cmd_name == "exit" {
|
|
||||||
println!("Goodbye!");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
match cmds.iter().find(|c| c.name == cmd_name) {
|
match cmds.iter().find(|c| c.name == cmd_name || (cmd_name == "exit" && c.name == "quit")) {
|
||||||
Some(cmd) => {
|
Some(cmd) => {
|
||||||
let cmd_args = &args[1..];
|
let cmd_args = &args[1..];
|
||||||
if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args {
|
if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args {
|
||||||
println!("Usage: {} {}", cmd.name, cmd.args);
|
println!("Usage: {} {}", cmd.name, cmd.args);
|
||||||
} else {
|
} else {
|
||||||
match (cmd.handler)(&node, current_store.as_ref(), cmd_args) {
|
match (cmd.handler)(&node, current_store.as_ref(), server.as_ref(), cmd_args) {
|
||||||
CommandResult::Ok => {}
|
CommandResult::Ok => {}
|
||||||
CommandResult::SwitchTo(h) => current_store = Some(h),
|
CommandResult::SwitchTo(h) => {
|
||||||
|
current_store = Some(h);
|
||||||
|
}
|
||||||
|
CommandResult::Quit => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,433 +0,0 @@
|
|||||||
//! Local Lattice node API with multi-store support
|
|
||||||
|
|
||||||
use lattice_core::{
|
|
||||||
DataDir, MetaStore, Node, SigChain, Store, Uuid,
|
|
||||||
log::LogError,
|
|
||||||
meta_store::MetaStoreError,
|
|
||||||
sigchain::SigChainError,
|
|
||||||
store::StoreError,
|
|
||||||
};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[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] lattice_core::node::NodeError),
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LatticeNodeBuilder {
|
|
||||||
pub data_dir: DataDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LatticeNodeBuilder {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { data_dir: DataDir::default() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build(self) -> Result<LatticeNode, NodeError> {
|
|
||||||
self.data_dir.ensure_dirs()?;
|
|
||||||
|
|
||||||
let key_path = self.data_dir.identity_key();
|
|
||||||
let node = if key_path.exists() {
|
|
||||||
Node::load(&key_path)?
|
|
||||||
} else {
|
|
||||||
let node = Node::generate();
|
|
||||||
node.save(&key_path)?;
|
|
||||||
node
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = MetaStore::open(self.data_dir.meta_db())?;
|
|
||||||
|
|
||||||
Ok(LatticeNode {
|
|
||||||
data_dir: self.data_dir,
|
|
||||||
node: Rc::new(node),
|
|
||||||
meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LatticeNodeBuilder {
|
|
||||||
fn default() -> Self { Self::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A local Lattice node (manages identity and store registry)
|
|
||||||
pub struct LatticeNode {
|
|
||||||
data_dir: DataDir,
|
|
||||||
node: Rc<Node>,
|
|
||||||
meta: MetaStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LatticeNode {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn data_path(&self) -> &Path {
|
|
||||||
self.data_dir.base()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the root store ID
|
|
||||||
pub fn root_store(&self) -> Result<Option<Uuid>, NodeError> {
|
|
||||||
Ok(self.meta.root_store()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the root store if set
|
|
||||||
pub fn open_root_store(&self) -> Result<Option<(StoreHandle, StoreInfo)>, NodeError> {
|
|
||||||
match self.meta.root_store()? {
|
|
||||||
Some(id) => Ok(Some(self.open_store(id)?)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize the node with a root store (fails if already initialized)
|
|
||||||
pub 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)?;
|
|
||||||
Ok(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.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 fn open_store(&self, store_id: Uuid) -> Result<(StoreHandle, StoreInfo), NodeError> {
|
|
||||||
self.data_dir.ensure_store_dirs(store_id)?;
|
|
||||||
|
|
||||||
let author_id_hex = hex::encode(self.node.public_key_bytes());
|
|
||||||
let log_path = self.data_dir.store_log_file(store_id, &author_id_hex);
|
|
||||||
|
|
||||||
let sigchain = if log_path.exists() {
|
|
||||||
SigChain::from_log(&log_path, *store_id.as_bytes(), self.node.public_key_bytes())?
|
|
||||||
} else {
|
|
||||||
SigChain::new(&log_path, *store_id.as_bytes(), self.node.public_key_bytes())
|
|
||||||
};
|
|
||||||
|
|
||||||
let store = Store::open(self.data_dir.store_state_db(store_id))?;
|
|
||||||
let entries_replayed = if log_path.exists() {
|
|
||||||
store.replay_log(&log_path)?
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let info = StoreInfo { store_id, entries_replayed };
|
|
||||||
|
|
||||||
// Spawn actor thread - actor owns store, sigchain, and node copy
|
|
||||||
let (tx, actor_handle) = crate::store_actor::spawn_store_actor(
|
|
||||||
store_id,
|
|
||||||
store,
|
|
||||||
sigchain,
|
|
||||||
(*self.node).clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let handle = StoreHandle {
|
|
||||||
store_id,
|
|
||||||
tx,
|
|
||||||
actor_handle: Some(actor_handle),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((handle, info))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A handle to a specific store - wraps channel to actor thread
|
|
||||||
pub struct StoreHandle {
|
|
||||||
store_id: Uuid,
|
|
||||||
tx: tokio::sync::mpsc::Sender<crate::store_actor::StoreCmd>,
|
|
||||||
actor_handle: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StoreHandle {
|
|
||||||
pub fn id(&self) -> Uuid { self.store_id }
|
|
||||||
|
|
||||||
pub async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, NodeError> {
|
|
||||||
use crate::store_actor::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<lattice_core::HeadInfo>, NodeError> {
|
|
||||||
use crate::store_actor::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) -> Result<Vec<(Vec<u8>, Vec<u8>)>, NodeError> {
|
|
||||||
use crate::store_actor::StoreCmd;
|
|
||||||
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
|
|
||||||
self.tx.send(StoreCmd::List { resp: resp_tx }).await
|
|
||||||
.map_err(|_| NodeError::ChannelClosed)?;
|
|
||||||
resp_rx.await
|
|
||||||
.map_err(|_| NodeError::ChannelClosed)?
|
|
||||||
.map_err(NodeError::Store)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn log_seq(&self) -> u64 {
|
|
||||||
use crate::store_actor::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 crate::store_actor::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<lattice_core::proto::AuthorState>, NodeError> {
|
|
||||||
use crate::store_actor::StoreCmd;
|
|
||||||
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
|
|
||||||
self.tx.send(StoreCmd::AuthorState { author: *author, resp: resp_tx }).await
|
|
||||||
.map_err(|_| NodeError::ChannelClosed)?;
|
|
||||||
resp_rx.await
|
|
||||||
.map_err(|_| NodeError::ChannelClosed)?
|
|
||||||
.map_err(NodeError::Store)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put(&self, key: &[u8], value: &[u8]) -> Result<u64, NodeError> {
|
|
||||||
use crate::store_actor::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 crate::store_actor::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) {
|
|
||||||
// Send shutdown command (non-blocking) and wait for actor to finish
|
|
||||||
// Use try_send to avoid panic in async context
|
|
||||||
let _ = self.tx.try_send(crate::store_actor::StoreCmd::Shutdown);
|
|
||||||
if let Some(handle) = self.actor_handle.take() {
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("Failed to create node");
|
|
||||||
|
|
||||||
assert!(node.info().stores.is_empty());
|
|
||||||
|
|
||||||
let store_id = node.create_store().expect("Failed to create store");
|
|
||||||
|
|
||||||
// Verify it's in the list
|
|
||||||
let stores = node.list_stores().expect("list failed");
|
|
||||||
assert!(stores.contains(&store_id));
|
|
||||||
|
|
||||||
let (handle, _) = node.open_store(store_id).expect("Failed to open store");
|
|
||||||
handle.put(b"/key", b"value").await.expect("put failed");
|
|
||||||
assert_eq!(handle.get(b"/key").await.unwrap(), Some(b"value".to_vec()));
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_store_isolation() {
|
|
||||||
let data_dir = temp_data_dir("meta_isolation");
|
|
||||||
|
|
||||||
let node = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("Failed to create node");
|
|
||||||
|
|
||||||
let store_a = node.create_store().expect("create A");
|
|
||||||
let store_b = node.create_store().expect("create B");
|
|
||||||
|
|
||||||
let (handle_a, _) = node.open_store(store_a).expect("open A");
|
|
||||||
handle_a.put(b"/key", b"from A").await.expect("put A");
|
|
||||||
|
|
||||||
let (handle_b, _) = node.open_store(store_b).expect("open B");
|
|
||||||
assert_eq!(handle_b.get(b"/key").await.unwrap(), None);
|
|
||||||
|
|
||||||
assert_eq!(handle_a.get(b"/key").await.unwrap(), Some(b"from A".to_vec()));
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_init_creates_root_store() {
|
|
||||||
let data_dir = temp_data_dir("init_root");
|
|
||||||
|
|
||||||
let node = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("create node");
|
|
||||||
|
|
||||||
// Initially no root store
|
|
||||||
assert!(node.root_store().unwrap().is_none());
|
|
||||||
|
|
||||||
// Init creates root store
|
|
||||||
let root_id = node.init().expect("init failed");
|
|
||||||
assert_eq!(node.root_store().unwrap(), Some(root_id));
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_duplicate_init_fails() {
|
|
||||||
let data_dir = temp_data_dir("init_dup");
|
|
||||||
|
|
||||||
let node = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("create node");
|
|
||||||
|
|
||||||
node.init().expect("first init");
|
|
||||||
|
|
||||||
// Second init should fail
|
|
||||||
match node.init() {
|
|
||||||
Err(NodeError::AlreadyInitialized) => (),
|
|
||||||
other => panic!("Expected AlreadyInitialized, got {:?}", other),
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_root_store_in_info_after_init() {
|
|
||||||
let data_dir = temp_data_dir("init_info");
|
|
||||||
|
|
||||||
// First session: init
|
|
||||||
let root_id = {
|
|
||||||
let node = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("create node");
|
|
||||||
node.init().expect("init")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Second session: root_store should persist
|
|
||||||
let node = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("reload node");
|
|
||||||
|
|
||||||
assert_eq!(node.root_store().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 = LatticeNodeBuilder { data_dir: data_dir.clone() }
|
|
||||||
.build()
|
|
||||||
.expect("create node");
|
|
||||||
let store_id = node.init().expect("init");
|
|
||||||
let (store, _) = node.open_store(store_id).expect("open store");
|
|
||||||
|
|
||||||
// Put twice with same value - second should be idempotent
|
|
||||||
let seq1 = store.put(b"/key", b"value").await.expect("put 1");
|
|
||||||
assert_eq!(seq1, 1);
|
|
||||||
|
|
||||||
let seq2 = store.put(b"/key", b"value").await.expect("put 2");
|
|
||||||
assert_eq!(seq2, 1, "Second put with same value should be idempotent (no new entry)");
|
|
||||||
|
|
||||||
assert_eq!(store.log_seq().await, 1, "Log should have 1 entry, not 2");
|
|
||||||
|
|
||||||
// Delete twice - second should be idempotent
|
|
||||||
let seq3 = store.delete(b"/key").await.expect("delete 1");
|
|
||||||
assert_eq!(seq3, 2);
|
|
||||||
|
|
||||||
let seq4 = store.delete(b"/key").await.expect("delete 2");
|
|
||||||
assert_eq!(seq4, 2, "Second delete should be idempotent (no new entry)");
|
|
||||||
|
|
||||||
assert_eq!(store.log_seq().await, 2, "Log should have 2 entries, not 3");
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(data_dir.base());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ blake3 = { workspace = true }
|
|||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
redb = { workspace = true }
|
redb = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
hostname = "0.4"
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
prost-build = { workspace = true }
|
prost-build = { workspace = true }
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+10
-3
@@ -1,7 +1,7 @@
|
|||||||
//! Lattice Core
|
//! Lattice Core
|
||||||
//!
|
//!
|
||||||
//! Core types for the Lattice distributed mesh:
|
//! Core types for the Lattice distributed mesh:
|
||||||
//! - **Node**: Identity with Ed25519 keypair
|
//! - **NodeIdentity**: Cryptographic identity with Ed25519 keypair
|
||||||
//! - **SigChain**: Append-only cryptographically signed log
|
//! - **SigChain**: Append-only cryptographically signed log
|
||||||
//! - **Entry**: Atomic operations in the log
|
//! - **Entry**: Atomic operations in the log
|
||||||
//! - **SyncState**: Per-author sequence tracking for reconciliation
|
//! - **SyncState**: Per-author sequence tracking for reconciliation
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
//! - **SignedEntry**: Entry creation, signing, and verification
|
//! - **SignedEntry**: Entry creation, signing, and verification
|
||||||
//! - **Log**: Append-only log file I/O
|
//! - **Log**: Append-only log file I/O
|
||||||
//! - **Store**: Persistent KV state from log replay
|
//! - **Store**: Persistent KV state from log replay
|
||||||
|
//! - **CausalIter**: Merge-sort iterator for HLC-ordered sync
|
||||||
|
|
||||||
|
pub mod node_identity;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
pub mod sigchain;
|
pub mod sigchain;
|
||||||
pub mod entry;
|
pub mod entry;
|
||||||
@@ -25,13 +27,16 @@ pub mod signed_entry;
|
|||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod meta_store;
|
pub mod meta_store;
|
||||||
|
pub mod causal_iter;
|
||||||
|
pub mod store_actor;
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
/// Maximum size of a serialized SignedEntry (16 MB)
|
/// Maximum size of a serialized SignedEntry (16 MB)
|
||||||
pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024;
|
pub const MAX_ENTRY_SIZE: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
pub use node::Node;
|
pub use node_identity::{NodeIdentity, PeerStatus};
|
||||||
pub use sigchain::SigChain;
|
pub use node::{Node, NodeBuilder, NodeInfo, StoreInfo, StoreHandle, NodeError, NodeEvent, PeerInfo, JoinAcceptance};
|
||||||
|
pub use sigchain::{SigChain, SigChainManager};
|
||||||
pub use entry::Entry;
|
pub use entry::Entry;
|
||||||
pub use sync_state::{SyncState, AuthorInfo, MissingRange};
|
pub use sync_state::{SyncState, AuthorInfo, MissingRange};
|
||||||
pub use hlc::HLC;
|
pub use hlc::HLC;
|
||||||
@@ -43,3 +48,5 @@ pub use store::Store;
|
|||||||
pub use meta_store::MetaStore;
|
pub use meta_store::MetaStore;
|
||||||
pub use proto::HeadInfo;
|
pub use proto::HeadInfo;
|
||||||
pub use uuid::Uuid;
|
pub use uuid::Uuid;
|
||||||
|
pub use causal_iter::CausalEntryIter;
|
||||||
|
pub use store_actor::{StoreActor, StoreCmd, StoreActorError, spawn_store_actor};
|
||||||
|
|||||||
+11
-11
@@ -207,7 +207,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::clock::MockClock;
|
use crate::clock::MockClock;
|
||||||
use crate::hlc::HLC;
|
use crate::hlc::HLC;
|
||||||
use crate::node::Node;
|
use crate::node_identity::NodeIdentity;
|
||||||
use crate::signed_entry::EntryBuilder;
|
use crate::signed_entry::EntryBuilder;
|
||||||
use std::env::temp_dir;
|
use std::env::temp_dir;
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ mod tests {
|
|||||||
let path = temp_log_path("single_v6");
|
let path = temp_log_path("single_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
let hlc = HLC::now_with_clock(&clock);
|
let hlc = HLC::now_with_clock(&clock);
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ mod tests {
|
|||||||
let path = temp_log_path("multiple_v6");
|
let path = temp_log_path("multiple_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
for i in 1..=5 {
|
for i in 1..=5 {
|
||||||
@@ -269,7 +269,7 @@ mod tests {
|
|||||||
let path = temp_log_path("after_v6");
|
let path = temp_log_path("after_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
@@ -301,7 +301,7 @@ mod tests {
|
|||||||
let path = temp_log_path("not_found_v6");
|
let path = temp_log_path("not_found_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
@@ -321,7 +321,7 @@ mod tests {
|
|||||||
let path = temp_log_path("reader_hash_v6");
|
let path = temp_log_path("reader_hash_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
@@ -368,7 +368,7 @@ mod tests {
|
|||||||
let path = temp_log_path("corrupted_v6");
|
let path = temp_log_path("corrupted_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
@@ -399,7 +399,7 @@ mod tests {
|
|||||||
let path = temp_log_path("truncated_v6");
|
let path = temp_log_path("truncated_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
@@ -430,7 +430,7 @@ mod tests {
|
|||||||
let path = temp_log_path("too_large_v6");
|
let path = temp_log_path("too_large_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
// Create payload larger than MAX_ENTRY_SIZE
|
// Create payload larger than MAX_ENTRY_SIZE
|
||||||
@@ -455,7 +455,7 @@ mod tests {
|
|||||||
let path = temp_log_path("boundary_last_v6");
|
let path = temp_log_path("boundary_last_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
@@ -506,7 +506,7 @@ mod tests {
|
|||||||
let path = temp_log_path("corruption_middle_v6");
|
let path = temp_log_path("corruption_middle_v6");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
// Write 3 entries
|
// Write 3 entries
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const STORES_TABLE: TableDefinition<&[u8], u64> = TableDefinition::new("stores")
|
|||||||
const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
|
const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
|
||||||
|
|
||||||
const META_ROOT_STORE: &str = "root_store";
|
const META_ROOT_STORE: &str = "root_store";
|
||||||
|
const META_NAME: &str = "name";
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum MetaStoreError {
|
pub enum MetaStoreError {
|
||||||
@@ -106,6 +107,28 @@ impl MetaStore {
|
|||||||
write_txn.commit()?;
|
write_txn.commit()?;
|
||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+925
-156
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-11
@@ -4,7 +4,7 @@
|
|||||||
//! before appending (correct seq, prev_hash, valid signature) and persists to disk.
|
//! before appending (correct seq, prev_hash, valid signature) and persists to disk.
|
||||||
|
|
||||||
use crate::log::{append_entry, read_entries, LogError};
|
use crate::log::{append_entry, read_entries, LogError};
|
||||||
use crate::node::Node;
|
use crate::node_identity::NodeIdentity;
|
||||||
use crate::proto::{Entry, SignedEntry};
|
use crate::proto::{Entry, SignedEntry};
|
||||||
use crate::signed_entry::{hash_signed_entry, verify_signed_entry};
|
use crate::signed_entry::{hash_signed_entry, verify_signed_entry};
|
||||||
use prost::Message;
|
use prost::Message;
|
||||||
@@ -146,6 +146,11 @@ impl SigChain {
|
|||||||
&self.last_hash
|
&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
|
/// Get the current length of the chain
|
||||||
pub fn len(&self) -> u64 {
|
pub fn len(&self) -> u64 {
|
||||||
self.next_seq - 1
|
self.next_seq - 1
|
||||||
@@ -224,7 +229,7 @@ impl SigChain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create and append a new entry using the node's key
|
/// Create and append a new entry using the node's key
|
||||||
pub fn create_entry(&mut self, node: &Node, ops: Vec<crate::proto::Operation>) -> Result<SignedEntry, SigChainError> {
|
pub fn create_entry(&mut self, node: &NodeIdentity, ops: Vec<crate::proto::Operation>) -> Result<SignedEntry, SigChainError> {
|
||||||
use crate::clock::SystemClock;
|
use crate::clock::SystemClock;
|
||||||
use crate::hlc::HLC;
|
use crate::hlc::HLC;
|
||||||
use crate::signed_entry::EntryBuilder;
|
use crate::signed_entry::EntryBuilder;
|
||||||
@@ -248,12 +253,96 @@ impl SigChain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::clock::MockClock;
|
use crate::clock::MockClock;
|
||||||
use crate::hlc::HLC;
|
use crate::hlc::HLC;
|
||||||
use crate::node::Node;
|
use crate::node_identity::NodeIdentity;
|
||||||
use crate::proto::{operation, Operation, PutOp};
|
use crate::proto::{operation, Operation, PutOp};
|
||||||
use crate::signed_entry::EntryBuilder;
|
use crate::signed_entry::EntryBuilder;
|
||||||
use std::env::temp_dir;
|
use std::env::temp_dir;
|
||||||
@@ -283,7 +372,7 @@ mod tests {
|
|||||||
let path = temp_log_path("append");
|
let path = temp_log_path("append");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||||
|
|
||||||
@@ -308,7 +397,7 @@ mod tests {
|
|||||||
let path = temp_log_path("multiple");
|
let path = temp_log_path("multiple");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
@@ -333,7 +422,7 @@ mod tests {
|
|||||||
let path = temp_log_path("from_log");
|
let path = temp_log_path("from_log");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
@@ -364,7 +453,7 @@ mod tests {
|
|||||||
let path = temp_log_path("wrong_seq");
|
let path = temp_log_path("wrong_seq");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
@@ -388,7 +477,7 @@ mod tests {
|
|||||||
let path = temp_log_path("wrong_prev");
|
let path = temp_log_path("wrong_prev");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
@@ -420,7 +509,7 @@ mod tests {
|
|||||||
let path = temp_log_path("wrong_author");
|
let path = temp_log_path("wrong_author");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let other_author = [99u8; 32]; // Different author
|
let other_author = [99u8; 32]; // Different author
|
||||||
let mut chain = SigChain::new(&path, TEST_STORE, other_author);
|
let mut chain = SigChain::new(&path, TEST_STORE, other_author);
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
@@ -444,7 +533,7 @@ mod tests {
|
|||||||
let path = temp_log_path("create");
|
let path = temp_log_path("create");
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
||||||
|
|
||||||
@@ -476,7 +565,7 @@ mod tests {
|
|||||||
std::fs::remove_file(&path_a).ok();
|
std::fs::remove_file(&path_a).ok();
|
||||||
std::fs::remove_file(&path_b).ok();
|
std::fs::remove_file(&path_b).ok();
|
||||||
|
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! - Computing entry hashes for prev_hash linking
|
//! - Computing entry hashes for prev_hash linking
|
||||||
|
|
||||||
use crate::hlc::HLC;
|
use crate::hlc::HLC;
|
||||||
use crate::node::{Node, NodeError};
|
use crate::node_identity::{NodeIdentity, NodeError};
|
||||||
use crate::proto::{Entry, Hlc, Operation, PutOp, DeleteOp, SignedEntry, operation};
|
use crate::proto::{Entry, Hlc, Operation, PutOp, DeleteOp, SignedEntry, operation};
|
||||||
use ed25519_dalek::{Signature, VerifyingKey};
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
use prost::Message;
|
use prost::Message;
|
||||||
@@ -116,14 +116,14 @@ impl EntryBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build and sign the entry, returning a SignedEntry
|
/// Build and sign the entry, returning a SignedEntry
|
||||||
pub fn sign(self, node: &Node) -> SignedEntry {
|
pub fn sign(self, node: &NodeIdentity) -> SignedEntry {
|
||||||
let entry = self.build();
|
let entry = self.build();
|
||||||
sign_entry(&entry, node)
|
sign_entry(&entry, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign an Entry to create a SignedEntry
|
/// Sign an Entry to create a SignedEntry
|
||||||
pub fn sign_entry(entry: &Entry, node: &Node) -> SignedEntry {
|
pub fn sign_entry(entry: &Entry, node: &NodeIdentity) -> SignedEntry {
|
||||||
let entry_bytes = entry.encode_to_vec();
|
let entry_bytes = entry.encode_to_vec();
|
||||||
let signature = node.sign(&entry_bytes);
|
let signature = node.sign(&entry_bytes);
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ pub fn verify_signed_entry(signed: &SignedEntry) -> Result<Entry, EntryError> {
|
|||||||
let signature = Signature::from_bytes(&sig_bytes);
|
let signature = Signature::from_bytes(&sig_bytes);
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
Node::verify_with_key(&public_key, &signed.entry_bytes, &signature)?;
|
NodeIdentity::verify_with_key(&public_key, &signed.entry_bytes, &signature)?;
|
||||||
|
|
||||||
// Decode entry
|
// Decode entry
|
||||||
let entry = Entry::decode(&signed.entry_bytes[..])?;
|
let entry = Entry::decode(&signed.entry_bytes[..])?;
|
||||||
@@ -192,7 +192,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sign_and_verify() {
|
fn test_sign_and_verify() {
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
let hlc = HLC::now_with_clock(&clock);
|
let hlc = HLC::now_with_clock(&clock);
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_verify_tampered_fails() {
|
fn test_verify_tampered_fails() {
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
let hlc = HLC::now_with_clock(&clock);
|
let hlc = HLC::now_with_clock(&clock);
|
||||||
|
|
||||||
@@ -227,8 +227,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_verify_wrong_key_fails() {
|
fn test_verify_wrong_key_fails() {
|
||||||
let node1 = Node::generate();
|
let node1 = NodeIdentity::generate();
|
||||||
let node2 = Node::generate();
|
let node2 = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
let hlc = HLC::now_with_clock(&clock);
|
let hlc = HLC::now_with_clock(&clock);
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hash_signed_entry() {
|
fn test_hash_signed_entry() {
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
let hlc = HLC::now_with_clock(&clock);
|
let hlc = HLC::now_with_clock(&clock);
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_prev_hash_chaining() {
|
fn test_prev_hash_chaining() {
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
// First entry
|
// First entry
|
||||||
|
|||||||
+308
-30
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use crate::log::{read_entries, LogError};
|
use crate::log::{read_entries, LogError};
|
||||||
use crate::proto::{operation, AuthorState, Entry, HeadInfo, HeadList, SignedEntry};
|
use crate::proto::{operation, AuthorState, Entry, HeadInfo, HeadList, SignedEntry};
|
||||||
|
use crate::sigchain::SigChainError;
|
||||||
use crate::signed_entry::hash_signed_entry;
|
use crate::signed_entry::hash_signed_entry;
|
||||||
use prost::Message;
|
use prost::Message;
|
||||||
use redb::{Database, ReadableTable, TableDefinition};
|
use redb::{Database, ReadableTable, TableDefinition};
|
||||||
@@ -41,6 +42,9 @@ pub enum StoreError {
|
|||||||
|
|
||||||
#[error("Decode error: {0}")]
|
#[error("Decode error: {0}")]
|
||||||
Decode(#[from] prost::DecodeError),
|
Decode(#[from] prost::DecodeError),
|
||||||
|
|
||||||
|
#[error("Sigchain error: {0}")]
|
||||||
|
SigChain(#[from] SigChainError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistent store for KV state with DAG conflict resolution
|
/// Persistent store for KV state with DAG conflict resolution
|
||||||
@@ -243,16 +247,36 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List all key-value pairs (winner values only)
|
/// List all key-value pairs (winner values only)
|
||||||
pub fn list_all(&self) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError> {
|
/// If include_deleted is true, includes tombstoned entries
|
||||||
|
pub fn list_all(&self, include_deleted: bool) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError> {
|
||||||
|
self.list_by_prefix(&[], include_deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all key-value pairs matching a prefix (winner values only)
|
||||||
|
/// Uses efficient range query on redb's sorted B-tree
|
||||||
|
/// If include_deleted is true, includes tombstoned entries
|
||||||
|
pub fn list_by_prefix(&self, prefix: &[u8], include_deleted: bool) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError> {
|
||||||
let read_txn = self.db.begin_read()?;
|
let read_txn = self.db.begin_read()?;
|
||||||
let table = read_txn.open_table(KV_TABLE)?;
|
let table = read_txn.open_table(KV_TABLE)?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for entry in table.iter()? {
|
|
||||||
|
// Use range query: from prefix to first key that doesn't match
|
||||||
|
for entry in table.range(prefix..)? {
|
||||||
let (key, value) = entry?;
|
let (key, value) = entry?;
|
||||||
|
let key_bytes = key.value();
|
||||||
|
|
||||||
|
// Stop when we've passed the prefix
|
||||||
|
if !key_bytes.starts_with(prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let heads = HeadList::decode(value.value())?.heads;
|
let heads = HeadList::decode(value.value())?.heads;
|
||||||
if let Some(winner) = Self::pick_winner(&heads) {
|
if let Some(winner) = Self::pick_winner(&heads) {
|
||||||
result.push((key.value().to_vec(), winner.value.clone()));
|
// Skip tombstones unless include_deleted is true
|
||||||
|
if include_deleted || !winner.tombstone {
|
||||||
|
result.push((key_bytes.to_vec(), winner.value.clone()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -320,7 +344,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::clock::MockClock;
|
use crate::clock::MockClock;
|
||||||
use crate::hlc::HLC;
|
use crate::hlc::HLC;
|
||||||
use crate::node::Node;
|
use crate::node_identity::NodeIdentity;
|
||||||
use crate::signed_entry::EntryBuilder;
|
use crate::signed_entry::EntryBuilder;
|
||||||
use std::env::temp_dir;
|
use std::env::temp_dir;
|
||||||
|
|
||||||
@@ -337,7 +361,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
@@ -387,7 +411,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let clock = MockClock::new(1000);
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
// First write
|
// First write
|
||||||
@@ -422,7 +446,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
|
|
||||||
// Create two heads
|
// Create two heads
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
@@ -469,7 +493,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
|
|
||||||
// Create two concurrent heads
|
// Create two concurrent heads
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
@@ -521,7 +545,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
|
|
||||||
// Create a single head
|
// Create a single head
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
@@ -569,8 +593,8 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let alice = Node::generate();
|
let alice = NodeIdentity::generate();
|
||||||
let bob = Node::generate();
|
let bob = NodeIdentity::generate();
|
||||||
|
|
||||||
// Initial state: K = v1
|
// Initial state: K = v1
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
@@ -628,9 +652,9 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let alice = Node::generate();
|
let alice = NodeIdentity::generate();
|
||||||
let bob = Node::generate();
|
let bob = NodeIdentity::generate();
|
||||||
let charlie = Node::generate();
|
let charlie = NodeIdentity::generate();
|
||||||
|
|
||||||
// Alice creates K = v1
|
// Alice creates K = v1
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
@@ -688,7 +712,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
|
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock1))
|
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock1))
|
||||||
@@ -721,7 +745,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
|
|
||||||
// First write: a = 1
|
// First write: a = 1
|
||||||
let clock1 = MockClock::new(1000);
|
let clock1 = MockClock::new(1000);
|
||||||
@@ -784,7 +808,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
|
|
||||||
let store = Store::open(&state_path).unwrap();
|
let store = Store::open(&state_path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
||||||
|
|
||||||
// First write: a = 1
|
// First write: a = 1
|
||||||
@@ -846,7 +870,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
|
|
||||||
let store = Store::open(&state_path).unwrap();
|
let store = Store::open(&state_path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
||||||
|
|
||||||
@@ -894,7 +918,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
|
|
||||||
let store = Store::open(&state_path).unwrap();
|
let store = Store::open(&state_path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
||||||
|
|
||||||
@@ -953,7 +977,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
|
|
||||||
let store = Store::open(&state_path).unwrap();
|
let store = Store::open(&state_path).unwrap();
|
||||||
let node = Node::generate();
|
let node = NodeIdentity::generate();
|
||||||
let author = node.public_key_bytes();
|
let author = node.public_key_bytes();
|
||||||
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
let mut sigchain = SigChain::new(&log_path, TEST_STORE, node.public_key_bytes());
|
||||||
|
|
||||||
@@ -1121,7 +1145,7 @@ mod tests {
|
|||||||
|
|
||||||
// Node A writes some entries
|
// Node A writes some entries
|
||||||
let store_a = Store::open(&path_a).unwrap();
|
let store_a = Store::open(&path_a).unwrap();
|
||||||
let node_a = Node::generate();
|
let node_a = NodeIdentity::generate();
|
||||||
|
|
||||||
// Write 3 entries on node A
|
// Write 3 entries on node A
|
||||||
for i in 1u64..=3 {
|
for i in 1u64..=3 {
|
||||||
@@ -1192,8 +1216,8 @@ mod tests {
|
|||||||
|
|
||||||
let store_a = Store::open(&path_a).unwrap();
|
let store_a = Store::open(&path_a).unwrap();
|
||||||
let store_b = Store::open(&path_b).unwrap();
|
let store_b = Store::open(&path_b).unwrap();
|
||||||
let node_a = Node::generate();
|
let node_a = NodeIdentity::generate();
|
||||||
let node_b = Node::generate();
|
let node_b = NodeIdentity::generate();
|
||||||
|
|
||||||
// Node A writes entries
|
// Node A writes entries
|
||||||
for i in 1u64..=2 {
|
for i in 1u64..=2 {
|
||||||
@@ -1279,9 +1303,9 @@ mod tests {
|
|||||||
let store_a = Store::open(&path_a).unwrap();
|
let store_a = Store::open(&path_a).unwrap();
|
||||||
let store_b = Store::open(&path_b).unwrap();
|
let store_b = Store::open(&path_b).unwrap();
|
||||||
let store_c = Store::open(&path_c).unwrap();
|
let store_c = Store::open(&path_c).unwrap();
|
||||||
let node_a = Node::generate();
|
let node_a = NodeIdentity::generate();
|
||||||
let node_b = Node::generate();
|
let node_b = NodeIdentity::generate();
|
||||||
let node_c = Node::generate();
|
let node_c = NodeIdentity::generate();
|
||||||
|
|
||||||
// Each node writes one entry
|
// Each node writes one entry
|
||||||
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&MockClock::new(1000)))
|
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&MockClock::new(1000)))
|
||||||
@@ -1365,8 +1389,8 @@ mod tests {
|
|||||||
|
|
||||||
let store_a = Store::open(&path_a).unwrap();
|
let store_a = Store::open(&path_a).unwrap();
|
||||||
let store_b = Store::open(&path_b).unwrap();
|
let store_b = Store::open(&path_b).unwrap();
|
||||||
let node_a = Node::generate();
|
let node_a = NodeIdentity::generate();
|
||||||
let node_b = Node::generate();
|
let node_b = NodeIdentity::generate();
|
||||||
|
|
||||||
// Both nodes write to the SAME key with different values
|
// Both nodes write to the SAME key with different values
|
||||||
// Use same HLC to force conflict (tie-break on author)
|
// Use same HLC to force conflict (tie-break on author)
|
||||||
@@ -1431,8 +1455,8 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
let store = Store::open(&path).unwrap();
|
let store = Store::open(&path).unwrap();
|
||||||
let node_low = Node::generate();
|
let node_low = NodeIdentity::generate();
|
||||||
let node_high = Node::generate();
|
let node_high = NodeIdentity::generate();
|
||||||
|
|
||||||
// Determine which node has "higher" author bytes
|
// Determine which node has "higher" author bytes
|
||||||
let (high_node, low_node) = if node_high.public_key_bytes() > node_low.public_key_bytes() {
|
let (high_node, low_node) = if node_high.public_key_bytes() > node_low.public_key_bytes() {
|
||||||
@@ -1469,4 +1493,258 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test case for multi-node sync: 3 nodes create multi-heads, then merge, then sync to new node.
|
||||||
|
///
|
||||||
|
/// Scenario:
|
||||||
|
/// 1. Node A, B, C each write to key "/a" independently (creating 3 heads)
|
||||||
|
/// 2. Node A does a final put to merge all heads
|
||||||
|
/// 3. After merge, node A should have only 1 head
|
||||||
|
/// 4. Simulate sync to new node D using SyncState diff
|
||||||
|
/// 5. Node D should end up with same state as A (1 head, not 3)
|
||||||
|
#[test]
|
||||||
|
fn test_multinode_sync_after_merge() {
|
||||||
|
let path_a = temp_db_path("multinode_a");
|
||||||
|
let path_d = temp_db_path("multinode_d");
|
||||||
|
let _ = std::fs::remove_file(&path_a);
|
||||||
|
let _ = std::fs::remove_file(&path_d);
|
||||||
|
|
||||||
|
// Create stores
|
||||||
|
let store_a = Store::open(&path_a).unwrap();
|
||||||
|
let store_d = Store::open(&path_d).unwrap();
|
||||||
|
|
||||||
|
// Create 3 nodes (virtual peers)
|
||||||
|
let node_a = NodeIdentity::generate();
|
||||||
|
let node_b = NodeIdentity::generate();
|
||||||
|
let node_c = NodeIdentity::generate();
|
||||||
|
|
||||||
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
|
// 1. Each node writes to "/a" independently (simulating offline concurrent writes)
|
||||||
|
// Node A: seq 1
|
||||||
|
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/a", b"from_a".to_vec())
|
||||||
|
.sign(&node_a);
|
||||||
|
store_a.apply_entry(&entry_a).unwrap();
|
||||||
|
|
||||||
|
// Node B: seq 1 (different author, same key - creates fork)
|
||||||
|
let entry_b = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/a", b"from_b".to_vec())
|
||||||
|
.sign(&node_b);
|
||||||
|
store_a.apply_entry(&entry_b).unwrap();
|
||||||
|
|
||||||
|
// Node C: seq 1 (third author, same key - creates third fork)
|
||||||
|
let entry_c = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/a", b"from_c".to_vec())
|
||||||
|
.sign(&node_c);
|
||||||
|
store_a.apply_entry(&entry_c).unwrap();
|
||||||
|
|
||||||
|
// After applying all 3 entries, store_a has 3 heads for "/a"
|
||||||
|
let heads_before_merge = store_a.get_heads(b"/a").unwrap();
|
||||||
|
assert_eq!(heads_before_merge.len(), 3, "Should have 3 heads before merge");
|
||||||
|
|
||||||
|
// 2. Node A does a final put referencing all heads (merge)
|
||||||
|
// Get the hashes of all current heads as parent_hashes
|
||||||
|
let parent_hashes: Vec<Vec<u8>> = heads_before_merge.iter()
|
||||||
|
.map(|h| h.hash.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let merge_entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash(hash_signed_entry(&entry_a).to_vec()) // Continues A's chain
|
||||||
|
.parent_hashes(parent_hashes) // References all heads
|
||||||
|
.put("/a", b"merged".to_vec())
|
||||||
|
.sign(&node_a);
|
||||||
|
store_a.apply_entry(&merge_entry).unwrap();
|
||||||
|
|
||||||
|
// After merge, should have only 1 head
|
||||||
|
let heads_after_merge = store_a.get_heads(b"/a").unwrap();
|
||||||
|
assert_eq!(heads_after_merge.len(), 1, "Should have 1 head after merge");
|
||||||
|
assert_eq!(heads_after_merge[0].value, b"merged");
|
||||||
|
|
||||||
|
// 3. Get sync state from store_a
|
||||||
|
let sync_state_a = store_a.sync_state().unwrap();
|
||||||
|
|
||||||
|
println!("Store A sync state:");
|
||||||
|
for (author, info) in sync_state_a.authors() {
|
||||||
|
println!(" author {:?}: seq={}, heads={:?}",
|
||||||
|
hex::encode(&author[..4]), info.seq,
|
||||||
|
info.heads.iter().map(|h| hex::encode(&h[..4])).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store D is empty, compute diff
|
||||||
|
let sync_state_d = store_d.sync_state().unwrap();
|
||||||
|
let missing = sync_state_d.diff(&sync_state_a);
|
||||||
|
|
||||||
|
println!("Missing ranges: {:?}", missing.len());
|
||||||
|
for m in &missing {
|
||||||
|
println!(" author {:?}: from_seq={}, to_seq={}",
|
||||||
|
hex::encode(&m.author[..4]), m.from_seq, m.to_seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should get missing ranges for all authors that have entries
|
||||||
|
assert!(!missing.is_empty(), "Should have missing entries to sync");
|
||||||
|
|
||||||
|
// 5. Apply all entries to store_d (simulating sync)
|
||||||
|
// In a real sync, we'd read entries from logs, but for this test,
|
||||||
|
// we just apply the same entries in order
|
||||||
|
store_d.apply_entry(&entry_a).unwrap();
|
||||||
|
store_d.apply_entry(&entry_b).unwrap();
|
||||||
|
store_d.apply_entry(&entry_c).unwrap();
|
||||||
|
store_d.apply_entry(&merge_entry).unwrap();
|
||||||
|
|
||||||
|
// 6. Check state on store_d
|
||||||
|
let heads_d = store_d.get_heads(b"/a").unwrap();
|
||||||
|
println!("Store D heads count: {}", heads_d.len());
|
||||||
|
for (i, h) in heads_d.iter().enumerate() {
|
||||||
|
println!(" head[{}]: value={:?}, author={}", i, String::from_utf8_lossy(&h.value), hex::encode(&h.author[..4]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUG CHECK: Store D should have same state as Store A (1 head, not 3)
|
||||||
|
assert_eq!(heads_d.len(), 1,
|
||||||
|
"BUG: Store D should have 1 head (merged) but has {} heads", heads_d.len());
|
||||||
|
assert_eq!(heads_d[0].value, b"merged");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path_a);
|
||||||
|
let _ = std::fs::remove_file(&path_d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test what happens when entries are applied in "wrong" order.
|
||||||
|
/// This simulates the real sync bug where:
|
||||||
|
/// - Sync iterates by author
|
||||||
|
/// - Author A's entries (including merge) are sent first
|
||||||
|
/// - Author B and C's entries are sent after
|
||||||
|
/// - The merge entry arrives BEFORE the entries it merges!
|
||||||
|
#[test]
|
||||||
|
fn test_multinode_sync_wrong_order() {
|
||||||
|
let path = temp_db_path("wrongorder");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let store = Store::open(&path).unwrap();
|
||||||
|
|
||||||
|
// Create 3 nodes
|
||||||
|
let node_a = NodeIdentity::generate();
|
||||||
|
let node_b = NodeIdentity::generate();
|
||||||
|
let node_c = NodeIdentity::generate();
|
||||||
|
|
||||||
|
let clock = MockClock::new(1000);
|
||||||
|
|
||||||
|
// Create entries (same as before)
|
||||||
|
let entry_a = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/a", b"from_a".to_vec())
|
||||||
|
.sign(&node_a);
|
||||||
|
|
||||||
|
let entry_b = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/a", b"from_b".to_vec())
|
||||||
|
.sign(&node_b);
|
||||||
|
|
||||||
|
let entry_c = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/a", b"from_c".to_vec())
|
||||||
|
.sign(&node_c);
|
||||||
|
|
||||||
|
// We need the hashes for parent_hashes - compute them
|
||||||
|
let hash_a = hash_signed_entry(&entry_a);
|
||||||
|
let hash_b = hash_signed_entry(&entry_b);
|
||||||
|
let hash_c = hash_signed_entry(&entry_c);
|
||||||
|
|
||||||
|
let merge_entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash(hash_a.to_vec())
|
||||||
|
.parent_hashes(vec![hash_a.to_vec(), hash_b.to_vec(), hash_c.to_vec()])
|
||||||
|
.put("/a", b"merged".to_vec())
|
||||||
|
.sign(&node_a);
|
||||||
|
|
||||||
|
// Apply in WRONG order: A's chain first (entry_a + merge), then B, then C
|
||||||
|
// This is what happens in sync when iterating by author
|
||||||
|
println!("Applying entry_a (A seq 1)...");
|
||||||
|
store.apply_entry(&entry_a).unwrap();
|
||||||
|
|
||||||
|
println!("Applying merge_entry (A seq 2) BEFORE B and C...");
|
||||||
|
store.apply_entry(&merge_entry).unwrap();
|
||||||
|
|
||||||
|
println!("Applying entry_b (B seq 1)...");
|
||||||
|
store.apply_entry(&entry_b).unwrap();
|
||||||
|
|
||||||
|
println!("Applying entry_c (C seq 1)...");
|
||||||
|
store.apply_entry(&entry_c).unwrap();
|
||||||
|
|
||||||
|
// Check final state
|
||||||
|
let heads = store.get_heads(b"/a").unwrap();
|
||||||
|
println!("Final heads count: {}", heads.len());
|
||||||
|
for (i, h) in heads.iter().enumerate() {
|
||||||
|
println!(" head[{}]: value={:?}", i, String::from_utf8_lossy(&h.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(heads.len(), 3,
|
||||||
|
"Wrong order application creates 3 heads (expected - sync handles ordering)");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_by_prefix_filters_tombstones() {
|
||||||
|
let path = temp_db_path("list_tombstones");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let store = Store::open(&path).unwrap();
|
||||||
|
let node = NodeIdentity::generate();
|
||||||
|
|
||||||
|
// Create a key under /test/ prefix
|
||||||
|
let clock1 = MockClock::new(1000);
|
||||||
|
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock1))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash([0u8; 32].to_vec())
|
||||||
|
.put("/test/key1", b"value1".to_vec())
|
||||||
|
.sign(&node);
|
||||||
|
store.apply_entry(&entry1).unwrap();
|
||||||
|
|
||||||
|
// Create another key
|
||||||
|
let clock2 = MockClock::new(2000);
|
||||||
|
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock2))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash(hash_signed_entry(&entry1).to_vec())
|
||||||
|
.put("/test/key2", b"value2".to_vec())
|
||||||
|
.sign(&node);
|
||||||
|
store.apply_entry(&entry2).unwrap();
|
||||||
|
|
||||||
|
// Delete key1
|
||||||
|
let clock3 = MockClock::new(3000);
|
||||||
|
let entry3 = EntryBuilder::new(3, HLC::now_with_clock(&clock3))
|
||||||
|
.store_id(TEST_STORE.to_vec())
|
||||||
|
.prev_hash(hash_signed_entry(&entry2).to_vec())
|
||||||
|
.parent_hashes(vec![hash_signed_entry(&entry1).to_vec()])
|
||||||
|
.delete(b"/test/key1")
|
||||||
|
.sign(&node);
|
||||||
|
store.apply_entry(&entry3).unwrap();
|
||||||
|
|
||||||
|
// list_by_prefix without include_deleted should only show key2
|
||||||
|
let entries = store.list_by_prefix(b"/test/", false).unwrap();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].0, b"/test/key2");
|
||||||
|
|
||||||
|
// list_by_prefix with include_deleted should show both (key1 as tombstone)
|
||||||
|
let entries_all = store.list_by_prefix(b"/test/", true).unwrap();
|
||||||
|
assert_eq!(entries_all.len(), 2);
|
||||||
|
|
||||||
|
// Verify list_all also respects the flag
|
||||||
|
let all_entries = store.list_all(false).unwrap();
|
||||||
|
assert_eq!(all_entries.len(), 1);
|
||||||
|
|
||||||
|
let all_entries_incl_deleted = store.list_all(true).unwrap();
|
||||||
|
assert_eq!(all_entries_incl_deleted.len(), 2);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
//! Store Actor - dedicated thread that owns Store and processes commands via channel
|
//! Store Actor - dedicated thread that owns Store and processes commands via channel
|
||||||
|
|
||||||
use lattice_core::{
|
use crate::{
|
||||||
EntryBuilder, HeadInfo, Node, SigChain, Store, Uuid,
|
EntryBuilder, HeadInfo, NodeIdentity, SigChain, SigChainManager, Store, Uuid,
|
||||||
hlc::HLC,
|
hlc::HLC,
|
||||||
proto::AuthorState,
|
proto::AuthorState,
|
||||||
sigchain::SigChainError,
|
sigchain::SigChainError,
|
||||||
store::StoreError,
|
store::StoreError,
|
||||||
|
sync_state::SyncState,
|
||||||
|
proto::SignedEntry,
|
||||||
|
log,
|
||||||
};
|
};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot, broadcast};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
/// Commands sent to the store actor
|
/// Commands sent to the store actor
|
||||||
@@ -21,6 +24,12 @@ pub enum StoreCmd {
|
|||||||
resp: oneshot::Sender<Result<Vec<HeadInfo>, StoreError>>,
|
resp: oneshot::Sender<Result<Vec<HeadInfo>, StoreError>>,
|
||||||
},
|
},
|
||||||
List {
|
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>>,
|
resp: oneshot::Sender<Result<Vec<(Vec<u8>, Vec<u8>)>, StoreError>>,
|
||||||
},
|
},
|
||||||
Put {
|
Put {
|
||||||
@@ -42,6 +51,22 @@ pub enum StoreCmd {
|
|||||||
author: [u8; 32],
|
author: [u8; 32],
|
||||||
resp: oneshot::Sender<Result<Option<AuthorState>, StoreError>>,
|
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,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +99,15 @@ impl std::fmt::Display for StoreActorError {
|
|||||||
|
|
||||||
impl std::error::Error for StoreActorError {}
|
impl std::error::Error for StoreActorError {}
|
||||||
|
|
||||||
/// The store actor - runs in its own thread, owns Store and SigChain
|
/// The store actor - runs in its own thread, owns Store and SigChainManager
|
||||||
pub struct StoreActor {
|
pub struct StoreActor {
|
||||||
store_id: Uuid,
|
store_id: Uuid,
|
||||||
store: Store,
|
store: Store,
|
||||||
sigchain: SigChain,
|
chain_manager: SigChainManager,
|
||||||
node: Node,
|
node: NodeIdentity,
|
||||||
rx: mpsc::Receiver<StoreCmd>,
|
rx: mpsc::Receiver<StoreCmd>,
|
||||||
|
/// Broadcast sender for emitting entries after they're committed locally
|
||||||
|
entry_tx: broadcast::Sender<SignedEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StoreActor {
|
impl StoreActor {
|
||||||
@@ -89,15 +116,28 @@ impl StoreActor {
|
|||||||
store_id: Uuid,
|
store_id: Uuid,
|
||||||
store: Store,
|
store: Store,
|
||||||
sigchain: SigChain,
|
sigchain: SigChain,
|
||||||
node: Node,
|
node: NodeIdentity,
|
||||||
rx: mpsc::Receiver<StoreCmd>,
|
rx: mpsc::Receiver<StoreCmd>,
|
||||||
|
entry_tx: broadcast::Sender<SignedEntry>,
|
||||||
) -> Self {
|
) -> 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 {
|
Self {
|
||||||
store_id,
|
store_id,
|
||||||
store,
|
store,
|
||||||
sigchain,
|
chain_manager,
|
||||||
node,
|
node,
|
||||||
rx,
|
rx,
|
||||||
|
entry_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +152,11 @@ impl StoreActor {
|
|||||||
StoreCmd::GetHeads { key, resp } => {
|
StoreCmd::GetHeads { key, resp } => {
|
||||||
let _ = resp.send(self.store.get_heads(&key));
|
let _ = resp.send(self.store.get_heads(&key));
|
||||||
}
|
}
|
||||||
StoreCmd::List { resp } => {
|
StoreCmd::List { include_deleted, resp } => {
|
||||||
let _ = resp.send(self.store.list_all());
|
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 } => {
|
StoreCmd::Put { key, value, resp } => {
|
||||||
let result = self.do_put(&key, &value);
|
let result = self.do_put(&key, &value);
|
||||||
@@ -124,7 +167,11 @@ impl StoreActor {
|
|||||||
let _ = resp.send(result);
|
let _ = resp.send(result);
|
||||||
}
|
}
|
||||||
StoreCmd::LogSeq { resp } => {
|
StoreCmd::LogSeq { resp } => {
|
||||||
let _ = resp.send(self.sigchain.len());
|
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 } => {
|
StoreCmd::AppliedSeq { resp } => {
|
||||||
let author = self.node.public_key_bytes();
|
let author = self.node.public_key_bytes();
|
||||||
@@ -135,6 +182,28 @@ impl StoreActor {
|
|||||||
StoreCmd::AuthorState { author, resp } => {
|
StoreCmd::AuthorState { author, resp } => {
|
||||||
let _ = resp.send(self.store.author_state(&author));
|
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 => {
|
StoreCmd::Shutdown => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -147,7 +216,8 @@ impl StoreActor {
|
|||||||
|
|
||||||
// Idempotency check (pure function)
|
// Idempotency check (pure function)
|
||||||
if !Store::needs_put(&heads, value) {
|
if !Store::needs_put(&heads, value) {
|
||||||
return Ok(self.sigchain.len()); // Idempotent, no new entry
|
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();
|
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
|
||||||
@@ -159,7 +229,8 @@ impl StoreActor {
|
|||||||
|
|
||||||
// Idempotency check (pure function)
|
// Idempotency check (pure function)
|
||||||
if !Store::needs_delete(&heads) {
|
if !Store::needs_delete(&heads) {
|
||||||
return Ok(self.sigchain.len()); // Idempotent, no new entry
|
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();
|
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
|
||||||
@@ -170,8 +241,11 @@ impl StoreActor {
|
|||||||
where
|
where
|
||||||
F: FnOnce(EntryBuilder) -> EntryBuilder,
|
F: FnOnce(EntryBuilder) -> EntryBuilder,
|
||||||
{
|
{
|
||||||
let seq = self.sigchain.len() + 1;
|
let local_author = self.node.public_key_bytes();
|
||||||
let prev_hash = self.sigchain.last_hash();
|
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())
|
let builder = EntryBuilder::new(seq, HLC::now())
|
||||||
.store_id(self.store_id.as_bytes().to_vec())
|
.store_id(self.store_id.as_bytes().to_vec())
|
||||||
@@ -179,23 +253,47 @@ impl StoreActor {
|
|||||||
.parent_hashes(parent_hashes);
|
.parent_hashes(parent_hashes);
|
||||||
let entry = build(builder).sign(&self.node);
|
let entry = build(builder).sign(&self.node);
|
||||||
|
|
||||||
self.sigchain.append(&entry)?;
|
// Append to local sigchain
|
||||||
|
let sigchain = self.chain_manager.get_or_create(local_author);
|
||||||
|
sigchain.append(&entry)?;
|
||||||
self.store.apply_entry(&entry)?;
|
self.store.apply_entry(&entry)?;
|
||||||
|
|
||||||
|
// Broadcast the entry to listeners (for gossip)
|
||||||
|
let _ = self.entry_tx.send(entry.clone());
|
||||||
|
|
||||||
Ok(seq)
|
Ok(seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn do_read_entries_after(
|
||||||
|
&self,
|
||||||
|
author: &[u8; 32],
|
||||||
|
from_hash: Option<[u8; 32]>,
|
||||||
|
) -> Result<Vec<SignedEntry>, StoreError> {
|
||||||
|
// Build log path for this author
|
||||||
|
let author_hex = hex::encode(author);
|
||||||
|
let log_path = self.chain_manager.logs_dir().join(format!("{}.log", author_hex));
|
||||||
|
|
||||||
|
if !log_path.exists() {
|
||||||
|
return Ok(Vec::new()); // No log file for this author
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use lattice_core's read_entries_after
|
||||||
|
log::read_entries_after(&log_path, from_hash)
|
||||||
|
.map_err(StoreError::from)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a store actor in a new thread, returns (sender, join_handle)
|
/// Spawn a store actor in a new thread, returns (cmd_tx, entry_tx, join_handle)
|
||||||
/// Uses std::thread since redb is blocking
|
/// Uses std::thread since redb is blocking
|
||||||
pub fn spawn_store_actor(
|
pub fn spawn_store_actor(
|
||||||
store_id: Uuid,
|
store_id: Uuid,
|
||||||
store: Store,
|
store: Store,
|
||||||
sigchain: SigChain,
|
sigchain: SigChain,
|
||||||
node: Node,
|
node: NodeIdentity,
|
||||||
) -> (mpsc::Sender<StoreCmd>, JoinHandle<()>) {
|
) -> (mpsc::Sender<StoreCmd>, broadcast::Sender<SignedEntry>, JoinHandle<()>) {
|
||||||
let (tx, rx) = mpsc::channel(32);
|
let (tx, rx) = mpsc::channel(32);
|
||||||
let actor = StoreActor::new(store_id, store, sigchain, node, rx);
|
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());
|
let handle = thread::spawn(move || actor.run());
|
||||||
(tx, handle)
|
(tx, entry_tx, handle)
|
||||||
}
|
}
|
||||||
+165
-19
@@ -1,21 +1,33 @@
|
|||||||
//! Sync state for causality tracking and reconciliation
|
//! Sync state for causality tracking and reconciliation
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
/// Author ID type (32-byte Ed25519 public key)
|
/// Author ID type (32-byte Ed25519 public key)
|
||||||
pub type Author = [u8; 32];
|
pub type Author = [u8; 32];
|
||||||
|
|
||||||
/// Per-author sync information (seq + hash for resume).
|
/// Per-author sync information: seq + all head hashes.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AuthorInfo {
|
pub struct AuthorInfo {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub hash: [u8; 32],
|
pub heads: HashSet<[u8; 32]>, // All head hashes for this author
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync state tracking per-author sequence numbers and hashes.
|
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.
|
/// Used during reconciliation to identify missing entries between peers.
|
||||||
/// Each author's highest seen sequence number and hash is tracked.
|
/// Tracks all head hashes per author to handle forks correctly.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct SyncState {
|
pub struct SyncState {
|
||||||
authors: HashMap<Author, AuthorInfo>,
|
authors: HashMap<Author, AuthorInfo>,
|
||||||
@@ -26,7 +38,7 @@ pub struct SyncState {
|
|||||||
pub struct MissingRange {
|
pub struct MissingRange {
|
||||||
pub author: Author,
|
pub author: Author,
|
||||||
pub from_seq: u64, // exclusive - we have up to this
|
pub from_seq: u64, // exclusive - we have up to this
|
||||||
pub from_hash: [u8; 32], // hash to resume reading after
|
pub from_hash: [u8; 32], // hash to resume reading after (zero = start)
|
||||||
pub to_seq: u64, // inclusive - peer has up to this
|
pub to_seq: u64, // inclusive - peer has up to this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +59,32 @@ impl SyncState {
|
|||||||
pub fn seq(&self, author: &Author) -> u64 {
|
pub fn seq(&self, author: &Author) -> u64 {
|
||||||
self.authors.get(author).map(|i| i.seq).unwrap_or(0)
|
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.
|
/// Set the info for an author (single hash convenience method).
|
||||||
pub fn set(&mut self, author: Author, seq: u64, hash: [u8; 32]) {
|
pub fn set(&mut self, author: Author, seq: u64, hash: [u8; 32]) {
|
||||||
self.authors.insert(author, AuthorInfo { seq, hash });
|
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.
|
/// Get all authors and their info.
|
||||||
@@ -61,18 +95,33 @@ impl SyncState {
|
|||||||
/// Compute what entries we're missing compared to a peer's state.
|
/// Compute what entries we're missing compared to a peer's state.
|
||||||
///
|
///
|
||||||
/// Returns ranges of entries we need from the peer.
|
/// Returns ranges of entries we need from the peer.
|
||||||
/// Each range includes the hash to resume reading after.
|
/// Compares hash sets when seq matches to detect forks.
|
||||||
pub fn diff(&self, peer: &SyncState) -> Vec<MissingRange> {
|
pub fn diff(&self, peer: &SyncState) -> Vec<MissingRange> {
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
|
|
||||||
for (author, peer_info) in peer.authors() {
|
for (author, peer_info) in peer.authors() {
|
||||||
let my_seq = self.seq(author);
|
let my_seq = self.seq(author);
|
||||||
if peer_info.seq > my_seq {
|
let my_heads = self.heads(author);
|
||||||
// We need entries from my_seq+1 to peer_info.seq
|
|
||||||
// Use our hash (or zero if we have nothing) as resume point
|
// We need entries if:
|
||||||
let from_hash = self.get(author)
|
// 1. Peer's seq is higher than ours, OR
|
||||||
.map(|i| i.hash)
|
// 2. Peer's seq equals ours but they have heads we don't (fork)
|
||||||
.unwrap_or([0u8; 32]);
|
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 {
|
missing.push(MissingRange {
|
||||||
author: *author,
|
author: *author,
|
||||||
@@ -86,15 +135,66 @@ impl SyncState {
|
|||||||
missing
|
missing
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge another sync state into this one (take max seq per author).
|
/// Merge another sync state into this one (union of heads, max seq).
|
||||||
pub fn merge(&mut self, other: &SyncState) {
|
pub fn merge(&mut self, other: &SyncState) {
|
||||||
for (author, info) in other.authors() {
|
for (author, info) in other.authors() {
|
||||||
let my_seq = self.seq(author);
|
if let Some(my_info) = self.authors.get_mut(author) {
|
||||||
if info.seq > my_seq {
|
// Union heads
|
||||||
self.set(*author, info.seq, info.hash);
|
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)]
|
#[cfg(test)]
|
||||||
@@ -176,4 +276,50 @@ mod tests {
|
|||||||
assert_eq!(a.seq(&author1), 10); // kept a's value
|
assert_eq!(a.seq(&author1), 10); // kept a's value
|
||||||
assert_eq!(a.seq(&author2), 8); // took b'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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ license.workspace = true
|
|||||||
lattice-core = { workspace = true }
|
lattice-core = { workspace = true }
|
||||||
iroh = { workspace = true }
|
iroh = { workspace = true }
|
||||||
iroh-gossip = { workspace = true }
|
iroh-gossip = { workspace = true }
|
||||||
|
prost = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
bytes = { 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]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -1,8 +1,23 @@
|
|||||||
//! Lattice Networking
|
//! Lattice Networking
|
||||||
//!
|
//!
|
||||||
//! Networking layer using Iroh:
|
//! Networking layer using Iroh:
|
||||||
|
//! - **Endpoint**: Network identity and connection management
|
||||||
//! - **Gossip**: Broadcasting changes across the mesh
|
//! - **Gossip**: Broadcasting changes across the mesh
|
||||||
//! - **Unicast**: Point-to-point communication for reconciliation
|
//! - **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 gossip;
|
||||||
pub mod unicast;
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
//! Unicast communication for direct peer-to-peer messaging
|
|
||||||
|
|
||||||
// TODO: Implement unicast using iroh
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "lattice-store"
|
|
||||||
description = "Log-based KV store with snapshots and watermarks"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
lattice-core = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio-test = { workspace = true }
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
//! Lattice Store
|
|
||||||
//!
|
|
||||||
//! Log-based Key-Value store:
|
|
||||||
//! - State is a "view" generated by replaying entries
|
|
||||||
//! - Watermarks for agreeing on safe compaction points
|
|
||||||
//! - Snapshots for replacing old logs
|
|
||||||
|
|
||||||
pub mod store;
|
|
||||||
pub mod snapshot;
|
|
||||||
pub mod watermark;
|
|
||||||
|
|
||||||
pub use store::Store;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//! Snapshots for log compaction
|
|
||||||
|
|
||||||
/// A verified snapshot that can replace old log entries.
|
|
||||||
pub struct Snapshot {
|
|
||||||
// TODO: snapshot data, watermark, verification
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
//! The main KV store
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// A log-based Key-Value store.
|
|
||||||
///
|
|
||||||
/// The store is a dynamic "view" generated by replaying entries.
|
|
||||||
pub struct Store {
|
|
||||||
data: HashMap<Vec<u8>, Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Store {
|
|
||||||
/// Create a new empty store.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a value by key.
|
|
||||||
pub fn get(&self, key: &[u8]) -> Option<&[u8]> {
|
|
||||||
self.data.get(key).map(|v| v.as_slice())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a value (this would normally go through the log).
|
|
||||||
pub fn set(&mut self, key: Vec<u8>, value: Vec<u8>) {
|
|
||||||
self.data.insert(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Store {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//! Watermarks for agreeing on safe compaction points
|
|
||||||
|
|
||||||
/// A watermark representing a safe cut-off point for log compaction.
|
|
||||||
///
|
|
||||||
/// Nodes use watermarks to agree on which entries can be safely
|
|
||||||
/// deleted and replaced with snapshots.
|
|
||||||
pub struct Watermark {
|
|
||||||
// TODO: watermark data
|
|
||||||
}
|
|
||||||
+44
-1
@@ -90,7 +90,7 @@ message SyncState {
|
|||||||
message Frontier {
|
message Frontier {
|
||||||
bytes author_id = 1; // Ed25519 public key (32 bytes)
|
bytes author_id = 1; // Ed25519 public key (32 bytes)
|
||||||
uint64 max_seq = 2; // Highest sequence number seen from this author
|
uint64 max_seq = 2; // Highest sequence number seen from this author
|
||||||
bytes last_hash = 3; // Hash of the last entry (for chain verification)
|
repeated bytes head_hashes = 3; // All head hashes for this author
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Log File Record (wrapper for storage)
|
// 5. Log File Record (wrapper for storage)
|
||||||
@@ -98,3 +98,46 @@ message LogRecord {
|
|||||||
bytes hash = 1; // BLAKE3 hash of entry_bytes (32 bytes)
|
bytes hash = 1; // BLAKE3 hash of entry_bytes (32 bytes)
|
||||||
bytes entry_bytes = 2; // Serialized SignedEntry
|
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