Skip to content

Commit b1d8ac0

Browse files
authored
Add History type and merge-commit model to graph-storage (#4238)
* Add History type and merge-commit model to graph-storage * Add History::merge with canonical-sort convergence to graph-storage * Append merge delta without re-sorting the whole history * Resurrect across merges by searching all ancestors, not the primary chain
1 parent a5e7a4f commit b1d8ac0

7 files changed

Lines changed: 493 additions & 174 deletions

File tree

document/graph-storage/src/crdt.rs

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use serde::{Deserialize, Serialize};
1111
#[derive(Clone, Debug, Serialize, Deserialize)]
1212
pub struct Delta {
1313
pub id: Rev,
14-
pub parents: Vec<Rev>,
14+
/// Primary parent; `None` for the root delta.
15+
pub parent: Option<Rev>,
1516
pub author: PeerId,
1617
pub timestamp: TimeStamp,
1718
pub kind: RegistryDelta,
@@ -24,11 +25,11 @@ pub struct Delta {
2425
}
2526

2627
impl Delta {
27-
pub fn new(parents: Vec<Rev>, author: PeerId, timestamp: TimeStamp, kind: RegistryDelta, reverse: RegistryDelta) -> Self {
28-
let id = compute_rev(&parents, author, timestamp, &kind);
28+
pub fn new(parent: Option<Rev>, author: PeerId, timestamp: TimeStamp, kind: RegistryDelta, reverse: RegistryDelta) -> Self {
29+
let id = compute_rev(parent, author, timestamp, &kind);
2930
Self {
3031
id,
31-
parents,
32+
parent,
3233
author,
3334
timestamp,
3435
kind,
@@ -37,6 +38,35 @@ impl Delta {
3738
}
3839
}
3940

41+
/// Build a merge delta joining `tips` into one node. See [`RegistryDelta::Merge`] for the semantics.
42+
pub fn merge(tips: impl IntoIterator<Item = Rev>, author: PeerId, timestamp: TimeStamp) -> Self {
43+
let mut parents: Vec<Rev> = tips.into_iter().collect();
44+
parents.sort_unstable();
45+
parents.dedup();
46+
let parent = parents.first().copied();
47+
let extra_parents = parents.split_first().map(|(_, rest)| rest.to_vec()).unwrap_or_default();
48+
let kind = RegistryDelta::Merge { extra_parents };
49+
let id = compute_rev(parent, author, timestamp, &kind);
50+
Self {
51+
id,
52+
parent,
53+
author,
54+
timestamp,
55+
reverse: kind.clone(),
56+
kind,
57+
attributes: Attributes::default(),
58+
}
59+
}
60+
61+
/// Every parent: the primary `parent` (absent for the root) plus a merge's `extra_parents`.
62+
pub fn all_parents(&self) -> impl Iterator<Item = Rev> + '_ {
63+
let extras = match &self.kind {
64+
RegistryDelta::Merge { extra_parents } => extra_parents.as_slice(),
65+
_ => &[],
66+
};
67+
self.parent.into_iter().chain(extras.iter().copied())
68+
}
69+
4070
/// Mark this delta as the last op of a user interaction, so the undo cursor treats it as a checkpoint.
4171
pub fn mark_interaction_end(&mut self, timestamp: TimeStamp) {
4272
self.attributes.set(attr::delta::INTERACTION_END, serde_json::Value::Bool(true), timestamp);
@@ -47,9 +77,9 @@ impl Delta {
4777
}
4878

4979
/// The content-addressed `Rev` this delta's identity fields hash to. Equals `id` for a delta built
50-
/// via `new`; differs only if `id` was tampered with or the hash derivation changed.
80+
/// via `new`/`merge`; differs only if `id` was tampered with or the hash derivation changed.
5181
pub fn recomputed_id(&self) -> Rev {
52-
compute_rev(&self.parents, self.author, self.timestamp, &self.kind)
82+
compute_rev(self.parent, self.author, self.timestamp, &self.kind)
5383
}
5484

5585
/// Whether `id` matches the recomputed content hash. `Delta` deserializes without checking this
@@ -148,6 +178,13 @@ pub enum RegistryDelta {
148178
ChangeDocumentAttribute {
149179
delta: AttributeDelta,
150180
},
181+
/// Joins divergent history tips into one shared node. A registry no-op on replay (it only collapses
182+
/// tips so `head` stays a single `Rev`); the joined tips are `Delta::parent` (the lowest `Rev`) plus
183+
/// these `extra_parents` (sorted). Identity is the parent set alone, so two peers merging the same
184+
/// tips mint the identical delta and it dedups.
185+
Merge {
186+
extra_parents: Vec<Rev>,
187+
},
151188
// Allow for future delta types without a model change
152189
Other(serde_json::Value),
153190
}

document/graph-storage/src/document.rs

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
use crate::{
2-
CrdtError, Delta, ExportSlot, HotOp, LamportClock, MAX_EXPORT_SLOTS, NetworkId, NodeId, NodeInput, PeerId, Registry, RegistryDelta, ResourceEntry, Rev, SourceValue, TimeStamp,
2+
CrdtError, Delta, ExportSlot, History, HotOp, LamportClock, MAX_EXPORT_SLOTS, NetworkId, NodeId, NodeInput, PeerId, Registry, RegistryDelta, ResourceEntry, Rev, SourceValue, TimeStamp,
33
apply_attribute_delta, reverse_attribute_delta,
44
};
5-
use std::collections::HashMap;
65

76
#[derive(Clone, Debug)]
87
pub struct Document {
@@ -20,9 +19,10 @@ pub struct Document {
2019
/// working registry keeps the staging-time timestamps. Benign while the local monotonic clock makes
2120
/// new edits win
2221
pub(crate) retired_snapshot: Registry,
23-
/// User's cursor in their local history chain.
24-
pub(crate) head: Rev,
25-
pub(crate) history: HashMap<Rev, Delta>,
22+
/// User's cursor in their local history chain. `None` on an empty document (no commits yet).
23+
pub(crate) head: Option<Rev>,
24+
/// Retired delta DAG in topological (append) order. See [`History`](crate::History).
25+
pub(crate) history: History,
2626
/// Revs undone past (most-recent last), so `redo` can re-apply them. Local-view state the DAG can't
2727
/// recover (a parent may have several children). A new edit while non-empty clears it.
2828
pub(crate) redo_stack: Vec<Rev>,
@@ -52,34 +52,50 @@ impl Document {
5252

5353
pub(crate) fn restore_node_from_history(&mut self, target: RegistryTarget, node_id: NodeId) -> Result<(), CrdtError> {
5454
let delta = self
55-
.history_iter()
56-
.find(|d| matches!(d.reverse, RegistryDelta::AddNode { id, .. } if id == node_id))
57-
.ok_or(CrdtError::NodeNotInHistory(node_id))?
58-
.clone();
55+
.find_in_ancestry(|d| matches!(d.reverse, RegistryDelta::AddNode { id, .. } if id == node_id))
56+
.ok_or(CrdtError::NodeNotInHistory(node_id))?;
5957
self.revert_delta(target, delta)
6058
}
6159

6260
pub(crate) fn restore_network_from_history(&mut self, target: RegistryTarget, network_id: NetworkId) -> Result<(), CrdtError> {
6361
// Find the Delta whose forward op removed this network. Its `reverse` is `AddNetwork`,
6462
// which is what we want to re-apply.
6563
let delta = self
66-
.history_iter()
67-
.find(|d| matches!(d.reverse, RegistryDelta::AddNetwork { id, .. } if id == network_id))
68-
.ok_or(CrdtError::NetworkNotInHistory(network_id))?
69-
.clone();
64+
.find_in_ancestry(|d| matches!(d.reverse, RegistryDelta::AddNetwork { id, .. } if id == network_id))
65+
.ok_or(CrdtError::NetworkNotInHistory(network_id))?;
7066
self.revert_delta(target, delta)
7167
}
7268

69+
/// Search every delta reachable from `head` (following all parents, including a merge's
70+
/// `extra_parents`) for the first matching `predicate`, breadth-first. Resurrection needs full
71+
/// ancestry reachability, so a node added only on a merged-in branch is still found.
72+
fn find_in_ancestry(&self, predicate: impl Fn(&Delta) -> bool) -> Option<Delta> {
73+
let mut queue: std::collections::VecDeque<Rev> = self.head.into_iter().collect();
74+
let mut seen: std::collections::HashSet<Rev> = self.head.into_iter().collect();
75+
while let Some(rev) = queue.pop_front() {
76+
let Some(delta) = self.history.get(rev) else { continue };
77+
if predicate(delta) {
78+
return Some(delta.clone());
79+
}
80+
for parent in delta.all_parents() {
81+
if seen.insert(parent) {
82+
queue.push_back(parent);
83+
}
84+
}
85+
}
86+
None
87+
}
88+
7389
/// Apply a delta's `reverse` as the new forward op (silent-zone undo). Force-applied: structural
7490
/// ops are idempotent, and LWW arms assign the reverse value unconditionally even though it carries
7591
/// the same timestamp as the forward op it undoes.
7692
pub(crate) fn revert_delta(&mut self, target: RegistryTarget, mut delta: Delta) -> Result<(), CrdtError> {
77-
std::mem::swap(&mut delta.kind, &mut delta.reverse);
78-
for parent in &delta.parents {
79-
if !self.history.contains_key(parent) {
80-
return Err(CrdtError::NotFoundInHistory(*parent));
93+
for parent in delta.all_parents() {
94+
if !self.history.contains(parent) {
95+
return Err(CrdtError::NotFoundInHistory(parent));
8196
}
8297
}
98+
std::mem::swap(&mut delta.kind, &mut delta.reverse);
8399
self.apply_op_with(target, delta.kind, delta.timestamp, ApplyMode::Force)
84100
}
85101

@@ -104,13 +120,13 @@ impl Document {
104120
/// targets, Remove on missing ones) since hot ops already produced the structural state.
105121
/// The point is to bump field timestamps to T_retire via the LWW arms.
106122
pub fn apply_delta(&mut self, delta: Delta) -> Result<(), CrdtError> {
107-
for parent in &delta.parents {
108-
if !self.history.contains_key(parent) {
109-
return Err(CrdtError::NotFoundInHistory(*parent));
123+
for parent in delta.all_parents() {
124+
if !self.history.contains(parent) {
125+
return Err(CrdtError::NotFoundInHistory(parent));
110126
}
111127
}
112128
self.apply_op_idempotent(delta.kind.clone(), delta.timestamp)?;
113-
self.history.insert(delta.id, delta);
129+
self.history.push(delta);
114130
Ok(())
115131
}
116132

@@ -271,7 +287,8 @@ impl Document {
271287
RegistryDelta::ChangeDocumentAttribute { delta } => {
272288
apply_attribute_delta(delta, timestamp, force, &mut registry.attributes);
273289
}
274-
RegistryDelta::Other(_) => {}
290+
// Merge is a structural sync point only; it mutates no registry state.
291+
RegistryDelta::Merge { .. } | RegistryDelta::Other(_) => {}
275292
}
276293
Ok(())
277294
}
@@ -407,35 +424,10 @@ impl Document {
407424
let snapshot = registry.resources.get(&id).cloned().unwrap_or_default();
408425
RegistryDelta::AddResource { id, entry: snapshot }
409426
}
427+
RegistryDelta::Merge { extra_parents } => RegistryDelta::Merge { extra_parents: extra_parents.clone() },
410428
&RegistryDelta::Other(_) => RegistryDelta::Other(serde_json::Value::Null),
411429
})
412430
}
413-
414-
/// Retired-only walk from `head` along first parents. Hot ops are excluded by design.
415-
fn history_iter(&self) -> HistoryIter<'_> {
416-
HistoryIter {
417-
document: self,
418-
parent_rev: self.head,
419-
}
420-
}
421-
}
422-
423-
struct HistoryIter<'a> {
424-
document: &'a Document,
425-
parent_rev: Rev,
426-
}
427-
428-
impl<'a> Iterator for HistoryIter<'a> {
429-
type Item = &'a Delta;
430-
431-
fn next(&mut self) -> Option<Self::Item> {
432-
let delta = self.document.history.get(&self.parent_rev)?;
433-
// First parent only for now. Local-chain walking (filter by author) is a follow-up. The root
434-
// delta has no parents, so fall back to the `0` sentinel: the next `get` misses and ends the
435-
// walk *after* yielding the root (using `?` here would drop the root instead).
436-
self.parent_rev = delta.parents.first().copied().unwrap_or(0);
437-
Some(delta)
438-
}
439431
}
440432

441433
/// Which of a [`Document`]'s two registries an apply targets: the working copy (retired state plus

0 commit comments

Comments
 (0)