From cc4492742a21b052b27e487e571134102e3099cd Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Thu, 9 Oct 2025 22:05:18 +0900 Subject: [PATCH 1/9] feat: add convergence layer --- "CRSL\343\201\256\346\224\271\344\277\256.md" | 449 ++++++++++++++++++ examples/cli.rs | 29 +- examples/content_versioning.rs | 9 +- readme.md | 3 +- src/convergence/metadata.rs | 35 ++ src/convergence/mod.rs | 5 + src/convergence/policies/lww.rs | 72 +++ src/convergence/policies/mod.rs | 2 + src/convergence/policy.rs | 29 ++ src/convergence/resolver.rs | 100 ++++ src/crdt/crdt_state.rs | 60 +-- src/crdt/operation.rs | 104 ++-- src/crdt/reducer.rs | 6 +- src/crdt/storage.rs | 109 +---- src/graph/dag.rs | 177 ++----- src/lib.rs | 1 + src/repo.rs | 45 +- 17 files changed, 842 insertions(+), 393 deletions(-) create mode 100644 "CRSL\343\201\256\346\224\271\344\277\256.md" create mode 100644 src/convergence/metadata.rs create mode 100644 src/convergence/mod.rs create mode 100644 src/convergence/policies/lww.rs create mode 100644 src/convergence/policies/mod.rs create mode 100644 src/convergence/policy.rs create mode 100644 src/convergence/resolver.rs diff --git "a/CRSL\343\201\256\346\224\271\344\277\256.md" "b/CRSL\343\201\256\346\224\271\344\277\256.md" new file mode 100644 index 0000000..cb5879f --- /dev/null +++ "b/CRSL\343\201\256\346\224\271\344\277\256.md" @@ -0,0 +1,449 @@ +# CRSLの改修 + +# 目的 + +CRSLの改修の詳細設計を書いていく。目的としては因果関係を表現可能とし、内容の収束のためのマージポリシーを選択可能とする。 + +# 詳細設計 + +## 全体アーキテクチャ + +```json +┌─────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ (ユーザーアプリケーション) │ +└────────────────────┬────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────┐ +│ Repository Layer (Repo) │ +│ - 高レベルAPI提供 │ +│ - オペレーションの調整 │ +│ - 自動マージのトリガー │ +└─────┬──────────────────────────────┬───────────────────┘ + │ │ + │ │ +┌─────▼──────────────────┐ ┌────────▼──────────────────┐ +│ CRDT State Layer │ │ Convergence Layer [新規] │ +│ - 操作の記録 │ │ - マージポリシー管理 │ +│ - LWW Reducer │ │ - ContentMetadata [移動] │ +│ [既存: 変更なし] │ │ - 衝突解決ロジック │ +└─────┬──────────────────┘ └────────┬──────────────────┘ + │ │ + │ │ +┌─────▼──────────────────────────────▼───────────────────┐ +│ Graph Layer (DAG) │ +│ - DAG構造の管理 │ +│ - サイクル検知 │ +│ - ノードの追加・取得 │ +│ - 同期機能 [新規: graph/sync.rs] │ +└─────┬──────────────────────────────────────────────────┘ + │ +┌─────▼────────────────────────────────────────────────────┐ +│ DASL Layer │ +│ - Node (汎用構造) │ +│ - CID生成 │ +│ [変更なし] │ +└──────────────────────────────────────────────────────────┘ +``` + +## レイヤー別設計について + +### DASL Layer + +変更なし + +### Graph Layer + +以下変更 + +- 関数名の変更 + - `fn add_version_node()` → `fn add_child_node()` + - `fn get_all_versions_for_genesis` → `fn get_nodes_by_genesis` + - ここでVersionって言葉を使うのは良くない。DAGは因果関係の表現であって、バージョンの表現(線形)ではないため +- 関数を削除 + - `fn get_history_from_version` +- versionって言葉の撤廃 + - 引数や変数名として使用している部分の修正 + +以下関数 + +```rust +impl DagGraph { + // ノード追加 + fn add_genesis_node(&mut self, payload: P, metadata: M) -> Result + fn add_child_node(&mut self, payload: P, parents: Vec, genesis: Cid, metadata: M) -> Result + /// ノードを取得 + pub fn get_node(&self, cid: &Cid) -> Result>>; + // ノード取得 + fn get_nodes_by_genesis(&self, genesis: &Cid) -> Result> + + // 構造解析 + fn collect_leaf_nodes(&self, nodes: &[Cid]) -> Result> + fn collect_nodes_with_children(&self, nodes: &[Cid]) -> Result> + fn calculate_latest(&self, genesis_id: &Cid) -> Result> + fn get_genesis(&self, node_cid: &Cid) -> Result + + // サイクル検知 + fn would_create_cycle_with(&mut self, new_cid: &Cid, parents: &[Cid]) -> Result +} +``` + +### CRDT Layer + +- mergeを追加 + - merge操作の場合は、mergeとして識別可能にする + +```rust +pub enum OperationType { + Create(T), + Update(T), + Delete, + Merge(T), // 追加 +} +``` + +- `target`の削除 + - targetにより、1つ前の状態を表現していたが、DAGにより1つ前の状態を表現できており冗長だったため、削除 + - 引数から削除 + - `fn load_operations_by_genesis()`の削除 + - 以下の構造体に変更 + +```rust +pub struct Operation { + pub id: OperationId, + pub genesis: ContentId, + pub kind: OperationType, + pub timestamp: Timestamp, + pub author: Author, +} +``` + +## Convergence Layer + +新しく追加するレイヤー: マージポリシーを持つ。また、ContentMetadataの構造体も持つ。 + +**全体構成** + +```json +src/convergence/ +├── mod.rs +├── metadata.rs # ContentMetadata定義 +├── policy.rs # MergePolicy trait, ResolveInput +├── resolver.rs # ConflictResolver +└── policies/ + ├── mod.rs + └── lww.rs # LwwMergePolicy実装 +``` + +**metadataの定義:** + +```rust +// src/convergence/metadata.rs + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContentMetadata { + /// ポリシータイプ名(例: "lww", "text", "custom-policy") + /// Noneの場合はデフォルト("lww") + policy_type: Option, +} + +impl ContentMetadata { + /// デフォルトのメタデータ(LWWポリシー) + pub fn new() -> Self { + Self { policy_type: None } + } + + /// 特定のポリシーを指定 + pub fn with_policy(policy_type: impl Into) -> Self { + Self { + policy_type: Some(policy_type.into()), + } + } + + /// ポリシータイプを取得(Noneの場合は"lww") + pub fn policy_type(&self) -> &str { + self.policy_type.as_deref().unwrap_or("lww") + } +} + +impl Default for ContentMetadata { + fn default() -> Self { + Self::new() + } +} + +``` + +関数: + +- `fn new() -> Self` +- `fn with_policy(policy_type: impl Into) -> Self` +- `fn policy_type(&self) -> &str` + +`policy.rs` : 複数のマージポリシーに対応するために抽象化する + +trait: + +```rust +pub trait MergePolicy

: Send + Sync { + /// 競合する複数ノードを解決して統合ペイロードを生成 + fn resolve(&self, nodes: &[ResolveInput

]) -> P; + + /// ポリシーの識別名("lww", "text"など) + fn name(&self) -> &str; +} + +/// 構造体 +pub struct ResolveInput

{ + pub cid: Cid, + pub payload: P, + pub timestamp: u64, +} +``` + +`resolve.rs` : marge nodeを作成する + +```rust +pub struct ConflictResolver { + _marker: PhantomData<(P, M)>, +} +``` + +関数: + +- `fn new() -> Self` +- `fn create_merge_node(&self, heads: &[Cid], dag: &DagGraph, P, M>, genesis: Cid, policy: &dyn MergePolicy

) -> Result>` +- `fn collect_inputs(&self, heads: &[Cid], dag: &DagGraph, P, M>) -> Result>>` + +## Repository Layer + +構造体の修正: + +```rust +pub struct Repo +where + OpStore: OperationStorage, + NodeStore: NodeStorage, // ContentMetadata追加 +{ + pub state: CrdtState, + pub dag: DagGraph, + resolver: ConflictResolver, // 追加 +} +``` + +初期化に引数を追加: + +```rust +pub fn new( + state: CrdtState, + dag: DagGraph, +) -> Self { + Self { + state, + dag, + resolver: ConflictResolver::new(), + } +} +``` + +`commit_operation()` にトリガーを追加: + +- `let metadata = ContentMetadata::default();` + +`commit_operation()`の修正: + +- Mergeのハンドリングを追加 +- 一旦はユーザーからは受け付けない + - ただ、手動マージも可能にする予定a + +```rust +/// 内部用:無限ループ防止機能付きcommit +/// +/// # Arguments +/// * `op` - コミットする操作 +/// * `skip_auto_merge` - trueの場合、自動マージをスキップ(Merge操作時に使用) +fn commit_operation_internal( + &mut self, + mut op: Operation, + skip_auto_merge: bool +) -> Result { + let metadata = ContentMetadata::default(); + + let cid = match &op.kind { + OperationType::Create(payload) => { + let genesis_cid = self.dag.add_genesis_node(payload.clone(), metadata)?; + op.genesis = genesis_cid; + genesis_cid + } + OperationType::Update(payload) => { + let parents = self.get_latest_parents(&op.genesis); + self.dag.add_child_node(payload.clone(), parents, op.genesis, metadata)? + } + OperationType::Delete => { + let parents = self.get_latest_parents(&op.genesis); + let ops = self.state.get_operations_by_genesis(&op.genesis)?; + let last_payload = ops + .iter() + .filter(|o| o.payload().is_some()) + .max_by_key(|o| o.timestamp) + .expect("content must exist") + .payload() + .unwrap() + .clone(); + self.dag.add_child_node(last_payload, parents, op.genesis, metadata)? + } + OperationType::Merge(payload) => { + // Merge専用:複数のheadを親として持つ + let parents = self.find_heads(&op.genesis)?; + self.dag.add_child_node(payload.clone(), parents, op.genesis, metadata)? + } + }; + + self.state.apply(op)?; + + // 自動マージのトリガー(無限ループ防止) + if !skip_auto_merge { + self.check_and_merge(&op.genesis)?; + } + + Ok(cid) +} + +/// 公開API +pub fn commit_operation(&mut self, op: Operation) -> Result { + // Merge操作はユーザーから受け付けない + if matches!(op.kind, OperationType::Merge(_)) { + return Err(CrdtError::Internal( + "Merge operations cannot be manually committed".to_string() + )); + } + + self.commit_operation_internal(op, false) +} + +``` + +`check_and_merge()` の追加: + +**役割**: + +- orchestration(調整): 分岐検知、ポリシー選択、結果の記録 +- delegation(委譲): 実際のマージノード生成はResolverに任せる + +```rust +/// 分岐を検知して自動マージを実行 +fn check_and_merge(&mut self, genesis: &Cid) -> Result> { + // 1. ヘッドを検索(分岐検知) + let heads = self.find_heads(genesis)?; + + // 2. 分岐がなければ何もしない + if heads.len() <= 1 { + return Ok(None); + } + + // 3. genesisノードからポリシータイプを取得(ポリシー選択) + let genesis_node = self.dag.get_node(genesis)? + .ok_or_else(|| CrdtError::Internal(format!("Genesis not found: {}", genesis)))?; + let policy_type = genesis_node.metadata().policy_type(); + + // 4. ポリシーオブジェクトを生成 + let policy = self.create_policy(policy_type)?; + + // 5. マージノード生成を委譲(Resolverに実行を任せる) + let merge_node = self.resolver.create_merge_node( + &heads, + &self.dag, + *genesis, + policy.as_ref(), + )?; + + let merge_op = Operation::new( + *genesis, + OperationType::Merge(merge_node.payload().clone()), + "auto-merge".to_string(), + ); + + // 7. commit_operationに委譲(書き込み処理は行わない) + let merge_cid = self.commit_operation_internal(merge_op, true)?; + + Ok(Some(merge_cid)) +} +``` + +### 関数の定義 +- `find_head()` +- `get_latest_parents()` +- `create_policy()` +```rust +/// ヘッドノード(リーフノード)を検索 +/// +/// # Returns +/// 分岐している場合は複数のCID、そうでなければ1つ以下 +fn find_heads(&self, genesis: &Cid) -> Result> { + let nodes = self.dag.get_nodes_by_genesis(genesis)?; + let leaf_nodes = self.dag.collect_leaf_nodes(&nodes)?; + Ok(leaf_nodes.into_iter().map(|(cid, _)| cid).collect()) +} + +/// 最新の親ノードを取得(通常は1つ、分岐時は複数) +fn get_latest_parents(&self, genesis: &Cid) -> Vec { + self.dag + .calculate_latest(genesis) + .ok() + .flatten() + .map(|cid| vec![cid]) + .unwrap_or_default() +} + +/// ポリシータイプからポリシーオブジェクトを生成 +fn create_policy(&self, policy_type: &str) -> Result>> { + match policy_type { + "lww" => Ok(Box::new(LwwMergePolicy)), + _ => Err(CrdtError::Internal( + format!("Unknown policy type: {}", policy_type) + )) + } +} +``` + + +操作履歴例: + +```rust +[ + Operation { + genesis: cid_a, + kind: Create(payload_a), + author: "alice" + }, // → DAGにcid_aを生成 + + Operation { + genesis: cid_a, + kind: Update(payload_b), + author: "bob" + }, // → DAGにcid_bを生成 + + Operation { + genesis: cid_a, + kind: Update(payload_c), + author: "bob" + }, // → DAGにcid_cを生成(cid_bの子) + + Operation { + genesis: cid_a, + kind: Update(payload_d), + author: "carol" + }, // → DAGにcid_dを生成(cid_bの子、cid_cと並列) + + Operation { + genesis: cid_a, + kind: Merge(payload_merged), + author: "auto-merge" + }, // → DAGにcid_eを生成(cid_cとcid_dの子) +] +``` + +- `fn get_history` は不要かもしれない + - ちょっと実装しながら考える \ No newline at end of file diff --git a/examples/cli.rs b/examples/cli.rs index cbb7eae..0102c95 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -111,8 +111,7 @@ fn main() -> Result<(), Box> { let author = author.unwrap_or_else(|| "anonymous".to_string()); - let op = Operation::new_with_genesis( - genesis_cid, + let op = Operation::new( genesis_cid, OperationType::Update(content.clone()), author, @@ -128,7 +127,7 @@ fn main() -> Result<(), Box> { let cid = Cid::try_from(content_id.as_str())?; // First try to get content from CRDT state - let content = repo.state.get_state(&cid, &cid); + let content = repo.state.get_state(&cid); // Determine the genesis ID let genesis_cid = if content.is_some() { @@ -216,22 +215,18 @@ fn main() -> Result<(), Box> { Commands::HistoryFromVersion { version_id } => { let version_cid = Cid::try_from(version_id.as_str())?; - match repo.dag.get_history_from_version(&version_cid) { - Ok(history) => { - println!("📜 History from version: {version_id}"); - for (i, version_cid) in history.iter().enumerate() { - let marker = if i == 0 { - "🌱" - } else if i == history.len() - 1 { - "✨" - } else { - "📝" - }; - println!(" {} {}: {}", marker, i + 1, version_cid); - } + match repo.dag.get_node(&version_cid) { + Ok(Some(node)) => { + println!("📄 Node info for version: {version_id}"); + println!(" Genesis CID: {:?}", node.genesis); + println!(" Parents: {:?}", node.parents()); + println!(" Timestamp: {}", node.timestamp()); + } + Ok(None) => { + eprintln!("❌ Version not found: {version_id}"); } Err(e) => { - eprintln!("❌ Error getting history from version: {e}"); + eprintln!("❌ Error fetching node: {e}"); } } } diff --git a/examples/content_versioning.rs b/examples/content_versioning.rs index 0d90c3a..22c37f0 100644 --- a/examples/content_versioning.rs +++ b/examples/content_versioning.rs @@ -60,8 +60,7 @@ fn main() { // 2. Update (v2) ← HEAD = v1 // ──────────────────────────────────────────────── // todo: find the latest root_id or maybe get root_id from latest node?? - let op_v2 = Operation::new_with_genesis( - cid.clone(), + let op_v2 = Operation::new( cid.clone(), OperationType::Update("Updated content".into()), "user1".into(), @@ -78,8 +77,7 @@ fn main() { // ──────────────────────────────────────────────── // 3. Update (v3a) ← branch A // ──────────────────────────────────────────────── - let op_v3a = Operation::new_with_genesis( - cid.clone(), + let op_v3a = Operation::new( content_id.clone(), OperationType::Update("Updated content 2".to_string()), "user2".to_string(), @@ -96,8 +94,7 @@ fn main() { // ──────────────────────────────────────────────── // 4. Update (v3b) ← branch B (parent = v2) // ──────────────────────────────────────────────── - let op_v3b = Operation::new_with_genesis( - cid.clone(), + let op_v3b = Operation::new( content_id.clone(), OperationType::Update("Updated content B".into()), "userB".into(), diff --git a/readme.md b/readme.md index 7ce426e..9a4b45b 100644 --- a/readme.md +++ b/readme.md @@ -55,8 +55,7 @@ fn main() { let genesis_cid = repo.commit_operation(create_op).unwrap(); // 2. Update content - let update_op = Operation::new_with_genesis( - content_id.clone(), + let update_op = Operation::new( genesis_cid, OperationType::Update(Content("Updated content".to_string())), "user1".to_string(), diff --git a/src/convergence/metadata.rs b/src/convergence/metadata.rs new file mode 100644 index 0000000..63a0805 --- /dev/null +++ b/src/convergence/metadata.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +/// Metadata that stores information required for convergence policies. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContentMetadata { + /// Policy type name (e.g. "lww", "text", "custom-policy"). + /// When this is `None`, it falls back to the default policy (currently "lww"). + policy_type: Option, +} + +impl ContentMetadata { + /// Create metadata with the default LWW policy. + pub fn new() -> Self { + Self { policy_type: None } + } + + /// Create metadata that uses the specified policy. + pub fn with_policy(policy_type: impl Into) -> Self { + Self { + policy_type: Some(policy_type.into()), + } + } + + /// Return the configured policy type; falls back to "lww" when unspecified. + pub fn policy_type(&self) -> &str { + self.policy_type.as_deref().unwrap_or("lww") + } +} + +impl Default for ContentMetadata { + fn default() -> Self { + Self::new() + } +} + diff --git a/src/convergence/mod.rs b/src/convergence/mod.rs new file mode 100644 index 0000000..a0d5993 --- /dev/null +++ b/src/convergence/mod.rs @@ -0,0 +1,5 @@ +pub mod metadata; +pub mod policy; +pub mod policies; +pub mod resolver; + diff --git a/src/convergence/policies/lww.rs b/src/convergence/policies/lww.rs new file mode 100644 index 0000000..6419107 --- /dev/null +++ b/src/convergence/policies/lww.rs @@ -0,0 +1,72 @@ +use crate::convergence::policy::{MergePolicy, ResolveInput}; + +/// A simple last-write-wins merge policy that selects the node with the +/// greatest timestamp. Ties fall back to the last node in the slice, mimicking +/// stable behaviour on equal timestamps. +#[derive(Debug, Default)] +pub struct LwwMergePolicy; + +impl MergePolicy

