Files
Graphite/document/graph-storage/src/delta.rs
Dennis Kobert b56af15e5e Add graph-storage crate (#4198)
* Add graph-storage crate

* Split graph-storage lib.rs into smaller modules

* Make graph_craft/core_types dependency optional

* Fix CRDT correctness issues flagged in graph-storage PR review

* Treat trailing empty export slots as value-equal in Network::value_equal

* Address graph-storage review: parameterize CrdtError, reject hot-log corruption, drop LamportClock Default

* Address graph-storage review: fix root-delta history walk, validate cross-network refs, make compute_deltas deterministic, harden Priority, index nodes by network

* Address graph-storage review: sort sources on deserialize, treat inputs_attributes length as structural, dedupe demo-artwork loader

* Add opt-in Delta rev validation, dedup resource sources on deserialize, fix doc grammar

* Persist scope injections through storage; harden gesture-end and embedded-source checks

* Review

* Review

* Review

* Use new types for NodeId and NetworkId

* Adress minor review comments

---------

Co-authored-by: Timon <me@timon.zip>
2026-06-13 16:47:04 +00:00

426 lines
14 KiB
Rust

use std::collections::HashSet;
use crate::{AttributeDelta, NetworkId, Node, NodeId, Registry, RegistryDelta, ResourceEntry, ResourceId};
/// Collect a `HashSet` walk (difference/intersection) into ascending order. The sets iterate in
/// random order, so sorting keeps `compute_deltas` emitting a deterministic delta sequence.
fn sorted<'a, T: Ord + Copy + 'a>(ids: impl Iterator<Item = &'a T>) -> Vec<T> {
let mut ids: Vec<T> = ids.copied().collect();
ids.sort_unstable();
ids
}
/// Minimal set of deltas to transform `from` into `to`.
///
/// Emits timestamp-less op shapes; the caller (`Document::commit_local` or equivalent) wraps each
/// in a `Delta` with a fresh clock tick.
pub fn compute_deltas(from: &Registry, to: &Registry) -> Vec<RegistryDelta> {
let mut deltas = Vec::new();
let from_network_ids: HashSet<NetworkId> = from.networks.keys().copied().collect();
let to_network_ids: HashSet<NetworkId> = to.networks.keys().copied().collect();
// AddNetwork before any AddNode that references it. `HashSet` difference/intersection iterate in
// random order, so every set walk below is sorted to keep the emitted delta sequence (and thus the
// resulting `Rev` chain) deterministic across runs.
for network_id in sorted(to_network_ids.difference(&from_network_ids)) {
deltas.push(RegistryDelta::AddNetwork {
id: network_id,
network: to.networks[&network_id].clone(),
});
}
let from_node_ids: HashSet<NodeId> = from.node_instances.keys().copied().collect();
let to_node_ids: HashSet<NodeId> = to.node_instances.keys().copied().collect();
for node_id in sorted(from_node_ids.difference(&to_node_ids)) {
deltas.push(RegistryDelta::RemoveNode {
id: node_id,
snapshot: from.node_instances[&node_id].clone(),
});
}
for node_id in sorted(to_node_ids.difference(&from_node_ids)) {
deltas.push(RegistryDelta::AddNode {
id: node_id,
node: to.node_instances[&node_id].clone(),
});
}
for node_id in sorted(from_node_ids.intersection(&to_node_ids)) {
let from_node = &from.node_instances[&node_id];
let to_node = &to.node_instances[&node_id];
// No `ChangeImplementation` op; the only path is remove + re-add. Same for input-count and
// containing-network changes (a moved node has no in-place op either). `inputs_attributes` is
// checked too: the per-slot loops below `zip` only the shared prefix, so a length change there
// must force a remove + re-add rather than silently dropping the extra slots.
let structural_change = !nodes_have_same_implementation(from_node, to_node) || from_node.inputs.len() != to_node.inputs.len() || from_node.network != to_node.network;
if structural_change {
deltas.push(RegistryDelta::RemoveNode {
id: node_id,
snapshot: from_node.clone(),
});
deltas.push(RegistryDelta::AddNode { id: node_id, node: to_node.clone() });
continue;
}
// Compare by value, ignoring the per-slot timestamp. Timestamps are derived from the diff
// (assigned by the caller via clock.tick), not part of the diff itself: a slot whose value
// is unchanged but whose timestamp differs should not emit a delta.
for (input_idx, (from_slot, to_slot)) in from_node.inputs.iter().zip(&to_node.inputs).enumerate() {
if from_slot.input != to_slot.input {
deltas.push(RegistryDelta::ChangeNodeInput {
id: node_id,
index: input_idx as u32,
new_input: to_slot.input.clone(),
});
}
}
for delta in compute_attribute_deltas(&from_node.attributes, &to_node.attributes) {
deltas.push(RegistryDelta::ChangeNodeAttribute { id: node_id, delta });
}
for (input_idx, (from_input, to_input)) in from_node.inputs.iter().zip(&to_node.inputs).enumerate() {
for delta in compute_attribute_deltas(&from_input.attributes, &to_input.attributes) {
deltas.push(RegistryDelta::ChangeNodeInputAttribute {
id: node_id,
index: input_idx as u32,
delta,
});
}
}
}
for network_id in sorted(from_network_ids.difference(&to_network_ids)) {
deltas.push(RegistryDelta::RemoveNetwork {
id: network_id,
snapshot: from.networks[&network_id].clone(),
});
}
for network_id in sorted(from_network_ids.intersection(&to_network_ids)) {
let from_network = &from.networks[&network_id];
let to_network = &to.networks[&network_id];
let max_len = from_network.exports.len().max(to_network.exports.len());
for slot_idx in 0..max_len {
let from_slot = from_network.exports.get(slot_idx);
let to_slot = to_network.exports.get(slot_idx);
let from_target = from_slot.and_then(|s| s.target.as_ref());
let to_target = to_slot.and_then(|s| s.target.as_ref());
if from_target != to_target {
deltas.push(RegistryDelta::SetNetworkExport {
id: network_id,
index: slot_idx as u32,
export: to_target.cloned(),
});
}
}
// Per-network attributes.
for delta in compute_attribute_deltas(&from_network.attributes, &to_network.attributes) {
deltas.push(RegistryDelta::ChangeNetworkAttribute { id: network_id, delta });
}
}
// Document-level attributes (`ui::doc::*`, format version, ...).
for delta in compute_attribute_deltas(&from.attributes, &to.attributes) {
deltas.push(RegistryDelta::ChangeDocumentAttribute { delta });
}
compute_resource_deltas(from, to, &mut deltas);
deltas
}
/// Diff the resource store, emitting whole-entry add/remove for resources that appear or vanish and
/// fine-grained hash/source ops for resources present in both. Value-only: per-entry and per-source
/// timestamps are derived by the caller, so an unchanged resource emits nothing.
fn compute_resource_deltas(from: &Registry, to: &Registry, deltas: &mut Vec<RegistryDelta>) {
let from_ids: HashSet<ResourceId> = from.resources.keys().copied().collect();
let to_ids: HashSet<ResourceId> = to.resources.keys().copied().collect();
for id in sorted(from_ids.difference(&to_ids)) {
deltas.push(RegistryDelta::RemoveResource {
id,
snapshot: from.resources[&id].clone(),
});
}
for id in sorted(to_ids.difference(&from_ids)) {
deltas.push(RegistryDelta::AddResource { id, entry: to.resources[&id].clone() });
}
for id in sorted(from_ids.intersection(&to_ids)) {
diff_resource_entry(id, &from.resources[&id], &to.resources[&id], deltas);
}
}
/// Per-entry diff for a resource present in both registries: hash change, then source chain
/// additions/changes/removals.
fn diff_resource_entry(id: ResourceId, from: &ResourceEntry, to: &ResourceEntry, deltas: &mut Vec<RegistryDelta>) {
if from.hash != to.hash {
deltas.push(RegistryDelta::SetResourceHash { id, hash: to.hash });
}
for (key, _) in &from.sources {
if to.source(key).is_none() {
deltas.push(RegistryDelta::RemoveSource { id, key: *key });
}
}
// Compare source bodies only; the per-source timestamp is derived from the diff, not part of it.
for (key, to_source) in &to.sources {
if from.source(key).is_none_or(|from_source| from_source.source != to_source.source) {
deltas.push(RegistryDelta::AddSource {
id,
key: *key,
source: to_source.source.clone(),
});
}
}
}
fn nodes_have_same_implementation(a: &Node, b: &Node) -> bool {
use crate::Implementation::*;
match (&a.implementation, &b.implementation) {
(ProtoNode(a_id), ProtoNode(b_id)) => a_id == b_id,
(Network(a_id), Network(b_id)) => a_id == b_id,
_ => false,
}
}
fn compute_attribute_deltas(from: &crate::Attributes, to: &crate::Attributes) -> Vec<AttributeDelta> {
let mut deltas = Vec::new();
for key in from.keys() {
if !to.contains_key(key) {
deltas.push(AttributeDelta { key: key.clone(), value: None });
}
}
// Compare by `value` only; the per-entry `timestamp` is derived from the diff, not part of it.
for (key, to_value) in to {
if from.get(key).is_none_or(|from_value| from_value.value != to_value.value) {
deltas.push(AttributeDelta {
key: key.clone(),
value: Some(to_value.value.clone()),
});
}
}
deltas
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Attributes, ExportSlot, Network, Node, NodeInput, TimeStamp};
#[test]
fn test_compute_deltas_empty() {
let registry = Registry::default();
let deltas = compute_deltas(&registry, &registry);
assert_eq!(deltas.len(), 0, "No deltas should be generated for identical registries");
}
/// The emitted delta sequence must not depend on `HashMap`/`HashSet` iteration order, which varies
/// per run and per compiler version. Building the same registry repeatedly (each `HashMap` gets a
/// fresh random seed) must yield identical `AddNode` order, since the diff sorts its set walks.
#[test]
fn compute_deltas_emits_nodes_in_deterministic_order() {
let make_registry = || {
let mut registry = Registry::default();
registry.networks.insert(NetworkId(0), Network::default());
for node_id in [50, 3, 17, 999, 1, 42, 8, 256, 100, 7] {
registry.node_instances.insert(NodeId(node_id), Node::dummy());
}
registry
};
let empty = Registry::default();
let add_node_ids = |registry: &Registry| -> Vec<NodeId> {
compute_deltas(&empty, registry)
.into_iter()
.filter_map(|delta| match delta {
RegistryDelta::AddNode { id: node_id, .. } => Some(node_id),
_ => None,
})
.collect()
};
let expected = vec![1, 3, 7, 8, 17, 42, 50, 100, 256, 999].into_iter().map(NodeId).collect::<Vec<_>>();
for _ in 0..16 {
assert_eq!(add_node_ids(&make_registry()), expected, "AddNode order must be deterministic (ascending)");
}
}
#[test]
fn test_compute_deltas_add_node() {
let from = Registry::default();
let mut to = from.clone();
let node = Node::dummy();
to.node_instances.insert(NodeId(42), node);
let deltas = compute_deltas(&from, &to);
assert_eq!(deltas.len(), 1);
assert!(matches!(deltas[0], RegistryDelta::AddNode { id: NodeId(42), .. }));
}
/// A change in `inputs_attributes` length is structural: the per-slot diff only `zip`s the shared
/// prefix, so it must force a remove + re-add rather than dropping the extra attribute slots.
#[test]
fn compute_deltas_treats_inputs_attributes_length_change_as_structural() {
// Same implementation/inputs/network in both registries; only `inputs_attributes` length differs.
let base = Node::dummy();
let mut from = Registry::default();
from.node_instances.insert(NodeId(42), base.clone());
let mut to = from.clone();
to.node_instances.get_mut(&NodeId(42)).unwrap().inputs.push(crate::InputSlot {
input: NodeInput::Import { index: 0 },
timestamp: TimeStamp::ORIGIN,
attributes: Attributes::new(),
});
let deltas = compute_deltas(&from, &to);
assert!(
deltas.iter().any(|delta| matches!(delta, RegistryDelta::RemoveNode { id: NodeId(42), .. })) && deltas.iter().any(|delta| matches!(delta, RegistryDelta::AddNode { id: NodeId(42), .. })),
"an inputs_attributes length change must emit RemoveNode + AddNode, got {deltas:?}"
);
}
#[test]
fn test_compute_deltas_change_network_attribute() {
use crate::{AttributesWrite, TimeStamp};
let mut from = Registry::default();
from.networks.insert(NetworkId(0), Network::default());
let mut to = from.clone();
to.networks
.get_mut(&NetworkId(0))
.unwrap()
.attributes
.set("ui::nav::width", serde_json::json!(640.0), TimeStamp::ORIGIN);
let deltas = compute_deltas(&from, &to);
assert_eq!(deltas.len(), 1, "a changed per-network attribute must emit one delta");
assert!(
matches!(&deltas[0], RegistryDelta::ChangeNetworkAttribute { id: NetworkId(0), delta } if delta.key == "ui::nav::width"),
"expected ChangeNetworkAttribute for ui::nav::width, got {:?}",
deltas[0]
);
}
#[test]
fn test_compute_deltas_remove_node() {
let mut from = Registry::default();
let node = Node::dummy();
from.node_instances.insert(NodeId(42), node);
let to = Registry::default();
let deltas = compute_deltas(&from, &to);
assert_eq!(deltas.len(), 1);
assert!(matches!(deltas[0], RegistryDelta::RemoveNode { id: NodeId(42), .. }));
}
#[test]
fn test_compute_deltas_modify_attribute() {
let mut from = Registry::default();
let mut node = Node::dummy();
let stamp = |counter: u64| TimeStamp { counter, peer: crate::PeerId(0) };
node.attributes.insert(
"test".to_string(),
crate::Value {
value: serde_json::json!("old"),
timestamp: stamp(0),
},
);
from.node_instances.insert(NodeId(42), node);
let mut to = from.clone();
to.node_instances.get_mut(&NodeId(42)).unwrap().attributes.insert(
"test".to_string(),
crate::Value {
value: serde_json::json!("new"),
timestamp: stamp(1),
},
);
let deltas = compute_deltas(&from, &to);
assert_eq!(deltas.len(), 1);
assert!(matches!(
&deltas[0],
RegistryDelta::ChangeNodeAttribute { id: NodeId(42), delta: AttributeDelta { key, value: Some(_) } } if key == "test"
));
}
/// Document-level attributes (the `Registry.attributes` bucket) must diff into
/// `ChangeDocumentAttribute` deltas, so a document-scoped attribute change reaches the commit path.
/// (Per-peer `ui::doc::*` view settings live in `session.json`, not here.)
#[test]
fn test_compute_deltas_document_attribute() {
let stamp = |counter: u64| TimeStamp { counter, peer: crate::PeerId(0) };
let from = Registry::default();
let mut to = from.clone();
to.attributes.insert(
"doc::test_attribute".to_string(),
crate::Value {
value: serde_json::json!("value"),
timestamp: stamp(1),
},
);
let deltas = compute_deltas(&from, &to);
assert_eq!(deltas.len(), 1);
assert!(matches!(
&deltas[0],
RegistryDelta::ChangeDocumentAttribute { delta: AttributeDelta { key, value: Some(_) } } if key == "doc::test_attribute"
));
}
#[test]
fn test_compute_deltas_network_changes() {
let make_slot = |id: u64| ExportSlot {
target: Some(NodeInput::Node { id: NodeId(id), index: 0 }),
timestamp: TimeStamp::ORIGIN,
};
let mut from = Registry::default();
from.networks.insert(
NetworkId(0),
Network {
exports: vec![make_slot(1), make_slot(2)],
..Default::default()
},
);
let mut to = from.clone();
to.networks.get_mut(&NetworkId(0)).unwrap().exports.push(make_slot(3));
let deltas = compute_deltas(&from, &to);
// Only slot 2 changed (added). Slots 0 and 1 are unchanged so they don't emit ops.
assert_eq!(deltas.len(), 1);
assert!(matches!(
&deltas[0],
RegistryDelta::SetNetworkExport {
id: NetworkId(0),
index: 2,
export: Some(NodeInput::Node { id: NodeId(3), .. }),
..
}
));
}
}