1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use crate::clock::LamportClock;
4use crate::entry::{Entry, GraphOp, Hash, Value};
5use crate::ontology::Ontology;
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct Node {
10 pub node_id: String,
11 pub node_type: String,
12 pub subtype: Option<String>,
13 pub label: String,
14 pub properties: BTreeMap<String, Value>,
15 pub property_clocks: HashMap<String, LamportClock>,
19 pub last_clock: LamportClock,
22 pub last_add_clock: LamportClock,
26 pub tombstoned: bool,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct Edge {
33 pub edge_id: String,
34 pub edge_type: String,
35 pub source_id: String,
36 pub target_id: String,
37 pub properties: BTreeMap<String, Value>,
38 pub property_clocks: HashMap<String, LamportClock>,
40 pub last_clock: LamportClock,
41 pub last_add_clock: LamportClock,
43 pub tombstoned: bool,
44}
45
46pub struct MaterializedGraph {
57 pub nodes: HashMap<String, Node>,
59 pub edges: HashMap<String, Edge>,
61 pub outgoing: HashMap<String, HashSet<String>>,
63 pub incoming: HashMap<String, HashSet<String>>,
65 pub by_type: HashMap<String, HashSet<String>>,
67 pub ontology: Ontology,
69 pub quarantined: HashSet<Hash>,
76}
77
78impl MaterializedGraph {
79 pub fn new(ontology: Ontology) -> Self {
81 Self {
82 nodes: HashMap::new(),
83 edges: HashMap::new(),
84 outgoing: HashMap::new(),
85 incoming: HashMap::new(),
86 by_type: HashMap::new(),
87 ontology,
88 quarantined: HashSet::new(),
89 }
90 }
91
92 pub fn apply(&mut self, entry: &Entry) {
99 match &entry.payload {
100 GraphOp::Checkpoint { ops, op_clocks, .. } => {
101 for (i, op) in ops.iter().enumerate() {
104 if let GraphOp::DefineOntology { ontology } = op {
111 self.ontology = ontology.clone();
112 continue;
113 }
114 let clock = if i < op_clocks.len() {
115 LamportClock::with_values(&entry.author, op_clocks[i].0, op_clocks[i].1)
116 } else {
117 entry.clock.clone() };
119 let synthetic = Entry::new(op.clone(), vec![], vec![], clock, &entry.author);
120 self.apply(&synthetic);
121 }
122 }
123 GraphOp::DefineOntology { .. } => {
124 }
126 GraphOp::ExtendOntology { extension } => {
127 if let Err(_e) = self.ontology.merge_extension(extension) {
128 self.quarantined.insert(entry.hash);
129 }
130 }
131 GraphOp::AddNode {
132 node_id,
133 node_type,
134 subtype,
135 label,
136 properties,
137 } => {
138 if let Err(_e) =
140 self.ontology
141 .validate_node(node_type, subtype.as_deref(), properties)
142 {
143 self.quarantined.insert(entry.hash);
144 return;
145 }
146 self.apply_add_node(
147 node_id,
148 node_type,
149 subtype.as_deref(),
150 label,
151 properties,
152 &entry.clock,
153 );
154 }
155 GraphOp::AddEdge {
156 edge_id,
157 edge_type,
158 source_id,
159 target_id,
160 properties,
161 } => {
162 if !self.ontology.edge_types.contains_key(edge_type.as_str()) {
164 self.quarantined.insert(entry.hash);
165 return;
166 }
167 if let (Some(src), Some(tgt)) = (
171 self.nodes.get(source_id.as_str()),
172 self.nodes.get(target_id.as_str()),
173 ) {
174 if self
175 .ontology
176 .validate_edge(edge_type, &src.node_type, &tgt.node_type, properties)
177 .is_err()
178 {
179 self.quarantined.insert(entry.hash);
180 return;
181 }
182 }
183 self.apply_add_edge(
184 edge_id,
185 edge_type,
186 source_id,
187 target_id,
188 properties,
189 &entry.clock,
190 );
191 }
192 GraphOp::UpdateProperty {
193 entity_id,
194 key,
195 value,
196 } => {
197 self.apply_update_property(entity_id, key, value, &entry.clock);
198 }
199 GraphOp::RemoveNode { node_id } => {
200 self.apply_remove_node(node_id, &entry.clock);
201 }
202 GraphOp::RemoveEdge { edge_id } => {
203 self.apply_remove_edge(edge_id, &entry.clock);
204 }
205 GraphOp::DefineLens { .. } => {
206 }
208 }
209 }
210
211 pub fn apply_all(&mut self, entries: &[&Entry]) {
213 for entry in entries {
214 self.apply(entry);
215 }
216 }
217
218 pub fn rebuild(&mut self, entries: &[&Entry]) {
220 self.nodes.clear();
221 self.edges.clear();
222 self.outgoing.clear();
223 self.incoming.clear();
224 self.by_type.clear();
225 self.quarantined.clear();
226 self.apply_all(entries);
227 }
228
229 pub fn get_node(&self, node_id: &str) -> Option<&Node> {
233 self.nodes.get(node_id).filter(|n| !n.tombstoned)
234 }
235
236 pub fn get_edge(&self, edge_id: &str) -> Option<&Edge> {
238 self.edges.get(edge_id).filter(|e| !e.tombstoned)
239 }
240
241 pub fn nodes_by_type(&self, node_type: &str) -> Vec<&Node> {
244 let mut types = vec![node_type.to_string()];
245 types.extend(
246 self.ontology
247 .descendants(node_type)
248 .into_iter()
249 .map(|s| s.to_string()),
250 );
251 types
252 .iter()
253 .flat_map(|t| self.by_type.get(t.as_str()))
254 .flatten()
255 .filter_map(|id| self.get_node(id))
256 .collect()
257 }
258
259 pub fn nodes_by_subtype(&self, subtype: &str) -> Vec<&Node> {
261 self.nodes
262 .values()
263 .filter(|n| !n.tombstoned && n.subtype.as_deref() == Some(subtype))
264 .collect()
265 }
266
267 pub fn nodes_by_property(&self, key: &str, value: &Value) -> Vec<&Node> {
269 self.nodes
270 .values()
271 .filter(|n| !n.tombstoned && n.properties.get(key) == Some(value))
272 .collect()
273 }
274
275 pub fn outgoing_edges(&self, node_id: &str) -> Vec<&Edge> {
277 match self.outgoing.get(node_id) {
278 Some(edge_ids) => edge_ids
279 .iter()
280 .filter_map(|eid| self.get_edge(eid))
281 .filter(|e| self.is_node_live(&e.target_id))
282 .collect(),
283 None => vec![],
284 }
285 }
286
287 pub fn incoming_edges(&self, node_id: &str) -> Vec<&Edge> {
289 match self.incoming.get(node_id) {
290 Some(edge_ids) => edge_ids
291 .iter()
292 .filter_map(|eid| self.get_edge(eid))
293 .filter(|e| self.is_node_live(&e.source_id))
294 .collect(),
295 None => vec![],
296 }
297 }
298
299 pub fn estimated_memory_bytes(&self) -> usize {
304 let mut total = 0;
305 for node in self.nodes.values() {
307 total += node.node_id.len() + node.node_type.len() + node.label.len();
308 total += node.subtype.as_ref().map_or(0, |s| s.len());
309 for (k, v) in &node.properties {
311 total += k.len() + std::mem::size_of_val(v) + 48; }
313 total += 128; }
315 for edge in self.edges.values() {
317 total += edge.edge_id.len() + edge.edge_type.len();
318 total += edge.source_id.len() + edge.target_id.len();
319 for (k, v) in &edge.properties {
320 total += k.len() + std::mem::size_of_val(v) + 48;
321 }
322 total += 128;
323 }
324 for (k, set) in &self.outgoing {
326 total += k.len() + set.len() * 32;
327 }
328 for (k, set) in &self.incoming {
329 total += k.len() + set.len() * 32;
330 }
331 for (k, set) in &self.by_type {
333 total += k.len() + set.len() * 32;
334 }
335 total += self.quarantined.len() * 48;
337 total
338 }
339
340 pub fn all_nodes(&self) -> Vec<&Node> {
342 self.nodes.values().filter(|n| !n.tombstoned).collect()
343 }
344
345 pub fn all_edges(&self) -> Vec<&Edge> {
347 self.edges
348 .values()
349 .filter(|e| {
350 !e.tombstoned && self.is_node_live(&e.source_id) && self.is_node_live(&e.target_id)
351 })
352 .collect()
353 }
354
355 pub fn neighbors(&self, node_id: &str) -> Vec<&str> {
357 self.outgoing_edges(node_id)
358 .iter()
359 .map(|e| e.target_id.as_str())
360 .collect()
361 }
362
363 pub fn reverse_neighbors(&self, node_id: &str) -> Vec<&str> {
365 self.incoming_edges(node_id)
366 .iter()
367 .map(|e| e.source_id.as_str())
368 .collect()
369 }
370
371 fn apply_add_node(
374 &mut self,
375 node_id: &str,
376 node_type: &str,
377 subtype: Option<&str>,
378 label: &str,
379 properties: &BTreeMap<String, Value>,
380 clock: &LamportClock,
381 ) {
382 if let Some(existing) = self.nodes.get_mut(node_id) {
383 existing.tombstoned = false;
385 if clock_wins(clock, &existing.last_add_clock) {
387 existing.last_add_clock = clock.clone();
388 }
389 if clock_wins(clock, &existing.last_clock) {
391 existing.label = label.to_string();
392 existing.subtype = subtype.map(|s| s.to_string());
393 existing.last_clock = clock.clone();
394 }
395 merge_properties_lww(
396 &mut existing.properties,
397 &mut existing.property_clocks,
398 properties,
399 clock,
400 );
401 } else {
402 let property_clocks: HashMap<String, LamportClock> = properties
403 .keys()
404 .map(|k| (k.clone(), clock.clone()))
405 .collect();
406 let node = Node {
407 node_id: node_id.to_string(),
408 node_type: node_type.to_string(),
409 subtype: subtype.map(|s| s.to_string()),
410 label: label.to_string(),
411 properties: properties.clone(),
412 property_clocks,
413 last_clock: clock.clone(),
414 last_add_clock: clock.clone(),
415 tombstoned: false,
416 };
417 self.by_type
418 .entry(node_type.to_string())
419 .or_default()
420 .insert(node_id.to_string());
421 self.nodes.insert(node_id.to_string(), node);
422 }
423 }
424
425 fn apply_add_edge(
426 &mut self,
427 edge_id: &str,
428 edge_type: &str,
429 source_id: &str,
430 target_id: &str,
431 properties: &BTreeMap<String, Value>,
432 clock: &LamportClock,
433 ) {
434 if let Some(existing) = self.edges.get_mut(edge_id) {
435 existing.tombstoned = false;
437 if clock_wins(clock, &existing.last_add_clock) {
438 existing.last_add_clock = clock.clone();
439 }
440 if clock_wins(clock, &existing.last_clock) {
441 existing.last_clock = clock.clone();
442 }
443 merge_properties_lww(
444 &mut existing.properties,
445 &mut existing.property_clocks,
446 properties,
447 clock,
448 );
449 } else {
450 let property_clocks: HashMap<String, LamportClock> = properties
451 .keys()
452 .map(|k| (k.clone(), clock.clone()))
453 .collect();
454 let edge = Edge {
455 edge_id: edge_id.to_string(),
456 edge_type: edge_type.to_string(),
457 source_id: source_id.to_string(),
458 target_id: target_id.to_string(),
459 properties: properties.clone(),
460 property_clocks,
461 last_clock: clock.clone(),
462 last_add_clock: clock.clone(),
463 tombstoned: false,
464 };
465 self.outgoing
466 .entry(source_id.to_string())
467 .or_default()
468 .insert(edge_id.to_string());
469 self.incoming
470 .entry(target_id.to_string())
471 .or_default()
472 .insert(edge_id.to_string());
473 self.edges.insert(edge_id.to_string(), edge);
474 }
475 }
476
477 fn apply_update_property(
478 &mut self,
479 entity_id: &str,
480 key: &str,
481 value: &Value,
482 clock: &LamportClock,
483 ) {
484 if let Some(node) = self.nodes.get_mut(entity_id) {
487 let dominated = node
488 .property_clocks
489 .get(key)
490 .map(|c| clock_wins(clock, c))
491 .unwrap_or(true);
492 if dominated {
493 node.properties.insert(key.to_string(), value.clone());
494 node.property_clocks.insert(key.to_string(), clock.clone());
495 }
496 if clock_wins(clock, &node.last_clock) {
498 node.last_clock = clock.clone();
499 }
500 } else if let Some(edge) = self.edges.get_mut(entity_id) {
501 let dominated = edge
502 .property_clocks
503 .get(key)
504 .map(|c| clock_wins(clock, c))
505 .unwrap_or(true);
506 if dominated {
507 edge.properties.insert(key.to_string(), value.clone());
508 edge.property_clocks.insert(key.to_string(), clock.clone());
509 }
510 if clock_wins(clock, &edge.last_clock) {
511 edge.last_clock = clock.clone();
512 }
513 }
514 }
516
517 fn apply_remove_node(&mut self, node_id: &str, clock: &LamportClock) {
518 if let Some(node) = self.nodes.get_mut(node_id) {
519 if clock_wins(clock, &node.last_add_clock) {
523 node.tombstoned = true;
524 node.last_clock = clock.clone();
525 }
526 }
527 }
530
531 fn apply_remove_edge(&mut self, edge_id: &str, clock: &LamportClock) {
532 if let Some(edge) = self.edges.get_mut(edge_id) {
533 if clock_wins(clock, &edge.last_add_clock) {
535 edge.tombstoned = true;
536 edge.last_clock = clock.clone();
537 }
538 }
539 }
540
541 fn is_node_live(&self, node_id: &str) -> bool {
542 self.nodes
543 .get(node_id)
544 .map(|n| !n.tombstoned)
545 .unwrap_or(false)
546 }
547}
548
549fn merge_properties_lww(
552 existing_props: &mut BTreeMap<String, Value>,
553 existing_clocks: &mut HashMap<String, LamportClock>,
554 new_props: &BTreeMap<String, Value>,
555 clock: &LamportClock,
556) {
557 for (k, v) in new_props {
558 let dominated = existing_clocks
559 .get(k)
560 .map(|c| clock_wins(clock, c))
561 .unwrap_or(true);
562 if dominated {
563 existing_props.insert(k.clone(), v.clone());
564 existing_clocks.insert(k.clone(), clock.clone());
565 }
566 }
567}
568
569fn clock_wins(new_clock: &LamportClock, existing_clock: &LamportClock) -> bool {
572 new_clock.cmp_order(existing_clock) == std::cmp::Ordering::Greater
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::entry::Entry;
579 use crate::ontology::{EdgeTypeDef, NodeTypeDef};
580
581 fn test_ontology() -> Ontology {
582 Ontology {
583 node_types: BTreeMap::from([
584 (
585 "entity".into(),
586 NodeTypeDef {
587 description: None,
588 properties: BTreeMap::new(),
589 subtypes: None,
590 parent_type: None,
591 },
592 ),
593 (
594 "signal".into(),
595 NodeTypeDef {
596 description: None,
597 properties: BTreeMap::new(),
598 subtypes: None,
599 parent_type: None,
600 },
601 ),
602 ]),
603 edge_types: BTreeMap::from([
604 (
605 "RUNS_ON".into(),
606 EdgeTypeDef {
607 description: None,
608 source_types: vec!["entity".into()],
609 target_types: vec!["entity".into()],
610 properties: BTreeMap::new(),
611 },
612 ),
613 (
614 "OBSERVES".into(),
615 EdgeTypeDef {
616 description: None,
617 source_types: vec!["signal".into()],
618 target_types: vec!["entity".into()],
619 properties: BTreeMap::new(),
620 },
621 ),
622 ]),
623 }
624 }
625
626 fn make_entry(op: GraphOp, clock_time: u64, author: &str) -> Entry {
627 Entry::new(
628 op,
629 vec![],
630 vec![],
631 LamportClock::with_values(author, clock_time, 0),
632 author,
633 )
634 }
635
636 #[test]
639 fn add_node_appears_in_query() {
640 let mut g = MaterializedGraph::new(test_ontology());
641 let entry = make_entry(
642 GraphOp::AddNode {
643 node_id: "server-1".into(),
644 node_type: "entity".into(),
645 label: "Server 1".into(),
646 properties: BTreeMap::from([("ip".into(), Value::String("10.0.0.1".into()))]),
647 subtype: None,
648 },
649 1,
650 "inst-a",
651 );
652 g.apply(&entry);
653
654 let node = g.get_node("server-1").unwrap();
655 assert_eq!(node.node_type, "entity");
656 assert_eq!(node.label, "Server 1");
657 assert_eq!(
658 node.properties.get("ip"),
659 Some(&Value::String("10.0.0.1".into()))
660 );
661 }
662
663 #[test]
664 fn add_edge_creates_adjacency() {
665 let mut g = MaterializedGraph::new(test_ontology());
666 g.apply(&make_entry(
667 GraphOp::AddNode {
668 node_id: "svc".into(),
669 node_type: "entity".into(),
670 label: "svc".into(),
671 properties: BTreeMap::new(),
672 subtype: None,
673 },
674 1,
675 "inst-a",
676 ));
677 g.apply(&make_entry(
678 GraphOp::AddNode {
679 node_id: "srv".into(),
680 node_type: "entity".into(),
681 label: "srv".into(),
682 properties: BTreeMap::new(),
683 subtype: None,
684 },
685 2,
686 "inst-a",
687 ));
688 g.apply(&make_entry(
689 GraphOp::AddEdge {
690 edge_id: "e1".into(),
691 edge_type: "RUNS_ON".into(),
692 source_id: "svc".into(),
693 target_id: "srv".into(),
694 properties: BTreeMap::new(),
695 },
696 3,
697 "inst-a",
698 ));
699
700 let out = g.outgoing_edges("svc");
702 assert_eq!(out.len(), 1);
703 assert_eq!(out[0].target_id, "srv");
704
705 let inc = g.incoming_edges("srv");
706 assert_eq!(inc.len(), 1);
707 assert_eq!(inc[0].source_id, "svc");
708
709 assert_eq!(g.neighbors("svc"), vec!["srv"]);
710 }
711
712 #[test]
713 fn update_property_reflected() {
714 let mut g = MaterializedGraph::new(test_ontology());
715 g.apply(&make_entry(
716 GraphOp::AddNode {
717 node_id: "s1".into(),
718 node_type: "entity".into(),
719 label: "s1".into(),
720 properties: BTreeMap::new(),
721 subtype: None,
722 },
723 1,
724 "inst-a",
725 ));
726 g.apply(&make_entry(
727 GraphOp::UpdateProperty {
728 entity_id: "s1".into(),
729 key: "cpu".into(),
730 value: Value::Float(85.5),
731 },
732 2,
733 "inst-a",
734 ));
735
736 let node = g.get_node("s1").unwrap();
737 assert_eq!(node.properties.get("cpu"), Some(&Value::Float(85.5)));
738 }
739
740 #[test]
741 fn remove_node_cascades_edges() {
742 let mut g = MaterializedGraph::new(test_ontology());
743 g.apply(&make_entry(
744 GraphOp::AddNode {
745 node_id: "a".into(),
746 node_type: "entity".into(),
747 label: "a".into(),
748 properties: BTreeMap::new(),
749 subtype: None,
750 },
751 1,
752 "inst-a",
753 ));
754 g.apply(&make_entry(
755 GraphOp::AddNode {
756 node_id: "b".into(),
757 node_type: "entity".into(),
758 label: "b".into(),
759 properties: BTreeMap::new(),
760 subtype: None,
761 },
762 2,
763 "inst-a",
764 ));
765 g.apply(&make_entry(
766 GraphOp::AddEdge {
767 edge_id: "e1".into(),
768 edge_type: "RUNS_ON".into(),
769 source_id: "a".into(),
770 target_id: "b".into(),
771 properties: BTreeMap::new(),
772 },
773 3,
774 "inst-a",
775 ));
776 assert_eq!(g.all_edges().len(), 1);
777
778 g.apply(&make_entry(
780 GraphOp::RemoveNode {
781 node_id: "b".into(),
782 },
783 4,
784 "inst-a",
785 ));
786 assert!(g.get_node("b").is_none());
787 assert_eq!(g.all_edges().len(), 0);
789 assert_eq!(g.outgoing_edges("a").len(), 0);
791 }
792
793 #[test]
794 fn remove_edge_preserves_nodes() {
795 let mut g = MaterializedGraph::new(test_ontology());
796 g.apply(&make_entry(
797 GraphOp::AddNode {
798 node_id: "a".into(),
799 node_type: "entity".into(),
800 label: "a".into(),
801 properties: BTreeMap::new(),
802 subtype: None,
803 },
804 1,
805 "inst-a",
806 ));
807 g.apply(&make_entry(
808 GraphOp::AddNode {
809 node_id: "b".into(),
810 node_type: "entity".into(),
811 label: "b".into(),
812 properties: BTreeMap::new(),
813 subtype: None,
814 },
815 2,
816 "inst-a",
817 ));
818 g.apply(&make_entry(
819 GraphOp::AddEdge {
820 edge_id: "e1".into(),
821 edge_type: "RUNS_ON".into(),
822 source_id: "a".into(),
823 target_id: "b".into(),
824 properties: BTreeMap::new(),
825 },
826 3,
827 "inst-a",
828 ));
829 g.apply(&make_entry(
830 GraphOp::RemoveEdge {
831 edge_id: "e1".into(),
832 },
833 4,
834 "inst-a",
835 ));
836
837 assert!(g.get_node("a").is_some());
839 assert!(g.get_node("b").is_some());
840 assert!(g.get_edge("e1").is_none());
842 assert_eq!(g.all_edges().len(), 0);
843 }
844
845 #[test]
846 fn query_by_type_filters() {
847 let mut g = MaterializedGraph::new(test_ontology());
848 g.apply(&make_entry(
849 GraphOp::AddNode {
850 node_id: "s1".into(),
851 node_type: "entity".into(),
852 label: "s1".into(),
853 properties: BTreeMap::new(),
854 subtype: None,
855 },
856 1,
857 "inst-a",
858 ));
859 g.apply(&make_entry(
860 GraphOp::AddNode {
861 node_id: "s2".into(),
862 node_type: "entity".into(),
863 label: "s2".into(),
864 properties: BTreeMap::new(),
865 subtype: None,
866 },
867 2,
868 "inst-a",
869 ));
870 g.apply(&make_entry(
871 GraphOp::AddNode {
872 node_id: "alert".into(),
873 node_type: "signal".into(),
874 label: "alert".into(),
875 properties: BTreeMap::new(),
876 subtype: None,
877 },
878 3,
879 "inst-a",
880 ));
881
882 let entities = g.nodes_by_type("entity");
883 assert_eq!(entities.len(), 2);
884 let signals = g.nodes_by_type("signal");
885 assert_eq!(signals.len(), 1);
886 assert_eq!(signals[0].node_id, "alert");
887 }
888
889 #[test]
890 fn query_by_property_filters() {
891 let mut g = MaterializedGraph::new(test_ontology());
892 g.apply(&make_entry(
893 GraphOp::AddNode {
894 node_id: "s1".into(),
895 node_type: "entity".into(),
896 label: "s1".into(),
897 properties: BTreeMap::from([("status".into(), Value::String("alive".into()))]),
898 subtype: None,
899 },
900 1,
901 "inst-a",
902 ));
903 g.apply(&make_entry(
904 GraphOp::AddNode {
905 node_id: "s2".into(),
906 node_type: "entity".into(),
907 label: "s2".into(),
908 properties: BTreeMap::from([("status".into(), Value::String("dead".into()))]),
909 subtype: None,
910 },
911 2,
912 "inst-a",
913 ));
914
915 let alive = g.nodes_by_property("status", &Value::String("alive".into()));
916 assert_eq!(alive.len(), 1);
917 assert_eq!(alive[0].node_id, "s1");
918 }
919
920 #[test]
921 fn materialization_from_empty() {
922 let mut g1 = MaterializedGraph::new(test_ontology());
924 let entries = vec![
925 make_entry(
926 GraphOp::DefineOntology {
927 ontology: test_ontology(),
928 },
929 0,
930 "inst-a",
931 ),
932 make_entry(
933 GraphOp::AddNode {
934 node_id: "a".into(),
935 node_type: "entity".into(),
936 label: "a".into(),
937 properties: BTreeMap::new(),
938 subtype: None,
939 },
940 1,
941 "inst-a",
942 ),
943 make_entry(
944 GraphOp::AddNode {
945 node_id: "b".into(),
946 node_type: "entity".into(),
947 label: "b".into(),
948 properties: BTreeMap::new(),
949 subtype: None,
950 },
951 2,
952 "inst-a",
953 ),
954 make_entry(
955 GraphOp::AddEdge {
956 edge_id: "e1".into(),
957 edge_type: "RUNS_ON".into(),
958 source_id: "a".into(),
959 target_id: "b".into(),
960 properties: BTreeMap::new(),
961 },
962 3,
963 "inst-a",
964 ),
965 ];
966 for e in &entries {
967 g1.apply(e);
968 }
969
970 let mut g2 = MaterializedGraph::new(test_ontology());
972 let refs: Vec<&Entry> = entries.iter().collect();
973 g2.rebuild(&refs);
974
975 assert_eq!(g1.all_nodes().len(), g2.all_nodes().len());
977 assert_eq!(g1.all_edges().len(), g2.all_edges().len());
978 for node in g1.all_nodes() {
979 let n2 = g2.get_node(&node.node_id).unwrap();
980 assert_eq!(node.node_type, n2.node_type);
981 assert_eq!(node.properties, n2.properties);
982 }
983 }
984
985 #[test]
986 fn incremental_equals_full() {
987 let entries = vec![
988 make_entry(
989 GraphOp::DefineOntology {
990 ontology: test_ontology(),
991 },
992 0,
993 "inst-a",
994 ),
995 make_entry(
996 GraphOp::AddNode {
997 node_id: "a".into(),
998 node_type: "entity".into(),
999 label: "a".into(),
1000 properties: BTreeMap::from([("x".into(), Value::Int(1))]),
1001 subtype: None,
1002 },
1003 1,
1004 "inst-a",
1005 ),
1006 make_entry(
1007 GraphOp::UpdateProperty {
1008 entity_id: "a".into(),
1009 key: "x".into(),
1010 value: Value::Int(2),
1011 },
1012 2,
1013 "inst-a",
1014 ),
1015 make_entry(
1016 GraphOp::AddNode {
1017 node_id: "b".into(),
1018 node_type: "entity".into(),
1019 label: "b".into(),
1020 properties: BTreeMap::new(),
1021 subtype: None,
1022 },
1023 3,
1024 "inst-a",
1025 ),
1026 make_entry(
1027 GraphOp::AddEdge {
1028 edge_id: "e1".into(),
1029 edge_type: "RUNS_ON".into(),
1030 source_id: "a".into(),
1031 target_id: "b".into(),
1032 properties: BTreeMap::new(),
1033 },
1034 4,
1035 "inst-a",
1036 ),
1037 make_entry(
1038 GraphOp::RemoveEdge {
1039 edge_id: "e1".into(),
1040 },
1041 5,
1042 "inst-a",
1043 ),
1044 ];
1045
1046 let mut g_inc = MaterializedGraph::new(test_ontology());
1048 for e in &entries {
1049 g_inc.apply(e);
1050 }
1051
1052 let mut g_full = MaterializedGraph::new(test_ontology());
1054 let refs: Vec<&Entry> = entries.iter().collect();
1055 g_full.rebuild(&refs);
1056
1057 assert_eq!(
1059 g_inc.get_node("a").unwrap().properties.get("x"),
1060 Some(&Value::Int(2))
1061 );
1062 assert_eq!(
1063 g_full.get_node("a").unwrap().properties.get("x"),
1064 Some(&Value::Int(2))
1065 );
1066 assert_eq!(g_inc.all_edges().len(), 0);
1068 assert_eq!(g_full.all_edges().len(), 0);
1069 }
1070
1071 #[test]
1072 fn lww_concurrent_property_update() {
1073 let mut g = MaterializedGraph::new(test_ontology());
1075 g.apply(&make_entry(
1076 GraphOp::AddNode {
1077 node_id: "s1".into(),
1078 node_type: "entity".into(),
1079 label: "s1".into(),
1080 properties: BTreeMap::new(),
1081 subtype: None,
1082 },
1083 1,
1084 "inst-a",
1085 ));
1086 g.apply(&make_entry(
1088 GraphOp::UpdateProperty {
1089 entity_id: "s1".into(),
1090 key: "status".into(),
1091 value: Value::String("alive".into()),
1092 },
1093 2,
1094 "inst-a",
1095 ));
1096 g.apply(&make_entry(
1098 GraphOp::UpdateProperty {
1099 entity_id: "s1".into(),
1100 key: "status".into(),
1101 value: Value::String("dead".into()),
1102 },
1103 3,
1104 "inst-b",
1105 ));
1106 assert_eq!(
1107 g.get_node("s1").unwrap().properties.get("status"),
1108 Some(&Value::String("dead".into()))
1109 );
1110 }
1111
1112 #[test]
1113 fn lww_tiebreak_by_instance_id() {
1114 let mut g = MaterializedGraph::new(test_ontology());
1116 g.apply(&make_entry(
1117 GraphOp::AddNode {
1118 node_id: "s1".into(),
1119 node_type: "entity".into(),
1120 label: "s1".into(),
1121 properties: BTreeMap::new(),
1122 subtype: None,
1123 },
1124 1,
1125 "inst-a",
1126 ));
1127 g.apply(&make_entry(
1129 GraphOp::UpdateProperty {
1130 entity_id: "s1".into(),
1131 key: "x".into(),
1132 value: Value::Int(1),
1133 },
1134 5,
1135 "inst-a",
1136 ));
1137 g.apply(&make_entry(
1138 GraphOp::UpdateProperty {
1139 entity_id: "s1".into(),
1140 key: "x".into(),
1141 value: Value::Int(2),
1142 },
1143 5,
1144 "inst-b",
1145 ));
1146 assert_eq!(
1148 g.get_node("s1").unwrap().properties.get("x"),
1149 Some(&Value::Int(1))
1150 );
1151 }
1152
1153 #[test]
1154 fn lww_per_property_concurrent_different_keys() {
1155 let mut g = MaterializedGraph::new(test_ontology());
1159 g.apply(&make_entry(
1160 GraphOp::AddNode {
1161 node_id: "s1".into(),
1162 node_type: "entity".into(),
1163 label: "s1".into(),
1164 properties: BTreeMap::from([
1165 ("x".into(), Value::Int(0)),
1166 ("y".into(), Value::Int(0)),
1167 ]),
1168 subtype: None,
1169 },
1170 1,
1171 "inst-a",
1172 ));
1173 g.apply(&make_entry(
1175 GraphOp::UpdateProperty {
1176 entity_id: "s1".into(),
1177 key: "x".into(),
1178 value: Value::Int(42),
1179 },
1180 3,
1181 "inst-a",
1182 ));
1183 g.apply(&make_entry(
1185 GraphOp::UpdateProperty {
1186 entity_id: "s1".into(),
1187 key: "y".into(),
1188 value: Value::Int(99),
1189 },
1190 3,
1191 "inst-b",
1192 ));
1193
1194 let node = g.get_node("s1").unwrap();
1195 assert_eq!(
1197 node.properties.get("x"),
1198 Some(&Value::Int(42)),
1199 "update to 'x' must not be rejected by concurrent update to 'y'"
1200 );
1201 assert_eq!(
1202 node.properties.get("y"),
1203 Some(&Value::Int(99)),
1204 "update to 'y' must not be rejected by concurrent update to 'x'"
1205 );
1206 }
1207
1208 #[test]
1209 fn lww_per_property_order_independent() {
1210 let mut g = MaterializedGraph::new(test_ontology());
1212 g.apply(&make_entry(
1213 GraphOp::AddNode {
1214 node_id: "s1".into(),
1215 node_type: "entity".into(),
1216 label: "s1".into(),
1217 properties: BTreeMap::from([
1218 ("x".into(), Value::Int(0)),
1219 ("y".into(), Value::Int(0)),
1220 ]),
1221 subtype: None,
1222 },
1223 1,
1224 "inst-a",
1225 ));
1226 g.apply(&make_entry(
1228 GraphOp::UpdateProperty {
1229 entity_id: "s1".into(),
1230 key: "y".into(),
1231 value: Value::Int(99),
1232 },
1233 3,
1234 "inst-b",
1235 ));
1236 g.apply(&make_entry(
1237 GraphOp::UpdateProperty {
1238 entity_id: "s1".into(),
1239 key: "x".into(),
1240 value: Value::Int(42),
1241 },
1242 3,
1243 "inst-a",
1244 ));
1245
1246 let node = g.get_node("s1").unwrap();
1247 assert_eq!(node.properties.get("x"), Some(&Value::Int(42)));
1248 assert_eq!(node.properties.get("y"), Some(&Value::Int(99)));
1249 }
1250
1251 #[test]
1252 fn add_wins_over_remove() {
1253 let mut g = MaterializedGraph::new(test_ontology());
1255 g.apply(&make_entry(
1256 GraphOp::AddNode {
1257 node_id: "s1".into(),
1258 node_type: "entity".into(),
1259 label: "s1".into(),
1260 properties: BTreeMap::new(),
1261 subtype: None,
1262 },
1263 1,
1264 "inst-a",
1265 ));
1266 g.apply(&make_entry(
1268 GraphOp::RemoveNode {
1269 node_id: "s1".into(),
1270 },
1271 2,
1272 "inst-a",
1273 ));
1274 assert!(g.get_node("s1").is_none());
1275
1276 g.apply(&make_entry(
1278 GraphOp::AddNode {
1279 node_id: "s1".into(),
1280 node_type: "entity".into(),
1281 label: "s1 v2".into(),
1282 properties: BTreeMap::new(),
1283 subtype: None,
1284 },
1285 3,
1286 "inst-b",
1287 ));
1288 let node = g.get_node("s1").unwrap();
1289 assert_eq!(node.label, "s1 v2");
1290 assert!(!node.tombstoned);
1291 }
1292
1293 #[test]
1297 fn checkpoint_replay_applies_inner_define_ontology() {
1298 let mut base = test_ontology();
1300 base.node_types.remove("signal");
1301 base.edge_types.remove("OBSERVES");
1302 let mut g = MaterializedGraph::new(base);
1303
1304 let checkpoint = make_entry(
1305 GraphOp::Checkpoint {
1306 ops: vec![
1307 GraphOp::DefineOntology {
1308 ontology: test_ontology(), },
1310 GraphOp::AddNode {
1311 node_id: "n1".into(),
1312 node_type: "entity".into(),
1313 label: "base-typed".into(),
1314 properties: BTreeMap::new(),
1315 subtype: None,
1316 },
1317 GraphOp::AddNode {
1318 node_id: "s1".into(),
1319 node_type: "signal".into(),
1320 label: "extension-typed".into(),
1321 properties: BTreeMap::new(),
1322 subtype: None,
1323 },
1324 ],
1325 op_clocks: vec![(1, 0), (1, 1), (1, 2)],
1326 compacted_at_physical_ms: 1,
1327 compacted_at_logical: 2,
1328 },
1329 1,
1330 "inst-a",
1331 );
1332 g.apply(&checkpoint);
1333
1334 assert!(g.get_node("n1").is_some());
1335 assert!(g.get_node("s1").is_some(), "extension-typed node lost");
1336 assert!(g.ontology.node_types.contains_key("signal"));
1337 assert!(g.quarantined.is_empty());
1338 }
1339}