for LwwMergePolicy { + fn resolve(&self, nodes: &[ResolveInput

]) -> P { + let winner = nodes + .iter() + .max_by_key(|input| input.timestamp) + .expect("LwwMergePolicy requires at least one candidate node"); + winner.payload.clone() + } + + fn name(&self) -> &str { + "lww" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::convergence::policy::ResolveInput; + use cid::Cid; + use multihash::Multihash; + + fn create_test_cid(label: &str) -> Cid { + let digest = Multihash::<64>::wrap(0x12, label.as_bytes()).unwrap(); + Cid::new_v1(0x55, digest) + } + + #[test] + fn selects_highest_timestamp() { + let policy = LwwMergePolicy::default(); + let inputs = vec![ + ResolveInput::new(create_test_cid("a"), "older".to_string(), 10), + ResolveInput::new(create_test_cid("b"), "newer".to_string(), 20), + ]; + + let result = policy.resolve(&inputs); + assert_eq!(result, "newer"); + } + + #[test] + fn ties_choose_last_entry() { + let policy = LwwMergePolicy::default(); + let inputs = vec![ + ResolveInput::new(create_test_cid("a"), "first".to_string(), 42), + ResolveInput::new(create_test_cid("b"), "second".to_string(), 42), + ]; + + let result = policy.resolve(&inputs); + assert_eq!(result, "second"); + } + + #[test] + fn selects_highest_timestamp_among_three() { + let policy = LwwMergePolicy::default(); + let inputs = vec![ + ResolveInput::new(create_test_cid("a"), "payload-a".to_string(), 5), + ResolveInput::new(create_test_cid("b"), "payload-b".to_string(), 10), + ResolveInput::new(create_test_cid("c"), "payload-c".to_string(), 8), + ]; + + let result = policy.resolve(&inputs); + assert_eq!(result, "payload-b"); + } +} + diff --git a/src/convergence/policies/mod.rs b/src/convergence/policies/mod.rs new file mode 100644 index 0000000..31cfd0c --- /dev/null +++ b/src/convergence/policies/mod.rs @@ -0,0 +1,2 @@ +pub mod lww; + diff --git a/src/convergence/policy.rs b/src/convergence/policy.rs new file mode 100644 index 0000000..38efdc7 --- /dev/null +++ b/src/convergence/policy.rs @@ -0,0 +1,29 @@ +use cid::Cid; + +/// Unites metadata about a DAG node that should be considered during merge resolution. +#[derive(Clone, Debug)] +pub struct ResolveInput

{ + pub cid: Cid, + pub payload: P, + pub timestamp: u64, +} + +impl

ResolveInput

{ + pub fn new(cid: Cid, payload: P, timestamp: u64) -> Self { + Self { + cid, + payload, + timestamp, + } + } +} + +/// A merge strategy that produces a converged payload from candidate nodes. +pub trait MergePolicy

: Send + Sync { + /// Resolve competing nodes into a single payload. + fn resolve(&self, nodes: &[ResolveInput

]) -> P; + + /// Return a descriptive name of the policy (e.g. "lww"). + fn name(&self) -> &str; +} + diff --git a/src/convergence/resolver.rs b/src/convergence/resolver.rs new file mode 100644 index 0000000..ac3a81f --- /dev/null +++ b/src/convergence/resolver.rs @@ -0,0 +1,100 @@ +use crate::convergence::policy::{MergePolicy, ResolveInput}; +use crate::graph::dag::DagGraph; +use crate::graph::storage::NodeStorage; +use crate::crdt::error::{CrdtError, Result as CrdtResult}; +use crate::dasl::node::Node; +use cid::Cid; +use std::marker::PhantomData; + +/// Responsible for orchestrating merge operations by delegating +/// DAG traversal and policy selection to dedicated components. +#[derive(Debug, Default, Clone)] +pub struct ConflictResolver { + _marker: PhantomData<(P, M)>, +} + +impl ConflictResolver +where + P: Clone, + M: Clone, +{ + pub fn new() -> Self { + Self { + _marker: PhantomData, + } + } + + pub fn create_merge_node( + &self, + heads: &[Cid], + dag: &DagGraph, + genesis: Cid, + policy: &dyn MergePolicy

, + ) -> CrdtResult> + where + S: NodeStorage, + P: serde::Serialize + for<'de> serde::Deserialize<'de>, + M: serde::Serialize + for<'de> serde::Deserialize<'de>, + { + if heads.is_empty() { + return Err(CrdtError::Internal( + "ConflictResolver requires at least one head to merge".to_string(), + )); + } + + let inputs = self.collect_inputs(heads, dag)?; + let merged_payload = policy.resolve(&inputs); + let metadata = self.merge_metadata(heads, dag)?; + let timestamp = Self::current_timestamp()?; + Ok(Node::new_child( + merged_payload, + heads.to_vec(), + genesis, + timestamp, + metadata, + )) + } + + fn collect_inputs(&self, heads: &[Cid], dag: &DagGraph) -> CrdtResult>> + where + S: NodeStorage, + P: serde::Serialize + for<'de> serde::Deserialize<'de>, + M: serde::Serialize + for<'de> serde::Deserialize<'de>, + { + let mut inputs = Vec::with_capacity(heads.len()); + for &cid in heads { + let node = dag + .get_node(&cid) + .map_err(CrdtError::Graph)? + .ok_or_else(|| CrdtError::Internal(format!("Head node not found: {cid}")))?; + inputs.push(ResolveInput::new(cid, node.payload().clone(), node.timestamp())); + } + Ok(inputs) + } + + fn merge_metadata(&self, heads: &[Cid], dag: &DagGraph) -> CrdtResult + where + S: NodeStorage, + P: serde::Serialize + for<'de> serde::Deserialize<'de>, + M: serde::Serialize + for<'de> serde::Deserialize<'de>, + { + let first_head = heads + .first() + .ok_or_else(|| CrdtError::Internal("No heads provided".to_string()))?; + let node = dag + .get_node(first_head) + .map_err(CrdtError::Graph)? + .ok_or_else(|| CrdtError::Internal(format!("Head node not found: {first_head}")))?; + Ok(node.metadata().clone()) + } + + fn current_timestamp() -> CrdtResult { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| CrdtError::Internal(format!("timestamp error: {e}"))) + .map(|duration| duration.as_secs()) + } +} + + diff --git a/src/crdt/crdt_state.rs b/src/crdt/crdt_state.rs index cac5e00..fa76dda 100644 --- a/src/crdt/crdt_state.rs +++ b/src/crdt/crdt_state.rs @@ -68,12 +68,12 @@ where self.apply(op) } else { Err(CrdtError::Validation(ValidationError::MissingCreate( - format!("No create operation found for target: {:?}", op.target), + format!("No create operation found for genesis: {:?}", op.genesis), ))) } } - pub fn get_state(&self, target: &ContentId, genesis: &ContentId) -> Option { - let ops = self.storage.load_operations(target, genesis).ok()?; + pub fn get_state(&self, genesis: &ContentId) -> Option { + let ops = self.storage.load_operations(genesis).ok()?; R::reduce(&ops) } @@ -81,7 +81,7 @@ where &self, genesis: &ContentId, ) -> Result>> { - self.storage.load_operations_by_genesis(genesis) + self.storage.load_operations(genesis) } /// Validates whether an operation is logically valid to apply. @@ -100,8 +100,8 @@ where /// * `false` - If the operation would violate logical constraints pub fn validate_operation(&self, op: &Operation) -> Result { match &op.kind { - OperationType::Update(_) | OperationType::Delete => { - let ops = self.storage.load_operations(&op.target, &op.genesis)?; + OperationType::Update(_) | OperationType::Delete | OperationType::Merge(_) => { + let ops = self.storage.load_operations(&op.genesis)?; Ok(ops .iter() .any(|o| matches!(o.kind, OperationType::Create(_)))) @@ -129,8 +129,7 @@ mod tests { ts: u64, kind: OperationType, ) -> Operation { - let mut op = Operation::new_with_genesis( - DummyContentId(id.to_string()), + let mut op = Operation::new( DummyContentId(id.to_string()), kind, "tester".into(), @@ -151,10 +150,7 @@ mod tests { state.apply(op).unwrap(); assert_eq!( - state.get_state( - &DummyContentId("1".to_string()), - &DummyContentId("1".to_string()) - ), + state.get_state(&DummyContentId("1".to_string())), Some(DummyPayload("A".to_string())) ); } @@ -173,10 +169,7 @@ mod tests { state.apply(op2).unwrap(); assert_eq!( - state.get_state( - &DummyContentId("1".to_string()), - &DummyContentId("1".to_string()) - ), + state.get_state(&DummyContentId("1".to_string())), Some(DummyPayload("B".to_string())) ); } @@ -196,10 +189,7 @@ mod tests { state.apply(op2).unwrap(); state.apply(op3).unwrap(); assert_eq!( - state.get_state( - &DummyContentId("1".to_string()), - &DummyContentId("1".to_string()) - ), + state.get_state(&DummyContentId("1".to_string())), None ); } @@ -234,10 +224,7 @@ mod tests { state.apply_with_validation(op2).unwrap(); assert_eq!( - state.get_state( - &DummyContentId("1".to_string()), - &DummyContentId("1".to_string()) - ), + state.get_state(&DummyContentId("1".to_string())), Some(DummyPayload("B".to_string())) ); } @@ -265,8 +252,7 @@ mod tests { // Simulate an update coming from another genesis (different series) but same target. let fake_genesis = DummyContentId("DIFFERENT".into()); - let update = Operation::new_with_genesis( - DummyContentId("X".into()), + let update = Operation::new( fake_genesis, OperationType::Update(DummyPayload("B".into())), "u1".into(), @@ -275,7 +261,7 @@ mod tests { // Should only get operations with matching genesis, so expect "A" assert_eq!( - state.get_state(&DummyContentId("X".into()), &DummyContentId("X".into())), + state.get_state(&DummyContentId("X".into())), Some(DummyPayload("A".into())) ); } @@ -288,36 +274,31 @@ mod tests { .unwrap(); let state: CrdtState = CrdtState::new(storage); - let target = DummyContentId("X".into()); let primary_genesis = DummyContentId("X".into()); let alt_genesis = DummyContentId("ALT".into()); - let mut primary_create = Operation::new_with_genesis( - target.clone(), + let mut primary_create = Operation::new( primary_genesis.clone(), OperationType::Create(DummyPayload("A".into())), "u1".into(), ); primary_create.timestamp = 100; - let mut alt_create = Operation::new_with_genesis( - target.clone(), + let mut alt_create = Operation::new( alt_genesis.clone(), OperationType::Create(DummyPayload("B".into())), "u2".into(), ); alt_create.timestamp = 150; - let mut alt_update = Operation::new_with_genesis( - target.clone(), + let mut alt_update = Operation::new( alt_genesis.clone(), OperationType::Update(DummyPayload("C".into())), "u2".into(), ); alt_update.timestamp = 300; - let mut primary_delete = Operation::new_with_genesis( - target.clone(), + let mut primary_delete = Operation::new( primary_genesis.clone(), OperationType::Delete, "u1".into(), @@ -329,12 +310,9 @@ mod tests { state.apply(alt_update.clone()).unwrap(); state.apply(primary_delete).unwrap(); - assert_eq!(state.get_state(&target, &primary_genesis), None); + assert_eq!(state.get_state(&primary_genesis), None); - assert_eq!( - state.get_state(&target, &alt_genesis), - Some(DummyPayload("C".into())) - ); + assert_eq!(state.get_state(&alt_genesis), Some(DummyPayload("C".into()))); let operations = state.get_operations_by_genesis(&alt_genesis).unwrap(); assert_eq!(operations.len(), 2); diff --git a/src/crdt/operation.rs b/src/crdt/operation.rs index d4a04c1..bd58039 100644 --- a/src/crdt/operation.rs +++ b/src/crdt/operation.rs @@ -15,6 +15,7 @@ pub enum OperationKind { Create, Update, Delete, + Merge, } /// Enum representing the type of operation @@ -27,6 +28,7 @@ pub enum OperationType { Create(T), Update(T), Delete, + Merge(T), } /// Helper methods to check the operation type @@ -36,6 +38,7 @@ impl OperationType { OperationType::Create(_) => OperationKind::Create, OperationType::Update(_) => OperationKind::Update, OperationType::Delete => OperationKind::Delete, + OperationType::Merge(_) => OperationKind::Merge, } } } @@ -52,7 +55,6 @@ impl OperationType { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Operation { pub id: OperationId, - pub target: ContentId, pub genesis: ContentId, pub kind: OperationType, pub timestamp: Timestamp, @@ -75,13 +77,11 @@ where /// # Returns /// /// A newly created operation object - pub fn new(target: ContentId, kind: OperationType, author: Author) -> Self { + pub fn new(genesis: ContentId, kind: OperationType, author: Author) -> Self { let timestamp = next_monotonic_timestamp(); let id = Ulid::new(); - let genesis = target.clone(); Self { id, - target, genesis, kind, timestamp, @@ -89,31 +89,6 @@ where } } - /// Creates a new operation with a genesis content - /// - /// # Arguments - /// - /// * `target` - ID of the content being operated on - /// * `root_id` - ID of the genesis content - /// * `kind` - Type of operation and its payload - pub fn new_with_genesis( - target: ContentId, - root_id: ContentId, - kind: OperationType, - author: Author, - ) -> Self { - let timestamp = next_monotonic_timestamp(); - let id = Ulid::new(); - Self { - id, - target, - genesis: root_id, - kind, - timestamp, - author, - } - } - /// Checks if this operation is of the given kind pub fn is_type(&self, kind: OperationKind) -> bool { self.kind.as_kind() == kind @@ -129,7 +104,9 @@ where /// or `None` for delete operations pub fn payload(&self) -> Option<&T> { match &self.kind { - OperationType::Create(v) | OperationType::Update(v) => Some(v), + OperationType::Create(v) + | OperationType::Update(v) + | OperationType::Merge(v) => Some(v), OperationType::Delete => None, } } @@ -147,42 +124,18 @@ mod tests { #[test] fn test_operation_new_create() { - let target = DummyContentId("test".into()); + let genesis = DummyContentId("test".into()); let payload = DummyPayload("test".into()); let author = "Alice".to_string(); let op = Operation::new( - target.clone(), - OperationType::Create(payload.clone()), - author.clone(), - ); - - assert!(op.id != Ulid::nil()); - assert_eq!(op.target, target); - assert_eq!(op.kind, OperationType::Create(payload.clone())); - assert!(op.timestamp > 0); - assert_eq!(op.author, author); - assert_eq!(op.payload(), Some(&payload)); - assert!(op.is_type(OperationKind::Create)); - assert!(!op.is_type(OperationKind::Update)); - assert!(!op.is_type(OperationKind::Delete)); - } - #[test] - fn test_operation_create_with_genesis() { - let target = DummyContentId("test".into()); - let genesis = DummyContentId("genesis".into()); - let payload = DummyPayload("test".into()); - let author = "Alice".to_string(); - - let op = Operation::new_with_genesis( - target.clone(), genesis.clone(), OperationType::Create(payload.clone()), author.clone(), ); assert!(op.id != Ulid::nil()); - assert_eq!(op.target, target); + assert_eq!(op.genesis, genesis); assert_eq!(op.kind, OperationType::Create(payload.clone())); assert!(op.timestamp > 0); assert_eq!(op.author, author); @@ -190,48 +143,38 @@ mod tests { assert!(op.is_type(OperationKind::Create)); assert!(!op.is_type(OperationKind::Update)); assert!(!op.is_type(OperationKind::Delete)); + assert!(!op.is_type(OperationKind::Merge)); } - #[test] fn test_operation_update() { - let target = DummyContentId("test".into()); let genesis = DummyContentId("genesis".into()); let payload = DummyPayload("updated".into()); let author = "Alice".to_string(); - let op = Operation::new_with_genesis( - target.clone(), - genesis.clone(), - OperationType::Update(payload.clone()), - author.clone(), - ); + let op = Operation::new(genesis.clone(), OperationType::Update(payload.clone()), author); assert!(op.id != Ulid::nil()); - assert_eq!(op.target, target); assert_eq!(op.kind, OperationType::Update(payload.clone())); assert!(op.timestamp > 0); - assert_eq!(op.author, author); assert_eq!(op.payload(), Some(&payload)); assert!(op.is_type(OperationKind::Update)); assert!(!op.is_type(OperationKind::Create)); assert!(!op.is_type(OperationKind::Delete)); + assert!(!op.is_type(OperationKind::Merge)); } #[test] fn test_operation_delete() { - let target = DummyContentId("test".into()); let genesis = DummyContentId("genesis".into()); let author = "Alice".to_string(); - let op = Operation::::new_with_genesis( - target.clone(), + let op = Operation::::new( genesis.clone(), OperationType::Delete, author.clone(), ); assert!(op.id != Ulid::nil()); - assert_eq!(op.target, target); assert_eq!(op.kind, OperationType::Delete); assert!(op.timestamp > 0); assert_eq!(op.author, author); @@ -239,4 +182,25 @@ mod tests { assert!(!op.is_type(OperationKind::Create)); assert!(op.is_type(OperationKind::Delete)); } + + #[test] + fn test_operation_merge() { + let genesis = DummyContentId("merge-genesis".into()); + let payload = DummyPayload("merged".into()); + let author = "auto".to_string(); + + let op = Operation::new( + genesis.clone(), + OperationType::Merge(payload.clone()), + author.clone(), + ); + + assert!(op.id != Ulid::nil()); + assert_eq!(op.genesis, genesis); + assert_eq!(op.kind, OperationType::Merge(payload.clone())); + assert!(op.timestamp > 0); + assert_eq!(op.author, author); + assert_eq!(op.payload(), Some(&payload)); + assert!(op.is_type(OperationKind::Merge)); + } } diff --git a/src/crdt/reducer.rs b/src/crdt/reducer.rs index 14d6690..31f33ed 100644 --- a/src/crdt/reducer.rs +++ b/src/crdt/reducer.rs @@ -19,7 +19,9 @@ where .then(a.id.to_bytes().cmp(&b.id.to_bytes())) }) .and_then(|op| match &op.kind { - OperationType::Create(v) | OperationType::Update(v) => Some(v.clone()), + OperationType::Create(v) + | OperationType::Update(v) + | OperationType::Merge(v) => Some(v.clone()), OperationType::Delete => None, }) } @@ -45,7 +47,6 @@ mod tests { ) -> Operation { Operation { id: Ulid::new(), - target: DummyContentId(id.to_string()), genesis: DummyContentId(id.to_string()), kind, timestamp: ts, @@ -62,7 +63,6 @@ mod tests { let ulid = Ulid::from_string(ulid_str).unwrap(); Operation { id: ulid, - target: DummyContentId(id.to_string()), genesis: DummyContentId(id.to_string()), kind, timestamp: ts, diff --git a/src/crdt/storage.rs b/src/crdt/storage.rs index 5e6182e..2d2e45c 100644 --- a/src/crdt/storage.rs +++ b/src/crdt/storage.rs @@ -9,15 +9,7 @@ use ulid::Ulid; pub trait OperationStorage { fn save_operation(&self, op: &Operation) -> Result<()>; - fn load_operations( - &self, - target: &ContentId, - genesis: &ContentId, - ) -> Result>>; - fn load_operations_by_genesis( - &self, - genesis: &ContentId, - ) -> Result>>; + fn load_operations(&self, genesis: &ContentId) -> Result>>; fn get_operation(&self, op_id: &Ulid) -> Result>>; } @@ -59,11 +51,7 @@ where Ok(()) } - fn load_operations( - &self, - target: &ContentId, - genesis: &ContentId, - ) -> Result>> { + fn load_operations(&self, genesis: &ContentId) -> Result>> { let mut result = Vec::new(); let mut iter = self .db @@ -75,36 +63,6 @@ where let mut key = Vec::new(); let mut value = Vec::new(); - while iter.valid() { - iter.current(&mut key, &mut value); - if let Ok((op, _)) = bincode::serde::decode_from_slice::, _>( - &value, - bincode::config::standard(), - ) { - if op.target == *target && op.genesis == *genesis { - result.push(op); - } - } - iter.advance(); - } - - Ok(result) - } - - fn load_operations_by_genesis( - &self, - genesis: &ContentId, - ) -> Result>> { - let mut result = Vec::new(); - let mut iter = self - .db - .borrow_mut() - .new_iter() - .map_err(CrdtError::Storage)?; - iter.seek_to_first(); - let mut key = Vec::new(); - let mut value = Vec::new(); - while iter.valid() { iter.current(&mut key, &mut value); if let Ok((op, _)) = bincode::serde::decode_from_slice::, _>( @@ -207,8 +165,7 @@ mod tests { OperationType::Create(payload.clone()), author.clone(), ); - let op2 = Operation::new_with_genesis( - target.clone(), + let op2 = Operation::new( target.clone(), OperationType::Update(payload.clone()), author.clone(), @@ -229,7 +186,6 @@ mod tests { fn test_load_operations() { let (storage, _dir) = setup_test_storage(); let target = DummyContentId("test".into()); - let target2 = DummyContentId("test2".into()); let genesis = DummyContentId("genesis".into()); let payload = DummyPayload("test".into()); let author = "Alice".to_string(); @@ -238,14 +194,12 @@ mod tests { OperationType::Create(payload.clone()), author.clone(), ); - let op2 = Operation::new_with_genesis( - target2.clone(), + let op2 = Operation::new( target.clone(), OperationType::Update(payload.clone()), author.clone(), ); - let op3 = Operation::new_with_genesis( - genesis.clone(), + let op3 = Operation::new( genesis.clone(), OperationType::Update(payload.clone()), author.clone(), @@ -254,12 +208,13 @@ mod tests { storage.save_operation(&op2).unwrap(); storage.save_operation(&op3).unwrap(); - let retrieved_ops = storage.load_operations(&target, &target); + let retrieved_ops = storage.load_operations(&target); assert!(retrieved_ops.is_ok()); let ops = retrieved_ops.unwrap(); - assert_eq!(ops.len(), 1); + assert_eq!(ops.len(), 2); assert!(ops.contains(&op1)); + assert!(ops.contains(&op2)); } /// Demonstrates that an Update with different genesis is **not** returned when querying by target. @@ -277,58 +232,16 @@ mod tests { storage.save_operation(&create).unwrap(); // Update with DIFFERENT genesis but same target - let update = Operation::new_with_genesis( - target.clone(), + let update = Operation::new( DummyContentId("DIFF".into()), OperationType::Update(DummyPayload("two".into())), "u1".into(), ); storage.save_operation(&update).unwrap(); - let ops = storage.load_operations(&target, &target).unwrap(); - // Should only get operations with matching genesis + let ops = storage.load_operations(&target).unwrap(); + // Should contain only the operations belonging to the requested genesis assert_eq!(ops.len(), 1); assert!(ops.contains(&create)); } - - #[test] - fn test_load_operations_by_genesis() { - let (storage, _dir) = setup_test_storage(); - let root = DummyContentId("root".into()); - let child_a = DummyContentId("child_a".into()); - let child_b = DummyContentId("child_b".into()); - let other_root = DummyContentId("other_root".into()); - let payload = DummyPayload("payload".into()); - - let create_root = Operation::new( - root.clone(), - OperationType::Create(payload.clone()), - "author".into(), - ); - let update_child_a = Operation::new_with_genesis( - child_a.clone(), - root.clone(), - OperationType::Update(payload.clone()), - "author".into(), - ); - let update_other = Operation::new_with_genesis( - child_b.clone(), - other_root.clone(), - OperationType::Update(payload.clone()), - "author".into(), - ); - - storage.save_operation(&create_root).unwrap(); - storage.save_operation(&update_child_a).unwrap(); - storage.save_operation(&update_other).unwrap(); - - let ops = storage - .load_operations_by_genesis(&root) - .expect("load by genesis should succeed"); - - assert_eq!(ops.len(), 2); - assert!(ops.contains(&create_root)); - assert!(ops.contains(&update_child_a)); - assert!(!ops.contains(&update_other)); - } } diff --git a/src/graph/dag.rs b/src/graph/dag.rs index 587597a..97e6771 100644 --- a/src/graph/dag.rs +++ b/src/graph/dag.rs @@ -95,7 +95,7 @@ where Ok(cid) } - /// Add a version node (subsequent version of content) + /// Add a child node (descendant of an existing node) /// /// # Arguments /// @@ -106,9 +106,9 @@ where /// /// # Returns /// - /// * `Cid` - The content Id of the new version node + /// * `Cid` - The content Id of the new child node /// - pub fn add_version_node( + pub fn add_child_node( &mut self, payload: P, parents: Vec, @@ -136,6 +136,23 @@ where Ok(cid) } + pub fn get_node(&self, cid: &Cid) -> Result>> { + self.storage.get(cid) + } + + pub fn get_nodes_by_genesis(&self, genesis_id: &Cid) -> Result> { + let mut result = Vec::new(); + let node_map = self.storage.get_node_map()?; + for (cid, _) in node_map { + if let Some(node) = self.storage.get(&cid)? { + if cid == *genesis_id || node.genesis == Some(*genesis_id) { + result.push(cid); + } + } + } + Ok(result) + } + fn current_timestamp() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -317,48 +334,17 @@ where /// /// * `Cid` - The genesis CID /// - pub fn get_genesis(&self, version_cid: &Cid) -> Result { - match self.storage.get(version_cid)? { + pub fn get_genesis(&self, node_cid: &Cid) -> Result { + match self.storage.get(node_cid)? { Some(node) => match node.genesis { Some(genesis_cid) => Ok(genesis_cid), - None => Ok(*version_cid), + None => Ok(*node_cid), }, - None => Err(GraphError::NodeNotFound(*version_cid)), - } - } - - /// Get history from a specific version - /// - /// # Arguments - /// - /// * `version_cid` - The version CID to get history from - /// - /// # Returns - /// - /// * `Vec` - History from oldest to newest - /// - pub fn get_history_from_version(&self, version_cid: &Cid) -> Result> { - let mut history = vec![]; - let mut current = *version_cid; - - loop { - let node = match self.storage.get(¤t)? { - Some(node) => node, - None => return Err(GraphError::NodeNotFound(current)), - }; - history.push(current); - - if node.parents().is_empty() { - break; - } - current = node.parents()[0]; + None => Err(GraphError::NodeNotFound(*node_cid)), } - - history.reverse(); - Ok(history) } - /// Calculates the latest version CID for a given genesis ID by finding the leaf node(s) with the most recent timestamp. + /// Calculates the latest node CID for a given genesis ID by finding the leaf node(s) with the most recent timestamp. /// /// # Arguments /// @@ -372,15 +358,15 @@ where /// /// Returns an error if node retrieval fails or an internal error occurs. pub fn calculate_latest(&self, genesis_id: &Cid) -> Result> { - let versions = self.get_all_versions_for_genesis(genesis_id)?; - if versions.is_empty() { + let nodes = self.get_nodes_by_genesis(genesis_id)?; + if nodes.is_empty() { return Ok(None); } - if versions.len() == 1 { - return Ok(Some(versions[0])); + if nodes.len() == 1 { + return Ok(Some(nodes[0])); } - let has_children = self.collect_nodes_with_children(&versions)?; - let mut leaf_nodes = self.collect_leaf_nodes(&versions, &has_children)?; + let has_children = self.collect_nodes_with_children(&nodes)?; + let mut leaf_nodes = self.collect_leaf_nodes(&nodes, &has_children)?; leaf_nodes.sort_by_key(|(_, timestamp)| std::cmp::Reverse(*timestamp)); Ok(leaf_nodes.first().map(|(cid, _)| *cid)) } @@ -388,13 +374,13 @@ where // Returns the set of nodes (CIDs) that are referenced as parents (i.e., nodes that have children) among the given versions. fn collect_nodes_with_children( &self, - versions: &[Cid], + nodes: &[Cid], ) -> Result> { let mut has_children = std::collections::HashSet::new(); - for &node_cid in versions { + for &node_cid in nodes { if let Some(node) = self.storage.get(&node_cid)? { for parent_cid in node.parents() { - if versions.contains(parent_cid) { + if nodes.contains(parent_cid) { has_children.insert(*parent_cid); } } @@ -406,11 +392,11 @@ where // Returns a list of leaf nodes (nodes without children) and their timestamps among the given versions. fn collect_leaf_nodes( &self, - versions: &[Cid], + nodes: &[Cid], has_children: &std::collections::HashSet, ) -> Result> { let mut leaf_nodes = Vec::new(); - for &node_cid in versions { + for &node_cid in nodes { if !has_children.contains(&node_cid) { if let Some(node) = self.storage.get(&node_cid)? { leaf_nodes.push((node_cid, node.timestamp())); @@ -420,19 +406,6 @@ where Ok(leaf_nodes) } - // Collects all CIDs of nodes related to the given genesis ID. - fn get_all_versions_for_genesis(&self, genesis_id: &Cid) -> Result> { - let mut versions = Vec::new(); - let node_map = self.storage.get_node_map()?; - for (cid, _) in node_map { - if let Some(node) = self.storage.get(&cid)? { - if cid == *genesis_id || node.genesis == Some(*genesis_id) { - versions.push(cid); - } - } - } - Ok(versions) - } } #[cfg(test)] @@ -735,7 +708,7 @@ mod tests { } #[test] - fn test_latest_head() { + fn test_calculate_latest_basic() { let cid_a = create_test_content_id(b"node_a"); let cid_b = create_test_content_id(b"node_b"); @@ -759,19 +732,19 @@ mod tests { } #[test] - fn test_add_version_node() { + fn test_add_child_node() { let mut dag = DagGraph::new(MockStorage::new()); let genesis_cid = dag.add_genesis_node("genesis".to_string(), ()).unwrap(); - let version_cid = dag - .add_version_node("version".to_string(), vec![genesis_cid], genesis_cid, ()) + let child_cid = dag + .add_child_node("child".to_string(), vec![genesis_cid], genesis_cid, ()) .unwrap(); let latest = dag.calculate_latest(&genesis_cid).unwrap(); - assert_eq!(latest, Some(version_cid)); + assert_eq!(latest, Some(child_cid)); } #[test] - fn test_empty_latest_head() { + fn test_calculate_latest_empty() { let dag = TestDag::new(MockStorage::new()); let cid_a = create_test_content_id(b"node_a"); @@ -889,60 +862,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_get_history_from_version_simple_path() { - let mut storage = MockStorage::new(); - let cid_a = create_test_content_id(b"node_a"); - let cid_b = create_test_content_id(b"node_b"); - let cid_c = create_test_content_id(b"node_c"); - storage.setup_graph(&[(cid_a, cid_b), (cid_b, cid_c)]); - let dag = DagGraph::>::new(storage); - - let history = dag.get_history_from_version(&cid_c).unwrap(); - assert_eq!(history, vec![cid_a, cid_b, cid_c]); - } - - #[test] - fn test_get_history_from_version_genesis_only() { - let storage = MockStorage::new(); - let genesis_cid = create_test_content_id(b"genesis"); - storage.edges.borrow_mut().entry(genesis_cid).or_default(); - let dag = DagGraph::>::new(storage); - - let history = dag.get_history_from_version(&genesis_cid).unwrap(); - assert_eq!(history, vec![genesis_cid]); - } - - #[test] - fn test_get_history_from_version_long_path() { - let mut storage = MockStorage::new(); - let cid_a = create_test_content_id(b"node_a"); - let cid_b = create_test_content_id(b"node_b"); - let cid_c = create_test_content_id(b"node_c"); - let cid_d = create_test_content_id(b"node_d"); - let cid_e = create_test_content_id(b"node_e"); - storage.setup_graph(&[ - (cid_a, cid_b), - (cid_b, cid_c), - (cid_c, cid_d), - (cid_d, cid_e), - ]); - let dag = DagGraph::>::new(storage); - - let history = dag.get_history_from_version(&cid_e).unwrap(); - assert_eq!(history, vec![cid_a, cid_b, cid_c, cid_d, cid_e]); - } - - #[test] - fn test_get_history_from_version_node_not_found() { - let storage = MockStorage::new(); - let dag = DagGraph::>::new(storage); - let non_existent_cid = create_test_content_id(b"non_existent"); - - let result = dag.get_history_from_version(&non_existent_cid); - assert!(result.is_err()); - } - #[test] fn test_calculate_latest_genesis_only() { let storage = MockStorage::new(); @@ -989,24 +908,24 @@ mod tests { } #[test] - fn test_get_all_versions_genesis_only() { + fn test_get_nodes_by_genesis_genesis_only() { let storage = MockStorage::new(); let genesis_cid = create_test_content_id(b"genesis"); storage.edges.borrow_mut().entry(genesis_cid).or_default(); let dag = DagGraph::>::new(storage); - let result = dag.get_all_versions_for_genesis(&genesis_cid).unwrap(); + let result = dag.get_nodes_by_genesis(&genesis_cid).unwrap(); assert_eq!(result, vec![genesis_cid]); } #[test] - fn test_get_all_versions_with_children() { + fn test_get_nodes_by_genesis_with_children() { let mut storage = MockStorage::new(); let genesis_cid = create_test_content_id(b"genesis"); let v1_cid = create_test_content_id(b"v1"); let v2_cid = create_test_content_id(b"v2"); storage.setup_graph(&[(genesis_cid, v1_cid), (v1_cid, v2_cid)]); let dag = DagGraph::>::new(storage); - let mut result = dag.get_all_versions_for_genesis(&genesis_cid).unwrap(); + let mut result = dag.get_nodes_by_genesis(&genesis_cid).unwrap(); result.sort(); let mut expected = vec![genesis_cid, v1_cid, v2_cid]; expected.sort(); @@ -1014,7 +933,7 @@ mod tests { } #[test] - fn test_get_all_versions_excludes_unrelated() { + fn test_get_nodes_by_genesis_excludes_unrelated() { let mut storage = MockStorage::new(); let genesis1_cid = create_test_content_id(b"genesis1"); let v1_cid = create_test_content_id(b"v1"); @@ -1022,7 +941,7 @@ mod tests { storage.setup_graph(&[(genesis1_cid, v1_cid)]); storage.edges.borrow_mut().entry(unrelated_cid).or_default(); let dag = DagGraph::>::new(storage); - let mut result = dag.get_all_versions_for_genesis(&genesis1_cid).unwrap(); + let mut result = dag.get_nodes_by_genesis(&genesis1_cid).unwrap(); result.sort(); let mut expected = vec![genesis1_cid, v1_cid]; expected.sort(); diff --git a/src/lib.rs b/src/lib.rs index 01eb03a..922ec78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod crdt; +pub mod convergence; pub mod dasl; pub mod graph; pub mod masl; diff --git a/src/repo.rs b/src/repo.rs index 7a0f590..cd098a6 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -45,7 +45,7 @@ where OperationType::Update(payload) => { let parents = self.get_latest_parents(&op.genesis); self.dag - .add_version_node(payload.clone(), parents, op.genesis, ())? + .add_child_node(payload.clone(), parents, op.genesis, ())? } OperationType::Delete => { let parents = self.get_latest_parents(&op.genesis); @@ -65,7 +65,12 @@ where .clone(); self.dag - .add_version_node(last_payload, parents, op.genesis, ())? + .add_child_node(last_payload, parents, op.genesis, ())? + } + OperationType::Merge(payload) => { + let parents = self.get_latest_parents(&op.genesis); + self.dag + .add_child_node(payload.clone(), parents, op.genesis, ())? } }; @@ -80,13 +85,9 @@ where /// Get the complete history from genesis pub fn get_history(&self, genesis: &Cid) -> Result> { - if let Some(latest) = self.latest(genesis) { - self.dag - .get_history_from_version(&latest) - .map_err(crate::crdt::error::CrdtError::Graph) - } else { - Ok(vec![]) - } + self.dag + .get_nodes_by_genesis(genesis) + .map_err(crate::crdt::error::CrdtError::Graph) } /// Get genesis from any version @@ -133,17 +134,10 @@ mod tests { } fn make_test_operation( - target: Cid, - kind: OperationType, - ) -> Operation { - Operation::new(target, kind, "test".into()) - } - fn make_test_operation_with_genesis( - target: Cid, genesis: Cid, kind: OperationType, ) -> Operation { - Operation::new_with_genesis(target, genesis, kind, "test".into()) + Operation::new(genesis, kind, "test".into()) } #[test] @@ -175,8 +169,7 @@ mod tests { ); let create_cid = repo.commit_operation(create_op).unwrap(); - let update_op = make_test_operation_with_genesis( - target, + let update_op = make_test_operation( create_cid, OperationType::Update(TestPayload("updated".to_string())), ); @@ -201,7 +194,7 @@ mod tests { ); let create_cid = repo.commit_operation(create_op).unwrap(); - let delete_op = make_test_operation_with_genesis(target, create_cid, OperationType::Delete); + let delete_op = make_test_operation(create_cid, OperationType::Delete); std::thread::sleep(std::time::Duration::from_millis(1)); let delete_cid = repo.commit_operation(delete_op).unwrap(); @@ -264,8 +257,7 @@ mod tests { let genesis_b = repo.commit_operation(create_b).unwrap(); // Update only series A - let update_a = make_test_operation_with_genesis( - shared_target, + let update_a = make_test_operation( genesis_a, OperationType::Update(TestPayload("A2".into())), ); @@ -301,8 +293,7 @@ mod tests { let cid2 = repo.commit_operation(create2).unwrap(); // User2 update in its own series - let update2 = make_test_operation_with_genesis( - shared_target, + let update2 = make_test_operation( cid2, OperationType::Update(TestPayload("u2_updated".into())), ); @@ -310,13 +301,13 @@ mod tests { repo.commit_operation(update2).unwrap(); // User1 delete - let del_op = make_test_operation_with_genesis(shared_target, cid1, OperationType::Delete); + let del_op = make_test_operation(cid1, OperationType::Delete); std::thread::sleep(std::time::Duration::from_millis(1)); repo.commit_operation(del_op).unwrap(); - assert_eq!(repo.state.get_state(&shared_target, &cid1), None); + assert_eq!(repo.state.get_state(&cid1), None); assert_eq!( - repo.state.get_state(&shared_target, &cid2), + repo.state.get_state(&cid2), Some(TestPayload("u2_updated".into())) ); } From bb46604ce4740768de874ce3a5e36d61f3a98631 Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Sat, 11 Oct 2025 15:21:21 +0900 Subject: [PATCH 2/9] feat: add test for convergence layer --- "CRSL\343\201\256\346\224\271\344\277\256.md" | 449 ------------------ examples/cli.rs | 7 +- src/convergence/metadata.rs | 1 - src/convergence/mod.rs | 3 +- src/convergence/policies/lww.rs | 7 +- src/convergence/policies/mod.rs | 1 - src/convergence/policy.rs | 1 - src/convergence/resolver.rs | 184 ++++++- src/crdt/crdt_state.rs | 23 +- src/crdt/operation.rs | 12 +- src/crdt/reducer.rs | 6 +- src/graph/dag.rs | 6 +- src/lib.rs | 2 +- src/repo.rs | 6 +- 14 files changed, 209 insertions(+), 499 deletions(-) delete mode 100644 "CRSL\343\201\256\346\224\271\344\277\256.md" diff --git "a/CRSL\343\201\256\346\224\271\344\277\256.md" "b/CRSL\343\201\256\346\224\271\344\277\256.md" deleted file mode 100644 index cb5879f..0000000 --- "a/CRSL\343\201\256\346\224\271\344\277\256.md" +++ /dev/null @@ -1,449 +0,0 @@ -# CRSLの改修 - -# 目的 - -CRSLの改修の詳細設計を書いていく。目的としては因果関係を表現可能とし、内容の収束のためのマージポリシーを選択可能とする。 - -# 詳細設計 - -## 全体アーキテクチャ - -```json -┌─────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ (ユーザーアプリケーション) │ -└────────────────────┬────────────────────────────────────┘ - │ -┌────────────────────▼────────────────────────────────────┐ -│ Repository Layer (Repo) │ -│ - 高レベルAPI提供 │ -│ - オペレーションの調整 │ -│ - 自動マージのトリガー │ -└─────┬──────────────────────────────┬───────────────────┘ - │ │ - │ │ -┌─────▼──────────────────┐ ┌────────▼──────────────────┐ -│ CRDT State Layer │ │ Convergence Layer [新規] │ -│ - 操作の記録 │ │ - マージポリシー管理 │ -│ - LWW Reducer │ │ - ContentMetadata [移動] │ -│ [既存: 変更なし] │ │ - 衝突解決ロジック │ -└─────┬──────────────────┘ └────────┬──────────────────┘ - │ │ - │ │ -┌─────▼──────────────────────────────▼───────────────────┐ -│ Graph Layer (DAG) │ -│ - DAG構造の管理 │ -│ - サイクル検知 │ -│ - ノードの追加・取得 │ -│ - 同期機能 [新規: graph/sync.rs] │ -└─────┬──────────────────────────────────────────────────┘ - │ -┌─────▼────────────────────────────────────────────────────┐ -│ DASL Layer │ -│ - Node (汎用構造) │ -│ - CID生成 │ -│ [変更なし] │ -└──────────────────────────────────────────────────────────┘ -``` - -## レイヤー別設計について - -### DASL Layer - -変更なし - -### Graph Layer - -以下変更 - -- 関数名の変更 - - `fn add_version_node()` → `fn add_child_node()` - - `fn get_all_versions_for_genesis` → `fn get_nodes_by_genesis` - - ここでVersionって言葉を使うのは良くない。DAGは因果関係の表現であって、バージョンの表現(線形)ではないため -- 関数を削除 - - `fn get_history_from_version` -- versionって言葉の撤廃 - - 引数や変数名として使用している部分の修正 - -以下関数 - -```rust -impl DagGraph { - // ノード追加 - fn add_genesis_node(&mut self, payload: P, metadata: M) -> Result - fn add_child_node(&mut self, payload: P, parents: Vec, genesis: Cid, metadata: M) -> Result - /// ノードを取得 - pub fn get_node(&self, cid: &Cid) -> Result>>; - // ノード取得 - fn get_nodes_by_genesis(&self, genesis: &Cid) -> Result> - - // 構造解析 - fn collect_leaf_nodes(&self, nodes: &[Cid]) -> Result> - fn collect_nodes_with_children(&self, nodes: &[Cid]) -> Result> - fn calculate_latest(&self, genesis_id: &Cid) -> Result> - fn get_genesis(&self, node_cid: &Cid) -> Result - - // サイクル検知 - fn would_create_cycle_with(&mut self, new_cid: &Cid, parents: &[Cid]) -> Result -} -``` - -### CRDT Layer - -- mergeを追加 - - merge操作の場合は、mergeとして識別可能にする - -```rust -pub enum OperationType { - Create(T), - Update(T), - Delete, - Merge(T), // 追加 -} -``` - -- `target`の削除 - - targetにより、1つ前の状態を表現していたが、DAGにより1つ前の状態を表現できており冗長だったため、削除 - - 引数から削除 - - `fn load_operations_by_genesis()`の削除 - - 以下の構造体に変更 - -```rust -pub struct Operation { - pub id: OperationId, - pub genesis: ContentId, - pub kind: OperationType, - pub timestamp: Timestamp, - pub author: Author, -} -``` - -## Convergence Layer - -新しく追加するレイヤー: マージポリシーを持つ。また、ContentMetadataの構造体も持つ。 - -**全体構成** - -```json -src/convergence/ -├── mod.rs -├── metadata.rs # ContentMetadata定義 -├── policy.rs # MergePolicy trait, ResolveInput -├── resolver.rs # ConflictResolver -└── policies/ - ├── mod.rs - └── lww.rs # LwwMergePolicy実装 -``` - -**metadataの定義:** - -```rust -// src/convergence/metadata.rs - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct ContentMetadata { - /// ポリシータイプ名(例: "lww", "text", "custom-policy") - /// Noneの場合はデフォルト("lww") - policy_type: Option, -} - -impl ContentMetadata { - /// デフォルトのメタデータ(LWWポリシー) - pub fn new() -> Self { - Self { policy_type: None } - } - - /// 特定のポリシーを指定 - pub fn with_policy(policy_type: impl Into) -> Self { - Self { - policy_type: Some(policy_type.into()), - } - } - - /// ポリシータイプを取得(Noneの場合は"lww") - pub fn policy_type(&self) -> &str { - self.policy_type.as_deref().unwrap_or("lww") - } -} - -impl Default for ContentMetadata { - fn default() -> Self { - Self::new() - } -} - -``` - -関数: - -- `fn new() -> Self` -- `fn with_policy(policy_type: impl Into) -> Self` -- `fn policy_type(&self) -> &str` - -`policy.rs` : 複数のマージポリシーに対応するために抽象化する - -trait: - -```rust -pub trait MergePolicy

