use std::collections::HashSet;
use std::sync::Arc;
use petgraph::graph::{EdgeIndex, NodeIndex};
use crate::datatypes::Value;
use crate::graph::features::timeseries::NodeTimeseries;
use crate::graph::schema::{
CompositeIndexKey, CompositeValue, EdgeData, IndexKey, InternedKey, NodeData,
};
use crate::graph::storage::column_store::ColumnStore;
#[cfg(test)]
thread_local! {
static JOURNAL_NODE_PRE_IMAGES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn reset_journal_node_pre_images() {
JOURNAL_NODE_PRE_IMAGES.set(0);
}
#[cfg(test)]
pub(crate) fn journal_node_pre_images() -> usize {
JOURNAL_NODE_PRE_IMAGES.get()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BucketId {
NodeType(String),
SecondaryLabel(InternedKey),
PropertyValue { key: IndexKey, value: Value },
RangeValue { key: IndexKey, value: Value },
CompositeTuple {
key: CompositeIndexKey,
value: CompositeValue,
},
}
#[derive(Debug)]
pub enum UndoEntry {
NodeAdded {
idx: NodeIndex,
node_type: InternedKey,
},
NodeWeight { idx: NodeIndex, prior: NodeData },
NodeRemoved { idx: NodeIndex, prior: NodeData },
EdgeAdded { idx: EdgeIndex },
EdgeWeight { idx: EdgeIndex, prior: EdgeData },
EdgeRemoved {
idx: EdgeIndex,
src: NodeIndex,
tgt: NodeIndex,
prior: EdgeData,
},
BucketAppended {
bucket: BucketId,
idx: NodeIndex,
bucket_was_new: bool,
},
BucketRemoved {
bucket: BucketId,
idx: NodeIndex,
pos: usize,
},
TimeseriesRemoved {
node: usize,
prior: Box<NodeTimeseries>,
},
ColumnarHandles {
node_type: String,
prior: Arc<ColumnStore>,
},
}
#[derive(Debug, Default)]
pub struct UndoJournal {
entries: Vec<UndoEntry>,
weighed_nodes: HashSet<NodeIndex>,
weighed_edges: HashSet<EdgeIndex>,
forked_columnar_types: HashSet<String>,
}
impl UndoJournal {
pub fn new() -> Self {
Self::default()
}
pub fn into_replay_order(self) -> impl Iterator<Item = UndoEntry> {
self.entries.into_iter().rev()
}
#[inline]
pub fn note_node_added(&mut self, idx: NodeIndex, node_type: InternedKey) {
self.entries.push(UndoEntry::NodeAdded { idx, node_type });
self.weighed_nodes.insert(idx);
}
#[inline]
pub fn note_node_weight(&mut self, idx: NodeIndex, prior: impl FnOnce() -> Option<NodeData>) {
if self.weighed_nodes.insert(idx) {
if let Some(prior) = prior() {
#[cfg(test)]
JOURNAL_NODE_PRE_IMAGES.set(JOURNAL_NODE_PRE_IMAGES.get() + 1);
self.entries.push(UndoEntry::NodeWeight { idx, prior });
}
}
}
#[inline]
pub fn note_node_removed(&mut self, idx: NodeIndex, prior: NodeData) {
self.entries.push(UndoEntry::NodeRemoved { idx, prior });
}
#[inline]
pub fn note_edge_added(&mut self, idx: EdgeIndex) {
self.entries.push(UndoEntry::EdgeAdded { idx });
self.weighed_edges.insert(idx);
}
#[inline]
pub fn note_edge_weight(&mut self, idx: EdgeIndex, prior: impl FnOnce() -> Option<EdgeData>) {
if self.weighed_edges.insert(idx) {
if let Some(prior) = prior() {
self.entries.push(UndoEntry::EdgeWeight { idx, prior });
}
}
}
#[inline]
pub fn note_edge_removed(
&mut self,
idx: EdgeIndex,
src: NodeIndex,
tgt: NodeIndex,
prior: EdgeData,
) {
self.entries.push(UndoEntry::EdgeRemoved {
idx,
src,
tgt,
prior,
});
}
#[inline]
pub fn note_bucket_appended(&mut self, bucket: BucketId, idx: NodeIndex, bucket_was_new: bool) {
self.entries.push(UndoEntry::BucketAppended {
bucket,
idx,
bucket_was_new,
});
}
#[inline]
pub fn note_bucket_removed(&mut self, bucket: BucketId, idx: NodeIndex, pos: usize) {
self.entries
.push(UndoEntry::BucketRemoved { bucket, idx, pos });
}
pub fn note_bucket_retain(
&mut self,
bucket: &BucketId,
members: impl Iterator<Item = NodeIndex>,
doomed: &HashSet<NodeIndex>,
) {
let mut hits: Vec<(usize, NodeIndex)> = members
.enumerate()
.filter(|(_, idx)| doomed.contains(idx))
.collect();
hits.reverse();
for (pos, idx) in hits {
self.note_bucket_removed(bucket.clone(), idx, pos);
}
}
#[inline]
pub fn note_columnar_fork(
&mut self,
node_type: &str,
prior: impl FnOnce() -> Option<Arc<ColumnStore>>,
) -> bool {
if !self.forked_columnar_types.contains(node_type) {
self.forked_columnar_types.insert(node_type.to_string());
if let Some(prior) = prior() {
self.entries.push(UndoEntry::ColumnarHandles {
node_type: node_type.to_string(),
prior,
});
return true;
}
}
false
}
#[inline]
pub fn note_timeseries_removed(&mut self, node: usize, prior: NodeTimeseries) {
self.entries.push(UndoEntry::TimeseriesRemoved {
node,
prior: Box::new(prior),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datatypes::Value;
use crate::graph::schema::StringInterner;
use std::collections::HashMap;
fn entries(journal: UndoJournal) -> Vec<UndoEntry> {
journal.into_replay_order().collect()
}
fn node(interner: &mut StringInterner, id: i64) -> NodeData {
NodeData::new(
Value::Int64(id),
Value::String(format!("n{id}")),
"T".to_string(),
HashMap::new(),
interner,
)
}
#[test]
fn weight_capture_is_once_per_entity() {
let mut interner = StringInterner::new();
let mut journal = UndoJournal::new();
let idx = NodeIndex::new(4);
let mut calls = 0;
for _ in 0..5 {
journal.note_node_weight(idx, || {
calls += 1;
Some(node(&mut interner, 1))
});
}
assert_eq!(calls, 1, "the pre-image must be cloned exactly once");
assert_eq!(entries(journal).len(), 1);
}
#[test]
fn created_nodes_skip_later_weight_capture() {
let mut interner = StringInterner::new();
let mut journal = UndoJournal::new();
let idx = NodeIndex::new(0);
journal.note_node_added(idx, InternedKey::from_str("T"));
let mut calls = 0;
journal.note_node_weight(idx, || {
calls += 1;
Some(node(&mut interner, 1))
});
assert_eq!(calls, 0, "a node created this statement needs no pre-image");
assert_eq!(entries(journal).len(), 1);
}
#[test]
fn structural_entries_are_never_deduplicated() {
let mut interner = StringInterner::new();
let mut journal = UndoJournal::new();
let idx = NodeIndex::new(2);
journal.note_node_removed(idx, node(&mut interner, 1));
journal.note_node_added(idx, InternedKey::from_str("T"));
journal.note_node_removed(idx, node(&mut interner, 2));
assert_eq!(
entries(journal).len(),
3,
"free-list reuse depends on every structural edit being replayed"
);
}
#[test]
fn replay_order_is_reverse_of_capture() {
let mut journal = UndoJournal::new();
journal.note_edge_added(EdgeIndex::new(0));
journal.note_edge_added(EdgeIndex::new(1));
journal.note_edge_added(EdgeIndex::new(2));
let seen: Vec<usize> = journal
.into_replay_order()
.map(|e| match e {
UndoEntry::EdgeAdded { idx } => idx.index(),
other => panic!("unexpected entry: {other:?}"),
})
.collect();
assert_eq!(seen, vec![2, 1, 0]);
}
#[test]
fn bucket_retain_records_positions_descending() {
let mut journal = UndoJournal::new();
let bucket = BucketId::NodeType("T".to_string());
let contents: Vec<NodeIndex> = (0..8).map(NodeIndex::new).collect();
let doomed: HashSet<NodeIndex> =
[NodeIndex::new(2), NodeIndex::new(6)].into_iter().collect();
journal.note_bucket_retain(&bucket, contents.iter().copied(), &doomed);
let positions: Vec<usize> = journal
.into_replay_order()
.map(|e| match e {
UndoEntry::BucketRemoved { pos, .. } => pos,
other => panic!("unexpected entry: {other:?}"),
})
.collect();
assert_eq!(positions, vec![2, 6]);
}
}