mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
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
This commit is contained in:
@@ -11,7 +11,8 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
pub id: Rev,
|
||||
pub parents: Vec<Rev>,
|
||||
/// Primary parent; `None` for the root delta.
|
||||
pub parent: Option<Rev>,
|
||||
pub author: PeerId,
|
||||
pub timestamp: TimeStamp,
|
||||
pub kind: RegistryDelta,
|
||||
@@ -24,11 +25,11 @@ pub struct Delta {
|
||||
}
|
||||
|
||||
impl Delta {
|
||||
pub fn new(parents: Vec<Rev>, author: PeerId, timestamp: TimeStamp, kind: RegistryDelta, reverse: RegistryDelta) -> Self {
|
||||
let id = compute_rev(&parents, author, timestamp, &kind);
|
||||
pub fn new(parent: Option<Rev>, author: PeerId, timestamp: TimeStamp, kind: RegistryDelta, reverse: RegistryDelta) -> Self {
|
||||
let id = compute_rev(parent, author, timestamp, &kind);
|
||||
Self {
|
||||
id,
|
||||
parents,
|
||||
parent,
|
||||
author,
|
||||
timestamp,
|
||||
kind,
|
||||
@@ -37,6 +38,35 @@ impl Delta {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a merge delta joining `tips` into one node. See [`RegistryDelta::Merge`] for the semantics.
|
||||
pub fn merge(tips: impl IntoIterator<Item = Rev>, author: PeerId, timestamp: TimeStamp) -> Self {
|
||||
let mut parents: Vec<Rev> = tips.into_iter().collect();
|
||||
parents.sort_unstable();
|
||||
parents.dedup();
|
||||
let parent = parents.first().copied();
|
||||
let extra_parents = parents.split_first().map(|(_, rest)| rest.to_vec()).unwrap_or_default();
|
||||
let kind = RegistryDelta::Merge { extra_parents };
|
||||
let id = compute_rev(parent, author, timestamp, &kind);
|
||||
Self {
|
||||
id,
|
||||
parent,
|
||||
author,
|
||||
timestamp,
|
||||
reverse: kind.clone(),
|
||||
kind,
|
||||
attributes: Attributes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every parent: the primary `parent` (absent for the root) plus a merge's `extra_parents`.
|
||||
pub fn all_parents(&self) -> impl Iterator<Item = Rev> + '_ {
|
||||
let extras = match &self.kind {
|
||||
RegistryDelta::Merge { extra_parents } => extra_parents.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
self.parent.into_iter().chain(extras.iter().copied())
|
||||
}
|
||||
|
||||
/// Mark this delta as the last op of a user interaction, so the undo cursor treats it as a checkpoint.
|
||||
pub fn mark_interaction_end(&mut self, timestamp: TimeStamp) {
|
||||
self.attributes.set(attr::delta::INTERACTION_END, serde_json::Value::Bool(true), timestamp);
|
||||
@@ -47,9 +77,9 @@ impl Delta {
|
||||
}
|
||||
|
||||
/// The content-addressed `Rev` this delta's identity fields hash to. Equals `id` for a delta built
|
||||
/// via `new`; differs only if `id` was tampered with or the hash derivation changed.
|
||||
/// via `new`/`merge`; differs only if `id` was tampered with or the hash derivation changed.
|
||||
pub fn recomputed_id(&self) -> Rev {
|
||||
compute_rev(&self.parents, self.author, self.timestamp, &self.kind)
|
||||
compute_rev(self.parent, self.author, self.timestamp, &self.kind)
|
||||
}
|
||||
|
||||
/// Whether `id` matches the recomputed content hash. `Delta` deserializes without checking this
|
||||
@@ -148,6 +178,13 @@ pub enum RegistryDelta {
|
||||
ChangeDocumentAttribute {
|
||||
delta: AttributeDelta,
|
||||
},
|
||||
/// Joins divergent history tips into one shared node. A registry no-op on replay (it only collapses
|
||||
/// tips so `head` stays a single `Rev`); the joined tips are `Delta::parent` (the lowest `Rev`) plus
|
||||
/// these `extra_parents` (sorted). Identity is the parent set alone, so two peers merging the same
|
||||
/// tips mint the identical delta and it dedups.
|
||||
Merge {
|
||||
extra_parents: Vec<Rev>,
|
||||
},
|
||||
// Allow for future delta types without a model change
|
||||
Other(serde_json::Value),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::{
|
||||
CrdtError, Delta, ExportSlot, HotOp, LamportClock, MAX_EXPORT_SLOTS, NetworkId, NodeId, NodeInput, PeerId, Registry, RegistryDelta, ResourceEntry, Rev, SourceValue, TimeStamp,
|
||||
CrdtError, Delta, ExportSlot, History, HotOp, LamportClock, MAX_EXPORT_SLOTS, NetworkId, NodeId, NodeInput, PeerId, Registry, RegistryDelta, ResourceEntry, Rev, SourceValue, TimeStamp,
|
||||
apply_attribute_delta, reverse_attribute_delta,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Document {
|
||||
@@ -20,9 +19,10 @@ pub struct Document {
|
||||
/// working registry keeps the staging-time timestamps. Benign while the local monotonic clock makes
|
||||
/// new edits win
|
||||
pub(crate) retired_snapshot: Registry,
|
||||
/// User's cursor in their local history chain.
|
||||
pub(crate) head: Rev,
|
||||
pub(crate) history: HashMap<Rev, Delta>,
|
||||
/// User's cursor in their local history chain. `None` on an empty document (no commits yet).
|
||||
pub(crate) head: Option<Rev>,
|
||||
/// Retired delta DAG in topological (append) order. See [`History`](crate::History).
|
||||
pub(crate) history: History,
|
||||
/// Revs undone past (most-recent last), so `redo` can re-apply them. Local-view state the DAG can't
|
||||
/// recover (a parent may have several children). A new edit while non-empty clears it.
|
||||
pub(crate) redo_stack: Vec<Rev>,
|
||||
@@ -52,10 +52,8 @@ impl Document {
|
||||
|
||||
pub(crate) fn restore_node_from_history(&mut self, target: RegistryTarget, node_id: NodeId) -> Result<(), CrdtError> {
|
||||
let delta = self
|
||||
.history_iter()
|
||||
.find(|d| matches!(d.reverse, RegistryDelta::AddNode { id, .. } if id == node_id))
|
||||
.ok_or(CrdtError::NodeNotInHistory(node_id))?
|
||||
.clone();
|
||||
.find_in_ancestry(|d| matches!(d.reverse, RegistryDelta::AddNode { id, .. } if id == node_id))
|
||||
.ok_or(CrdtError::NodeNotInHistory(node_id))?;
|
||||
self.revert_delta(target, delta)
|
||||
}
|
||||
|
||||
@@ -63,23 +61,41 @@ impl Document {
|
||||
// Find the Delta whose forward op removed this network. Its `reverse` is `AddNetwork`,
|
||||
// which is what we want to re-apply.
|
||||
let delta = self
|
||||
.history_iter()
|
||||
.find(|d| matches!(d.reverse, RegistryDelta::AddNetwork { id, .. } if id == network_id))
|
||||
.ok_or(CrdtError::NetworkNotInHistory(network_id))?
|
||||
.clone();
|
||||
.find_in_ancestry(|d| matches!(d.reverse, RegistryDelta::AddNetwork { id, .. } if id == network_id))
|
||||
.ok_or(CrdtError::NetworkNotInHistory(network_id))?;
|
||||
self.revert_delta(target, delta)
|
||||
}
|
||||
|
||||
/// Search every delta reachable from `head` (following all parents, including a merge's
|
||||
/// `extra_parents`) for the first matching `predicate`, breadth-first. Resurrection needs full
|
||||
/// ancestry reachability, so a node added only on a merged-in branch is still found.
|
||||
fn find_in_ancestry(&self, predicate: impl Fn(&Delta) -> bool) -> Option<Delta> {
|
||||
let mut queue: std::collections::VecDeque<Rev> = self.head.into_iter().collect();
|
||||
let mut seen: std::collections::HashSet<Rev> = self.head.into_iter().collect();
|
||||
while let Some(rev) = queue.pop_front() {
|
||||
let Some(delta) = self.history.get(rev) else { continue };
|
||||
if predicate(delta) {
|
||||
return Some(delta.clone());
|
||||
}
|
||||
for parent in delta.all_parents() {
|
||||
if seen.insert(parent) {
|
||||
queue.push_back(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply a delta's `reverse` as the new forward op (silent-zone undo). Force-applied: structural
|
||||
/// ops are idempotent, and LWW arms assign the reverse value unconditionally even though it carries
|
||||
/// the same timestamp as the forward op it undoes.
|
||||
pub(crate) fn revert_delta(&mut self, target: RegistryTarget, mut delta: Delta) -> Result<(), CrdtError> {
|
||||
std::mem::swap(&mut delta.kind, &mut delta.reverse);
|
||||
for parent in &delta.parents {
|
||||
if !self.history.contains_key(parent) {
|
||||
return Err(CrdtError::NotFoundInHistory(*parent));
|
||||
for parent in delta.all_parents() {
|
||||
if !self.history.contains(parent) {
|
||||
return Err(CrdtError::NotFoundInHistory(parent));
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut delta.kind, &mut delta.reverse);
|
||||
self.apply_op_with(target, delta.kind, delta.timestamp, ApplyMode::Force)
|
||||
}
|
||||
|
||||
@@ -104,13 +120,13 @@ impl Document {
|
||||
/// targets, Remove on missing ones) since hot ops already produced the structural state.
|
||||
/// The point is to bump field timestamps to T_retire via the LWW arms.
|
||||
pub fn apply_delta(&mut self, delta: Delta) -> Result<(), CrdtError> {
|
||||
for parent in &delta.parents {
|
||||
if !self.history.contains_key(parent) {
|
||||
return Err(CrdtError::NotFoundInHistory(*parent));
|
||||
for parent in delta.all_parents() {
|
||||
if !self.history.contains(parent) {
|
||||
return Err(CrdtError::NotFoundInHistory(parent));
|
||||
}
|
||||
}
|
||||
self.apply_op_idempotent(delta.kind.clone(), delta.timestamp)?;
|
||||
self.history.insert(delta.id, delta);
|
||||
self.history.push(delta);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -271,7 +287,8 @@ impl Document {
|
||||
RegistryDelta::ChangeDocumentAttribute { delta } => {
|
||||
apply_attribute_delta(delta, timestamp, force, &mut registry.attributes);
|
||||
}
|
||||
RegistryDelta::Other(_) => {}
|
||||
// Merge is a structural sync point only; it mutates no registry state.
|
||||
RegistryDelta::Merge { .. } | RegistryDelta::Other(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -407,35 +424,10 @@ impl Document {
|
||||
let snapshot = registry.resources.get(&id).cloned().unwrap_or_default();
|
||||
RegistryDelta::AddResource { id, entry: snapshot }
|
||||
}
|
||||
RegistryDelta::Merge { extra_parents } => RegistryDelta::Merge { extra_parents: extra_parents.clone() },
|
||||
&RegistryDelta::Other(_) => RegistryDelta::Other(serde_json::Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
/// Retired-only walk from `head` along first parents. Hot ops are excluded by design.
|
||||
fn history_iter(&self) -> HistoryIter<'_> {
|
||||
HistoryIter {
|
||||
document: self,
|
||||
parent_rev: self.head,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryIter<'a> {
|
||||
document: &'a Document,
|
||||
parent_rev: Rev,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for HistoryIter<'a> {
|
||||
type Item = &'a Delta;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let delta = self.document.history.get(&self.parent_rev)?;
|
||||
// First parent only for now. Local-chain walking (filter by author) is a follow-up. The root
|
||||
// delta has no parents, so fall back to the `0` sentinel: the next `get` misses and ends the
|
||||
// walk *after* yielding the root (using `?` here would drop the root instead).
|
||||
self.parent_rev = delta.parents.first().copied().unwrap_or(0);
|
||||
Some(delta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of a [`Document`]'s two registries an apply targets: the working copy (retired state plus
|
||||
|
||||
180
document/graph-storage/src/history.rs
Normal file
180
document/graph-storage/src/history.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Retired delta history: the durable, append-only DAG of committed deltas.
|
||||
//!
|
||||
//! [`History`] owns the deltas in topological order (every parent precedes its children) plus an
|
||||
//! index from [`Rev`] to position for O(1) lookup. The order is a valid replay order, so it is what
|
||||
//! gets serialized to the on-disk history file and what [`crate::Session::replay_from_history`]
|
||||
//! consumes. Retired commits have a single writer in every regime (solo editing, or leader-ordered
|
||||
//! collaboration where the leader serializes retired commits), so appending preserves the order by
|
||||
//! construction. The only operation that introduces out-of-order deltas is [`merge`](History::merge),
|
||||
//! which re-sorts the combined set into the canonical order to restore the invariant.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{AttributesWrite, CrdtError, Delta, Rev, TimeStamp};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct History {
|
||||
/// Deltas in topological order. Mutated only via [`push`](Self::push).
|
||||
deltas: Vec<Delta>,
|
||||
/// `Rev` to its position in `deltas`. Kept in sync with `deltas` by every mutator.
|
||||
index: HashMap<Rev, usize>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Build from deltas already in topological order (the on-disk load path), indexing them in place.
|
||||
pub fn from_ordered(deltas: Vec<Delta>) -> Self {
|
||||
let index = deltas.iter().enumerate().map(|(position, delta)| (delta.id, position)).collect();
|
||||
Self { deltas, index }
|
||||
}
|
||||
|
||||
pub fn get(&self, rev: Rev) -> Option<&Delta> {
|
||||
self.index.get(&rev).map(|&position| &self.deltas[position])
|
||||
}
|
||||
|
||||
pub fn contains(&self, rev: Rev) -> bool {
|
||||
self.index.contains_key(&rev)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.deltas.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.deltas.is_empty()
|
||||
}
|
||||
|
||||
/// Append a delta after its parents, keeping `deltas` and `index` in sync. A duplicate `Rev`
|
||||
/// (idempotent re-apply) overwrites the existing entry in place rather than appending, so the
|
||||
/// order and index are unchanged.
|
||||
pub fn push(&mut self, delta: Delta) {
|
||||
if let Some(&position) = self.index.get(&delta.id) {
|
||||
self.deltas[position] = delta;
|
||||
return;
|
||||
}
|
||||
self.index.insert(delta.id, self.deltas.len());
|
||||
self.deltas.push(delta);
|
||||
}
|
||||
|
||||
/// Deltas in topological order (a valid replay order).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Delta> + '_ {
|
||||
self.deltas.iter()
|
||||
}
|
||||
|
||||
/// Absorb `incoming` (dedup by `Rev`) and canonically re-sort the whole combined history.
|
||||
///
|
||||
/// The sort is deterministic (topological, ties broken by `Rev`), so two peers that absorb the same
|
||||
/// delta set produce byte-identical history, not merely two different valid orderings. This is the
|
||||
/// history-convergence mechanism: arrival order is erased. Callers update the registry separately
|
||||
/// (LWW apply is commutative, so the registry converges regardless of order).
|
||||
pub fn merge(&mut self, incoming: impl IntoIterator<Item = Delta>) {
|
||||
for delta in incoming {
|
||||
self.push(delta);
|
||||
}
|
||||
self.canonical_sort();
|
||||
}
|
||||
|
||||
/// Re-order `deltas` into the canonical topological order and rebuild the index: parents precede
|
||||
/// children, and among deltas whose parents are all emitted the lowest `Rev` goes first. O(V + E).
|
||||
fn canonical_sort(&mut self) {
|
||||
// Unsatisfied in-history parent count per delta, plus reverse edges to decrement as parents emit.
|
||||
let mut pending_parents: HashMap<Rev, usize> = HashMap::with_capacity(self.deltas.len());
|
||||
let mut children: HashMap<Rev, Vec<Rev>> = HashMap::new();
|
||||
for delta in &self.deltas {
|
||||
let in_history_parents = delta.all_parents().filter(|parent| self.index.contains_key(parent)).count();
|
||||
pending_parents.insert(delta.id, in_history_parents);
|
||||
for parent in delta.all_parents() {
|
||||
if self.index.contains_key(&parent) {
|
||||
children.entry(parent).or_default().push(delta.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ready set as a min-heap on `Rev` (via `Reverse`) so ties resolve deterministically.
|
||||
let mut ready: std::collections::BinaryHeap<std::cmp::Reverse<Rev>> = pending_parents.iter().filter(|(_, count)| **count == 0).map(|(rev, _)| std::cmp::Reverse(*rev)).collect();
|
||||
|
||||
let mut order: Vec<Rev> = Vec::with_capacity(self.deltas.len());
|
||||
while let Some(std::cmp::Reverse(rev)) = ready.pop() {
|
||||
order.push(rev);
|
||||
for child in children.get(&rev).into_iter().flatten() {
|
||||
let count = pending_parents.get_mut(child).expect("child is in history");
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
ready.push(std::cmp::Reverse(*child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `order` is a permutation of the existing revs, so reorder `deltas` to match and rebuild the index.
|
||||
let mut by_rev: HashMap<Rev, Delta> = self.deltas.drain(..).map(|delta| (delta.id, delta)).collect();
|
||||
self.index.clear();
|
||||
for (position, rev) in order.iter().enumerate() {
|
||||
if let Some(delta) = by_rev.remove(rev) {
|
||||
self.index.insert(*rev, position);
|
||||
self.deltas.push(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current tips: revs that no other delta lists as a parent (the divergent heads). A linear
|
||||
/// history has exactly one tip; concurrent branches have several. Sorted ascending for determinism.
|
||||
pub fn tips(&self) -> Vec<Rev> {
|
||||
let referenced: std::collections::HashSet<Rev> = self.deltas.iter().flat_map(|delta| delta.all_parents()).collect();
|
||||
let mut tips: Vec<Rev> = self.deltas.iter().map(|delta| delta.id).filter(|rev| !referenced.contains(rev)).collect();
|
||||
tips.sort_unstable();
|
||||
tips
|
||||
}
|
||||
|
||||
/// Mark a retired delta as the end of a user interaction. Mutates only the delta's attributes
|
||||
/// (excluded from its `Rev`), so the index stays valid. Returns whether the delta was found.
|
||||
pub fn mark_interaction_end(&mut self, rev: Rev, timestamp: TimeStamp) -> bool {
|
||||
match self.index.get(&rev) {
|
||||
Some(&position) => {
|
||||
self.deltas[position].mark_interaction_end(timestamp);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a local annotation attribute (e.g. a commit message) on a retired delta in place. Excluded
|
||||
/// from the delta's `Rev`, so identity and the index are unchanged. Returns whether the delta was found.
|
||||
pub fn annotate(&mut self, rev: Rev, key: &str, value: serde_json::Value, timestamp: TimeStamp) -> bool {
|
||||
match self.index.get(&rev) {
|
||||
Some(&position) => {
|
||||
self.deltas[position].attributes.set(key, value, timestamp);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only mutable access to the first stored delta, for corrupting it to exercise `verify`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn first_mut(&mut self) -> Option<&mut Delta> {
|
||||
self.deltas.first_mut()
|
||||
}
|
||||
|
||||
/// Verify the two stored invariants for history loaded from an untrusted source: every delta's
|
||||
/// content-addressed `id` matches its recomputed hash, and the deltas are in topological order
|
||||
/// (each delta's in-history parents precede it). Returns the first violation found.
|
||||
pub fn verify(&self) -> Result<(), CrdtError> {
|
||||
let mut seen: std::collections::HashSet<Rev> = std::collections::HashSet::with_capacity(self.deltas.len());
|
||||
for delta in &self.deltas {
|
||||
let expected = delta.recomputed_id();
|
||||
if delta.id != expected {
|
||||
return Err(CrdtError::RevMismatch { stored: delta.id, expected });
|
||||
}
|
||||
for parent in delta.all_parents() {
|
||||
if self.index.contains_key(&parent) && !seen.contains(&parent) {
|
||||
return Err(CrdtError::NotFoundInHistory(parent));
|
||||
}
|
||||
}
|
||||
seen.insert(delta.id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,28 @@ impl std::fmt::Display for NetworkId {
|
||||
|
||||
/// Content-addressed identity for a `Delta`.
|
||||
/// 128-bit blake3 truncation: comfortable collision headroom for any plausible document lifetime
|
||||
/// without being adversarial-grade. Same delta content always produces the same `Rev`.
|
||||
pub type Rev = u128;
|
||||
/// without being adversarial-grade. Same delta content always produces the same `Rev`. Non-zero so
|
||||
/// `Option<Rev>` (a missing/root parent) is niche-optimized to the same size as a bare `Rev`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Rev(pub std::num::NonZeroU128);
|
||||
|
||||
impl Rev {
|
||||
/// Wrap a raw value, or `None` if it is zero.
|
||||
pub fn new(value: u128) -> Option<Self> {
|
||||
std::num::NonZeroU128::new(value).map(Self)
|
||||
}
|
||||
|
||||
pub fn get(self) -> u128 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Rev {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Root network ID. The renderable graph lives in `networks[&ROOT_NETWORK]`.
|
||||
pub const ROOT_NETWORK: NetworkId = NetworkId(0);
|
||||
@@ -88,12 +108,26 @@ impl LamportClock {
|
||||
}
|
||||
|
||||
/// Hash the identity-bearing fields of a `Delta` with blake3 and truncate to 128 bits.
|
||||
pub(crate) fn compute_rev(parents: &[Rev], author: PeerId, timestamp: TimeStamp, delta_type: &RegistryDelta) -> Rev {
|
||||
///
|
||||
/// A [`RegistryDelta::Merge`] is addressed by its sorted parent set alone (author and timestamp
|
||||
/// excluded), so two peers merging the same tips mint the identical `Rev` and dedup. Every other
|
||||
/// delta hashes `(parent, author, timestamp, kind)`.
|
||||
pub(crate) fn compute_rev(parent: Option<Rev>, author: PeerId, timestamp: TimeStamp, delta_type: &RegistryDelta) -> Rev {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let bytes = rmp_serde::to_vec(&(parents, author, timestamp, delta_type)).expect("Delta identity fields must serialize");
|
||||
let bytes = match delta_type {
|
||||
RegistryDelta::Merge { extra_parents } => {
|
||||
let mut parents: Vec<Rev> = parent.into_iter().chain(extra_parents.iter().copied()).collect();
|
||||
parents.sort_unstable();
|
||||
parents.dedup();
|
||||
rmp_serde::to_vec(&("merge", parents)).expect("Merge identity fields must serialize")
|
||||
}
|
||||
_ => rmp_serde::to_vec(&(parent, author, timestamp, delta_type)).expect("Delta identity fields must serialize"),
|
||||
};
|
||||
hasher.update(&bytes);
|
||||
let digest = hasher.finalize();
|
||||
let mut truncated = [0u8; 16];
|
||||
truncated.copy_from_slice(&digest.as_bytes()[..16]);
|
||||
Rev::from_le_bytes(truncated)
|
||||
// A 128-bit blake3 truncation is zero with probability 2^-128 (never in practice); map it to 1 so
|
||||
// the non-zero invariant is total rather than relying on a panic that can't realistically fire.
|
||||
Rev::new(u128::from_le_bytes(truncated)).unwrap_or(Rev(std::num::NonZeroU128::MIN))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod attributes;
|
||||
pub mod crdt;
|
||||
pub mod delta;
|
||||
pub mod document;
|
||||
pub mod history;
|
||||
pub mod ids;
|
||||
pub mod model;
|
||||
pub mod registry;
|
||||
@@ -21,6 +22,7 @@ pub use attributes::*;
|
||||
pub use crdt::*;
|
||||
pub use document::*;
|
||||
pub use from_runtime::{RuntimeConversion, decode_declaration, encode_declaration};
|
||||
pub use history::History;
|
||||
pub use ids::*;
|
||||
pub use metadata_source::{InputMetadataEntry, NetworkMetadataEntry, NoMetadata, NodeMetadataEntry, NodeMetadataSource, Position};
|
||||
pub use model::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::from_runtime;
|
||||
use crate::{ApplyMode, AttributesWrite, Delta, Document, LamportClock, NetworkId, NodeId, NodeMetadataSource, PeerId, Registry, RegistryDelta, RegistryTarget, ResourceEntry, Rev, TimeStamp, UserId};
|
||||
use crate::{ApplyMode, Delta, Document, History, LamportClock, NetworkId, NodeId, NodeMetadataSource, PeerId, Registry, RegistryDelta, RegistryTarget, ResourceEntry, Rev, TimeStamp, UserId};
|
||||
use graphene_resource::{ResourceHash, ResourceId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -30,9 +30,9 @@ impl Session {
|
||||
document: Document {
|
||||
working_registry: Registry::default(),
|
||||
retired_snapshot: Registry::default(),
|
||||
history: HashMap::new(),
|
||||
history: History::new(),
|
||||
hot_log: Vec::new(),
|
||||
head: 0,
|
||||
head: None,
|
||||
redo_stack: Vec::new(),
|
||||
clock: LamportClock::new(peer),
|
||||
peer,
|
||||
@@ -173,21 +173,22 @@ impl Session {
|
||||
|
||||
let reverse = self.document.compute_reverse_delta(target, &op)?;
|
||||
let timestamp = self.document.clock.tick();
|
||||
let parents = if self.document.head == 0 { Vec::new() } else { vec![self.document.head] };
|
||||
let parent = self.document.head;
|
||||
let author = self.document.peer;
|
||||
|
||||
let delta = Delta::new(parents, author, timestamp, op, reverse);
|
||||
let delta = Delta::new(parent, author, timestamp, op, reverse);
|
||||
let rev = delta.id;
|
||||
|
||||
for parent in &delta.parents {
|
||||
if !self.document.history.contains_key(parent) {
|
||||
return Err(CrdtError::NotFoundInHistory(*parent));
|
||||
}
|
||||
// `parent` is `None` for the root commit; otherwise it must already be in history.
|
||||
if let Some(parent) = parent
|
||||
&& !self.document.history.contains(parent)
|
||||
{
|
||||
return Err(CrdtError::NotFoundInHistory(parent));
|
||||
}
|
||||
let mode = if idempotent { ApplyMode::Idempotent } else { ApplyMode::Live };
|
||||
self.document.apply_op_with(target, delta.kind.clone(), delta.timestamp, mode)?;
|
||||
self.document.history.insert(rev, delta);
|
||||
self.document.head = rev;
|
||||
self.document.history.push(delta);
|
||||
self.document.head = Some(rev);
|
||||
produced.push(rev);
|
||||
}
|
||||
|
||||
@@ -195,10 +196,11 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Wrap an already-materialized snapshot. Trusts `registry` to match `history`; advances the
|
||||
/// clock past every observed timestamp but does not re-apply ops.
|
||||
pub fn load(peer: PeerId, registry: Registry, history: HashMap<Rev, Delta>, head: Rev, redo_stack: Vec<Rev>, next_node_counter: u64) -> Self {
|
||||
/// clock past every observed timestamp but does not re-apply ops. `history` is taken in on-disk
|
||||
/// (topological) order.
|
||||
pub fn load(peer: PeerId, registry: Registry, history: Vec<Delta>, head: Option<Rev>, redo_stack: Vec<Rev>, next_node_counter: u64) -> Self {
|
||||
let mut clock = LamportClock::new(peer);
|
||||
for delta in history.values() {
|
||||
for delta in &history {
|
||||
clock.observe(delta.timestamp);
|
||||
}
|
||||
|
||||
@@ -208,7 +210,7 @@ impl Session {
|
||||
// `load`) build the working registry on top, leaving `retired_snapshot` at retired.
|
||||
retired_snapshot: registry.clone(),
|
||||
working_registry: registry,
|
||||
history,
|
||||
history: History::from_ordered(history),
|
||||
hot_log: Vec::new(),
|
||||
head,
|
||||
redo_stack,
|
||||
@@ -230,8 +232,8 @@ impl Session {
|
||||
for delta in deltas {
|
||||
let rev = delta.id;
|
||||
session.document.apply_op_idempotent(delta.kind.clone(), delta.timestamp)?;
|
||||
session.document.history.insert(rev, delta);
|
||||
session.document.head = rev;
|
||||
session.document.history.push(delta);
|
||||
session.document.head = Some(rev);
|
||||
}
|
||||
|
||||
// Pure retired-delta replay: no hot ops, so the working registry is fully retired.
|
||||
@@ -250,6 +252,38 @@ impl Session {
|
||||
self.document.replay_hot_op(hot_op)
|
||||
}
|
||||
|
||||
/// Integrate `incoming` retired deltas from another branch and emit a [`RegistryDelta::Merge`]
|
||||
/// joining the resulting tips, returning the new merge `Rev` (or `None` if `incoming` adds nothing).
|
||||
/// Applies each incoming op to the registry, then hands the set to [`History::merge`]. Incoming
|
||||
/// deltas must arrive in causal order.
|
||||
pub fn merge(&mut self, incoming: impl IntoIterator<Item = Delta>) -> Result<Option<Rev>, CrdtError> {
|
||||
let mut absorbed: Vec<Delta> = Vec::new();
|
||||
for delta in incoming {
|
||||
if self.document.history.contains(delta.id) {
|
||||
continue;
|
||||
}
|
||||
self.document.apply_op_idempotent(delta.kind.clone(), delta.timestamp)?;
|
||||
absorbed.push(delta);
|
||||
}
|
||||
if absorbed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
self.document.history.merge(absorbed);
|
||||
let tips = self.document.history.tips();
|
||||
let timestamp = self.document.clock.tick();
|
||||
let merge = Delta::merge(tips, self.document.peer, timestamp);
|
||||
let merge_rev = merge.id;
|
||||
// The merge's parents are the current tips, so it sorts last: `push` preserves the canonical
|
||||
// order without re-sorting the whole history.
|
||||
self.document.history.push(merge);
|
||||
self.document.head = Some(merge_rev);
|
||||
|
||||
// Merge runs with an empty hot log; keep the retired snapshot in step with the working registry.
|
||||
self.document.retired_snapshot = self.document.working_registry.clone();
|
||||
Ok(Some(merge_rev))
|
||||
}
|
||||
|
||||
/// Promote hot ops with timestamp `≤ up_to` into retired deltas, re-applied with fresh
|
||||
/// retirement timestamps so LWW arms bump field timestamps to `T_retire`.
|
||||
///
|
||||
@@ -273,9 +307,7 @@ impl Session {
|
||||
/// Called once per interaction by the editor-facing commit path (not by resource/internal commits).
|
||||
pub fn mark_interaction_end(&mut self, rev: Rev) {
|
||||
let timestamp = self.document.clock.tick();
|
||||
if let Some(delta) = self.document.history.get_mut(&rev) {
|
||||
delta.mark_interaction_end(timestamp);
|
||||
}
|
||||
self.document.history.mark_interaction_end(rev, timestamp);
|
||||
}
|
||||
|
||||
/// Low-level: set a local annotation attribute (e.g. a commit message) on a retired delta in place.
|
||||
@@ -283,7 +315,7 @@ impl Session {
|
||||
/// delta was found. The `Gdd` layer re-persists the affected history frame after calling this.
|
||||
pub fn annotate_delta(&mut self, rev: Rev, key: &str, value: serde_json::Value) -> bool {
|
||||
let timestamp = self.document.clock.tick();
|
||||
self.document.history.get_mut(&rev).map(|delta| delta.attributes.set(key, value, timestamp)).is_some()
|
||||
self.document.history.annotate(rev, key, value, timestamp)
|
||||
}
|
||||
|
||||
/// Whether there is a retired commit at `head` that can be undone in the silent zone (a commit
|
||||
@@ -296,21 +328,22 @@ impl Session {
|
||||
/// first-parents and checking whether it bottoms out at the root with no earlier interaction boundary to
|
||||
/// land on. If so, there is nothing before this interaction to undo to, so undo is disabled.
|
||||
pub fn can_undo(&self) -> bool {
|
||||
if self.document.head == 0 || self.document.last_broadcast_rev == Some(self.document.head) {
|
||||
let Some(head) = self.document.head else { return false };
|
||||
if self.document.last_broadcast_rev == Some(head) {
|
||||
return false;
|
||||
}
|
||||
self.interaction_start_parent(self.document.head).is_some_and(|parent| parent != 0)
|
||||
self.interaction_start_parent(head).is_some()
|
||||
}
|
||||
|
||||
/// Walk the interaction containing `rev` back along first-parents to its first delta, returning that
|
||||
/// delta's parent (the rev the cursor would rest on after undoing this interaction, or `0` for the root).
|
||||
/// Mirrors the boundary condition in [`undo`](Self::undo): stop when the parent is a `interaction_end`
|
||||
/// boundary or the root.
|
||||
/// Walk the interaction containing `rev` back along first-parents to its first delta, returning the
|
||||
/// rev the cursor would rest on after undoing this interaction, or `None` if that is the root (the
|
||||
/// earliest interaction, which is not undoable). Mirrors the boundary condition in [`undo`](Self::undo):
|
||||
/// stop when the parent is an `interaction_end` boundary or the root.
|
||||
fn interaction_start_parent(&self, rev: Rev) -> Option<Rev> {
|
||||
let mut current = rev;
|
||||
loop {
|
||||
let parent = self.document.history.get(¤t)?.parents.first().copied().unwrap_or(0);
|
||||
if parent == 0 || self.document.history.get(&parent).is_some_and(|d| d.is_interaction_end()) {
|
||||
let parent = self.document.history.get(current)?.parent?;
|
||||
if self.document.history.get(parent).is_some_and(|d| d.is_interaction_end()) {
|
||||
return Some(parent);
|
||||
}
|
||||
current = parent;
|
||||
@@ -330,20 +363,22 @@ impl Session {
|
||||
if !self.can_undo() {
|
||||
return Err(CrdtError::NothingToUndo);
|
||||
}
|
||||
let checkpoint = self.document.head;
|
||||
let checkpoint = self.document.head.ok_or(CrdtError::NothingToUndo)?;
|
||||
|
||||
// Revert this interaction's last delta, then keep going back until `head` rests on the previous
|
||||
// interaction's boundary (its `interaction_end` delta) or the root.
|
||||
loop {
|
||||
let rev = self.document.head;
|
||||
let delta = self.document.history.get(&rev).ok_or(CrdtError::NotFoundInHistory(rev))?.clone();
|
||||
let parent = delta.parents.first().copied().unwrap_or(0);
|
||||
let rev = self.document.head.ok_or(CrdtError::NothingToUndo)?;
|
||||
let delta = self.document.history.get(rev).ok_or(CrdtError::NotFoundInHistory(rev))?.clone();
|
||||
let parent = delta.parent;
|
||||
|
||||
self.document.revert_delta(RegistryTarget::Working, delta)?;
|
||||
self.document.head = parent;
|
||||
|
||||
if parent == 0 || self.document.history.get(&parent).is_some_and(|d| d.is_interaction_end()) {
|
||||
break;
|
||||
match parent {
|
||||
None => break,
|
||||
Some(parent) if self.document.history.get(parent).is_some_and(|d| d.is_interaction_end()) => break,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,15 +396,12 @@ impl Session {
|
||||
let checkpoint = self.document.redo_stack.pop().ok_or(CrdtError::NothingToRedo)?;
|
||||
|
||||
let mut forward = Vec::new();
|
||||
let mut cursor = checkpoint;
|
||||
let mut cursor = Some(checkpoint);
|
||||
while cursor != self.document.head {
|
||||
let delta = self.document.history.get(&cursor).ok_or(CrdtError::NotFoundInHistory(cursor))?.clone();
|
||||
let parent = delta.parents.first().copied().unwrap_or(0);
|
||||
let Some(rev) = cursor else { break };
|
||||
let delta = self.document.history.get(rev).ok_or(CrdtError::NotFoundInHistory(rev))?.clone();
|
||||
cursor = delta.parent;
|
||||
forward.push(delta);
|
||||
cursor = parent;
|
||||
if cursor == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Force-apply so each forward value wins the LWW tie against the reverse that undo force-applied
|
||||
@@ -377,7 +409,7 @@ impl Session {
|
||||
for delta in forward.into_iter().rev() {
|
||||
self.document.force_apply_op(delta.kind.clone(), delta.timestamp)?;
|
||||
}
|
||||
self.document.head = checkpoint;
|
||||
self.document.head = Some(checkpoint);
|
||||
|
||||
// Redo runs with an empty hot log; keep the retired snapshot in lockstep with the working registry.
|
||||
self.document.retired_snapshot = self.document.working_registry.clone();
|
||||
@@ -395,21 +427,15 @@ impl Session {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Retired deltas in append order, which is a valid replay order (parents before children).
|
||||
pub fn history(&self) -> impl Iterator<Item = &Delta> + '_ {
|
||||
self.document.history.values()
|
||||
self.document.history.iter()
|
||||
}
|
||||
|
||||
/// Verify that every delta's content-addressed `id` matches its recomputed hash. `Delta` skips this
|
||||
/// on deserialize to keep loading cheap, so call this after loading history from an untrusted source
|
||||
/// (it walks the whole history and rehashes each delta). Returns the first mismatch found.
|
||||
/// Verify the retired history loaded from an untrusted source: content-addressed ids match their
|
||||
/// recomputed hashes, and the deltas are topologically ordered. See [`History::verify`].
|
||||
pub fn verify_history(&self) -> Result<(), CrdtError> {
|
||||
for (&stored, delta) in &self.document.history {
|
||||
let expected = delta.recomputed_id();
|
||||
if stored != expected || delta.id != expected {
|
||||
return Err(CrdtError::RevMismatch { stored, expected });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.document.history.verify()
|
||||
}
|
||||
|
||||
/// Every resource hash referenced by the current registry *or* anywhere in history. Undo removes a
|
||||
@@ -420,7 +446,7 @@ impl Session {
|
||||
pub fn all_referenced_resource_hashes(&self) -> HashSet<ResourceHash> {
|
||||
let mut hashes: HashSet<ResourceHash> = self.document.working_registry.resources.values().filter_map(|entry| entry.hash).collect();
|
||||
|
||||
for delta in self.document.history.values() {
|
||||
for delta in self.document.history.iter() {
|
||||
match &delta.kind {
|
||||
RegistryDelta::AddResource { entry, .. } => hashes.extend(entry.hash),
|
||||
RegistryDelta::RemoveResource { snapshot, .. } => hashes.extend(snapshot.hash),
|
||||
@@ -431,54 +457,27 @@ impl Session {
|
||||
hashes
|
||||
}
|
||||
|
||||
/// History in deterministic causal order: a topological sort with ties among
|
||||
/// ready deltas broken by `Rev`. Every parent precedes its children, so the result is a valid
|
||||
/// replay order.
|
||||
/// The order is a pure function of the delta set, so two peers holding the same
|
||||
/// history serialize byte-identical output. Parents outside this history (already-known ancestors)
|
||||
/// don't gate emission. O(V + E) in deltas and parent edges.
|
||||
pub fn history_topological(&self) -> Vec<&Delta> {
|
||||
let history = &self.document.history;
|
||||
|
||||
// Unsatisfied in-history parent count per delta, plus reverse edges to decrement as parents emit.
|
||||
let mut pending_parents: HashMap<Rev, usize> = HashMap::with_capacity(history.len());
|
||||
let mut children: HashMap<Rev, Vec<Rev>> = HashMap::new();
|
||||
for (rev, delta) in history {
|
||||
let in_history_parents = delta.parents.iter().filter(|parent| history.contains_key(parent)).count();
|
||||
pending_parents.insert(*rev, in_history_parents);
|
||||
for parent in &delta.parents {
|
||||
if history.contains_key(parent) {
|
||||
children.entry(*parent).or_default().push(*rev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ready set as a min-heap on `Rev` (via `Reverse`) so ties resolve deterministically.
|
||||
let mut ready: std::collections::BinaryHeap<std::cmp::Reverse<Rev>> = pending_parents.iter().filter(|(_, count)| **count == 0).map(|(rev, _)| std::cmp::Reverse(*rev)).collect();
|
||||
|
||||
let mut ordered = Vec::with_capacity(history.len());
|
||||
while let Some(std::cmp::Reverse(rev)) = ready.pop() {
|
||||
ordered.push(&history[&rev]);
|
||||
for child in children.get(&rev).into_iter().flatten() {
|
||||
let count = pending_parents.get_mut(child).expect("child is in history");
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
ready.push(std::cmp::Reverse(*child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ordered
|
||||
}
|
||||
|
||||
pub fn hot_log(&self) -> &[HotOp] {
|
||||
&self.document.hot_log
|
||||
}
|
||||
|
||||
pub fn head_rev(&self) -> Rev {
|
||||
pub fn head_rev(&self) -> Option<Rev> {
|
||||
self.document.head
|
||||
}
|
||||
|
||||
/// Test-only: every retired delta, cloned, for feeding one session's branch into another's `merge`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cloned_deltas(&self) -> Vec<Delta> {
|
||||
self.document.history.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Test-only: commit a single op as a retired delta on the local chain, returning the result so a
|
||||
/// test can observe a resurrection failure (e.g. `NotFoundInHistory`).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn commit_op_for_test(&mut self, op: RegistryDelta) -> Result<(), CrdtError> {
|
||||
self.commit_ops(std::iter::once(op), false).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn redo_stack(&self) -> &[Rev] {
|
||||
&self.document.redo_stack
|
||||
}
|
||||
|
||||
@@ -22,11 +22,10 @@ fn remove_node_op(node_id: NodeId) -> RegistryDelta {
|
||||
fn commit_op(document: &mut Document, op: RegistryDelta) {
|
||||
let reverse = document.compute_reverse_delta(RegistryTarget::Working, &op).expect("compute_reverse_delta failed");
|
||||
let timestamp = document.clock.tick();
|
||||
let parents = if document.head == 0 { Vec::new() } else { vec![document.head] };
|
||||
let delta = Delta::new(parents, document.peer, timestamp, op, reverse);
|
||||
let delta = Delta::new(document.head, document.peer, timestamp, op, reverse);
|
||||
let rev = delta.id;
|
||||
document.apply_delta(delta).expect("apply_retired_delta failed");
|
||||
document.head = rev;
|
||||
document.head = Some(rev);
|
||||
}
|
||||
|
||||
/// Every applied op must advance the local clock past the op's timestamp, so any subsequent
|
||||
@@ -117,17 +116,17 @@ fn verify_history_detects_rev_mismatch() {
|
||||
|
||||
session.verify_history().expect("a freshly built history must validate");
|
||||
|
||||
// Tamper one delta's stored id (the field, not its key) so it no longer matches its content hash.
|
||||
let some_rev = *session.document.history.keys().next().expect("history is non-empty");
|
||||
session.document.history.get_mut(&some_rev).expect("delta exists").id = 0xdead_beef;
|
||||
// Tamper one delta's stored id so it no longer matches its content hash.
|
||||
session.document.history.first_mut().expect("history is non-empty").id = crate::Rev::new(0xdead_beef).unwrap();
|
||||
|
||||
assert!(matches!(session.verify_history(), Err(crate::CrdtError::RevMismatch { .. })), "a tampered delta id must be flagged");
|
||||
}
|
||||
|
||||
/// `history_topological` emits parents before children and is a pure function of the delta set:
|
||||
/// two sessions independently built from the same network produce byte-identical history order.
|
||||
/// History iteration emits parents before children and is a pure function of the delta set: two
|
||||
/// sessions independently built from the same network produce byte-identical history order. (The
|
||||
/// append-order invariant guarantees this directly, with no separate topological sort.)
|
||||
#[test]
|
||||
fn history_topological_is_causal_and_deterministic() {
|
||||
fn history_is_causal_and_deterministic() {
|
||||
let resources = graphene_resource::ResourceRegistry::new();
|
||||
|
||||
let build = || {
|
||||
@@ -141,23 +140,99 @@ fn history_topological_is_causal_and_deterministic() {
|
||||
let session_a = build();
|
||||
let session_b = build();
|
||||
|
||||
let order_a: Vec<crate::Rev> = session_a.history_topological().iter().map(|delta| delta.id).collect();
|
||||
let order_b: Vec<crate::Rev> = session_b.history_topological().iter().map(|delta| delta.id).collect();
|
||||
let order_a: Vec<crate::Rev> = session_a.history().map(|delta| delta.id).collect();
|
||||
let order_b: Vec<crate::Rev> = session_b.history().map(|delta| delta.id).collect();
|
||||
|
||||
assert!(order_a.len() > 1, "expected a multi-delta history to make ordering meaningful");
|
||||
assert_eq!(order_a, order_b, "same delta set must serialize in the same topological order");
|
||||
assert_eq!(order_a, order_b, "same delta set must serialize in the same order");
|
||||
|
||||
// Every parent that's part of this history precedes its child.
|
||||
let position: std::collections::HashMap<crate::Rev, usize> = order_a.iter().enumerate().map(|(i, rev)| (*rev, i)).collect();
|
||||
for delta in session_a.history_topological() {
|
||||
for parent in &delta.parents {
|
||||
if let Some(parent_pos) = position.get(parent) {
|
||||
assert!(*parent_pos < position[&delta.id], "parent {parent} must precede child {} in topological order", delta.id);
|
||||
for delta in session_a.history() {
|
||||
for parent in delta.all_parents() {
|
||||
if let Some(parent_pos) = position.get(&parent) {
|
||||
assert!(*parent_pos < position[&delta.id], "parent {parent} must precede child {} in order", delta.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_document_attribute(key: &str, value: u32) -> RegistryDelta {
|
||||
RegistryDelta::ChangeDocumentAttribute {
|
||||
delta: crate::AttributeDelta {
|
||||
key: key.to_string(),
|
||||
value: Some(serde_json::json!(value)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Two peers that each integrate the other's concurrent branch converge to byte-identical history:
|
||||
/// the merge commit is parent-set-addressed (same `Rev` on both) and the canonical sort erases the
|
||||
/// arrival-order difference. Exercises `Session::merge`, the `Merge` variant, and `canonical_sort`.
|
||||
#[test]
|
||||
fn merge_converges_to_identical_history() {
|
||||
// Shared base commit, then a concurrent edit on each peer's own clone of that base.
|
||||
let mut session_a = Session::with_peer(PeerId(1));
|
||||
session_a.commit_op_for_test(set_document_attribute("compute::base", 0)).expect("base commit");
|
||||
let mut session_b = session_a.clone();
|
||||
|
||||
session_a.commit_op_for_test(set_document_attribute("compute::a", 1)).expect("A edit");
|
||||
session_b.commit_op_for_test(set_document_attribute("compute::b", 2)).expect("B edit");
|
||||
|
||||
// Cross-merge: feed each peer the other's full delta set. The shared base dedups by `Rev`.
|
||||
let deltas_a = session_a.cloned_deltas();
|
||||
let deltas_b = session_b.cloned_deltas();
|
||||
let merge_a = session_a.merge(deltas_b).expect("merge into A failed").expect("A produced a merge");
|
||||
let merge_b = session_b.merge(deltas_a).expect("merge into B failed").expect("B produced a merge");
|
||||
|
||||
assert_eq!(merge_a, merge_b, "same tips must mint the identical parent-set-addressed merge commit");
|
||||
|
||||
let order_a: Vec<crate::Rev> = session_a.history().map(|d| d.id).collect();
|
||||
let order_b: Vec<crate::Rev> = session_b.history().map(|d| d.id).collect();
|
||||
assert_eq!(order_a, order_b, "both peers must converge to byte-identical history order");
|
||||
assert_eq!(session_a.head_rev(), session_b.head_rev(), "both peers land on the same merge head");
|
||||
}
|
||||
|
||||
/// Resurrection must reach into a merged-in branch: a network added then removed on the other peer's
|
||||
/// branch lives only under the merge's secondary parent, so a `SetNetworkExport` targeting it after
|
||||
/// the merge can only restore it by traversing all ancestors (not the primary-parent chain).
|
||||
#[test]
|
||||
fn resurrection_reaches_across_a_merge() {
|
||||
let network_id = NetworkId(7);
|
||||
|
||||
// Shared base, then peer B adds and removes network 7 on its own branch.
|
||||
let mut session_a = Session::with_peer(PeerId(1));
|
||||
session_a.commit_op_for_test(set_document_attribute("compute::base", 0)).expect("base commit");
|
||||
let mut session_b = session_a.clone();
|
||||
|
||||
session_a.commit_op_for_test(set_document_attribute("compute::a", 1)).expect("A edit");
|
||||
session_b
|
||||
.commit_op_for_test(RegistryDelta::AddNetwork {
|
||||
id: network_id,
|
||||
network: Network::default(),
|
||||
})
|
||||
.expect("B AddNetwork");
|
||||
session_b
|
||||
.commit_op_for_test(RegistryDelta::RemoveNetwork {
|
||||
id: network_id,
|
||||
snapshot: Network::default(),
|
||||
})
|
||||
.expect("B RemoveNetwork");
|
||||
|
||||
// A merges B's branch: 7's AddNetwork now lives only under the merge's secondary parent.
|
||||
session_a.merge(session_b.cloned_deltas()).expect("merge failed");
|
||||
|
||||
// A SetNetworkExport on 7 must resurrect it by walking into the merged-in branch. Before the
|
||||
// all-ancestors fix this failed with NetworkNotInHistory (the primary-parent walk missed B's branch).
|
||||
session_a
|
||||
.commit_op_for_test(RegistryDelta::SetNetworkExport {
|
||||
id: network_id,
|
||||
index: 0,
|
||||
export: None,
|
||||
})
|
||||
.expect("resurrection must find the AddNetwork on the merged-in branch");
|
||||
}
|
||||
|
||||
/// Committing the same NodeNetwork twice must produce zero history entries on the second commit.
|
||||
/// Without value-only diffing in compute_deltas, the second commit would emit spurious
|
||||
/// ChangeNodeInput / ChangeNodeAttribute ops because self.registry has real timestamps while the
|
||||
@@ -758,12 +833,12 @@ fn add_node_rev_is_independent_of_attribute_insertion_order() {
|
||||
let forward: Vec<&str> = keys.to_vec();
|
||||
let reversed: Vec<&str> = keys.iter().rev().copied().collect();
|
||||
|
||||
let parents = vec![1, 2];
|
||||
let parent = crate::Rev::new(1);
|
||||
let author = PeerId(7);
|
||||
let timestamp = TimeStamp { counter: 42, peer: PeerId(7) };
|
||||
|
||||
let delta_forward = Delta::new(
|
||||
parents.clone(),
|
||||
parent,
|
||||
author,
|
||||
timestamp,
|
||||
RegistryDelta::AddNode {
|
||||
@@ -776,7 +851,7 @@ fn add_node_rev_is_independent_of_attribute_insertion_order() {
|
||||
},
|
||||
);
|
||||
let delta_reversed = Delta::new(
|
||||
parents,
|
||||
parent,
|
||||
author,
|
||||
timestamp,
|
||||
RegistryDelta::AddNode {
|
||||
|
||||
Reference in New Issue
Block a user