: Send + Sync { - /// 競合する複数ノードを解決して統合ペイロードを生成 - fn resolve(&self, nodes: &[ResolveInput

]) -> P; - - /// ポリシーの識別名("lww", "text"など) - fn name(&self) -> &str; -} - -/// 構造体 -pub struct ResolveInput

{ - pub cid: Cid, - pub payload: P, - pub timestamp: u64, -} -``` - -`resolve.rs` : marge nodeを作成する - -```rust -pub struct ConflictResolver { - _marker: PhantomData<(P, M)>, -} -``` - -関数: - -- `fn new() -> Self` -- `fn create_merge_node(&self, heads: &[Cid], dag: &DagGraph, P, M>, genesis: Cid, policy: &dyn MergePolicy

) -> Result>` -- `fn collect_inputs(&self, heads: &[Cid], dag: &DagGraph, P, M>) -> Result>>` - -## Repository Layer - -構造体の修正: - -```rust -pub struct Repo -where - OpStore: OperationStorage, - NodeStore: NodeStorage, // ContentMetadata追加 -{ - pub state: CrdtState, - pub dag: DagGraph, - resolver: ConflictResolver, // 追加 -} -``` - -初期化に引数を追加: - -```rust -pub fn new( - state: CrdtState, - dag: DagGraph, -) -> Self { - Self { - state, - dag, - resolver: ConflictResolver::new(), - } -} -``` - -`commit_operation()` にトリガーを追加: - -- `let metadata = ContentMetadata::default();` - -`commit_operation()`の修正: - -- Mergeのハンドリングを追加 -- 一旦はユーザーからは受け付けない - - ただ、手動マージも可能にする予定a - -```rust -/// 内部用:無限ループ防止機能付きcommit -/// -/// # Arguments -/// * `op` - コミットする操作 -/// * `skip_auto_merge` - trueの場合、自動マージをスキップ(Merge操作時に使用) -fn commit_operation_internal( - &mut self, - mut op: Operation, - skip_auto_merge: bool -) -> Result { - let metadata = ContentMetadata::default(); - - let cid = match &op.kind { - OperationType::Create(payload) => { - let genesis_cid = self.dag.add_genesis_node(payload.clone(), metadata)?; - op.genesis = genesis_cid; - genesis_cid - } - OperationType::Update(payload) => { - let parents = self.get_latest_parents(&op.genesis); - self.dag.add_child_node(payload.clone(), parents, op.genesis, metadata)? - } - OperationType::Delete => { - let parents = self.get_latest_parents(&op.genesis); - let ops = self.state.get_operations_by_genesis(&op.genesis)?; - let last_payload = ops - .iter() - .filter(|o| o.payload().is_some()) - .max_by_key(|o| o.timestamp) - .expect("content must exist") - .payload() - .unwrap() - .clone(); - self.dag.add_child_node(last_payload, parents, op.genesis, metadata)? - } - OperationType::Merge(payload) => { - // Merge専用:複数のheadを親として持つ - let parents = self.find_heads(&op.genesis)?; - self.dag.add_child_node(payload.clone(), parents, op.genesis, metadata)? - } - }; - - self.state.apply(op)?; - - // 自動マージのトリガー(無限ループ防止) - if !skip_auto_merge { - self.check_and_merge(&op.genesis)?; - } - - Ok(cid) -} - -/// 公開API -pub fn commit_operation(&mut self, op: Operation) -> Result { - // Merge操作はユーザーから受け付けない - if matches!(op.kind, OperationType::Merge(_)) { - return Err(CrdtError::Internal( - "Merge operations cannot be manually committed".to_string() - )); - } - - self.commit_operation_internal(op, false) -} - -``` - -`check_and_merge()` の追加: - -**役割**: - -- orchestration(調整): 分岐検知、ポリシー選択、結果の記録 -- delegation(委譲): 実際のマージノード生成はResolverに任せる - -```rust -/// 分岐を検知して自動マージを実行 -fn check_and_merge(&mut self, genesis: &Cid) -> Result> { - // 1. ヘッドを検索(分岐検知) - let heads = self.find_heads(genesis)?; - - // 2. 分岐がなければ何もしない - if heads.len() <= 1 { - return Ok(None); - } - - // 3. genesisノードからポリシータイプを取得(ポリシー選択) - let genesis_node = self.dag.get_node(genesis)? - .ok_or_else(|| CrdtError::Internal(format!("Genesis not found: {}", genesis)))?; - let policy_type = genesis_node.metadata().policy_type(); - - // 4. ポリシーオブジェクトを生成 - let policy = self.create_policy(policy_type)?; - - // 5. マージノード生成を委譲(Resolverに実行を任せる) - let merge_node = self.resolver.create_merge_node( - &heads, - &self.dag, - *genesis, - policy.as_ref(), - )?; - - let merge_op = Operation::new( - *genesis, - OperationType::Merge(merge_node.payload().clone()), - "auto-merge".to_string(), - ); - - // 7. commit_operationに委譲(書き込み処理は行わない) - let merge_cid = self.commit_operation_internal(merge_op, true)?; - - Ok(Some(merge_cid)) -} -``` - -### 関数の定義 -- `find_head()` -- `get_latest_parents()` -- `create_policy()` -```rust -/// ヘッドノード(リーフノード)を検索 -/// -/// # Returns -/// 分岐している場合は複数のCID、そうでなければ1つ以下 -fn find_heads(&self, genesis: &Cid) -> Result> { - let nodes = self.dag.get_nodes_by_genesis(genesis)?; - let leaf_nodes = self.dag.collect_leaf_nodes(&nodes)?; - Ok(leaf_nodes.into_iter().map(|(cid, _)| cid).collect()) -} - -/// 最新の親ノードを取得(通常は1つ、分岐時は複数) -fn get_latest_parents(&self, genesis: &Cid) -> Vec { - self.dag - .calculate_latest(genesis) - .ok() - .flatten() - .map(|cid| vec![cid]) - .unwrap_or_default() -} - -/// ポリシータイプからポリシーオブジェクトを生成 -fn create_policy(&self, policy_type: &str) -> Result>> { - match policy_type { - "lww" => Ok(Box::new(LwwMergePolicy)), - _ => Err(CrdtError::Internal( - format!("Unknown policy type: {}", policy_type) - )) - } -} -``` - - -操作履歴例: - -```rust -[ - Operation { - genesis: cid_a, - kind: Create(payload_a), - author: "alice" - }, // → DAGにcid_aを生成 - - Operation { - genesis: cid_a, - kind: Update(payload_b), - author: "bob" - }, // → DAGにcid_bを生成 - - Operation { - genesis: cid_a, - kind: Update(payload_c), - author: "bob" - }, // → DAGにcid_cを生成(cid_bの子) - - Operation { - genesis: cid_a, - kind: Update(payload_d), - author: "carol" - }, // → DAGにcid_dを生成(cid_bの子、cid_cと並列) - - Operation { - genesis: cid_a, - kind: Merge(payload_merged), - author: "auto-merge" - }, // → DAGにcid_eを生成(cid_cとcid_dの子) -] -``` - -- `fn get_history` は不要かもしれない - - ちょっと実装しながら考える \ No newline at end of file diff --git a/examples/cli.rs b/examples/cli.rs index 0102c95..adf9ef2 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -111,11 +111,8 @@ fn main() -> Result<(), Box> { let author = author.unwrap_or_else(|| "anonymous".to_string()); - let op = Operation::new( - genesis_cid, - OperationType::Update(content.clone()), - author, - ); + let op = + Operation::new(genesis_cid, OperationType::Update(content.clone()), author); let version_cid = repo.commit_operation(op)?; diff --git a/src/convergence/metadata.rs b/src/convergence/metadata.rs index 63a0805..047792e 100644 --- a/src/convergence/metadata.rs +++ b/src/convergence/metadata.rs @@ -32,4 +32,3 @@ impl Default for ContentMetadata { Self::new() } } - diff --git a/src/convergence/mod.rs b/src/convergence/mod.rs index a0d5993..533e6d9 100644 --- a/src/convergence/mod.rs +++ b/src/convergence/mod.rs @@ -1,5 +1,4 @@ pub mod metadata; -pub mod policy; pub mod policies; +pub mod policy; pub mod resolver; - diff --git a/src/convergence/policies/lww.rs b/src/convergence/policies/lww.rs index 6419107..dc2892e 100644 --- a/src/convergence/policies/lww.rs +++ b/src/convergence/policies/lww.rs @@ -34,7 +34,7 @@ mod tests { #[test] fn selects_highest_timestamp() { - let policy = LwwMergePolicy::default(); + let policy = LwwMergePolicy; let inputs = vec![ ResolveInput::new(create_test_cid("a"), "older".to_string(), 10), ResolveInput::new(create_test_cid("b"), "newer".to_string(), 20), @@ -46,7 +46,7 @@ mod tests { #[test] fn ties_choose_last_entry() { - let policy = LwwMergePolicy::default(); + let policy = LwwMergePolicy; let inputs = vec![ ResolveInput::new(create_test_cid("a"), "first".to_string(), 42), ResolveInput::new(create_test_cid("b"), "second".to_string(), 42), @@ -58,7 +58,7 @@ mod tests { #[test] fn selects_highest_timestamp_among_three() { - let policy = LwwMergePolicy::default(); + let policy = LwwMergePolicy; let inputs = vec![ ResolveInput::new(create_test_cid("a"), "payload-a".to_string(), 5), ResolveInput::new(create_test_cid("b"), "payload-b".to_string(), 10), @@ -69,4 +69,3 @@ mod tests { assert_eq!(result, "payload-b"); } } - diff --git a/src/convergence/policies/mod.rs b/src/convergence/policies/mod.rs index 31cfd0c..a2c0f6d 100644 --- a/src/convergence/policies/mod.rs +++ b/src/convergence/policies/mod.rs @@ -1,2 +1 @@ pub mod lww; - diff --git a/src/convergence/policy.rs b/src/convergence/policy.rs index 38efdc7..827d105 100644 --- a/src/convergence/policy.rs +++ b/src/convergence/policy.rs @@ -26,4 +26,3 @@ pub trait MergePolicy

: Send + Sync { /// Return a descriptive name of the policy (e.g. "lww"). fn name(&self) -> &str; } - diff --git a/src/convergence/resolver.rs b/src/convergence/resolver.rs index ac3a81f..d875325 100644 --- a/src/convergence/resolver.rs +++ b/src/convergence/resolver.rs @@ -1,8 +1,8 @@ use crate::convergence::policy::{MergePolicy, ResolveInput}; -use crate::graph::dag::DagGraph; -use crate::graph::storage::NodeStorage; use crate::crdt::error::{CrdtError, Result as CrdtResult}; use crate::dasl::node::Node; +use crate::graph::dag::DagGraph; +use crate::graph::storage::NodeStorage; use cid::Cid; use std::marker::PhantomData; @@ -55,7 +55,11 @@ where )) } - fn collect_inputs(&self, heads: &[Cid], dag: &DagGraph) -> CrdtResult>> + fn collect_inputs( + &self, + heads: &[Cid], + dag: &DagGraph, + ) -> CrdtResult>> where S: NodeStorage, P: serde::Serialize + for<'de> serde::Deserialize<'de>, @@ -67,7 +71,11 @@ where .get_node(&cid) .map_err(CrdtError::Graph)? .ok_or_else(|| CrdtError::Internal(format!("Head node not found: {cid}")))?; - inputs.push(ResolveInput::new(cid, node.payload().clone(), node.timestamp())); + inputs.push(ResolveInput::new( + cid, + node.payload().clone(), + node.timestamp(), + )); } Ok(inputs) } @@ -97,4 +105,172 @@ where } } +#[cfg(test)] +mod tests { + use super::*; + use crate::convergence::metadata::ContentMetadata; + use crate::convergence::policies::lww::LwwMergePolicy; + use crate::convergence::policy::{MergePolicy, ResolveInput}; + use crate::crdt::error::CrdtError; + use crate::dasl::node::Node; + use crate::graph::error::{GraphError, Result as GraphResult}; + use crate::graph::storage::NodeStorage; + use multihash::Multihash; + use serde::{Deserialize, Serialize}; + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + + #[derive(Clone, Default)] + struct MemoryNodeStorage { + nodes: Rc>>>, + } + + impl MemoryNodeStorage + where + P: Clone + Serialize + for<'de> Deserialize<'de>, + M: Clone + Serialize + for<'de> Deserialize<'de>, + { + fn insert(&self, node: &Node) -> GraphResult<()> { + let cid = node + .content_id() + .map_err(|e| GraphError::NodeOperation(e.to_string()))?; + self.nodes.borrow_mut().insert(cid, node.clone()); + Ok(()) + } + } + + impl NodeStorage for MemoryNodeStorage + where + P: Clone + Serialize + for<'de> Deserialize<'de>, + M: Clone + Serialize + for<'de> Deserialize<'de>, + { + fn get(&self, content_id: &Cid) -> GraphResult>> { + Ok(self.nodes.borrow().get(content_id).cloned()) + } + + fn put(&self, node: &Node) -> GraphResult<()> { + self.insert(node) + } + + fn delete(&self, content_id: &Cid) -> GraphResult<()> { + self.nodes.borrow_mut().remove(content_id); + Ok(()) + } + + fn get_node_map(&self) -> GraphResult>> { + let mut map = HashMap::new(); + for (cid, node) in self.nodes.borrow().iter() { + map.insert(*cid, node.parents().to_vec()); + } + Ok(map) + } + } + struct AssertingPolicy { + expected: Vec<(Cid, String, u64)>, + result: String, + } + + impl MergePolicy for AssertingPolicy { + fn resolve(&self, nodes: &[ResolveInput]) -> String { + assert_eq!(nodes.len(), self.expected.len()); + for (input, expected) in nodes.iter().zip(&self.expected) { + assert_eq!(input.cid, expected.0); + assert_eq!(input.payload, expected.1); + assert_eq!(input.timestamp, expected.2); + } + self.result.clone() + } + + fn name(&self) -> &str { + "assert" + } + } + + fn create_test_cid(label: &str) -> Cid { + let digest = Multihash::<64>::wrap(0x12, label.as_bytes()).unwrap(); + Cid::new_v1(0x55, digest) + } + + #[test] + fn create_merge_node_merges_heads() { + let storage = MemoryNodeStorage::::default(); + let dag = DagGraph::new(storage.clone()); + + let metadata = ContentMetadata::with_policy("custom"); + let genesis_node = Node::new_genesis("genesis".to_string(), 1, metadata.clone()); + let genesis_cid = genesis_node.content_id().unwrap(); + dag.storage.put(&genesis_node).unwrap(); + + let head_a = Node::new_child( + "payload-a".to_string(), + vec![genesis_cid], + genesis_cid, + 10, + metadata.clone(), + ); + let head_a_cid = head_a.content_id().unwrap(); + dag.storage.put(&head_a).unwrap(); + + let head_b = Node::new_child( + "payload-b".to_string(), + vec![genesis_cid], + genesis_cid, + 20, + metadata.clone(), + ); + let head_b_cid = head_b.content_id().unwrap(); + dag.storage.put(&head_b).unwrap(); + + let policy = AssertingPolicy { + expected: vec![ + (head_a_cid, "payload-a".to_string(), 10), + (head_b_cid, "payload-b".to_string(), 20), + ], + result: "merged".to_string(), + }; + + let resolver = ConflictResolver::::new(); + let merge_node = resolver + .create_merge_node(&[head_a_cid, head_b_cid], &dag, genesis_cid, &policy) + .unwrap(); + + assert_eq!(merge_node.payload(), "merged"); + assert_eq!(merge_node.parents(), &vec![head_a_cid, head_b_cid]); + assert_eq!(merge_node.metadata(), &metadata); + assert_eq!(merge_node.genesis, Some(genesis_cid)); + assert!(merge_node.timestamp() > 0); + } + + #[test] + fn create_merge_node_requires_non_empty_heads() { + let dag = DagGraph::new(MemoryNodeStorage::::default()); + let resolver = ConflictResolver::::new(); + let policy = LwwMergePolicy; + let genesis = create_test_cid("genesis"); + + let result = resolver.create_merge_node(&[], &dag, genesis, &policy); + + assert!(matches!( + result, + Err(CrdtError::Internal(message)) if message.contains("requires at least one head") + )); + } + + #[test] + fn create_merge_node_fails_when_head_missing() { + let dag = DagGraph::new(MemoryNodeStorage::::default()); + let resolver = ConflictResolver::::new(); + let policy = LwwMergePolicy; + let missing_head = create_test_cid("missing-head"); + let genesis = create_test_cid("genesis"); + + let result = resolver.create_merge_node(&[missing_head], &dag, genesis, &policy); + + assert!(matches!( + result, + Err(CrdtError::Internal(message)) if message.contains("Head node not found") + )); + } +} diff --git a/src/crdt/crdt_state.rs b/src/crdt/crdt_state.rs index fa76dda..1aa1c3f 100644 --- a/src/crdt/crdt_state.rs +++ b/src/crdt/crdt_state.rs @@ -129,11 +129,7 @@ mod tests { ts: u64, kind: OperationType, ) -> Operation { - let mut op = Operation::new( - DummyContentId(id.to_string()), - kind, - "tester".into(), - ); + let mut op = Operation::new(DummyContentId(id.to_string()), kind, "tester".into()); op.timestamp = ts; op } @@ -188,10 +184,7 @@ mod tests { state.apply(op1).unwrap(); state.apply(op2).unwrap(); state.apply(op3).unwrap(); - assert_eq!( - state.get_state(&DummyContentId("1".to_string())), - None - ); + assert_eq!(state.get_state(&DummyContentId("1".to_string())), None); } #[test] @@ -298,11 +291,8 @@ mod tests { ); alt_update.timestamp = 300; - let mut primary_delete = Operation::new( - primary_genesis.clone(), - OperationType::Delete, - "u1".into(), - ); + let mut primary_delete = + Operation::new(primary_genesis.clone(), OperationType::Delete, "u1".into()); primary_delete.timestamp = 400; state.apply(primary_create).unwrap(); @@ -312,7 +302,10 @@ mod tests { assert_eq!(state.get_state(&primary_genesis), None); - assert_eq!(state.get_state(&alt_genesis), Some(DummyPayload("C".into()))); + assert_eq!( + state.get_state(&alt_genesis), + Some(DummyPayload("C".into())) + ); let operations = state.get_operations_by_genesis(&alt_genesis).unwrap(); assert_eq!(operations.len(), 2); diff --git a/src/crdt/operation.rs b/src/crdt/operation.rs index bd58039..58c3486 100644 --- a/src/crdt/operation.rs +++ b/src/crdt/operation.rs @@ -104,9 +104,9 @@ where /// or `None` for delete operations pub fn payload(&self) -> Option<&T> { match &self.kind { - OperationType::Create(v) - | OperationType::Update(v) - | OperationType::Merge(v) => Some(v), + OperationType::Create(v) | OperationType::Update(v) | OperationType::Merge(v) => { + Some(v) + } OperationType::Delete => None, } } @@ -151,7 +151,11 @@ mod tests { let payload = DummyPayload("updated".into()); let author = "Alice".to_string(); - let op = Operation::new(genesis.clone(), OperationType::Update(payload.clone()), author); + let op = Operation::new( + genesis.clone(), + OperationType::Update(payload.clone()), + author, + ); assert!(op.id != Ulid::nil()); assert_eq!(op.kind, OperationType::Update(payload.clone())); diff --git a/src/crdt/reducer.rs b/src/crdt/reducer.rs index 31f33ed..5fced1e 100644 --- a/src/crdt/reducer.rs +++ b/src/crdt/reducer.rs @@ -19,9 +19,9 @@ where .then(a.id.to_bytes().cmp(&b.id.to_bytes())) }) .and_then(|op| match &op.kind { - OperationType::Create(v) - | OperationType::Update(v) - | OperationType::Merge(v) => Some(v.clone()), + OperationType::Create(v) | OperationType::Update(v) | OperationType::Merge(v) => { + Some(v.clone()) + } OperationType::Delete => None, }) } diff --git a/src/graph/dag.rs b/src/graph/dag.rs index 97e6771..1bb2c89 100644 --- a/src/graph/dag.rs +++ b/src/graph/dag.rs @@ -372,10 +372,7 @@ where } // Returns the set of nodes (CIDs) that are referenced as parents (i.e., nodes that have children) among the given versions. - fn collect_nodes_with_children( - &self, - nodes: &[Cid], - ) -> Result> { + fn collect_nodes_with_children(&self, nodes: &[Cid]) -> Result> { let mut has_children = std::collections::HashSet::new(); for &node_cid in nodes { if let Some(node) = self.storage.get(&node_cid)? { @@ -405,7 +402,6 @@ where } Ok(leaf_nodes) } - } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index 922ec78..b1ff0b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ -pub mod crdt; pub mod convergence; +pub mod crdt; pub mod dasl; pub mod graph; pub mod masl; diff --git a/src/repo.rs b/src/repo.rs index cd098a6..2559d88 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -257,10 +257,8 @@ mod tests { let genesis_b = repo.commit_operation(create_b).unwrap(); // Update only series A - let update_a = make_test_operation( - genesis_a, - OperationType::Update(TestPayload("A2".into())), - ); + let update_a = + make_test_operation(genesis_a, OperationType::Update(TestPayload("A2".into()))); std::thread::sleep(std::time::Duration::from_millis(1)); let latest_a = repo.commit_operation(update_a).unwrap(); From 9a7c41789c6a416a39b7008b2246a63e2a0e47fd Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Mon, 13 Oct 2025 12:42:15 +0900 Subject: [PATCH 3/9] feat: update cli commands --- .../latest-version-calculation/design.md | 226 ----- .../requirements.md | 68 -- .../specs/latest-version-calculation/tasks.md | 63 -- Makefile | 132 +-- examples/cli.rs | 302 +++++-- src/crdt/operation.rs | 3 + src/crdt/reducer.rs | 2 + src/repo.rs | 805 ++++++++++++++++-- 8 files changed, 999 insertions(+), 602 deletions(-) delete mode 100644 .kiro/specs/latest-version-calculation/design.md delete mode 100644 .kiro/specs/latest-version-calculation/requirements.md delete mode 100644 .kiro/specs/latest-version-calculation/tasks.md diff --git a/.kiro/specs/latest-version-calculation/design.md b/.kiro/specs/latest-version-calculation/design.md deleted file mode 100644 index 4f467de..0000000 --- a/.kiro/specs/latest-version-calculation/design.md +++ /dev/null @@ -1,226 +0,0 @@ -# 設計書 - -## 概要 - -この機能は、既存のgenesis追跡機能を活用して、永続化なしで最新バージョンを効率的に計算します。DAG構造を分析し、リーフノード(子を持たないノード)を特定することで、任意のコンテンツの最新バージョンを動的に取得します。 - -## アーキテクチャ - -### 全体構成 - -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ CLI Layer │ │ Repo Layer │ │ DAG Layer │ -│ │ │ │ │ │ -│ show command │───▶│ latest() │───▶│ calculate_latest│ -│ │ │ │ │ │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ - ┌─────────────────┐ ┌─────────────────┐ - │ CRDT State │ │ Node Storage │ - │ │ │ │ - │ get_state() │ │ get_node_map() │ - │ │ │ get() │ - └─────────────────┘ └─────────────────┘ -``` - -### 計算フロー - -1. **Genesis関連ノード取得**: 全ノードからgenesis情報でフィルタ -2. **親子関係分析**: 各ノードの親情報から子を持つノードを特定 -3. **リーフノード特定**: 子を持たないノードを収集 -4. **最新選択**: タイムスタンプで最新のリーフノードを選択 - -## コンポーネントと インターフェース - -### DagGraph の拡張 - -```rust -impl DagGraph -where - S: NodeStorage, - P: serde::Serialize + serde::de::DeserializeOwned, - M: serde::Serialize + serde::de::DeserializeOwned, -{ - /// Genesis IDから最新バージョンを計算 - pub fn calculate_latest(&self, genesis_id: &Cid) -> Result>; - - /// Genesis IDに関連する全ノードを取得 - fn get_all_versions_for_genesis(&self, genesis_id: &Cid) -> Result>; -} -``` - -### Repo の修正 - -```rust -impl Repo { - /// 最新バージョンを取得(毎回計算) - pub fn latest(&self, genesis_id: &Cid) -> Option; - - /// 既存のcommit_operationは変更なし(headsは使わない) - pub fn commit_operation(&mut self, op: Operation) -> Result; -} -``` - -### CLI の拡張 - -```rust -Commands::Show { content_id } => { - // 基本情報表示 - // 最新バージョン情報追加 - // Genesis情報表示 -} -``` - -## データモデル - -### 既存のNode構造(変更なし) - -```rust -pub struct Node { - pub payload: P, - pub parents: Vec, - pub genesis: Option, // ← これを活用 - pub timestamp: u64, - pub metadata: M, -} -``` - -## エラーハンドリング - -### エラーの種類と対応 - -1. **Genesis ID不存在**: `Ok(None)` を返す(正常ケース) -2. **ノード取得エラー**: `GraphError` を伝播 -3. **リーフノードなし**: `GraphError::Internal` (理論上発生しない) - -### エラーハンドリング戦略 - -```rust -pub fn calculate_latest(&self, genesis_id: &Cid) -> Result> { - // Genesis存在確認(存在しない場合はNone) - let versions = self.get_all_versions_for_genesis(genesis_id)?; - if versions.is_empty() { - return Ok(None); - } - - // 実際のエラーのみ伝播 - // ... -} -``` - -## テスト戦略 - -### 単体テスト - -1. **DagGraph::calculate_latest()** - - 単一バージョン(genesis のみ) - - 線形履歴(複数バージョン) - - 分岐履歴(複数リーフ) - - 存在しないgenesis ID - -2. **DagGraph::get_all_versions_for_genesis()** - - Genesis自身の検出 - - 子ノードの検出 - - 無関係ノードの除外 - -3. **Repo::latest()** - - 正常ケース - - エラーケース - - 空の履歴 - -### テストデータ - -```rust -// テスト用のDAG構造例 -// Genesis -> V1 -> V2 -> V3 (linear) -// Genesis -> V1 -> V2a, V2b (branched) -``` - -## 実装の詳細 - -### アルゴリズムの実装 - -```rust -pub fn calculate_latest(&self, genesis_id: &Cid) -> Result> { - // Step 1: Genesis IDに関連する全ノードを取得 - let versions = self.get_all_versions_for_genesis(genesis_id)?; - - if versions.is_empty() { - return Ok(None); - } - - // 単一ノードの場合は即座に返す - if versions.len() == 1 { - return Ok(Some(versions[0])); - } - - // Step 2: 親として参照されているノードを特定 - let mut has_children = HashSet::new(); - - for &node_cid in &versions { - if let Some(node) = self.storage.get(&node_cid)? { - for parent_cid in node.parents() { - if versions.contains(parent_cid) { - has_children.insert(*parent_cid); - } - } - } - } - - // Step 3: リーフノード(子を持たないノード)を収集 - let mut leaf_nodes = Vec::new(); - - for &node_cid in &versions { - if !has_children.contains(&node_cid) { - if let Some(node) = self.storage.get(&node_cid)? { - leaf_nodes.push((node_cid, node.timestamp())); - } - } - } - - // Step 4: 最新のリーフノードを返す - leaf_nodes.sort_by_key(|(_, timestamp)| std::cmp::Reverse(*timestamp)); - Ok(leaf_nodes.first().map(|(cid, _)| *cid)) -} -``` - -### Genesis関連ノード取得の実装 - -```rust -fn get_all_versions_for_genesis(&self, genesis_id: &Cid) -> Result> { - let mut versions = Vec::new(); - let node_map = self.storage.get_node_map()?; - - // 全ノードを確認してgenesis情報でフィルタ - for (cid, _) in node_map { - if let Some(node) = self.storage.get(&cid)? { - // ノード自身がgenesisか、またはgenesisを参照している - if cid == *genesis_id || node.genesis == Some(*genesis_id) { - versions.push(cid); - } - } - } - - Ok(versions) -} -``` - -### パフォーマンス最適化 - -1. **早期リターン**: 単一ノードの場合は即座に返す -2. **効率的なフィルタリング**: genesis情報による事前フィルタ -3. **メモリ効率**: 一時的なデータ構造のみ使用 - -## セキュリティ考慮事項 - -1. **入力検証**: CID形式の検証 -2. **リソース制限**: 大量ノードでのメモリ使用量制限 -3. **エラー情報**: 内部構造を露出しないエラーメッセージ - -## 運用考慮事項 - -1. **ログ出力**: 計算時間とノード数のログ -2. **メトリクス**: パフォーマンス監視用メトリクス -3. **デバッグ**: 計算過程の可視化機能 \ No newline at end of file diff --git a/.kiro/specs/latest-version-calculation/requirements.md b/.kiro/specs/latest-version-calculation/requirements.md deleted file mode 100644 index b2f5622..0000000 --- a/.kiro/specs/latest-version-calculation/requirements.md +++ /dev/null @@ -1,68 +0,0 @@ -# 要件定義書 - -## 概要 - -この機能は、HEADの永続化を必要とせずに、任意のコンテンツの最新バージョンを効率的に取得することを可能にします。ノードに既存のgenesis追跡機能を活用することで、DAG構造を分析し、特定のコンテンツのバージョン履歴内でリーフノード(子を持たないノード)を特定することで、任意のコンテンツの最新バージョンを動的に計算できます。 -a -## 要件 - -### 要件1 - -**ユーザーストーリー:** CRSLライブラリを使用する開発者として、genesis IDによって任意のコンテンツの最新バージョンを取得したい。これにより、バージョン履歴を手動で追跡することなく、最新の状態にアクセスできるようになる。 - -#### 受け入れ基準 - -1. `repo.latest(genesis_id)`を呼び出した時、システムはそのコンテンツの最新バージョンCIDを返すこと -2. genesis IDが存在しない時、システムはエラーを投げることなくNoneを返すこと -3. バージョンが1つしかない(genesisノード)時、システムはgenesis CID自体を返すこと -4. 線形履歴に複数のバージョンがある時、システムはリーフノードのCIDを返すこと - -### 要件2 - -**ユーザーストーリー:** 開発者として、最新バージョンの計算が永続化ストレージなしで動作することを望む。これにより、システムがシンプルに保たれ、追加のストレージ管理が不要になる。 - -#### 受け入れ基準 - -1. アプリケーションが再起動した時、システムは依然として最新バージョンを正しく計算できること -2. 最新バージョンを計算する時、システムは永続的なHEADストレージを必要としないこと -3. 複数のプロセスが同じデータにアクセスする時、それぞれが独立して正しい最新バージョンを計算できること - -### 要件3 - -**ユーザーストーリー:** 開発者として、最新バージョンの計算が効率的であることを望む。これにより、適度なバージョン履歴があってもアプリケーションのパフォーマンスに影響しない。 - -#### 受け入れ基準 - -1. kバージョンを持つコンテンツの最新バージョンを計算する時、アルゴリズムはO(N)時間計算量で完了すること(Nは全ノード数、genesisでフィルタ) -2. 複数のコンテンツがある時、1つのコンテンツの最新バージョン計算は関連のないコンテンツバージョンをスキャンしないこと -3. バージョン履歴が線形の時、この一般的なケースに対して計算が最適化されること - -### 要件4 - -**ユーザーストーリー:** 開発者として、分岐したバージョン履歴の適切な処理を望む。これにより、コンテンツが並行して編集された時にシステムが予測可能に動作する。 - -#### 受け入れ基準 - -1. 複数のリーフノード(分岐)がある時、システムは最新のタイムスタンプを持つものを返すこと -2. 2つのリーフノードが同じタイムスタンプを持つ時、システムは一貫して1つを返すこと(決定的な動作) -3. 最新バージョンを計算する時、システムは親子関係をチェックしてリーフノードを正しく特定すること - -### 要件5 - -**ユーザーストーリー:** CLIユーザーとして、コンテンツを表示する時に最新バージョン情報を見たい。これにより、データの現在の状態を理解できる。 - -#### 受け入れ基準 - -1. `show `コマンドを実行した時、出力に最新バージョンCIDが含まれること -2. コンテンツに複数のバージョンがある時、コマンドは要求されたバージョンと最新バージョンの両方を表示すること -3. 要求されたcontent_idが既に最新バージョンの時、コマンドはこれを明確に示すこと - -### 要件6 - -**ユーザーストーリー:** 開発者として、既存コードとの後方互換性を望む。これにより、現在のアプリケーションが変更なしで動作し続ける。 - -#### 受け入れ基準 - -1. 既存コードが`repo.latest()`を呼び出す時、同じインターフェースで動作し続けること -2. システムに既存のインメモリHEAD情報がある時、パフォーマンスのためのフォールバックとして使用されること -3. メモリベースから計算ベースのアプローチに移行する時、既存の機能は変更されないこと \ No newline at end of file diff --git a/.kiro/specs/latest-version-calculation/tasks.md b/.kiro/specs/latest-version-calculation/tasks.md deleted file mode 100644 index ca54935..0000000 --- a/.kiro/specs/latest-version-calculation/tasks.md +++ /dev/null @@ -1,63 +0,0 @@ -# 実装計画 - -- [ ] 1. DagGraphに最新バージョン計算機能を実装 - - `calculate_latest()`メソッドを追加してgenesis IDから最新バージョンを計算する - - `get_all_versions_for_genesis()`ヘルパーメソッドを実装してgenesis関連ノードを取得する - - リーフノード特定アルゴリズムを実装して子を持たないノードを見つける - - _要件: 1.1, 1.4, 3.1, 4.1, 4.3_ - -- [ ] 2. DagGraphの単体テストを作成 - - 単一バージョン(genesisのみ)のテストケースを作成する - - 線形履歴(複数バージョン)のテストケースを作成する - - 分岐履歴(複数リーフ)のテストケースを作成する - - 存在しないgenesis IDのテストケースを作成する - - _要件: 1.2, 4.1, 4.2_ - -- [ ] 3. Repoレイヤーのlatest()メソッドを更新 - - `latest()`メソッドを計算ベースに変更してDAGから最新バージョンを取得する - - 既存のインターフェースを維持して後方互換性を保つ - - エラーハンドリングを実装してNoneとエラーを適切に処理する - - _要件: 1.1, 2.1, 6.1_ - -- [ ] 4. commit_operationメソッドを更新 - - headsフィールドへの保存を削除して計算ベースに移行する - - 親ノード取得を`latest()`メソッド経由に変更する - - 既存の動作を維持してCreate/Update/Delete操作が正常に動作することを確認する - - _要件: 2.2, 6.3_ - -- [ ] 5. Repoレイヤーの単体テストを作成 - - `latest()`メソッドの正常ケースをテストする - - 存在しないgenesis IDでのNone返却をテストする - - `commit_operation()`の動作確認テストを作成する - - _要件: 1.1, 1.2, 2.3_ - -- [ ] 6. CLIのshowコマンドを拡張 - - 最新バージョンCIDの表示機能を追加する - - Genesis情報の表示機能を追加する - - 要求されたバージョンと最新バージョンの関係を明確に表示する - - _要件: 5.1, 5.2, 5.3_ - -- [ ] 7. CLI統合テストを作成 - - `show`コマンドでの最新バージョン表示をテストする - - 複数バージョンがある場合の表示をテストする - - エラーケースでの適切な表示をテストする - - _要件: 5.1, 5.2, 5.3_ - -- [ ] 8. パフォーマンステストを実装 - - 大量ノード(1000+)での計算時間を測定するテストを作成する - - メモリ使用量を監視するテストを作成する - - 複数コンテンツでの独立性を確認するテストを作成する - - _要件: 3.1, 3.2, 3.3_ - -- [ ] 9. エラーハンドリングの統合テスト - - ノード取得エラー時の適切な伝播をテストする - - Genesis ID不存在時のNone返却をテストする - - 各レイヤーでのエラー処理の一貫性をテストする - - _要件: 1.2, 2.1_ - -- [ ] 10. 全体統合テストとドキュメント更新 - - 全機能の統合動作テストを実行する - - READMEに新機能の使用例を追加する - - APIドキュメントを更新して新しいメソッドを説明する - - パフォーマンス特性をドキュメント化する - - _要件: 6.1, 6.3_ \ No newline at end of file diff --git a/Makefile b/Makefile index 4041a65..7079884 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test fmt clippy check clean cli-init cli-create cli-update cli-show cli-history cli-history-from-version cli-genesis demo dev-setup +.PHONY: help test fmt clippy check clean clean-data cli-init cli-create cli-update cli-show cli-history demo dev-setup help: @echo "CRSL Development Commands:" @@ -14,9 +14,7 @@ help: @echo " make cli-create - Create sample content" @echo " make cli-update - Update content (requires GENESIS_ID)" @echo " make cli-show - Show content (requires ID)" - @echo " make cli-history - Show history from genesis (requires GENESIS_ID)" - @echo " make cli-history-from-version - Show history from version (requires VERSION_ID)" - @echo " make cli-genesis - Get genesis from version (requires VERSION_ID)" + @echo " make cli-history - Show history from genesis (requires GENESIS_ID, optional MODE=linear)" @echo " make demo - Run complete demo workflow" @echo " make dev-setup - Setup development environment" @@ -41,6 +39,9 @@ clean-data: rm -rf crsl_data/ rm -rf test_db/ +# Default history mode for CLI targets (tree | linear) +MODE ?= tree + # CLI Commands cli-init: cargo run --example cli -- init @@ -82,33 +83,12 @@ ifndef GENESIS_ID @echo "Error: GENESIS_ID is required" @echo "Usage: make cli-history GENESIS_ID=" @echo "" + @echo "Optional: MODE=linear" @echo "Example:" - @echo " make cli-history GENESIS_ID=QmExample123" - @exit 1 -endif - cargo run --example cli -- history -g $(GENESIS_ID) - -cli-history-from-version: -ifndef VERSION_ID - @echo "Error: VERSION_ID is required" - @echo "Usage: make cli-history-from-version VERSION_ID=" - @echo "" - @echo "Example:" - @echo " make cli-history-from-version VERSION_ID=QmExample123" - @exit 1 -endif - cargo run --example cli -- history-from-version -v $(VERSION_ID) - -cli-genesis: -ifndef VERSION_ID - @echo "Error: VERSION_ID is required" - @echo "Usage: make cli-genesis VERSION_ID=" - @echo "" - @echo "Example:" - @echo " make cli-genesis VERSION_ID=QmExample123" + @echo " make cli-history GENESIS_ID=QmExample123 MODE=linear" @exit 1 endif - cargo run --example cli -- genesis -v $(VERSION_ID) + cargo run --example cli -- history -g $(GENESIS_ID) --mode $(MODE) # Development setup dev-setup: cli-init cli-create @@ -123,70 +103,48 @@ dev-setup: cli-init cli-create # Demo workflow demo: clean-data cli-init - @echo "" - @echo "=== CRSL Version History Demo ===" - @echo "This demo will create one content, update it multiple times, and show all versions with latest verification." - @echo "" - @echo "📝 Step 1: Creating initial content..." - @CONTENT_GENESIS=$$(cargo run --example cli -- create -c "Initial Document v1.0" -a "alice" 2>/dev/null | grep "Content ID:" | awk '{print $$3}'); \ - echo "Created content with Genesis ID: $$CONTENT_GENESIS"; \ - echo ""; \ - echo "🔄 Step 2: Updating content multiple times..."; \ - echo " Updating to v1.1..."; \ - VERSION_1=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Updated Document v1.1 - Added introduction" -a "alice" 2>/dev/null | grep "New Version:" | awk '{print $$3}'); \ - echo " Updating to v1.2..."; \ - VERSION_2=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Updated Document v1.2 - Added API documentation" -a "alice" 2>/dev/null | grep "New Version:" | awk '{print $$3}'); \ - echo " Updating to v1.3..."; \ - VERSION_3=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Updated Document v1.3 - Added deployment guide" -a "alice" 2>/dev/null | grep "New Version:" | awk '{print $$3}'); \ - echo " Updating to v1.4..."; \ - VERSION_4=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Updated Document v1.4 - Added troubleshooting section" -a "alice" 2>/dev/null | grep "New Version:" | awk '{print $$3}'); \ - echo " Updating to v1.5..."; \ - VERSION_5=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Updated Document v1.5 - Final version with complete documentation" -a "alice" 2>/dev/null | grep "New Version:" | awk '{print $$3}'); \ - echo " Content now has 6 versions (including genesis)"; \ - echo ""; \ - echo "📊 Step 3: Displaying complete version history..."; \ - echo ""; \ - echo "=== Complete Version History ==="; \ - cargo run --example cli -- history -g $$CONTENT_GENESIS; \ + @set -e; \ echo ""; \ - echo "🔍 Step 4: Verifying latest version for each version..."; \ + echo "=== CRSL Version History Demo ==="; \ + echo "This demo seeds a branching storyline and inspects it from two perspectives."; \ echo ""; \ - echo "=== Version Verification ==="; \ - echo "Getting version list from history..."; \ - VERSION_LIST=$$(cargo run --example cli -- history -g $$CONTENT_GENESIS 2>/dev/null | grep -E "🌱|📝|✨" | awk '{print $$3}'); \ - echo "Version list: $$VERSION_LIST"; \ + echo "📝 Step 1: Preparing sample history..."; \ + CONTENT_GENESIS=$$(cargo run --example cli -- create -c "Initial draft by Alice" -a "alice" 2>/dev/null | grep "Genesis:" | awk '{print $$2}'); \ + if [ -z "$$CONTENT_GENESIS" ]; then \ + echo " ! Failed to create sample content"; \ + exit 1; \ + fi; \ + echo " > Genesis CID: $$CONTENT_GENESIS"; \ + A1=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Chapter A" -a "alice" 2>/dev/null | grep "Version" | awk '{print $$3}'); \ + echo " > Alice adds Chapter A: $$A1"; \ + B1=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Chapter B" -a "bob" 2>/dev/null | grep "Version" | awk '{print $$3}'); \ + echo " > Bob adds Chapter B (linear): $$B1"; \ + B_BRANCH=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Bob's branch revisited" -a "bob" --parent $$A1 2>/dev/null | grep "New Version" | awk '{print $$3}'); \ + echo " > Bob branches from Chapter A: $$B_BRANCH"; \ + MERGE_TRIGGER=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Merged storyline" -a "carol" 2>/dev/null | grep "Version" | awk '{print $$3}'); \ + echo " > Carol pushes merge-friendly update: $$MERGE_TRIGGER"; \ + AUTO_MERGE=$$(cargo run --example cli -- history -g $$CONTENT_GENESIS --mode linear 2>/dev/null | grep "🔀" | awk '{print $$3}' | head -1); \ + if [ -n "$$AUTO_MERGE" ]; then \ + echo " > 🤖 Auto-merge produced merge node: $$AUTO_MERGE"; \ + else \ + echo " > ⚠️ Auto-merge node not detected"; \ + fi; \ + FINAL=$$(cargo run --example cli -- update -g $$CONTENT_GENESIS -c "Conclusion" -a "alice" 2>/dev/null | grep "Version" | awk '{print $$3}'); \ + echo " > Alice writes conclusion: $$FINAL"; \ echo ""; \ - echo "Checking Genesis (v1.0):"; \ - cargo run --example cli -- show $$CONTENT_GENESIS; \ + echo "📊 Step 2: Inspecting history views..."; \ echo ""; \ - echo "Checking Version 1 (v1.1):"; \ - VERSION_1=$$(echo "$$VERSION_LIST" | head -1); \ - cargo run --example cli -- show $$VERSION_1; \ - echo ""; \ - echo "Checking Version 2 (v1.2):"; \ - VERSION_2=$$(echo "$$VERSION_LIST" | head -2 | tail -1); \ - cargo run --example cli -- show $$VERSION_2; \ - echo ""; \ - echo "Checking Version 3 (v1.3):"; \ - VERSION_3=$$(echo "$$VERSION_LIST" | head -3 | tail -1); \ - cargo run --example cli -- show $$VERSION_3; \ - echo ""; \ - echo "Checking Version 4 (v1.4):"; \ - VERSION_4=$$(echo "$$VERSION_LIST" | head -4 | tail -1); \ - cargo run --example cli -- show $$VERSION_4; \ - echo ""; \ - echo "Checking Version 5 (v1.5):"; \ - VERSION_5=$$(echo "$$VERSION_LIST" | head -5 | tail -1); \ - cargo run --example cli -- show $$VERSION_5; \ + echo "=== Branching History (node list) ==="; \ + cargo run --example cli -- history -g $$CONTENT_GENESIS; \ echo ""; \ - echo "🎯 Step 5: Latest version summary..."; \ + echo "=== Linear Timeline (version list) ==="; \ + cargo run --example cli -- history -g $$CONTENT_GENESIS --mode linear; \ echo ""; \ - echo "Latest version: $$VERSION_5"; \ - echo "Total versions: 6 (1 genesis + 5 updates)"; \ + echo "=== Version CIDs ==="; \ + VERSION_LIST=$$(cargo run --example cli -- history -g $$CONTENT_GENESIS --mode linear 2>/dev/null | grep -E "🌱|🧩|🔀|✨" | awk '{print $$3}'); \ + echo "$$VERSION_LIST" | tr ' ' '\n'; \ echo ""; \ echo "=== Demo completed successfully! ==="; \ - echo "✓ Created 1 content with 6 versions"; \ - echo "✓ Applied 5 sequential updates"; \ - echo "✓ Demonstrated latest version calculation for each version"; \ - echo "✓ Verified that only the last version is marked as latest"; \ - echo ""; \ \ No newline at end of file + echo "✓ Sample storyline created"; \ + echo "✓ Branching nodes and ordered versions displayed"; \ + echo "" \ No newline at end of file diff --git a/examples/cli.rs b/examples/cli.rs index adf9ef2..f36bebf 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -1,5 +1,6 @@ use cid::Cid; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; +use crsl_lib::convergence::metadata::ContentMetadata; use crsl_lib::crdt::{ crdt_state::CrdtState, operation::{Operation, OperationType}, @@ -8,9 +9,15 @@ use crsl_lib::crdt::{ use crsl_lib::dasl::cid::ContentId; use crsl_lib::graph::{dag::DagGraph, storage::LeveldbNodeStorage}; use crsl_lib::repo::Repo; +use std::collections::{HashMap, HashSet}; use std::error::Error; use std::path::{Path, PathBuf}; +type CliRepo = + Repo, LeveldbNodeStorage, String>; + +const DEFAULT_REPO_PATH: &str = "./crsl_data"; + #[derive(clap::Parser, Clone)] struct Cli { #[command(subcommand)] @@ -36,6 +43,8 @@ enum Commands { content: String, #[arg(short, long)] author: Option, + #[arg(long)] + parent: Option, }, Show { content_id: String, @@ -43,14 +52,8 @@ enum Commands { History { #[arg(short, long)] genesis_id: String, - }, - HistoryFromVersion { - #[arg(short, long)] - version_id: String, - }, - Genesis { - #[arg(short, long)] - version_id: String, + #[arg(long, value_enum, default_value_t = HistoryMode::Tree)] + mode: HistoryMode, }, } @@ -68,18 +71,14 @@ fn main() -> Result<(), Box> { println!("Initialized CRSL repository at {path:?}"); } other_command => { - let repo_path = Path::new("./crsl_data"); + let repo_path = Path::new(DEFAULT_REPO_PATH); if !repo_path.join(".crsl").exists() { eprintln!("Repository not found. Run 'init' first."); return Ok(()); } - let op_storage = LeveldbStorage::open(repo_path.join("ops"))?; - let node_storage = LeveldbNodeStorage::open(repo_path.join("nodes")); - let state = CrdtState::new(op_storage); - let dag = DagGraph::new(node_storage); - let mut repo = Repo::new(state, dag); + let mut repo = open_repo(repo_path)?; match other_command { Commands::Create { content, author } => { @@ -96,29 +95,42 @@ fn main() -> Result<(), Box> { println!(" Content ID: {cid}"); println!(" Genesis: {version_cid}"); println!(" Version: {version_cid}"); - println!( - "🔍 Debug: Latest head for genesis {}: {:?}", - version_cid, - repo.latest(&version_cid) - ); } Commands::Update { genesis_id, content, author, + parent, } => { + let author = author.unwrap_or_else(|| "anonymous".to_string()); let genesis_cid = Cid::try_from(genesis_id.as_str())?; - let author = author.unwrap_or_else(|| "anonymous".to_string()); + let mut op = Operation::new( + genesis_cid, + OperationType::Update(content.clone()), + author.clone(), + ); - let op = - Operation::new(genesis_cid, OperationType::Update(content.clone()), author); + if let Some(parent) = parent { + let parent_cid = Cid::try_from(parent.as_str())?; + op.parents.push(parent_cid); + println!("📝 Branched update:"); + println!(" Parent Version: {parent_cid}"); + } else { + println!("📝 Updated content:"); + } let version_cid = repo.commit_operation(op)?; - - println!("📝 Updated content:"); println!(" Genesis ID: {genesis_id}"); println!(" New Version: {version_cid}"); + + if let Some(latest) = repo.latest(&genesis_cid) { + if latest == version_cid { + println!(" ✅ This is now the latest head"); + } else { + println!(" ℹ️ Latest head remains: {latest}"); + } + } } Commands::Show { content_id } => { let cid = Cid::try_from(content_id.as_str())?; @@ -183,67 +195,199 @@ fn main() -> Result<(), Box> { } } } - Commands::History { genesis_id } => { + Commands::History { genesis_id, mode } => { let genesis_cid = Cid::try_from(genesis_id.as_str())?; + let result = match mode { + HistoryMode::Tree => display_branching_history(&repo, &genesis_cid), + HistoryMode::Linear => display_linear_history(&repo, &genesis_cid), + }; - match repo.get_history(&genesis_cid) { - Ok(history) => { - println!("📜 History for genesis: {genesis_id}"); - if history.is_empty() { - println!(" No history found (genesis only)"); - } else { - for (i, version_cid) in history.iter().enumerate() { - let marker = if i == 0 { - "🌱" - } else if i == history.len() - 1 { - "✨" - } else { - "📝" - }; - println!(" {} {}: {}", marker, i + 1, version_cid); - } - } - } - Err(e) => { - eprintln!("❌ Error getting history: {e}"); - } - } - } - Commands::HistoryFromVersion { version_id } => { - let version_cid = Cid::try_from(version_id.as_str())?; - - match repo.dag.get_node(&version_cid) { - Ok(Some(node)) => { - println!("📄 Node info for version: {version_id}"); - println!(" Genesis CID: {:?}", node.genesis); - println!(" Parents: {:?}", node.parents()); - println!(" Timestamp: {}", node.timestamp()); - } - Ok(None) => { - eprintln!("❌ Version not found: {version_id}"); - } - Err(e) => { - eprintln!("❌ Error fetching node: {e}"); - } - } - } - Commands::Genesis { version_id } => { - let version_cid = Cid::try_from(version_id.as_str())?; - - match repo.get_genesis(&version_cid) { - Ok(genesis_cid) => { - println!("🌱 Genesis for version: {version_id}"); - println!(" Genesis CID: {genesis_cid}"); - } - Err(e) => { - eprintln!("❌ Error getting genesis: {e}"); - } + if let Err(e) = result { + eprintln!("❌ Error rendering history: {e}"); } } - _ => unreachable!(), + Commands::Init { .. } => unreachable!("init should be handled before repo setup"), } } } Ok(()) } + +fn open_repo(repo_path: &Path) -> Result> { + let op_storage = LeveldbStorage::open(repo_path.join("ops"))?; + let node_storage = LeveldbNodeStorage::open(repo_path.join("nodes")); + let state = CrdtState::new(op_storage); + let dag = DagGraph::new(node_storage); + Ok(Repo::new(state, dag)) +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum HistoryMode { + Tree, + Linear, +} + +fn display_branching_history(repo: &CliRepo, genesis: &Cid) -> Result<(), Box> { + let adjacency = repo + .branching_history(genesis) + .map_err(Box::::from)?; + println!("📜 Branching history for genesis: {genesis}"); + + let mut visited = HashSet::new(); + let mut counter = 1; + print_branching_node( + repo, + &adjacency, + genesis, + "", + true, + &mut visited, + &mut counter, + )?; + Ok(()) +} + +fn print_branching_node( + repo: &CliRepo, + adjacency: &HashMap>, + current: &Cid, + prefix: &str, + is_last: bool, + visited: &mut HashSet, + counter: &mut usize, +) -> Result<(), crsl_lib::crdt::error::CrdtError> { + if !visited.insert(*current) { + return Ok(()); + } + + let node = repo + .dag + .get_node(current) + .map_err(crsl_lib::crdt::error::CrdtError::Graph)?; + + let (marker, detail) = match node { + Some(ref n) => { + let marker = if n.parents().is_empty() { + "🌱" + } else if n.parents().len() > 1 { + "🔀" + } else { + "🧩" + }; + let summary = clean_payload_summary(n.payload()); + let label = format!("node{}", *counter); + *counter += 1; + (marker, format!("{label}: {current} | {summary}")) + } + None => { + let label = format!("node{}", *counter); + *counter += 1; + ("❓", format!("{label}: {current} (missing)")) + } + }; + + let branch_symbol = if prefix.is_empty() { + "" + } else if is_last { + "└── " + } else { + "├── " + }; + println!("{prefix}{branch_symbol}{marker} {detail}"); + + let mut children: Vec<(Cid, u64)> = adjacency + .get(current) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|cid| { + let ts = repo + .dag + .get_node(&cid) + .map_err(crsl_lib::crdt::error::CrdtError::Graph)? + .map(|n| n.timestamp()) + .unwrap_or(0); + Ok::<(Cid, u64), crsl_lib::crdt::error::CrdtError>((cid, ts)) + }) + .collect::>()?; + + children.sort_by_key(|(_, ts)| *ts); + children.dedup_by(|a, b| a.0 == b.0); + let total = children.len(); + + for (index, (child, _)) in children.into_iter().enumerate() { + let child_is_last = index + 1 == total; + let new_prefix = if prefix.is_empty() { + if is_last { + " ".to_string() + } else { + "│ ".to_string() + } + } else if is_last { + format!("{prefix} ") + } else { + format!("{prefix}│ ") + }; + print_branching_node( + repo, + adjacency, + &child, + &new_prefix, + child_is_last, + visited, + counter, + )?; + } + + Ok(()) +} + +fn display_linear_history(repo: &CliRepo, genesis: &Cid) -> Result<(), Box> { + let mut path = repo + .linear_history(genesis) + .map_err(Box::::from)?; + + path.dedup(); + + if path.is_empty() { + println!("(no timeline entries for genesis {genesis})"); + return Ok(()); + } + + println!("🧭 Timeline for genesis: {genesis}"); + for (index, cid) in path.iter().enumerate() { + let node = repo + .dag + .get_node(cid) + .map_err(crsl_lib::crdt::error::CrdtError::Graph)?; + let (marker, info) = match node { + Some(ref n) => { + let marker = if index == 0 { + "🌱" + } else if n.parents().len() > 1 { + "🔀" + } else if index == path.len() - 1 { + "✨" + } else { + "🧩" + }; + let summary = clean_payload_summary(n.payload()); + (marker, format!("node{}: {cid} | {summary}", index + 1)) + } + None => ("❓", format!("node{}: {cid} (missing)", index + 1)), + }; + println!(" {marker} {info}"); + } + + Ok(()) +} + +fn clean_payload_summary(payload: &str) -> String { + let trimmed = payload.trim(); + if trimmed.len() <= 48 { + trimmed.to_string() + } else { + format!("{}…", &trimmed[..45]) + } +} diff --git a/src/crdt/operation.rs b/src/crdt/operation.rs index 58c3486..4ec622e 100644 --- a/src/crdt/operation.rs +++ b/src/crdt/operation.rs @@ -59,6 +59,8 @@ pub struct Operation { pub kind: OperationType, pub timestamp: Timestamp, pub author: Author, + #[serde(default = "Vec::new")] + pub parents: Vec, } impl Operation @@ -86,6 +88,7 @@ where kind, timestamp, author, + parents: Vec::new(), } } diff --git a/src/crdt/reducer.rs b/src/crdt/reducer.rs index 5fced1e..24403cb 100644 --- a/src/crdt/reducer.rs +++ b/src/crdt/reducer.rs @@ -51,6 +51,7 @@ mod tests { kind, timestamp: ts, author: "test".into(), + parents: Vec::new(), } } @@ -67,6 +68,7 @@ mod tests { kind, timestamp: ts, author: "test".into(), + parents: Vec::new(), } } diff --git a/src/repo.rs b/src/repo.rs index 2559d88..f587b48 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1,4 +1,8 @@ -use crate::crdt::error::Result; +use crate::convergence::{ + metadata::ContentMetadata, policies::lww::LwwMergePolicy, policy::MergePolicy, + resolver::ConflictResolver, +}; +use crate::crdt::error::{CrdtError, Result}; use crate::{ crdt::{ crdt_state::CrdtState, @@ -10,67 +14,137 @@ use crate::{ }; use cid::Cid; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; pub struct Repo where OpStore: OperationStorage, - NodeStore: NodeStorage, + NodeStore: NodeStorage, Payload: Clone + Serialize + for<'de> Deserialize<'de> + Debug, { pub state: CrdtState, - pub dag: DagGraph, + pub dag: DagGraph, + resolver: ConflictResolver, } impl Repo where OpStore: OperationStorage, - NodeStore: NodeStorage, + NodeStore: NodeStorage, Payload: Clone + Serialize + for<'de> Deserialize<'de> + Debug, { pub fn new( state: CrdtState, - dag: DagGraph, + dag: DagGraph, ) -> Self { - Self { state, dag } + Self { + state, + dag, + resolver: ConflictResolver::new(), + } + } + + pub fn commit_operation(&mut self, op: Operation) -> Result { + // Recently, Merge operations cannot be manually committed + if matches!(op.kind, OperationType::Merge(_)) { + return Err(CrdtError::Internal( + "Merge operations cannot be manually committed".to_string(), + )); + } + + self.commit_operation_internal(op, false) } - pub fn commit_operation(&mut self, mut op: Operation) -> Result { + fn commit_operation_internal( + &mut self, + op: Operation, + skip_auto_merge: bool, + ) -> Result { + let mut op = op; + + if !skip_auto_merge { + match &op.kind { + OperationType::Update(_) | OperationType::Delete => { + if op.parents.is_empty() { + let merged_head = self + .check_and_merge(&op.genesis)? + .or_else(|| self.dag.calculate_latest(&op.genesis).ok().flatten()) + .ok_or_else(|| { + CrdtError::Internal(format!( + "No head available for genesis {} to attach operation", + op.genesis + )) + })?; + + op.parents = vec![merged_head]; + } else { + self.validate_parent_genesis(&op.genesis, &op.parents)?; + } + } + OperationType::Merge(_) => { + if op.parents.is_empty() { + op.parents = self.find_heads(&op.genesis)?; + } + self.validate_parent_genesis(&op.genesis, &op.parents)?; + } + OperationType::Create(_) => {} + } + } + let cid = match &op.kind { OperationType::Create(payload) => { - let genesis_cid = self.dag.add_genesis_node(payload.clone(), ())?; + let genesis_cid = self + .dag + .add_genesis_node(payload.clone(), ContentMetadata::default())?; op.genesis = genesis_cid; genesis_cid } - OperationType::Update(payload) => { - let parents = self.get_latest_parents(&op.genesis); - self.dag - .add_child_node(payload.clone(), parents, op.genesis, ())? - } + OperationType::Update(payload) => self.dag.add_child_node( + payload.clone(), + op.parents.clone(), + op.genesis, + ContentMetadata::default(), + )?, OperationType::Delete => { - let parents = self.get_latest_parents(&op.genesis); - - // For delete operations, find the latest payload in the original genesis chain - let ops = self - .state - .get_operations_by_genesis(&op.genesis) - .expect("Failed to load operations for delete"); + let ops = self.state.get_operations_by_genesis(&op.genesis)?; let last_payload = ops .iter() - .filter(|o| o.payload().is_some()) - .max_by_key(|o| o.timestamp) - .expect("content must exist for delete operation") - .payload() - .unwrap() - .clone(); - - self.dag - .add_child_node(last_payload, parents, op.genesis, ())? + .filter_map(|operation| { + operation + .payload() + .cloned() + .map(|payload| (operation.timestamp, payload)) + }) + .max_by_key(|(timestamp, _)| *timestamp) + .map(|(_, payload)| payload) + .ok_or_else(|| { + CrdtError::Internal(format!( + "content must exist for delete operation: {}", + op.genesis + )) + })?; + + self.dag.add_child_node( + last_payload, + op.parents.clone(), + op.genesis, + ContentMetadata::default(), + )? } OperationType::Merge(payload) => { - let parents = self.get_latest_parents(&op.genesis); - self.dag - .add_child_node(payload.clone(), parents, op.genesis, ())? + let parents = if !op.parents.is_empty() { + self.validate_parent_genesis(&op.genesis, &op.parents)?; + op.parents.clone() + } else { + self.find_heads(&op.genesis)? + }; + self.dag.add_child_node( + payload.clone(), + parents, + op.genesis, + ContentMetadata::default(), + )? } }; @@ -82,29 +156,173 @@ where pub fn latest(&self, genesis_id: &Cid) -> Option { self.dag.calculate_latest(genesis_id).ok().flatten() } + /// Convenience wrapper around `DagGraph::get_genesis` + pub fn get_genesis(&self, cid: &Cid) -> Result { + self.dag.get_genesis(cid).map_err(CrdtError::Graph) + } + + pub fn get_operations_with_index( + &self, + genesis: &Cid, + ) -> Result)>> { + let mut ops = self.state.get_operations_by_genesis(genesis)?; + ops.sort_by_key(|op| op.timestamp); + Ok(ops + .into_iter() + .enumerate() + .map(|(idx, op)| (idx + 1, op)) + .collect()) + } + + /// Get the latest parent nodes for the given genesis + fn validate_parent_genesis(&self, genesis: &Cid, parents: &[Cid]) -> Result<()> { + for parent in parents { + let parent_genesis = self.dag.get_genesis(parent).map_err(CrdtError::Graph)?; + if &parent_genesis != genesis { + return Err(CrdtError::Internal(format!( + "Parent {parent} does not belong to genesis {genesis}" + ))); + } + } + Ok(()) + } - /// Get the complete history from genesis - pub fn get_history(&self, genesis: &Cid) -> Result> { - self.dag + fn check_and_merge(&mut self, genesis: &Cid) -> Result> { + let heads = self.find_heads(genesis)?; + + if heads.len() <= 1 { + return Ok(None); + } + + let genesis_node = self + .dag + .get_node(genesis) + .map_err(CrdtError::Graph)? + .ok_or_else(|| CrdtError::Internal(format!("Genesis not found: {genesis}")))?; + let policy_type = genesis_node.metadata().policy_type(); + let policy = self.create_policy(policy_type)?; + + let merge_node = + self.resolver + .create_merge_node(&heads, &self.dag, *genesis, policy.as_ref())?; + + let merge_op = Operation::new( + *genesis, + OperationType::Merge(merge_node.payload().clone()), + "auto-merge".to_string(), + ); + + let merge_cid = self.commit_operation_internal(merge_op, true)?; + + Ok(Some(merge_cid)) + } + + fn find_heads(&self, genesis: &Cid) -> Result> { + let nodes = self + .dag .get_nodes_by_genesis(genesis) - .map_err(crate::crdt::error::CrdtError::Graph) + .map_err(CrdtError::Graph)?; + if nodes.is_empty() { + return Ok(vec![]); + } + + let node_set: HashSet = nodes.iter().copied().collect(); + let mut parents_within = HashSet::new(); + + for cid in &nodes { + if let Some(node) = self.dag.get_node(cid).map_err(CrdtError::Graph)? { + for parent in node.parents() { + if node_set.contains(parent) { + parents_within.insert(*parent); + } + } + } + } + + Ok(nodes + .into_iter() + .filter(|cid| !parents_within.contains(cid)) + .collect()) } - /// Get genesis from any version - pub fn get_genesis(&self, version: &Cid) -> Result { - self.dag - .get_genesis(version) - .map_err(crate::crdt::error::CrdtError::Graph) + fn create_policy(&self, policy_type: &str) -> Result>> { + match policy_type { + "lww" => Ok(Box::new(LwwMergePolicy)), + other => Err(CrdtError::Internal(format!("Unknown policy type: {other}"))), + } + } + /// Return parent -> children adjacency for the specified genesis (DAG structure). + pub fn branching_history(&self, genesis: &Cid) -> Result>> { + let nodes = self + .dag + .get_nodes_by_genesis(genesis) + .map_err(CrdtError::Graph)?; + + let mut adjacency: HashMap> = HashMap::new(); + for &cid in &nodes { + if let Some(node) = self.dag.get_node(&cid).map_err(CrdtError::Graph)? { + for parent in node.parents() { + adjacency.entry(*parent).or_default().insert(cid); + } + adjacency.entry(cid).or_default(); + } + } + + Ok(adjacency + .into_iter() + .map(|(cid, set)| { + let mut children: Vec = set.into_iter().collect(); + children.sort(); + (cid, children) + }) + .collect()) } - /// Get the latest parent nodes for the given genesis - fn get_latest_parents(&self, genesis: &Cid) -> Vec { - self.dag - .calculate_latest(genesis) - .ok() - .flatten() - .map(|head| vec![head]) - .unwrap_or_default() + /// Find a linear path from genesis to the latest head. + pub fn linear_history(&self, genesis: &Cid) -> Result> { + let adjacency = self.branching_history(genesis)?; + let mut path = Vec::new(); + let mut current = *genesis; + let mut visited = HashSet::new(); + + while visited.insert(current) { + path.push(current); + let children = adjacency.get(¤t).cloned().unwrap_or_default(); + + if children.is_empty() { + break; + } + + let mut best: Option<(Cid, (bool, u64))> = None; + for child in children { + let info = self.node_characteristics(&child)?; + if let Some((_, best_info)) = &best { + if info > *best_info { + best = Some((child, info)); + } + } else { + best = Some((child, info)); + } + } + + let Some((next, _)) = best else { + break; + }; + + current = next; + } + + Ok(path) + } + + fn node_characteristics(&self, cid: &Cid) -> Result<(bool, u64)> { + let node = self + .dag + .get_node(cid) + .map_err(CrdtError::Graph)? + .ok_or_else(|| CrdtError::Internal(format!("Node not found: {cid}")))?; + let is_merge = node.parents().len() > 1; + Ok((is_merge, node.timestamp())) } } @@ -120,8 +338,11 @@ mod tests { #[serde(transparent)] struct TestPayload(String); - type TestRepo = - Repo, LeveldbNodeStorage, TestPayload>; + type TestRepo = Repo< + LeveldbStorage, + LeveldbNodeStorage, + TestPayload, + >; fn setup_test_repo() -> (TestRepo, tempfile::TempDir) { let dir = tempdir().unwrap(); @@ -140,15 +361,19 @@ mod tests { Operation::new(genesis, kind, "test".into()) } + fn sleep_for_ordering() { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + #[test] fn test_create_operation() { let (mut repo, _) = setup_test_repo(); - let target = Cid::new_v1( + let initial_genesis = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"test").unwrap(), ); let payload = TestPayload("test content".to_string()); - let op = make_test_operation(target, OperationType::Create(payload.clone())); + let op = make_test_operation(initial_genesis, OperationType::Create(payload.clone())); let cid = repo.commit_operation(op).unwrap(); @@ -159,12 +384,12 @@ mod tests { #[test] fn test_update_operation() { let (mut repo, _) = setup_test_repo(); - let target = Cid::new_v1( + let initial_genesis = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"test").unwrap(), ); let create_op = make_test_operation( - target, + initial_genesis, OperationType::Create(TestPayload("initial".to_string())), ); let create_cid = repo.commit_operation(create_op).unwrap(); @@ -173,7 +398,7 @@ mod tests { create_cid, OperationType::Update(TestPayload("updated".to_string())), ); - std::thread::sleep(std::time::Duration::from_millis(1)); + sleep_for_ordering(); let update_cid = repo.commit_operation(update_op).unwrap(); assert!(repo.latest(&create_cid).is_some()); @@ -181,21 +406,140 @@ mod tests { assert_ne!(create_cid, update_cid); } + #[test] + fn test_update_with_explicit_parent_is_respected() { + let (mut repo, _) = setup_test_repo(); + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"explicit-parent").unwrap(), + ); + let create_op = make_test_operation( + initial_genesis, + OperationType::Create(TestPayload("root".to_string())), + ); + let genesis = repo.commit_operation(create_op).unwrap(); + + let update_auto = make_test_operation( + genesis, + OperationType::Update(TestPayload("child-1".to_string())), + ); + sleep_for_ordering(); + let auto_cid = repo.commit_operation(update_auto).unwrap(); + + let mut update_branch = make_test_operation( + genesis, + OperationType::Update(TestPayload("branch-from-genesis".to_string())), + ); + update_branch.parents.push(genesis); + sleep_for_ordering(); + let branch_cid = repo.commit_operation(update_branch).unwrap(); + + let branch_node = repo + .dag + .get_node(&branch_cid) + .unwrap() + .expect("branch node"); + assert_eq!(branch_node.parents(), &[genesis]); + + let auto_node = repo.dag.get_node(&auto_cid).unwrap().expect("auto node"); + assert_eq!(auto_node.parents(), &[genesis]); + } + + #[test] + fn test_update_rejects_parent_from_other_genesis() { + let (mut repo, _) = setup_test_repo(); + let seed_a = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"genesis-a").unwrap(), + ); + let seed_b = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"genesis-b").unwrap(), + ); + + let genesis_a = repo + .commit_operation(make_test_operation( + seed_a, + OperationType::Create(TestPayload("A".into())), + )) + .unwrap(); + let genesis_b = repo + .commit_operation(make_test_operation( + seed_b, + OperationType::Create(TestPayload("B".into())), + )) + .unwrap(); + + let mut bad_update = + make_test_operation(genesis_a, OperationType::Update(TestPayload("bad".into()))); + bad_update.parents.push(genesis_b); + + let err = repo.commit_operation(bad_update).unwrap_err(); + match err { + CrdtError::Internal(message) => { + assert!(message.contains("does not belong")); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn test_multiple_children_from_same_parent() { + let (mut repo, _) = setup_test_repo(); + let seed = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"shared-parent").unwrap(), + ); + + let genesis = repo + .commit_operation(make_test_operation( + seed, + OperationType::Create(TestPayload("root".into())), + )) + .unwrap(); + + let mut child_a = make_test_operation( + genesis, + OperationType::Update(TestPayload("child-a".into())), + ); + child_a.parents.push(genesis); + let child_a_cid = repo.commit_operation(child_a).unwrap(); + + let mut child_b = make_test_operation( + genesis, + OperationType::Update(TestPayload("child-b".into())), + ); + child_b.parents.push(genesis); + sleep_for_ordering(); + let child_b_cid = repo.commit_operation(child_b).unwrap(); + + let node_a = repo.dag.get_node(&child_a_cid).unwrap().expect("child_a"); + assert_eq!(node_a.parents(), &[genesis]); + + let node_b = repo.dag.get_node(&child_b_cid).unwrap().expect("child_b"); + assert_eq!(node_b.parents(), &[genesis]); + + let heads = repo.find_heads(&genesis).unwrap(); + assert_eq!(heads.len(), 2); + assert!(heads.contains(&child_a_cid)); + assert!(heads.contains(&child_b_cid)); + } + #[test] fn test_delete_operation() { let (mut repo, _) = setup_test_repo(); - let target = Cid::new_v1( + let initial_genesis = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"test").unwrap(), ); let create_op = make_test_operation( - target, + initial_genesis, OperationType::Create(TestPayload("initial".to_string())), ); let create_cid = repo.commit_operation(create_op).unwrap(); let delete_op = make_test_operation(create_cid, OperationType::Delete); - std::thread::sleep(std::time::Duration::from_millis(1)); + sleep_for_ordering(); let delete_cid = repo.commit_operation(delete_op).unwrap(); assert!(repo.latest(&create_cid).is_some()); @@ -204,26 +548,26 @@ mod tests { } #[test] - fn test_multiple_targets() { + fn test_multiple_genesis_entries() { let (mut repo, _) = setup_test_repo(); - let target1 = Cid::new_v1( + let genesis1 = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"test1").unwrap(), ); - let target2 = Cid::new_v1( + let genesis2 = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"test2").unwrap(), ); let create1_op = make_test_operation( - target1, - OperationType::Create(TestPayload("target1".to_string())), + genesis1, + OperationType::Create(TestPayload("entry1".to_string())), ); let create1_cid = repo.commit_operation(create1_op).unwrap(); let create2_op = make_test_operation( - target2, - OperationType::Create(TestPayload("target2".to_string())), + genesis2, + OperationType::Create(TestPayload("entry2".to_string())), ); let create2_cid = repo.commit_operation(create2_op).unwrap(); @@ -237,21 +581,21 @@ mod tests { #[test] fn test_update_keeps_series_isolated() { let (mut repo, _) = setup_test_repo(); - let shared_target = Cid::new_v1( + let placeholder_genesis = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"update_shared").unwrap(), ); // Series A let create_a = make_test_operation( - shared_target, + placeholder_genesis, OperationType::Create(TestPayload("A1".into())), ); let genesis_a = repo.commit_operation(create_a).unwrap(); // Series B let create_b = make_test_operation( - shared_target, + placeholder_genesis, OperationType::Create(TestPayload("B1".into())), ); let genesis_b = repo.commit_operation(create_b).unwrap(); @@ -259,33 +603,32 @@ mod tests { // Update only series A let update_a = make_test_operation(genesis_a, OperationType::Update(TestPayload("A2".into()))); - std::thread::sleep(std::time::Duration::from_millis(1)); + sleep_for_ordering(); let latest_a = repo.commit_operation(update_a).unwrap(); - // 確認: series A の latest は更新され、series B は変わらない assert_eq!(repo.latest(&genesis_a).unwrap(), latest_a); assert_eq!(repo.latest(&genesis_b).unwrap(), genesis_b); } - /// Failing test: Delete on one series still uses `target` and may fetch wrong payload. + /// Failing test: Delete on one series still uses the legacy lookup and may fetch the wrong payload. #[test] - fn test_delete_mixes_series_due_to_target_lookup() { + fn test_delete_mixes_series_due_to_legacy_lookup() { let (mut repo, _) = setup_test_repo(); - let shared_target = Cid::new_v1( + let placeholder_genesis = Cid::new_v1( 0x55, multihash::Multihash::<64>::wrap(0x12, b"shared").unwrap(), ); // User1: Create let create1 = make_test_operation( - shared_target, + placeholder_genesis, OperationType::Create(TestPayload("u1".into())), ); let cid1 = repo.commit_operation(create1).unwrap(); // User2: parallel series let create2 = make_test_operation( - shared_target, + placeholder_genesis, OperationType::Create(TestPayload("u2".into())), ); let cid2 = repo.commit_operation(create2).unwrap(); @@ -295,12 +638,11 @@ mod tests { cid2, OperationType::Update(TestPayload("u2_updated".into())), ); - std::thread::sleep(std::time::Duration::from_millis(1)); + sleep_for_ordering(); repo.commit_operation(update2).unwrap(); - // User1 delete let del_op = make_test_operation(cid1, OperationType::Delete); - std::thread::sleep(std::time::Duration::from_millis(1)); + sleep_for_ordering(); repo.commit_operation(del_op).unwrap(); assert_eq!(repo.state.get_state(&cid1), None); @@ -309,4 +651,309 @@ mod tests { Some(TestPayload("u2_updated".into())) ); } + + #[test] + fn test_manual_merge_operations_are_rejected() { + let (mut repo, _) = setup_test_repo(); + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"merge").unwrap(), + ); + let create = make_test_operation( + initial_genesis, + OperationType::Create(TestPayload("base".into())), + ); + let genesis = repo.commit_operation(create).unwrap(); + + let merge_op = make_test_operation( + genesis, + OperationType::Merge(TestPayload("should-fail".into())), + ); + + let err = repo.commit_operation(merge_op).unwrap_err(); + match err { + CrdtError::Internal(message) => { + assert!(message.contains("Merge operations cannot be manually committed")) + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn test_auto_merge_creates_merge_operation() { + let (mut repo, _) = setup_test_repo(); + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"autoMerge").unwrap(), + ); + let create = make_test_operation( + initial_genesis, + OperationType::Create(TestPayload("root".into())), + ); + let genesis = repo.commit_operation(create).unwrap(); + + // Create two explicit branches from the same genesis using commit_operation + let mut branch1_op = make_test_operation( + genesis, + OperationType::Update(TestPayload("branch-1".into())), + ); + branch1_op.parents.push(genesis); + let branch1_cid = repo.commit_operation(branch1_op).unwrap(); + sleep_for_ordering(); + let mut branch2_op = make_test_operation( + genesis, + OperationType::Update(TestPayload("branch-2".into())), + ); + branch2_op.parents.push(genesis); + let branch2_cid = repo.commit_operation(branch2_op).unwrap(); + sleep_for_ordering(); + + // Committing a regular update should trigger auto-merge + let update = + make_test_operation(genesis, OperationType::Update(TestPayload("latest".into()))); + repo.commit_operation(update).unwrap(); + + let ops = repo.state.get_operations_by_genesis(&genesis).unwrap(); + assert!(ops + .iter() + .any(|op| matches!(op.kind, OperationType::Merge(_)))); + + // After auto-merge, the content should converge to a single head + let heads_after_merge = repo.find_heads(&genesis).unwrap(); + assert_eq!(heads_after_merge.len(), 1); + assert!(!heads_after_merge.contains(&branch1_cid)); + assert!(!heads_after_merge.contains(&branch2_cid)); + } + + #[test] + fn test_auto_merge_from_intermediate_branch() { + let (mut repo, _) = setup_test_repo(); + let seed = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"intermediate-merge").unwrap(), + ); + + // genesis a + let genesis = repo + .commit_operation(make_test_operation( + seed, + OperationType::Create(TestPayload("a".into())), + )) + .unwrap(); + + // main chain: a -> b -> d -> e -> f + let mut last_main = genesis; + let mut d_cid = genesis; + for label in ["b", "d", "e", "f"] { + let mut op = + make_test_operation(genesis, OperationType::Update(TestPayload((*label).into()))); + op.parents.push(last_main); + sleep_for_ordering(); + let cid = repo.commit_operation(op).unwrap(); + if label == "d" { + d_cid = cid; + } + last_main = cid; + } + let f_cid = last_main; + + // branch from d: g -> h + let mut g_op = make_test_operation(genesis, OperationType::Update(TestPayload("g".into()))); + g_op.parents.push(d_cid); + sleep_for_ordering(); + let g_cid = repo.commit_operation(g_op).unwrap(); + + let mut h_op = make_test_operation(genesis, OperationType::Update(TestPayload("h".into()))); + h_op.parents.push(g_cid); + sleep_for_ordering(); + let h_cid = repo.commit_operation(h_op).unwrap(); + + // auto-merge will trigger when committing a new update without explicit parents + sleep_for_ordering(); + + let latest_op = + make_test_operation(genesis, OperationType::Update(TestPayload("latest".into()))); + let latest_cid = repo.commit_operation(latest_op).unwrap(); + + let heads = repo.find_heads(&genesis).unwrap(); + assert_eq!(heads.len(), 1); + assert_eq!(heads[0], latest_cid); + + let latest_node = repo + .dag + .get_node(&latest_cid) + .unwrap() + .expect("latest node"); + let latest_parents = latest_node.parents(); + assert_eq!(latest_parents.len(), 1); + let merge_cid = latest_parents[0]; + + let merge_node = repo.dag.get_node(&merge_cid).unwrap().expect("merge node"); + let merge_parents = merge_node.parents(); + assert_eq!(merge_parents.len(), 2); + assert!(merge_parents.contains(&f_cid)); + assert!(merge_parents.contains(&h_cid)); + } + + #[test] + fn test_branching_history_returns_adjacency() { + let (mut repo, _) = setup_test_repo(); + let genesis_seed = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"branching").unwrap(), + ); + let create = make_test_operation( + genesis_seed, + OperationType::Create(TestPayload("root".into())), + ); + let genesis = repo.commit_operation(create).unwrap(); + + let branch_a_payload = TestPayload("branch-a".into()); + let branch_a = repo + .dag + .add_child_node( + branch_a_payload.clone(), + vec![genesis], + genesis, + ContentMetadata::default(), + ) + .unwrap(); + repo.state + .apply(Operation::new( + genesis, + OperationType::Update(branch_a_payload), + "manual".into(), + )) + .unwrap(); + + let branch_b_payload = TestPayload("branch-b".into()); + let branch_b = repo + .dag + .add_child_node( + branch_b_payload.clone(), + vec![genesis], + genesis, + ContentMetadata::default(), + ) + .unwrap(); + repo.state + .apply(Operation::new( + genesis, + OperationType::Update(branch_b_payload), + "manual".into(), + )) + .unwrap(); + + let adjacency = repo.branching_history(&genesis).unwrap(); + let children = adjacency.get(&genesis).cloned().unwrap_or_default(); + + assert!(children.contains(&branch_a)); + assert!(children.contains(&branch_b)); + assert!(adjacency.contains_key(&branch_a)); + assert!(adjacency.contains_key(&branch_b)); + } + + #[test] + fn test_linear_history_prefers_merge_path() { + let (mut repo, _) = setup_test_repo(); + let genesis_seed = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"linear").unwrap(), + ); + let create = make_test_operation( + genesis_seed, + OperationType::Create(TestPayload("root".into())), + ); + let genesis = repo.commit_operation(create).unwrap(); + + let branch_a_payload = TestPayload("A".into()); + let branch_a = repo + .dag + .add_child_node( + branch_a_payload.clone(), + vec![genesis], + genesis, + ContentMetadata::default(), + ) + .unwrap(); + repo.state + .apply(Operation::new( + genesis, + OperationType::Update(branch_a_payload), + "manual".into(), + )) + .unwrap(); + + sleep_for_ordering(); + + let branch_b_payload = TestPayload("B".into()); + let branch_b = repo + .dag + .add_child_node( + branch_b_payload.clone(), + vec![genesis], + genesis, + ContentMetadata::default(), + ) + .unwrap(); + repo.state + .apply(Operation::new( + genesis, + OperationType::Update(branch_b_payload), + "manual".into(), + )) + .unwrap(); + + let merge_payload = TestPayload("merged".into()); + let merge_cid = repo + .dag + .add_child_node( + merge_payload.clone(), + vec![branch_a, branch_b], + genesis, + ContentMetadata::default(), + ) + .unwrap(); + repo.state + .apply(Operation::new( + genesis, + OperationType::Merge(merge_payload.clone()), + "auto-merge".into(), + )) + .unwrap(); + + sleep_for_ordering(); + + let latest_payload = TestPayload("latest".into()); + let latest_cid = repo + .dag + .add_child_node( + latest_payload.clone(), + vec![merge_cid], + genesis, + ContentMetadata::default(), + ) + .unwrap(); + repo.state + .apply(Operation::new( + genesis, + OperationType::Update(latest_payload), + "manual".into(), + )) + .unwrap(); + + let path = repo.linear_history(&genesis).unwrap(); + assert_eq!(path.last(), Some(&latest_cid)); + assert!(path.contains(&merge_cid)); + assert!(path.iter().any(|cid| cid == &branch_a || cid == &branch_b)); + if let (Some(branch_pos), Some(merge_pos)) = ( + path.iter() + .position(|cid| cid == &branch_a || cid == &branch_b), + path.iter().position(|cid| cid == &merge_cid), + ) { + assert!(branch_pos < merge_pos); + } else { + panic!("branch or merge node missing from linear history"); + } + } } From b8e83136945dcb221f75ffd20ded4af300b95257 Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Tue, 14 Oct 2025 00:11:03 +0900 Subject: [PATCH 4/9] refactor: update repolayer --- src/repo.rs | 58 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/src/repo.rs b/src/repo.rs index f587b48..f621d2a 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -104,7 +104,7 @@ where payload.clone(), op.parents.clone(), op.genesis, - ContentMetadata::default(), + self.resolve_metadata_for_commit(&op.genesis, &op.parents)?, )?, OperationType::Delete => { let ops = self.state.get_operations_by_genesis(&op.genesis)?; @@ -129,22 +129,13 @@ where last_payload, op.parents.clone(), op.genesis, - ContentMetadata::default(), + self.resolve_metadata_for_commit(&op.genesis, &op.parents)?, )? } - OperationType::Merge(payload) => { - let parents = if !op.parents.is_empty() { - self.validate_parent_genesis(&op.genesis, &op.parents)?; - op.parents.clone() - } else { - self.find_heads(&op.genesis)? - }; - self.dag.add_child_node( - payload.clone(), - parents, - op.genesis, - ContentMetadata::default(), - )? + OperationType::Merge(_) => { + return Err(CrdtError::Internal( + "Merge operations must be committed via auto-merge".to_string(), + )) } }; @@ -206,13 +197,22 @@ where self.resolver .create_merge_node(&heads, &self.dag, *genesis, policy.as_ref())?; - let merge_op = Operation::new( + self.validate_parent_genesis(genesis, &heads)?; + + let merge_cid = self.dag.add_child_node( + merge_node.payload().clone(), + heads.clone(), + *genesis, + merge_node.metadata().clone(), + )?; + + let mut merge_op = Operation::new( *genesis, OperationType::Merge(merge_node.payload().clone()), "auto-merge".to_string(), ); - - let merge_cid = self.commit_operation_internal(merge_op, true)?; + merge_op.parents = heads; + self.state.apply(merge_op)?; Ok(Some(merge_cid)) } @@ -251,6 +251,28 @@ where other => Err(CrdtError::Internal(format!("Unknown policy type: {other}"))), } } + + fn resolve_metadata_for_commit( + &self, + genesis: &Cid, + parents: &[Cid], + ) -> Result { + if let Some(parent) = parents.first() { + let node = self + .dag + .get_node(parent) + .map_err(CrdtError::Graph)? + .ok_or_else(|| CrdtError::Internal(format!("Parent node not found: {parent}")))?; + Ok(node.metadata().clone()) + } else { + let genesis_node = self + .dag + .get_node(genesis) + .map_err(CrdtError::Graph)? + .ok_or_else(|| CrdtError::Internal(format!("Genesis not found: {genesis}")))?; + Ok(genesis_node.metadata().clone()) + } + } /// Return parent -> children adjacency for the specified genesis (DAG structure). pub fn branching_history(&self, genesis: &Cid) -> Result>> { let nodes = self From bdaee73b16228b739d3b2ece04b4db69def0d4f8 Mon Sep 17 00:00:00 2001 From: YuseiWhite <82315017+YuseiWhite@users.noreply.github.com> Date: Fri, 31 Oct 2025 23:34:06 +0900 Subject: [PATCH 5/9] chore(cli): clarify wording of latest version message --- examples/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/cli.rs b/examples/cli.rs index f36bebf..613f209 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -168,7 +168,7 @@ fn main() -> Result<(), Box> { if latest_version == cid { println!(" Latest version: {latest_version} ✅ (this is the latest)"); } else { - println!(" Latest version: {latest_version} ⚠️ (this is not the latest)"); + println!(" Latest version: {latest_version} ⚠️ (not the latest version)"); } } else { println!(" Latest version: Not found"); @@ -187,7 +187,7 @@ fn main() -> Result<(), Box> { if latest_version == cid { println!(" Latest version: {latest_version} ✅ (this is the latest)"); } else { - println!(" Latest version: {latest_version} ⚠️ (this is not the latest)"); + println!(" Latest version: {latest_version} ⚠️ (not the latest version)"); } } else { println!(" Latest version: Not found"); From 89c74ff0534f303d98eab5100200d56a423d7c39 Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Sun, 9 Nov 2025 23:00:40 +0900 Subject: [PATCH 6/9] fix: upate database --- examples/cli.rs | 13 +- examples/content_versioning.rs | 6 +- readme.md | 8 +- src/crdt/crdt_state.rs | 14 + src/crdt/storage.rs | 267 ++++++++-------- src/graph/dag.rs | 269 +++++++++++----- src/graph/storage.rs | 110 ++++--- src/lib.rs | 1 + src/repo.rs | 551 +++++++++++++++++++++++---------- src/storage/mod.rs | 3 + src/storage/shared_leveldb.rs | 160 ++++++++++ 11 files changed, 968 insertions(+), 434 deletions(-) create mode 100644 src/storage/mod.rs create mode 100644 src/storage/shared_leveldb.rs diff --git a/examples/cli.rs b/examples/cli.rs index f36bebf..e9f2a3d 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -9,6 +9,7 @@ use crsl_lib::crdt::{ use crsl_lib::dasl::cid::ContentId; use crsl_lib::graph::{dag::DagGraph, storage::LeveldbNodeStorage}; use crsl_lib::repo::Repo; +use crsl_lib::storage::SharedLeveldb; use std::collections::{HashMap, HashSet}; use std::error::Error; use std::path::{Path, PathBuf}; @@ -63,12 +64,11 @@ fn main() -> Result<(), Box> { match cli.cmd { Commands::Init { path } => { std::fs::create_dir_all(&path)?; - std::fs::create_dir_all(path.join("ops"))?; - std::fs::create_dir_all(path.join("nodes"))?; + std::fs::create_dir_all(path.join("store"))?; std::fs::write(path.join(".crsl"), "")?; - println!("Initialized CRSL repository at {path:?}"); + println!("Initialized CRSL repository at {path:?} (single LevelDB store)"); } other_command => { let repo_path = Path::new(DEFAULT_REPO_PATH); @@ -215,10 +215,9 @@ fn main() -> Result<(), Box> { } fn open_repo(repo_path: &Path) -> Result> { - let op_storage = LeveldbStorage::open(repo_path.join("ops"))?; - let node_storage = LeveldbNodeStorage::open(repo_path.join("nodes")); - let state = CrdtState::new(op_storage); - let dag = DagGraph::new(node_storage); + let shared = SharedLeveldb::open(repo_path.join("store"))?; + let state = CrdtState::new(LeveldbStorage::new(shared.clone())); + let dag = DagGraph::new(LeveldbNodeStorage::new(shared)); Ok(Repo::new(state, dag)) } diff --git a/examples/content_versioning.rs b/examples/content_versioning.rs index 22c37f0..c245246 100644 --- a/examples/content_versioning.rs +++ b/examples/content_versioning.rs @@ -14,6 +14,7 @@ use crsl_lib::{ storage::LeveldbStorage as OpStore, }, graph::{dag::DagGraph, storage::LeveldbNodeStorage as NodeStorage}, + storage::SharedLeveldb, }; use tempfile::tempdir; @@ -23,8 +24,9 @@ type ContentState = CrdtState; fn main() { let tmp = tempdir().expect("tmp dir"); - let op_store = OpStore::open(tmp.path().join("ops")).unwrap(); - let node_store = NodeStorage::open(tmp.path().join("nodes")); + let shared = SharedLeveldb::open(tmp.path().join("store")).unwrap(); + let op_store = OpStore::new(shared.clone()); + let node_store = NodeStorage::new(shared); let state = ContentState::new(op_store); let mut _dag = DagGraph::<_, Content, ()>::new(node_store); diff --git a/readme.md b/readme.md index 9a4b45b..5640caa 100644 --- a/readme.md +++ b/readme.md @@ -24,6 +24,7 @@ use crsl_lib::{ }, graph::{dag::DagGraph, storage::LeveldbNodeStorage as NodeStorage}, repo::Repo, + storage::SharedLeveldb, }; use tempfile::tempdir; use cid::Cid; @@ -34,10 +35,9 @@ struct Content(String); fn main() { // Initialize storage let tmp = tempdir().expect("tmp dir"); - let op_store = OpStore::open(tmp.path().join("ops")).unwrap(); - let node_store = NodeStorage::open(tmp.path().join("nodes")); - let state = CrdtState::new(op_store); - let dag = DagGraph::new(node_store); + let shared = SharedLeveldb::open(tmp.path().join("store")).unwrap(); + let state = CrdtState::new(OpStore::new(shared.clone())); + let dag = DagGraph::new(NodeStorage::new(shared)); let mut repo = Repo::new(state, dag); // Create a content ID (in practice, you'd use a proper CID) diff --git a/src/crdt/crdt_state.rs b/src/crdt/crdt_state.rs index 1aa1c3f..dab1708 100644 --- a/src/crdt/crdt_state.rs +++ b/src/crdt/crdt_state.rs @@ -4,6 +4,7 @@ use crate::crdt::reducer::Reducer; use crate::crdt::storage::OperationStorage; use std::fmt::Debug; use std::marker::PhantomData; +use ulid::Ulid; /// A generic CRDT state container that manages operations on content. /// /// `CrdtState` provides a high-level interface for applying operations to content @@ -39,6 +40,10 @@ where _marker: PhantomData, } } + + pub fn storage(&self) -> &S { + &self.storage + } /// Applies an operation to the CRDT state without validation. /// /// This method directly saves the operation to storage without checking its validity. @@ -84,6 +89,14 @@ where self.storage.load_operations(genesis) } + pub fn get_operation(&self, op_id: &Ulid) -> Result>> { + self.storage.get_operation(op_id) + } + + pub fn delete_operation(&self, op_id: &Ulid) -> Result<()> { + self.storage.delete_operation(op_id) + } + /// Validates whether an operation is logically valid to apply. /// /// This method performs the following checks: @@ -124,6 +137,7 @@ mod tests { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] struct DummyPayload(String); + /// Helper for constructing operations with deterministic timestamps. fn make_op( id: u64, ts: u64, diff --git a/src/crdt/storage.rs b/src/crdt/storage.rs index 2d2e45c..42c8409 100644 --- a/src/crdt/storage.rs +++ b/src/crdt/storage.rs @@ -1,42 +1,91 @@ use crate::crdt::error::{CrdtError, Result}; use crate::crdt::operation::Operation; +use crate::storage::{BatchError, LeveldbBatchGuard, SharedLeveldb, SharedLeveldbAccess}; use bincode; -use rusty_leveldb::{LdbIterator, Options, DB as Database}; -use std::cell::RefCell; +use rusty_leveldb::LdbIterator; use std::marker::PhantomData; use std::path::Path; +use std::rc::Rc; use ulid::Ulid; +/// Abstraction over the persistent storage used by `CrdtState`. pub trait OperationStorage { fn save_operation(&self, op: &Operation) -> Result<()>; fn load_operations(&self, genesis: &ContentId) -> Result>>; fn get_operation(&self, op_id: &Ulid) -> Result>>; + fn delete_operation(&self, op_id: &Ulid) -> Result<()>; + fn begin_batch(&self) -> std::result::Result, BatchError> { + Err(BatchError::Unsupported) + } } +/// LevelDB-backed implementation of [`OperationStorage`]. +#[derive(Clone)] pub struct LeveldbStorage { - db: RefCell, + shared: Rc, _marker: PhantomData<(ContentId, T)>, } impl LeveldbStorage { pub fn open>(path: P) -> Result { - let opts = Options { - create_if_missing: true, - ..Default::default() - }; - let db = Database::open(path, opts).map_err(CrdtError::Storage)?; - Ok(LeveldbStorage { - db: RefCell::new(db), + let shared = SharedLeveldb::open(path).map_err(CrdtError::Storage)?; + Ok(Self::new(shared)) + } + + pub fn new(shared: Rc) -> Self { + Self { + shared, _marker: PhantomData, - }) + } } + /// Builds the LevelDB key prefix used for operations (`0x01` namespace). fn make_key(id: &Ulid) -> Vec { let mut key = Vec::with_capacity(1 + 16); key.push(0x01); key.extend_from_slice(id.to_bytes().as_ref()); key } + + /// Serialises an operation into the binary format persisted in LevelDB. + fn encode_operation(op: &Operation) -> Result> + where + ContentId: serde::Serialize, + T: serde::Serialize, + { + let value = bincode::serde::encode_to_vec(op, bincode::config::standard())?; + Ok(value) + } + + /// Writes value bytes either to the active batch or directly to the DB. + fn put_bytes(&self, key: &[u8], value: &[u8]) -> Result<()> { + if self + .shared + .with_active_batch(|batch| batch.put(key, value)) + .is_none() + { + self.shared.db().borrow_mut().put(key, value)?; + } + Ok(()) + } + + /// Deletes the given key, respecting an active batch if present. + fn delete_key(&self, key: &[u8]) -> Result<()> { + if self + .shared + .with_active_batch(|batch| batch.delete(key)) + .is_none() + { + self.shared.db().borrow_mut().delete(key)?; + } + Ok(()) + } +} + +impl SharedLeveldbAccess for LeveldbStorage { + fn shared_leveldb(&self) -> Option> { + Some(self.shared.clone()) + } } impl OperationStorage for LeveldbStorage @@ -44,25 +93,28 @@ where ContentId: serde::Serialize + for<'de> serde::Deserialize<'de> + PartialEq + std::fmt::Debug, T: serde::Serialize + for<'de> serde::Deserialize<'de> + std::fmt::Debug, { + fn begin_batch(&self) -> std::result::Result, BatchError> { + self.shared.begin_batch() + } + fn save_operation(&self, op: &Operation) -> Result<()> { let key = Self::make_key(&op.id); - let value = bincode::serde::encode_to_vec(op, bincode::config::standard())?; - self.db.borrow_mut().put(&key, &value)?; - Ok(()) + let value = Self::encode_operation(op)?; + self.put_bytes(&key, &value) } fn load_operations(&self, genesis: &ContentId) -> Result>> { let mut result = Vec::new(); let mut iter = self - .db + .shared + .db() .borrow_mut() .new_iter() .map_err(CrdtError::Storage)?; - // todo: Implement efficient search methods iter.seek_to_first(); + let mut key = Vec::new(); let mut value = Vec::new(); - while iter.valid() { iter.current(&mut key, &mut value); if let Ok((op, _)) = bincode::serde::decode_from_slice::, _>( @@ -81,7 +133,7 @@ where fn get_operation(&self, op_id: &Ulid) -> Result>> { let key = Self::make_key(op_id); - match self.db.borrow_mut().get(&key) { + match self.shared.db().borrow_mut().get(&key) { Some(raw) => { let (op, _) = bincode::serde::decode_from_slice::, _>( &raw, @@ -92,156 +144,93 @@ where None => Ok(None), } } + + fn delete_operation(&self, op_id: &Ulid) -> Result<()> { + let key = Self::make_key(op_id); + self.delete_key(&key) + } } #[cfg(test)] mod tests { use super::*; - use crate::crdt::operation::{Operation, OperationType}; + use crate::crdt::operation::OperationType; + use crate::storage::SharedLeveldb; use serde::{Deserialize, Serialize}; use tempfile::tempdir; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] - struct DummyContentId(String); + struct DummyContentId(u64); - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct DummyPayload(String); - fn setup_test_storage() -> ( + fn make_op(id: u64, payload: &str) -> Operation { + Operation::new( + DummyContentId(id), + OperationType::Update(DummyPayload(payload.to_string())), + "tester".into(), + ) + } + + fn setup_storage() -> ( LeveldbStorage, tempfile::TempDir, ) { let dir = tempdir().unwrap(); - let storage = LeveldbStorage::open(dir.path()).unwrap(); - (storage, dir) + let shared = SharedLeveldb::open(dir.path()).unwrap(); + (LeveldbStorage::new(shared), dir) } #[test] - fn test_save_operation() { - let (storage, _dir) = setup_test_storage(); - let target = DummyContentId("test".into()); - let payload = DummyPayload("test".into()); - let author = "Alice".to_string(); - let op = Operation::new( - target.clone(), - OperationType::Create(payload.clone()), - author.clone(), - ); - + fn save_and_load_roundtrip() { + let (storage, _dir) = setup_storage(); + let op = make_op(1, "hello"); storage.save_operation(&op).unwrap(); - let retrieved_op = storage.get_operation(&op.id); - assert!(retrieved_op.is_ok()); - assert_eq!(retrieved_op.unwrap(), Some(op)); + let retrieved = storage + .get_operation(&op.id) + .unwrap() + .expect("operation should exist"); + assert_eq!(retrieved, op); + + let all = storage.load_operations(&DummyContentId(1)).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0], op); } #[test] - fn test_get_operation() { - let (storage, _dir) = setup_test_storage(); - let target = DummyContentId("test".into()); - let payload = DummyPayload("test".into()); - let author = "Alice".to_string(); - let op = Operation::new( - target.clone(), - OperationType::Create(payload.clone()), - author.clone(), - ); + fn delete_operation_removes_entry() { + let (storage, _dir) = setup_storage(); + let op = make_op(7, "bye"); storage.save_operation(&op).unwrap(); - let retrieved_op = storage.get_operation(&op.id); - - assert!(retrieved_op.is_ok()); - assert_eq!(retrieved_op.unwrap(), Some(op)); + storage.delete_operation(&op.id).unwrap(); + assert!(storage.get_operation(&op.id).unwrap().is_none()); } #[test] - fn test_save_and_get_multiple_operations() { - let (storage, _dir) = setup_test_storage(); - let target = DummyContentId("test".into()); - let payload = DummyPayload("test".into()); - let author = "Alice".to_string(); - let op1 = Operation::new( - target.clone(), - OperationType::Create(payload.clone()), - author.clone(), - ); - let op2 = Operation::new( - target.clone(), - OperationType::Update(payload.clone()), - author.clone(), - ); - storage.save_operation(&op1).unwrap(); - storage.save_operation(&op2).unwrap(); - - let retrieved_ops = storage.get_operation(&op1.id); - let retrieved_ops2 = storage.get_operation(&op2.id); - - assert!(retrieved_ops.is_ok()); - assert_eq!(retrieved_ops.unwrap(), Some(op1)); - assert!(retrieved_ops2.is_ok()); - assert_eq!(retrieved_ops2.unwrap(), Some(op2)); - } + fn batch_commit_persists_operations() { + let (storage, _dir) = setup_storage(); - #[test] - fn test_load_operations() { - let (storage, _dir) = setup_test_storage(); - let target = DummyContentId("test".into()); - let genesis = DummyContentId("genesis".into()); - let payload = DummyPayload("test".into()); - let author = "Alice".to_string(); - let op1 = Operation::new( - target.clone(), - OperationType::Create(payload.clone()), - author.clone(), - ); - let op2 = Operation::new( - target.clone(), - OperationType::Update(payload.clone()), - author.clone(), - ); - let op3 = Operation::new( - genesis.clone(), - OperationType::Update(payload.clone()), - author.clone(), - ); - storage.save_operation(&op1).unwrap(); - storage.save_operation(&op2).unwrap(); - storage.save_operation(&op3).unwrap(); - - let retrieved_ops = storage.load_operations(&target); - - assert!(retrieved_ops.is_ok()); - let ops = retrieved_ops.unwrap(); - assert_eq!(ops.len(), 2); - assert!(ops.contains(&op1)); - assert!(ops.contains(&op2)); - } - - /// Demonstrates that an Update with different genesis is **not** returned when querying by target. - #[test] - fn test_same_target_different_genesis_ignored() { - let (storage, _dir) = setup_test_storage(); - let target = DummyContentId("shared".into()); - let payload = DummyPayload("one".into()); - // Create (genesis = target) - let create = Operation::new( - target.clone(), - OperationType::Create(payload.clone()), - "u1".into(), - ); - storage.save_operation(&create).unwrap(); - - // Update with DIFFERENT genesis but same target - let update = Operation::new( - DummyContentId("DIFF".into()), - OperationType::Update(DummyPayload("two".into())), - "u1".into(), - ); - storage.save_operation(&update).unwrap(); - - let ops = storage.load_operations(&target).unwrap(); - // Should contain only the operations belonging to the requested genesis - assert_eq!(ops.len(), 1); - assert!(ops.contains(&create)); + let guard = storage.begin_batch().unwrap(); + let op_a = make_op(10, "a"); + let op_b = make_op(10, "b"); + + storage.save_operation(&op_a).unwrap(); + storage.save_operation(&op_b).unwrap(); + + // Operations are not visible before commit. + assert!(storage.get_operation(&op_a.id).unwrap().is_none()); + + guard.commit().unwrap(); + + assert!(storage.get_operation(&op_a.id).unwrap().is_some()); + assert!(storage.get_operation(&op_b.id).unwrap().is_some()); + + let all = storage.load_operations(&DummyContentId(10)).unwrap(); + assert_eq!(all.len(), 2); + assert!(all.contains(&op_a)); + assert!(all.contains(&op_b)); } } diff --git a/src/graph/dag.rs b/src/graph/dag.rs index 1bb2c89..5ada4a1 100644 --- a/src/graph/dag.rs +++ b/src/graph/dag.rs @@ -2,7 +2,7 @@ use crate::dasl::node::Node; use crate::graph::error::{GraphError, Result}; use crate::graph::storage::NodeStorage; use cid::Cid; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::time::{SystemTime, UNIX_EPOCH}; @@ -39,101 +39,137 @@ where } } - /// Add an edge to the graph - /// - /// # Arguments - /// - /// * `payload` - The payload - /// * `parents` - The parent content Ids - /// * `metadata` - The metadata - /// - /// # Returns - /// - /// * `Cid` - The content Id of the new node - /// pub fn add_node(&mut self, payload: P, parents: Vec, metadata: M) -> Result { - let timestamp = Self::current_timestamp()?; - let node = Node::new_genesis(payload, timestamp, metadata); - let new_cid = node.content_id()?; - if self.would_create_cycle_with(&new_cid, &parents)? { - return Err(GraphError::CycleDetected); + if parents.is_empty() { + let (cid, node) = self.prepare_genesis_node(payload, metadata)?; + return self.persist_and_cache(cid, node); } - self.storage.put(&node)?; - - // Update cache incrementally for the new node - self.ensure_subgraph_cached(&parents)?; - for &parent in &parents { - self.edges_forward.entry(parent).or_default().push(new_cid); + let mut inferred_genesis: Option = None; + for parent in &parents { + let node = self + .storage + .get(parent)? + .ok_or(GraphError::NodeNotFound(*parent))?; + let candidate = node.genesis.unwrap_or(*parent); + match inferred_genesis { + Some(existing) if existing != candidate => { + return Err(GraphError::InvalidParent(format!( + "parents belong to different genesis series ({existing:?} vs {candidate:?})" + ))); + } + None => inferred_genesis = Some(candidate), + _ => {} + } } - self.edges_forward.entry(new_cid).or_default(); + let genesis = inferred_genesis.ok_or_else(|| { + GraphError::Internal("child node requires at least one parent".to_string()) + })?; - Ok(new_cid) + let (cid, node) = self.prepare_child_node(payload, parents, genesis, metadata)?; + self.persist_and_cache(cid, node) } - /// Add a genesis node (first version of content) - /// - /// # Arguments - /// - /// * `payload` - The payload - /// * `metadata` - The metadata - /// - /// # Returns - /// - /// * `Cid` - The content Id of the new genesis node - /// pub fn add_genesis_node(&mut self, payload: P, metadata: M) -> Result { + let (cid, node) = self.prepare_genesis_node(payload, metadata)?; + self.persist_and_cache(cid, node) + } + + pub fn add_child_node( + &mut self, + payload: P, + parents: Vec, + genesis: Cid, + metadata: M, + ) -> Result { + let (cid, node) = self.prepare_child_node(payload, parents, genesis, metadata)?; + self.persist_and_cache(cid, node) + } + + pub fn prepare_genesis_node(&mut self, payload: P, metadata: M) -> Result<(Cid, Node)> { let timestamp = Self::current_timestamp()?; let node = Node::new_genesis(payload, timestamp, metadata); let cid = node.content_id()?; - - self.storage.put(&node)?; - - // Initialize cache entry for genesis node - self.edges_forward.entry(cid).or_default(); - - Ok(cid) + Ok((cid, node)) } - /// Add a child node (descendant of an existing node) - /// - /// # Arguments - /// - /// * `payload` - The payload - /// * `parents` - The parent content Ids - /// * `genesis` - The genesis CID that this node belongs to - /// * `metadata` - The metadata - /// - /// # Returns - /// - /// * `Cid` - The content Id of the new child node - /// - pub fn add_child_node( + pub fn prepare_child_node( &mut self, payload: P, parents: Vec, genesis: Cid, metadata: M, - ) -> Result { + ) -> Result<(Cid, Node)> { let timestamp = Self::current_timestamp()?; let node = Node::new_child(payload, parents.clone(), genesis, timestamp, metadata); let cid = node.content_id()?; - // Use optimized genesis-based cycle detection if self.would_create_cycle_with(&cid, &parents)? { return Err(GraphError::CycleDetected); } + Ok((cid, node)) + } + + /// Persists the prepared node and updates the adjacency cache. + fn persist_and_cache(&mut self, cid: Cid, node: Node) -> Result { self.storage.put(&node)?; + self.register_prepared_node(cid, &node)?; + Ok(cid) + } - // Update cache incrementally for the new node - self.ensure_subgraph_cached(&parents)?; - for &parent in &parents { + pub fn register_prepared_node(&mut self, cid: Cid, node: &Node) -> Result<()> { + let parents = node.parents(); + if parents.is_empty() { + self.edges_forward.entry(cid).or_default(); + return Ok(()); + } + + self.ensure_subgraph_cached(parents)?; + for &parent in parents { self.edges_forward.entry(parent).or_default().push(cid); } self.edges_forward.entry(cid).or_default(); + Ok(()) + } - Ok(cid) + pub fn rollback_pending_node(&mut self, cid: &Cid, parents: &[Cid]) { + if let Some(children) = self.edges_forward.get_mut(cid) { + children.clear(); + } + self.edges_forward.remove(cid); + + for parent in parents { + if let Some(children) = self.edges_forward.get_mut(parent) { + children.retain(|child| child != cid); + } + } + } + + pub fn remove_node(&mut self, cid: &Cid) -> Result<()> { + let node = self + .storage + .get(cid)? + .ok_or(GraphError::NodeNotFound(*cid))?; + + if let Some(children) = self.edges_forward.get(cid) { + if !children.is_empty() { + return Err(GraphError::Internal(format!( + "cannot remove node {cid:?} with existing children" + ))); + } + } + + for parent in node.parents() { + if let Some(children) = self.edges_forward.get_mut(parent) { + children.retain(|child| child != cid); + } + } + + self.edges_forward.remove(cid); + self.storage.delete(cid)?; + + Ok(()) } pub fn get_node(&self, cid: &Cid) -> Result>> { @@ -181,17 +217,14 @@ where /// Ensure a subgraph is cached for the given parents and their ancestors /// This implements lazy, incremental cache building fn ensure_subgraph_cached(&mut self, parents: &[Cid]) -> Result<()> { - let mut to_process = Vec::new(); - - // First, check which parents need caching - for &parent in parents { - if !self.edges_forward.contains_key(&parent) { - to_process.push(parent); - } - } + let mut to_process: Vec = parents + .iter() + .copied() + .filter(|parent| !self.edges_forward.contains_key(parent)) + .collect(); // Process nodes that aren't cached yet - let mut processed = std::collections::HashSet::new(); + let mut processed = HashSet::new(); while let Some(current) = to_process.pop() { if processed.contains(¤t) || self.edges_forward.contains_key(¤t) { continue; @@ -223,7 +256,7 @@ where return true; } let mut stack = vec![start]; - let mut visited = std::collections::HashSet::new(); + let mut visited = HashSet::new(); while let Some(node) = stack.pop() { if node == target { return true; @@ -246,7 +279,7 @@ where node_map.insert(*new_cid, parents.to_vec()); let mut to_process = parents.to_vec(); - let mut processed = std::collections::HashSet::new(); + let mut processed = HashSet::new(); while let Some(current_cid) = to_process.pop() { if processed.contains(¤t_cid) { @@ -267,8 +300,8 @@ where pub fn detect_cycle_cid(node_map: &HashMap>) -> Result { let graph = Self::build_adjacency_list(node_map); - let mut visited = std::collections::HashSet::new(); - let mut rec_stack = std::collections::HashSet::new(); + let mut visited = HashSet::new(); + let mut rec_stack = HashSet::new(); for node in graph.keys() { if !visited.contains(node) @@ -302,8 +335,8 @@ where fn has_cycle( node: Cid, graph: &HashMap>, - visited: &mut std::collections::HashSet, - rec_stack: &mut std::collections::HashSet, + visited: &mut HashSet, + rec_stack: &mut HashSet, ) -> bool { visited.insert(node); rec_stack.insert(node); @@ -372,8 +405,8 @@ where } // Returns the set of nodes (CIDs) that are referenced as parents (i.e., nodes that have children) among the given versions. - fn collect_nodes_with_children(&self, nodes: &[Cid]) -> Result> { - let mut has_children = std::collections::HashSet::new(); + fn collect_nodes_with_children(&self, nodes: &[Cid]) -> Result> { + let mut has_children = HashSet::new(); for &node_cid in nodes { if let Some(node) = self.storage.get(&node_cid)? { for parent_cid in node.parents() { @@ -390,7 +423,7 @@ where fn collect_leaf_nodes( &self, nodes: &[Cid], - has_children: &std::collections::HashSet, + has_children: &HashSet, ) -> Result> { let mut leaf_nodes = Vec::new(); for &node_cid in nodes { @@ -407,8 +440,10 @@ where #[cfg(test)] mod tests { use super::*; + use crate::graph::storage::LeveldbNodeStorage; use std::cell::RefCell; use std::collections::BTreeMap; + use tempfile::tempdir; type TestDag = DagGraph>; @@ -945,4 +980,74 @@ mod tests { // unrelated_cid should not be included assert!(!result.contains(&unrelated_cid)); } + + #[test] + fn test_remove_node_without_children() { + let temp_dir = tempdir().unwrap(); + let storage = LeveldbNodeStorage::>::open(temp_dir.path()); + let mut dag = DagGraph::new(storage); + + let genesis = dag + .add_genesis_node("payload".to_string(), BTreeMap::new()) + .unwrap(); + + dag.remove_node(&genesis).unwrap(); + assert!(dag.get_node(&genesis).unwrap().is_none()); + } + + #[test] + fn test_remove_node_with_children_fails() { + let temp_dir = tempdir().unwrap(); + let storage = LeveldbNodeStorage::>::open(temp_dir.path()); + let mut dag = DagGraph::new(storage); + + let genesis = dag + .add_genesis_node("payload".to_string(), BTreeMap::new()) + .unwrap(); + dag.add_child_node("child".to_string(), vec![genesis], genesis, BTreeMap::new()) + .unwrap(); + + let err = dag.remove_node(&genesis); + assert!(err.is_err()); + } + + #[test] + fn test_prepare_register_and_rollback_node() { + let temp_dir = tempdir().unwrap(); + let storage = LeveldbNodeStorage::>::open(temp_dir.path()); + let mut dag = DagGraph::new(storage); + + let (genesis_cid, genesis_node) = dag + .prepare_genesis_node("payload".to_string(), BTreeMap::new()) + .unwrap(); + dag.storage.put(&genesis_node).unwrap(); + dag.register_prepared_node(genesis_cid, &genesis_node) + .unwrap(); + assert!(dag.edges_forward.contains_key(&genesis_cid)); + + let (child_cid, child_node) = dag + .prepare_child_node( + "child".to_string(), + vec![genesis_cid], + genesis_cid, + BTreeMap::new(), + ) + .unwrap(); + + dag.register_prepared_node(child_cid, &child_node).unwrap(); + assert!(dag.edges_forward.contains_key(&child_cid)); + + dag.rollback_pending_node(&child_cid, child_node.parents()); + + assert!( + !dag.edges_forward.contains_key(&child_cid), + "rollback should remove pending child" + ); + if let Some(children) = dag.edges_forward.get(&genesis_cid) { + assert!( + !children.contains(&child_cid), + "rollback should detach child from parent adjacency" + ); + } + } } diff --git a/src/graph/storage.rs b/src/graph/storage.rs index cb13af5..2116919 100644 --- a/src/graph/storage.rs +++ b/src/graph/storage.rs @@ -1,12 +1,13 @@ use crate::dasl::node::Node; use crate::graph::error::{GraphError, Result}; +use crate::storage::{SharedLeveldb, SharedLeveldbAccess}; use cid::Cid; -use rusty_leveldb::{LdbIterator, Options, DB as Database}; -use std::cell::RefCell; +use rusty_leveldb::LdbIterator; use std::collections::HashMap; use std::path::Path; -use std::path::PathBuf; +use std::rc::Rc; +/// Minimal interface required for persisting DAG nodes. pub trait NodeStorage { fn get(&self, content_id: &Cid) -> Result>>; fn put(&self, node: &Node) -> Result<()>; @@ -14,46 +15,81 @@ pub trait NodeStorage { fn get_node_map(&self) -> Result>>; } +/// [`NodeStorage`] implementation backed by a shared LevelDB instance. pub struct LeveldbNodeStorage { - db: RefCell, - path: PathBuf, + shared: Rc, _marker: std::marker::PhantomData<(P, M)>, } impl Clone for LeveldbNodeStorage { fn clone(&self) -> Self { - let opts = Options { - create_if_missing: true, - ..Default::default() - }; - let db = Database::open(&self.path, opts).expect("Failed to clone database"); Self { - db: RefCell::new(db), - path: self.path.clone(), + shared: self.shared.clone(), _marker: std::marker::PhantomData, } } } impl LeveldbNodeStorage { + /// Opens LevelDB and wraps it in a shared handle. pub fn open>(path: Pth) -> Self { - let opts = Options { - create_if_missing: true, - ..Default::default() - }; - let db = Database::open(path.as_ref(), opts).unwrap(); + let shared = SharedLeveldb::open(path).expect("Failed to open LevelDB"); + Self::new(shared) + } + + /// Creates the storage from an existing [`SharedLeveldb`] handle. + pub fn new(shared: Rc) -> Self { Self { - db: RefCell::new(db), - path: path.as_ref().to_path_buf(), + shared, _marker: std::marker::PhantomData, } } + + /// Builds the LevelDB key for nodes, prefixed with the `0x10` namespace. fn make_key(cid: &Cid) -> Vec { let mut v = Vec::with_capacity(1 + cid.to_bytes().len()); v.push(0x10); v.extend_from_slice(&cid.to_bytes()); v } + + /// Writes either into the active batch, or directly into the DB if no batch is active. + fn write_bytes(&self, key: &[u8], value: &[u8]) -> Result<()> { + if self + .shared + .with_active_batch(|batch| batch.put(key, value)) + .is_none() + { + self.shared + .db() + .borrow_mut() + .put(key, value) + .map_err(GraphError::Storage)?; + } + Ok(()) + } + + /// Deletes the given key, falling back to the DB when no batch is active. + fn delete_key(&self, key: &[u8]) -> Result<()> { + if self + .shared + .with_active_batch(|batch| batch.delete(key)) + .is_none() + { + self.shared + .db() + .borrow_mut() + .delete(key) + .map_err(GraphError::Storage)?; + } + Ok(()) + } +} + +impl SharedLeveldbAccess for LeveldbNodeStorage { + fn shared_leveldb(&self) -> Option> { + Some(self.shared.clone()) + } } impl NodeStorage for LeveldbNodeStorage @@ -63,7 +99,7 @@ where { fn get(&self, cid: &Cid) -> Result>> { let key = Self::make_key(cid); - match self.db.borrow_mut().get(&key) { + match self.shared.db().borrow_mut().get(&key) { Some(raw) => { let node = Node::from_bytes(&raw).map_err(|e| GraphError::NodeOperation(e.to_string()))?; @@ -81,26 +117,20 @@ where .content_id() .map_err(|e| GraphError::NodeOperation(e.to_string()))?; let key = Self::make_key(&cid); - self.db - .borrow_mut() - .put(&key, &bytes) - .map_err(GraphError::Storage)?; - Ok(()) + self.write_bytes(&key, &bytes) } fn delete(&self, cid: &Cid) -> Result<()> { let key = Self::make_key(cid); - self.db - .borrow_mut() - .delete(&key) - .map_err(GraphError::Storage)?; - Ok(()) + self.delete_key(&key) } + /// Walks all nodes and constructs an adjacency map (parent → children). fn get_node_map(&self) -> Result>> { let mut node_map = HashMap::new(); let mut iter = self - .db + .shared + .db() .borrow_mut() .new_iter() .map_err(GraphError::Storage)?; @@ -111,17 +141,12 @@ where while iter.valid() { iter.current(&mut key, &mut value); if !key.is_empty() && key[0] == 0x10 { - match Node::::from_bytes(&value) { - Ok(node) => { - let node_cid = node - .content_id() - .map_err(|e| GraphError::NodeOperation(e.to_string()))?; - node_map.insert(node_cid, node.parents().to_vec()); - } - Err(e) => { - println!("Error deserializing node: {e}"); - } - } + let node = Node::::from_bytes(&value) + .map_err(|e| GraphError::NodeOperation(e.to_string()))?; + let node_cid = node + .content_id() + .map_err(|e| GraphError::NodeOperation(e.to_string()))?; + node_map.insert(node_cid, node.parents().to_vec()); } iter.advance(); } @@ -136,6 +161,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use tempfile::tempdir; + /// Creates a simple test node helper. fn create_test_node(payload: &str) -> Node { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/lib.rs b/src/lib.rs index b1ff0b2..0e98d05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod dasl; pub mod graph; pub mod masl; pub mod repo; +pub mod storage; diff --git a/src/repo.rs b/src/repo.rs index f621d2a..ac06aee 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -3,6 +3,7 @@ use crate::convergence::{ resolver::ConflictResolver, }; use crate::crdt::error::{CrdtError, Result}; +use crate::storage::{BatchError, LeveldbBatchGuard, SharedLeveldb, SharedLeveldbAccess}; use crate::{ crdt::{ crdt_state::CrdtState, @@ -10,17 +11,25 @@ use crate::{ reducer::LwwReducer, storage::OperationStorage, }, + dasl::node::Node, graph::{dag::DagGraph, storage::NodeStorage}, }; use cid::Cid; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fmt::Debug; +use std::rc::Rc; + +struct PendingNode { + cid: Cid, + parents: Vec, + metadata: ContentMetadata, +} pub struct Repo where - OpStore: OperationStorage, - NodeStore: NodeStorage, + OpStore: OperationStorage + SharedLeveldbAccess, + NodeStore: NodeStorage + SharedLeveldbAccess, Payload: Clone + Serialize + for<'de> Deserialize<'de> + Debug, { pub state: CrdtState, @@ -30,8 +39,8 @@ where impl Repo where - OpStore: OperationStorage, - NodeStore: NodeStorage, + OpStore: OperationStorage + SharedLeveldbAccess, + NodeStore: NodeStorage + SharedLeveldbAccess, Payload: Clone + Serialize + for<'de> Deserialize<'de> + Debug, { pub fn new( @@ -56,82 +65,133 @@ where self.commit_operation_internal(op, false) } + pub fn latest(&self, genesis_id: &Cid) -> Option { + self.dag.calculate_latest(genesis_id).ok().flatten() + } + + /// Convenience wrapper around `DagGraph::get_genesis` + pub fn get_genesis(&self, cid: &Cid) -> Result { + self.dag.get_genesis(cid).map_err(CrdtError::Graph) + } + + pub fn get_operations_with_index( + &self, + genesis: &Cid, + ) -> Result)>> { + let mut ops = self.state.get_operations_by_genesis(genesis)?; + ops.sort_by_key(|op| op.timestamp); + Ok(ops + .into_iter() + .enumerate() + .map(|(idx, op)| (idx + 1, op)) + .collect()) + } + + /// Return parent -> children adjacency for the specified genesis (DAG structure). + pub fn branching_history(&self, genesis: &Cid) -> Result>> { + let nodes = self + .dag + .get_nodes_by_genesis(genesis) + .map_err(CrdtError::Graph)?; + + let mut adjacency: HashMap> = HashMap::new(); + for &cid in &nodes { + if let Some(node) = self.dag.get_node(&cid).map_err(CrdtError::Graph)? { + for parent in node.parents() { + adjacency.entry(*parent).or_default().insert(cid); + } + adjacency.entry(cid).or_default(); + } + } + + Ok(adjacency + .into_iter() + .map(|(cid, set)| { + let mut children: Vec = set.into_iter().collect(); + children.sort(); + (cid, children) + }) + .collect()) + } + + /// Find a linear path from genesis to the latest head. + pub fn linear_history(&self, genesis: &Cid) -> Result> { + let adjacency = self.branching_history(genesis)?; + let mut path = Vec::new(); + let mut current = *genesis; + let mut visited = HashSet::new(); + + while visited.insert(current) { + path.push(current); + let children = adjacency.get(¤t).cloned().unwrap_or_default(); + + if children.is_empty() { + break; + } + + let mut best: Option<(Cid, (bool, u64))> = None; + for child in children { + let info = self.node_characteristics(&child)?; + if let Some((_, best_info)) = &best { + if info > *best_info { + best = Some((child, info)); + } + } else { + best = Some((child, info)); + } + } + + let Some((next, _)) = best else { + break; + }; + + current = next; + } + + Ok(path) + } + + fn shared_leveldb(&self) -> Result> { + let op_db = self.state.storage().shared_leveldb().ok_or_else(|| { + CrdtError::Internal("operation storage does not support batching".into()) + })?; + let node_db = + self.dag.storage.shared_leveldb().ok_or_else(|| { + CrdtError::Internal("node storage does not support batching".into()) + })?; + + if !Rc::ptr_eq(&op_db, &node_db) { + return Err(CrdtError::Internal( + "operation and node storage must share the same LevelDB instance for transactions" + .into(), + )); + } + + Ok(op_db) + } + fn commit_operation_internal( &mut self, op: Operation, skip_auto_merge: bool, ) -> Result { let mut op = op; + let shared = self.shared_leveldb()?; + let batch_guard = Self::begin_shared_batch(&shared)?; + let mut pending_nodes: Vec = Vec::new(); if !skip_auto_merge { - match &op.kind { - OperationType::Update(_) | OperationType::Delete => { - if op.parents.is_empty() { - let merged_head = self - .check_and_merge(&op.genesis)? - .or_else(|| self.dag.calculate_latest(&op.genesis).ok().flatten()) - .ok_or_else(|| { - CrdtError::Internal(format!( - "No head available for genesis {} to attach operation", - op.genesis - )) - })?; - - op.parents = vec![merged_head]; - } else { - self.validate_parent_genesis(&op.genesis, &op.parents)?; - } - } - OperationType::Merge(_) => { - if op.parents.is_empty() { - op.parents = self.find_heads(&op.genesis)?; - } - self.validate_parent_genesis(&op.genesis, &op.parents)?; - } - OperationType::Create(_) => {} - } + self.ensure_parent_context(&mut op, &mut pending_nodes)?; } - let cid = match &op.kind { + let cid = match op.kind.clone() { OperationType::Create(payload) => { - let genesis_cid = self - .dag - .add_genesis_node(payload.clone(), ContentMetadata::default())?; - op.genesis = genesis_cid; - genesis_cid + self.stage_create(payload, &mut op, &mut pending_nodes)? } - OperationType::Update(payload) => self.dag.add_child_node( - payload.clone(), - op.parents.clone(), - op.genesis, - self.resolve_metadata_for_commit(&op.genesis, &op.parents)?, - )?, - OperationType::Delete => { - let ops = self.state.get_operations_by_genesis(&op.genesis)?; - let last_payload = ops - .iter() - .filter_map(|operation| { - operation - .payload() - .cloned() - .map(|payload| (operation.timestamp, payload)) - }) - .max_by_key(|(timestamp, _)| *timestamp) - .map(|(_, payload)| payload) - .ok_or_else(|| { - CrdtError::Internal(format!( - "content must exist for delete operation: {}", - op.genesis - )) - })?; - - self.dag.add_child_node( - last_payload, - op.parents.clone(), - op.genesis, - self.resolve_metadata_for_commit(&op.genesis, &op.parents)?, - )? + OperationType::Update(payload) => { + self.stage_update(payload, &op, &mut pending_nodes)? } + OperationType::Delete => self.stage_delete(&op, &mut pending_nodes)?, OperationType::Merge(_) => { return Err(CrdtError::Internal( "Merge operations must be committed via auto-merge".to_string(), @@ -139,30 +199,155 @@ where } }; - self.state.apply(op)?; + if let Err(err) = self.state.apply(op) { + self.rollback_pending_nodes(&pending_nodes); + return Err(err); + } + + if let Err(status) = batch_guard.commit() { + self.rollback_pending_nodes(&pending_nodes); + return Err(CrdtError::Storage(status)); + } Ok(cid) } - pub fn latest(&self, genesis_id: &Cid) -> Option { - self.dag.calculate_latest(genesis_id).ok().flatten() + fn begin_shared_batch(shared: &SharedLeveldb) -> Result> { + shared.begin_batch().map_err(|err| match err { + BatchError::Unsupported => CrdtError::Internal( + "current storage backend does not support transactions".to_string(), + ), + BatchError::AlreadyActive => CrdtError::Internal( + "a transaction is already active on the shared LevelDB".to_string(), + ), + BatchError::Commit(status) => CrdtError::Storage(status), + }) } - /// Convenience wrapper around `DagGraph::get_genesis` - pub fn get_genesis(&self, cid: &Cid) -> Result { - self.dag.get_genesis(cid).map_err(CrdtError::Graph) + + fn rollback_pending_nodes(&mut self, pending: &[PendingNode]) { + for node in pending.iter().rev() { + self.dag.rollback_pending_node(&node.cid, &node.parents); + } } - pub fn get_operations_with_index( - &self, - genesis: &Cid, - ) -> Result)>> { - let mut ops = self.state.get_operations_by_genesis(genesis)?; - ops.sort_by_key(|op| op.timestamp); - Ok(ops - .into_iter() - .enumerate() - .map(|(idx, op)| (idx + 1, op)) - .collect()) + fn ensure_parent_context( + &mut self, + op: &mut Operation, + pending_nodes: &mut Vec, + ) -> Result<()> { + match &op.kind { + OperationType::Update(_) | OperationType::Delete => { + if op.parents.is_empty() { + let merged_head = self + .check_and_merge(&op.genesis, pending_nodes)? + .or_else(|| self.dag.calculate_latest(&op.genesis).ok().flatten()) + .ok_or_else(|| { + CrdtError::Internal(format!( + "No head available for genesis {} to attach operation", + op.genesis + )) + })?; + + op.parents = vec![merged_head]; + } else { + self.validate_parent_genesis(&op.genesis, &op.parents)?; + } + } + OperationType::Merge(_) => { + if op.parents.is_empty() { + op.parents = self.find_heads(&op.genesis)?; + } + self.validate_parent_genesis(&op.genesis, &op.parents)?; + } + OperationType::Create(_) => {} + } + Ok(()) + } + + fn stage_create( + &mut self, + payload: Payload, + op: &mut Operation, + pending_nodes: &mut Vec, + ) -> Result { + let (genesis_cid, node) = self + .dag + .prepare_genesis_node(payload, ContentMetadata::default())?; + let cid = self.stage_prepared_node(genesis_cid, node, pending_nodes)?; + op.genesis = cid; + Ok(cid) + } + + fn stage_update( + &mut self, + payload: Payload, + op: &Operation, + pending_nodes: &mut Vec, + ) -> Result { + let metadata = + self.resolve_metadata_for_commit(&op.genesis, &op.parents, pending_nodes.as_slice())?; + let (cid, node) = + self.dag + .prepare_child_node(payload, op.parents.clone(), op.genesis, metadata)?; + self.stage_prepared_node(cid, node, pending_nodes) + } + + fn stage_delete( + &mut self, + op: &Operation, + pending_nodes: &mut Vec, + ) -> Result { + let ops = self.state.get_operations_by_genesis(&op.genesis)?; + let last_payload = ops + .iter() + .filter_map(|operation| { + operation + .payload() + .cloned() + .map(|payload| (operation.timestamp, payload)) + }) + .max_by_key(|(timestamp, _)| *timestamp) + .map(|(_, payload)| payload) + .ok_or_else(|| { + CrdtError::Internal(format!( + "content must exist for delete operation: {}", + op.genesis + )) + })?; + + let metadata = + self.resolve_metadata_for_commit(&op.genesis, &op.parents, pending_nodes.as_slice())?; + let (cid, node) = + self.dag + .prepare_child_node(last_payload, op.parents.clone(), op.genesis, metadata)?; + self.stage_prepared_node(cid, node, pending_nodes) + } + + fn stage_prepared_node( + &mut self, + cid: Cid, + node: Node, + pending_nodes: &mut Vec, + ) -> Result { + let pending = self.persist_prepared_node(cid, &node)?; + pending_nodes.push(pending); + Ok(cid) + } + + fn persist_prepared_node( + &mut self, + cid: Cid, + node: &Node, + ) -> Result { + self.dag.storage.put(node).map_err(CrdtError::Graph)?; + self.dag + .register_prepared_node(cid, node) + .map_err(CrdtError::Graph)?; + Ok(PendingNode { + cid, + parents: node.parents().to_vec(), + metadata: node.metadata().clone(), + }) } /// Get the latest parent nodes for the given genesis @@ -178,7 +363,11 @@ where Ok(()) } - fn check_and_merge(&mut self, genesis: &Cid) -> Result> { + fn check_and_merge( + &mut self, + genesis: &Cid, + pending_nodes: &mut Vec, + ) -> Result> { let heads = self.find_heads(genesis)?; if heads.len() <= 1 { @@ -199,12 +388,16 @@ where self.validate_parent_genesis(genesis, &heads)?; - let merge_cid = self.dag.add_child_node( - merge_node.payload().clone(), - heads.clone(), - *genesis, - merge_node.metadata().clone(), - )?; + let (merge_cid, node) = self + .dag + .prepare_child_node( + merge_node.payload().clone(), + heads.clone(), + *genesis, + merge_node.metadata().clone(), + ) + .map_err(CrdtError::Graph)?; + let pending = self.persist_prepared_node(merge_cid, &node)?; let mut merge_op = Operation::new( *genesis, @@ -212,7 +405,13 @@ where "auto-merge".to_string(), ); merge_op.parents = heads; - self.state.apply(merge_op)?; + if let Err(err) = self.state.apply(merge_op) { + self.dag + .rollback_pending_node(&pending.cid, &pending.parents); + return Err(err); + } + + pending_nodes.push(pending); Ok(Some(merge_cid)) } @@ -256,8 +455,12 @@ where &self, genesis: &Cid, parents: &[Cid], + pending_nodes: &[PendingNode], ) -> Result { if let Some(parent) = parents.first() { + if let Some(pending) = pending_nodes.iter().find(|pending| &pending.cid == parent) { + return Ok(pending.metadata.clone()); + } let node = self .dag .get_node(parent) @@ -265,6 +468,9 @@ where .ok_or_else(|| CrdtError::Internal(format!("Parent node not found: {parent}")))?; Ok(node.metadata().clone()) } else { + if let Some(pending) = pending_nodes.iter().find(|pending| &pending.cid == genesis) { + return Ok(pending.metadata.clone()); + } let genesis_node = self .dag .get_node(genesis) @@ -273,70 +479,6 @@ where Ok(genesis_node.metadata().clone()) } } - /// Return parent -> children adjacency for the specified genesis (DAG structure). - pub fn branching_history(&self, genesis: &Cid) -> Result>> { - let nodes = self - .dag - .get_nodes_by_genesis(genesis) - .map_err(CrdtError::Graph)?; - - let mut adjacency: HashMap> = HashMap::new(); - for &cid in &nodes { - if let Some(node) = self.dag.get_node(&cid).map_err(CrdtError::Graph)? { - for parent in node.parents() { - adjacency.entry(*parent).or_default().insert(cid); - } - adjacency.entry(cid).or_default(); - } - } - - Ok(adjacency - .into_iter() - .map(|(cid, set)| { - let mut children: Vec = set.into_iter().collect(); - children.sort(); - (cid, children) - }) - .collect()) - } - - /// Find a linear path from genesis to the latest head. - pub fn linear_history(&self, genesis: &Cid) -> Result> { - let adjacency = self.branching_history(genesis)?; - let mut path = Vec::new(); - let mut current = *genesis; - let mut visited = HashSet::new(); - - while visited.insert(current) { - path.push(current); - let children = adjacency.get(¤t).cloned().unwrap_or_default(); - - if children.is_empty() { - break; - } - - let mut best: Option<(Cid, (bool, u64))> = None; - for child in children { - let info = self.node_characteristics(&child)?; - if let Some((_, best_info)) = &best { - if info > *best_info { - best = Some((child, info)); - } - } else { - best = Some((child, info)); - } - } - - let Some((next, _)) = best else { - break; - }; - - current = next; - } - - Ok(path) - } - fn node_characteristics(&self, cid: &Cid) -> Result<(bool, u64)> { let node = self .dag @@ -354,7 +496,9 @@ mod tests { use crate::crdt::operation::{Operation, OperationType}; use crate::crdt::storage::LeveldbStorage; use crate::graph::storage::LeveldbNodeStorage; + use std::cell::Cell; use tempfile::tempdir; + use ulid::Ulid; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(transparent)] @@ -368,8 +512,9 @@ mod tests { fn setup_test_repo() -> (TestRepo, tempfile::TempDir) { let dir = tempdir().unwrap(); - let op_storage = LeveldbStorage::open(dir.path().join("ops")).unwrap(); - let node_storage = LeveldbNodeStorage::open(dir.path().join("nodes")); + let shared = SharedLeveldb::open(dir.path().join("store")).unwrap(); + let op_storage = LeveldbStorage::new(shared.clone()); + let node_storage = LeveldbNodeStorage::new(shared); let state = CrdtState::new(op_storage); let dag = DagGraph::new(node_storage); let repo = Repo::new(state, dag); @@ -387,6 +532,62 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(1)); } + struct FailingOperationStorage { + inner: S, + fail_next: Cell, + } + + impl FailingOperationStorage { + fn fail_on_first(inner: S) -> Self { + Self { + inner, + fail_next: Cell::new(true), + } + } + } + + impl OperationStorage for FailingOperationStorage + where + S: OperationStorage, + { + fn save_operation(&self, op: &Operation) -> crate::crdt::error::Result<()> { + if self.fail_next.replace(false) { + Err(CrdtError::Internal( + "forced failure for testing".to_string(), + )) + } else { + self.inner.save_operation(op) + } + } + + fn load_operations( + &self, + genesis: &ContentId, + ) -> crate::crdt::error::Result>> { + self.inner.load_operations(genesis) + } + + fn get_operation( + &self, + op_id: &Ulid, + ) -> crate::crdt::error::Result>> { + self.inner.get_operation(op_id) + } + + fn delete_operation(&self, op_id: &Ulid) -> crate::crdt::error::Result<()> { + self.inner.delete_operation(op_id) + } + } + + impl SharedLeveldbAccess for FailingOperationStorage + where + S: SharedLeveldbAccess, + { + fn shared_leveldb(&self) -> Option> { + self.inner.shared_leveldb() + } + } + #[test] fn test_create_operation() { let (mut repo, _) = setup_test_repo(); @@ -428,6 +629,40 @@ mod tests { assert_ne!(create_cid, update_cid); } + #[test] + fn test_create_operation_rolls_back_on_state_failure() { + let dir = tempdir().unwrap(); + let shared = SharedLeveldb::open(dir.path().join("store")).unwrap(); + let op_storage = + FailingOperationStorage::fail_on_first(LeveldbStorage::new(shared.clone())); + let node_storage = LeveldbNodeStorage::new(shared); + let state = CrdtState::new(op_storage); + let dag = DagGraph::new(node_storage); + let mut repo = Repo::new(state, dag); + + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"rollback-test").unwrap(), + ); + let op = make_test_operation( + initial_genesis, + OperationType::Create(TestPayload("should not persist".to_string())), + ); + let op_id = op.id; + + let result = repo.commit_operation(op); + assert!(result.is_err()); + + let node_map = repo.dag.storage.get_node_map().unwrap(); + assert!( + node_map.is_empty(), + "expected DAG to be empty after rollback, found {node_map:?}" + ); + assert!( + repo.state.get_operation(&op_id).unwrap().is_none(), + "operation was persisted despite failure" + ); + } #[test] fn test_update_with_explicit_parent_is_respected() { let (mut repo, _) = setup_test_repo(); diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..b36909c --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,3 @@ +mod shared_leveldb; + +pub use shared_leveldb::{BatchError, LeveldbBatchGuard, SharedLeveldb, SharedLeveldbAccess}; diff --git a/src/storage/shared_leveldb.rs b/src/storage/shared_leveldb.rs new file mode 100644 index 0000000..0a9cc46 --- /dev/null +++ b/src/storage/shared_leveldb.rs @@ -0,0 +1,160 @@ +use rusty_leveldb::{Options, Status, WriteBatch, DB as Database}; +use std::cell::RefCell; +use std::path::Path; +use std::rc::Rc; + +#[derive(Debug)] +pub enum BatchError { + Unsupported, + AlreadyActive, + Commit(Status), +} + +pub struct SharedLeveldb { + db: RefCell, + active_batch: RefCell>, +} + +impl SharedLeveldb { + pub fn open>(path: P) -> Result, Status> { + let opts = Options { + create_if_missing: true, + ..Default::default() + }; + let db = Database::open(path, opts)?; + Ok(Rc::new(Self { + db: RefCell::new(db), + active_batch: RefCell::new(None), + })) + } + + pub fn begin_batch(&self) -> Result, BatchError> { + let mut slot = self.active_batch.borrow_mut(); + if slot.is_some() { + return Err(BatchError::AlreadyActive); + } + *slot = Some(WriteBatch::default()); + Ok(LeveldbBatchGuard { + shared: self, + committed: false, + }) + } + + fn commit_batch(&self) -> Result<(), Status> { + let mut slot = self.active_batch.borrow_mut(); + let Some(batch) = slot.take() else { + return Ok(()); + }; + self.db.borrow_mut().write(batch, true) + } + + fn abort_batch(&self) { + self.active_batch.borrow_mut().take(); + } + + pub fn with_active_batch(&self, f: F) -> Option + where + F: FnOnce(&mut WriteBatch) -> R, + { + let mut slot = self.active_batch.borrow_mut(); + slot.as_mut().map(f) + } + + pub fn db(&self) -> &RefCell { + &self.db + } +} + +pub struct LeveldbBatchGuard<'a> { + shared: &'a SharedLeveldb, + committed: bool, +} + +impl<'a> LeveldbBatchGuard<'a> { + pub fn commit(mut self) -> Result<(), Status> { + self.shared.commit_batch()?; + self.committed = true; + Ok(()) + } +} + +impl Drop for LeveldbBatchGuard<'_> { + fn drop(&mut self) { + if !self.committed { + self.shared.abort_batch(); + } + } +} + +pub trait SharedLeveldbAccess { + fn shared_leveldb(&self) -> Option>; +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn begin_batch_prevents_nested_batches() { + let dir = tempdir().unwrap(); + let shared = SharedLeveldb::open(dir.path()).expect("open shared db"); + + let guard = shared.begin_batch().expect("begin first batch"); + match shared.begin_batch() { + Err(BatchError::AlreadyActive) => {} + Ok(_) => panic!("expected AlreadyActive error, got Ok"), + Err(err) => panic!("unexpected batch error: {err:?}"), + } + drop(guard); + + shared + .begin_batch() + .expect("batch should be available after guard drop") + .commit() + .expect("commit empty batch"); + } + + #[test] + fn commit_batch_persists_operations() { + let dir = tempdir().unwrap(); + let shared = SharedLeveldb::open(dir.path()).expect("open shared db"); + + let guard = shared.begin_batch().expect("begin batch"); + let key = b"test-key"; + let value = b"test-value"; + + let inserted = shared.with_active_batch(|batch| batch.put(key, value)); + assert!(inserted.is_some(), "expected active batch to exist"); + + guard.commit().expect("commit batch"); + + let stored = shared + .db() + .borrow_mut() + .get(key) + .expect("value should exist after commit"); + assert_eq!(stored.as_slice(), value); + } + + #[test] + fn dropping_guard_discards_pending_operations() { + let dir = tempdir().unwrap(); + let shared = SharedLeveldb::open(dir.path()).expect("open shared db"); + let key = b"discard-key"; + let value = b"discard-value"; + + { + let _guard = shared.begin_batch().expect("begin batch"); + let inserted = shared.with_active_batch(|batch| batch.put(key, value)); + assert!(inserted.is_some(), "expected active batch to exist"); + // guard dropped here without commit + } + + let result = shared.db().borrow_mut().get(key); + assert!( + result.is_none(), + "value should not be persisted when batch guard is dropped without commit" + ); + } +} From f1a5e70bb4dfe02d33674276cfbed1279f7e8c7b Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Sat, 15 Nov 2025 15:17:34 +0900 Subject: [PATCH 7/9] feat: add test case --- src/repo.rs | 288 ++++++++++++++++++++++++++++++++++ src/storage/shared_leveldb.rs | 15 ++ 2 files changed, 303 insertions(+) diff --git a/src/repo.rs b/src/repo.rs index ac06aee..807dc0b 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -495,7 +495,9 @@ mod tests { use super::*; use crate::crdt::operation::{Operation, OperationType}; use crate::crdt::storage::LeveldbStorage; + use crate::graph::error::GraphError; use crate::graph::storage::LeveldbNodeStorage; + use rusty_leveldb::{Status, StatusCode}; use std::cell::Cell; use tempfile::tempdir; use ulid::Ulid; @@ -538,12 +540,23 @@ mod tests { } impl FailingOperationStorage { + fn new(inner: S) -> Self { + Self { + inner, + fail_next: Cell::new(false), + } + } + fn fail_on_first(inner: S) -> Self { Self { inner, fail_next: Cell::new(true), } } + + fn fail_on_next(&self) { + self.fail_next.set(true); + } } impl OperationStorage for FailingOperationStorage @@ -588,6 +601,56 @@ mod tests { } } + struct FailingNodeStorage { + inner: S, + fail_next_put: Cell, + } + + impl FailingNodeStorage { + fn fail_on_first_put(inner: S) -> Self { + Self { + inner, + fail_next_put: Cell::new(true), + } + } + } + + impl NodeStorage for FailingNodeStorage + where + S: NodeStorage, + { + fn get(&self, content_id: &Cid) -> crate::graph::error::Result>> { + self.inner.get(content_id) + } + + fn put(&self, node: &Node) -> crate::graph::error::Result<()> { + if self.fail_next_put.replace(false) { + Err(GraphError::Internal( + "injected node storage failure".to_string(), + )) + } else { + self.inner.put(node) + } + } + + fn delete(&self, content_id: &Cid) -> crate::graph::error::Result<()> { + self.inner.delete(content_id) + } + + fn get_node_map(&self) -> crate::graph::error::Result>> { + self.inner.get_node_map() + } + } + + impl SharedLeveldbAccess for FailingNodeStorage + where + S: SharedLeveldbAccess, + { + fn shared_leveldb(&self) -> Option> { + self.inner.shared_leveldb() + } + } + #[test] fn test_create_operation() { let (mut repo, _) = setup_test_repo(); @@ -604,6 +667,45 @@ mod tests { assert_eq!(repo.latest(&cid).unwrap(), cid); } + #[test] + fn test_create_operation_fails_when_node_storage_errors() { + let dir = tempdir().unwrap(); + let shared = SharedLeveldb::open(dir.path().join("store")).unwrap(); + let op_storage = LeveldbStorage::new(shared.clone()); + let node_storage = + FailingNodeStorage::fail_on_first_put(LeveldbNodeStorage::new(shared.clone())); + let state = CrdtState::new(op_storage); + let dag = DagGraph::new(node_storage); + let mut repo = Repo::new(state, dag); + + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"create-fail").unwrap(), + ); + let op = make_test_operation( + initial_genesis, + OperationType::Create(TestPayload("should fail".to_string())), + ); + let op_id = op.id; + + let err = repo.commit_operation(op).unwrap_err(); + match err { + CrdtError::Graph(GraphError::Internal(message)) => { + assert!(message.contains("injected node storage failure")); + } + other => panic!("unexpected error: {other:?}"), + } + + assert!( + repo.state.get_operation(&op_id).unwrap().is_none(), + "operation should not be persisted on failure" + ); + assert!( + repo.dag.storage.get_node_map().unwrap().is_empty(), + "dag should remain empty when node storage fails" + ); + } + #[test] fn test_update_operation() { let (mut repo, _) = setup_test_repo(); @@ -629,6 +731,36 @@ mod tests { assert_ne!(create_cid, update_cid); } + #[test] + fn test_update_operation_without_existing_head_fails() { + let (mut repo, _) = setup_test_repo(); + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"update-no-head").unwrap(), + ); + let op = make_test_operation( + initial_genesis, + OperationType::Update(TestPayload("orphaned".to_string())), + ); + + let err = repo.commit_operation(op).unwrap_err(); + match err { + CrdtError::Internal(message) => { + assert!(message.contains("No head available")); + } + other => panic!("unexpected error: {other:?}"), + } + + let stored_ops = repo + .state + .get_operations_by_genesis(&initial_genesis) + .unwrap(); + assert!( + stored_ops.is_empty(), + "update should not persist when no head exists" + ); + } + #[test] fn test_create_operation_rolls_back_on_state_failure() { let dir = tempdir().unwrap(); @@ -663,6 +795,119 @@ mod tests { "operation was persisted despite failure" ); } + + #[test] + fn test_create_operation_rolls_back_when_batch_commit_fails() { + let (mut repo, _) = setup_test_repo(); + let shared = repo + .state + .storage() + .shared_leveldb() + .expect("shared leveldb instance"); + shared.inject_commit_failure(Status::new(StatusCode::IOError, "forced commit failure")); + + let initial_genesis = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"batch-failure").unwrap(), + ); + let op = make_test_operation( + initial_genesis, + OperationType::Create(TestPayload("batch-fail".to_string())), + ); + let op_id = op.id; + + let err = repo.commit_operation(op).unwrap_err(); + match err { + CrdtError::Storage(status) => { + assert_eq!(status.code, StatusCode::IOError); + assert!(status.err.contains("forced commit failure")); + } + other => panic!("unexpected error: {other:?}"), + } + + assert!( + repo.state.get_operation(&op_id).unwrap().is_none(), + "operation should not persist when batch commit fails" + ); + assert!( + repo.dag.storage.get_node_map().unwrap().is_empty(), + "dag should be rolled back when batch commit fails" + ); + } + + #[test] + fn test_rollback_pending_nodes_restores_heads_after_failure() { + let dir = tempdir().unwrap(); + let shared = SharedLeveldb::open(dir.path().join("store")).unwrap(); + let op_storage = FailingOperationStorage::new(LeveldbStorage::new(shared.clone())); + let node_storage = LeveldbNodeStorage::new(shared); + let state = CrdtState::new(op_storage); + let dag = DagGraph::new(node_storage); + let mut repo = Repo::new(state, dag); + + let seed = Cid::new_v1( + 0x55, + multihash::Multihash::<64>::wrap(0x12, b"rollback-pending").unwrap(), + ); + let create = make_test_operation(seed, OperationType::Create(TestPayload("root".into()))); + let genesis = repo.commit_operation(create).unwrap(); + + let mut branch1 = make_test_operation( + genesis, + OperationType::Update(TestPayload("branch-1".into())), + ); + branch1.parents.push(genesis); + let branch1_cid = repo.commit_operation(branch1).unwrap(); + sleep_for_ordering(); + + let mut branch2 = make_test_operation( + genesis, + OperationType::Update(TestPayload("branch-2".into())), + ); + branch2.parents.push(genesis); + let branch2_cid = repo.commit_operation(branch2).unwrap(); + + let original_heads = repo.find_heads(&genesis).unwrap(); + assert_eq!(original_heads.len(), 2); + assert!(original_heads.contains(&branch1_cid)); + assert!(original_heads.contains(&branch2_cid)); + + repo.state.storage().fail_on_next(); + + let update = make_test_operation( + genesis, + OperationType::Update(TestPayload("should-rollback".into())), + ); + let err = repo.commit_operation(update).unwrap_err(); + match err { + CrdtError::Internal(message) => { + assert!(message.contains("forced failure for testing")); + } + other => panic!("unexpected error: {other:?}"), + } + + let heads_after = repo.find_heads(&genesis).unwrap(); + assert_eq!(heads_after.len(), 2); + assert!(heads_after.contains(&branch1_cid)); + assert!(heads_after.contains(&branch2_cid)); + + let ops = repo.state.get_operations_by_genesis(&genesis).unwrap(); + assert_eq!( + ops.len(), + 3, + "rollback should leave only the original create and two branch updates" + ); + + let node_map = repo.dag.storage.get_node_map().unwrap(); + assert!(node_map.contains_key(&genesis)); + assert!(node_map.contains_key(&branch1_cid)); + assert!(node_map.contains_key(&branch2_cid)); + assert_eq!( + node_map.len(), + 3, + "no additional DAG nodes should remain after rollback" + ); + } #[test] fn test_update_with_explicit_parent_is_respected() { let (mut repo, _) = setup_test_repo(); @@ -804,6 +1049,49 @@ mod tests { assert_ne!(create_cid, delete_cid); } + #[test] + fn test_delete_operation_without_existing_payload_fails() { + let (mut repo, _) = setup_test_repo(); + let (genesis_cid, genesis_node) = repo + .dag + .prepare_genesis_node( + TestPayload("dangling".to_string()), + ContentMetadata::default(), + ) + .unwrap(); + repo.dag.storage.put(&genesis_node).unwrap(); + repo.dag + .register_prepared_node(genesis_cid, &genesis_node) + .unwrap(); + + let op = make_test_operation(genesis_cid, OperationType::Delete); + let op_id = op.id; + + let err = repo.commit_operation(op).unwrap_err(); + match err { + CrdtError::Internal(message) => { + assert!(message.contains("content must exist")); + } + other => panic!("unexpected error: {other:?}"), + } + + assert!( + repo.state.get_operation(&op_id).unwrap().is_none(), + "delete operation should not be stored when payload is missing" + ); + assert!( + repo.state + .get_operations_by_genesis(&genesis_cid) + .unwrap() + .is_empty(), + "operation history should remain empty on failure" + ); + assert!( + repo.dag.get_node(&genesis_cid).unwrap().is_some(), + "existing genesis node should remain after failed delete" + ); + } + #[test] fn test_multiple_genesis_entries() { let (mut repo, _) = setup_test_repo(); diff --git a/src/storage/shared_leveldb.rs b/src/storage/shared_leveldb.rs index 0a9cc46..12b112f 100644 --- a/src/storage/shared_leveldb.rs +++ b/src/storage/shared_leveldb.rs @@ -13,6 +13,8 @@ pub enum BatchError { pub struct SharedLeveldb { db: RefCell, active_batch: RefCell>, + #[cfg(test)] + commit_fail_status: RefCell>, } impl SharedLeveldb { @@ -25,6 +27,8 @@ impl SharedLeveldb { Ok(Rc::new(Self { db: RefCell::new(db), active_batch: RefCell::new(None), + #[cfg(test)] + commit_fail_status: RefCell::new(None), })) } @@ -45,6 +49,10 @@ impl SharedLeveldb { let Some(batch) = slot.take() else { return Ok(()); }; + #[cfg(test)] + if let Some(status) = self.commit_fail_status.borrow_mut().take() { + return Err(status); + } self.db.borrow_mut().write(batch, true) } @@ -90,6 +98,13 @@ pub trait SharedLeveldbAccess { fn shared_leveldb(&self) -> Option>; } +#[cfg(test)] +impl SharedLeveldb { + pub fn inject_commit_failure(&self, status: Status) { + self.commit_fail_status.borrow_mut().replace(status); + } +} + #[cfg(test)] mod tests { use super::*; From 34b39f1531bb7b3b8d99c706e0b4123eb6e6a7bd Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Mon, 17 Nov 2025 16:36:26 +0900 Subject: [PATCH 8/9] fix: update tamestamp --- src/convergence/resolver.rs | 2 +- src/dasl/node.rs | 2 +- src/graph/dag.rs | 2 +- src/graph/storage.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/convergence/resolver.rs b/src/convergence/resolver.rs index d875325..545c124 100644 --- a/src/convergence/resolver.rs +++ b/src/convergence/resolver.rs @@ -101,7 +101,7 @@ where SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| CrdtError::Internal(format!("timestamp error: {e}"))) - .map(|duration| duration.as_secs()) + .map(|duration| duration.as_nanos() as u64) } } diff --git a/src/dasl/node.rs b/src/dasl/node.rs index 9b371fe..7de932c 100644 --- a/src/dasl/node.rs +++ b/src/dasl/node.rs @@ -21,7 +21,7 @@ const RAW_CODE: u64 = 0x55; /// * `payload` - The main content/data of the entry. /// * `parents` - A vector of content ids (Content Identifiers) pointing to parent entries. /// * `genesis` - The genesis CID that this node belongs to (None for genesis nodes, Some(genesis_cid) for child nodes). -/// * `timestamp` - Unix timestamp representing when the entry was created. +/// * `timestamp` - Unix timestamp in nanoseconds representing when the entry was created. /// * `metadata` - Additional information about the entry (e.g., author, tags, or other attributes). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(bound = "P: Serialize + for<'a> Deserialize<'a>, M: Serialize + for<'a> Deserialize<'a>")] diff --git a/src/graph/dag.rs b/src/graph/dag.rs index 5ada4a1..5d04a2f 100644 --- a/src/graph/dag.rs +++ b/src/graph/dag.rs @@ -193,7 +193,7 @@ where SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(GraphError::Timestamp) - .map(|d| d.as_secs()) + .map(|d| d.as_nanos() as u64) } /// Check if adding an edge (new node with parents) would create a cycle diff --git a/src/graph/storage.rs b/src/graph/storage.rs index 2116919..508c6b4 100644 --- a/src/graph/storage.rs +++ b/src/graph/storage.rs @@ -166,7 +166,7 @@ mod tests { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() - .as_secs(); + .as_nanos() as u64; Node::new_genesis(payload.to_string(), timestamp, "metadata".to_string()) } From 9e5655839b8b33e8fbf21532675302de26df831b Mon Sep 17 00:00:00 2001 From: Yu-da-1 Date: Tue, 2 Dec 2025 00:46:16 +0900 Subject: [PATCH 9/9] fix: update use enum for content policy type and switch DAG timestamps to nanoseconds --- src/convergence/metadata.rs | 42 +++++++++++++++++++++++++++++++------ src/graph/dag.rs | 1 + 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/convergence/metadata.rs b/src/convergence/metadata.rs index 047792e..8fbd514 100644 --- a/src/convergence/metadata.rs +++ b/src/convergence/metadata.rs @@ -1,11 +1,36 @@ use serde::{Deserialize, Serialize}; +/// Built-in and custom convergence policy types. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum PolicyType { + /// Last-Write-Wins policy. + Lww, + /// Any non-builtin policy, identified by its name. + Custom(String), +} + +impl From<&str> for PolicyType { + fn from(value: &str) -> Self { + match value { + "lww" => PolicyType::Lww, + other => PolicyType::Custom(other.to_string()), + } + } +} + +impl From for PolicyType { + fn from(value: String) -> Self { + PolicyType::from(value.as_str()) + } +} + /// Metadata that stores information required for convergence policies. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct ContentMetadata { - /// Policy type name (e.g. "lww", "text", "custom-policy"). - /// When this is `None`, it falls back to the default policy (currently "lww"). - policy_type: Option, + /// Policy type (e.g. Lww, custom named policy). + /// + /// When this is `None`, it falls back to the default policy (currently Lww). + policy_type: Option, } impl ContentMetadata { @@ -15,15 +40,20 @@ impl ContentMetadata { } /// Create metadata that uses the specified policy. - pub fn with_policy(policy_type: impl Into) -> Self { + /// + /// This accepts either a concrete `PolicyType` or a string like `"lww"` or `"custom-policy"`. + pub fn with_policy(policy_type: impl Into) -> Self { Self { policy_type: Some(policy_type.into()), } } - /// Return the configured policy type; falls back to "lww" when unspecified. + /// Return the configured policy type name; falls back to `"lww"` when unspecified. pub fn policy_type(&self) -> &str { - self.policy_type.as_deref().unwrap_or("lww") + match &self.policy_type { + Some(PolicyType::Lww) | None => "lww", + Some(PolicyType::Custom(name)) => name.as_str(), + } } } diff --git a/src/graph/dag.rs b/src/graph/dag.rs index 5d04a2f..a952ef6 100644 --- a/src/graph/dag.rs +++ b/src/graph/dag.rs @@ -189,6 +189,7 @@ where Ok(result) } + /// Returns the current time in nanoseconds since the Unix epoch. fn current_timestamp() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH)