use rustc_hash::FxHashSet;
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, RemovedEmbedding,
TypeSchema,
};
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) };
static JOURNAL_COLUMNAR_CELLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
static JOURNAL_COLUMNAR_APPENDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn reset_journal_columnar_appends() {
JOURNAL_COLUMNAR_APPENDS.set(0);
}
#[cfg(test)]
pub(crate) fn journal_columnar_appends() -> usize {
JOURNAL_COLUMNAR_APPENDS.get()
}
#[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()
}
#[cfg(test)]
pub(crate) fn reset_journal_columnar_cells() {
JOURNAL_COLUMNAR_CELLS.set(0);
}
#[cfg(test)]
pub(crate) fn journal_columnar_cells() -> usize {
JOURNAL_COLUMNAR_CELLS.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>,
},
EmbeddingRemoved {
store_key: (String, String),
node: usize,
prior: Box<RemovedEmbedding>,
},
ColumnarCell {
node_type: InternedKey,
row_id: u32,
key: InternedKey,
prior: Option<Value>,
},
ColumnarSchemaGrown {
node_type: InternedKey,
prior_schema: Arc<TypeSchema>,
prior_column_count: usize,
},
ColumnarRowsAppended {
node_type: InternedKey,
prior_row_count: u32,
prior_schema: Arc<TypeSchema>,
prior_column_count: usize,
store_was_new: bool,
},
ColumnarTitle {
node_type: InternedKey,
row_id: u32,
prior: Option<Value>,
},
ColumnarTombstone { node_type: InternedKey, row_id: u32 },
}
pub(crate) enum ColumnarWrite<'a> {
Cell(InternedKey),
ReplaceRow(&'a [InternedKey]),
}
pub(crate) struct ColumnarPreImages {
cells: Vec<(InternedKey, Option<Value>)>,
grown: Option<(Arc<TypeSchema>, usize)>,
}
impl ColumnarPreImages {
pub(crate) fn capture(store: &ColumnStore, row_id: u32, write: ColumnarWrite<'_>) -> Self {
let mut cells = Vec::new();
let mut grows = false;
match write {
ColumnarWrite::Cell(key) => {
grows = store.slot(key).is_none();
cells.push((key, store.get(row_id, key)));
}
ColumnarWrite::ReplaceRow(keys) => {
for (key, value) in store.row_properties(row_id) {
cells.push((key, Some(value)));
}
for &key in keys {
grows |= store.slot(key).is_none();
cells.push((key, store.get(row_id, key)));
}
}
}
Self {
cells,
grown: grows.then(|| (store.schema_arc(), store.column_count())),
}
}
pub(crate) fn record(self, journal: &mut UndoJournal, node_type: InternedKey, row_id: u32) {
if let Some((prior_schema, prior_column_count)) = self.grown {
journal.entries.push(UndoEntry::ColumnarSchemaGrown {
node_type,
prior_schema,
prior_column_count,
});
}
for (key, prior) in self.cells {
#[cfg(test)]
JOURNAL_COLUMNAR_CELLS.set(JOURNAL_COLUMNAR_CELLS.get() + 1);
journal.entries.push(UndoEntry::ColumnarCell {
node_type,
row_id,
key,
prior,
});
}
}
}
pub(crate) struct ColumnarAppendPreImage {
row_count: u32,
schema: Arc<TypeSchema>,
column_count: usize,
}
impl ColumnarAppendPreImage {
#[inline]
pub(crate) fn capture(store: &ColumnStore) -> Self {
Self {
row_count: store.row_count(),
schema: store.schema_arc(),
column_count: store.column_count(),
}
}
#[inline]
pub(crate) fn record(
self,
journal: &mut UndoJournal,
node_type: InternedKey,
store_was_new: bool,
) {
#[cfg(test)]
JOURNAL_COLUMNAR_APPENDS.set(JOURNAL_COLUMNAR_APPENDS.get() + 1);
journal.entries.push(UndoEntry::ColumnarRowsAppended {
node_type,
prior_row_count: self.row_count,
prior_schema: self.schema,
prior_column_count: self.column_count,
store_was_new,
});
}
}
#[derive(Debug, Default)]
pub struct UndoJournal {
entries: Vec<UndoEntry>,
weighed_nodes: FxHashSet<NodeIndex>,
weighed_edges: FxHashSet<EdgeIndex>,
appended_types: FxHashSet<InternedKey>,
}
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 claim_columnar_append(&mut self, node_type: InternedKey) -> bool {
self.appended_types.insert(node_type)
}
#[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_title(
&mut self,
node_type: InternedKey,
row_id: u32,
prior: Option<Value>,
) {
self.entries.push(UndoEntry::ColumnarTitle {
node_type,
row_id,
prior,
});
}
#[inline]
pub fn note_columnar_tombstone(&mut self, node_type: InternedKey, row_id: u32) {
self.entries
.push(UndoEntry::ColumnarTombstone { node_type, row_id });
}
#[inline]
pub fn note_timeseries_removed(&mut self, node: usize, prior: NodeTimeseries) {
self.entries.push(UndoEntry::TimeseriesRemoved {
node,
prior: Box::new(prior),
});
}
#[inline]
pub fn note_embedding_removed(
&mut self,
store_key: (String, String),
node: usize,
prior: RemovedEmbedding,
) {
self.entries.push(UndoEntry::EmbeddingRemoved {
store_key,
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]);
}
fn cell_store(interner: &mut StringInterner) -> (ColumnStore, InternedKey, InternedKey) {
let a = interner.get_or_intern("a");
let b = interner.get_or_intern("b");
let schema = Arc::new(crate::graph::schema::TypeSchema::from_keys([a, b]));
let mut store = ColumnStore::new_mixed(schema);
store.push_row(&[(a, Value::Int64(1)), (b, Value::Int64(2))]);
(store, a, b)
}
#[test]
fn columnar_cells_are_not_deduplicated_and_replay_oldest_last() {
let mut interner = StringInterner::new();
let (mut store, a, _b) = cell_store(&mut interner);
let node_type = InternedKey::from_str("T");
let mut journal = UndoJournal::new();
ColumnarPreImages::capture(&store, 0, ColumnarWrite::Cell(a)).record(
&mut journal,
node_type,
0,
);
store.set(0, a, &Value::Int64(10), None);
ColumnarPreImages::capture(&store, 0, ColumnarWrite::Cell(a)).record(
&mut journal,
node_type,
0,
);
store.set(0, a, &Value::Int64(20), None);
for entry in journal.into_replay_order() {
match entry {
UndoEntry::ColumnarCell {
row_id, key, prior, ..
} => {
store.set(row_id, key, &prior.unwrap_or(Value::Null), None);
}
other => panic!("unexpected entry: {other:?}"),
}
}
assert_eq!(
store.get(0, a),
Some(Value::Int64(1)),
"reverse replay must land the pre-statement capture last"
);
}
#[test]
fn schema_growth_is_captured_before_its_cells() {
let mut interner = StringInterner::new();
let (store, _a, _b) = cell_store(&mut interner);
let fresh = interner.get_or_intern("fresh");
let node_type = InternedKey::from_str("T");
let mut journal = UndoJournal::new();
ColumnarPreImages::capture(&store, 0, ColumnarWrite::Cell(fresh)).record(
&mut journal,
node_type,
0,
);
let kinds: Vec<&'static str> = journal
.into_replay_order()
.map(|entry| match entry {
UndoEntry::ColumnarCell { .. } => "cell",
UndoEntry::ColumnarSchemaGrown {
prior_column_count, ..
} => {
assert_eq!(prior_column_count, 2, "the pre-growth column count");
"schema"
}
other => panic!("unexpected entry: {other:?}"),
})
.collect();
assert_eq!(
kinds,
vec!["cell", "schema"],
"replay must restore the cell while its column still exists, then \
drop the column"
);
}
#[test]
fn an_existing_key_captures_no_schema_entry() {
let mut interner = StringInterner::new();
let (store, a, _b) = cell_store(&mut interner);
let mut journal = UndoJournal::new();
ColumnarPreImages::capture(&store, 0, ColumnarWrite::Cell(a)).record(
&mut journal,
InternedKey::from_str("T"),
0,
);
assert_eq!(entries(journal).len(), 1);
}
#[test]
fn replace_row_captures_present_cells_and_incoming_keys() {
let mut interner = StringInterner::new();
let (store, a, b) = cell_store(&mut interner);
let fresh = interner.get_or_intern("fresh");
let mut journal = UndoJournal::new();
ColumnarPreImages::capture(&store, 0, ColumnarWrite::ReplaceRow(&[a, fresh])).record(
&mut journal,
InternedKey::from_str("T"),
0,
);
let mut cells: Vec<(InternedKey, Option<Value>)> = journal
.into_replay_order()
.filter_map(|entry| match entry {
UndoEntry::ColumnarCell { key, prior, .. } => Some((key, prior)),
UndoEntry::ColumnarSchemaGrown { .. } => None,
other => panic!("unexpected entry: {other:?}"),
})
.collect();
cells.sort_by_key(|(key, _)| key.as_u64());
let mut expected = vec![
(a, Some(Value::Int64(1))),
(b, Some(Value::Int64(2))),
(a, Some(Value::Int64(1))),
(fresh, None),
];
expected.sort_by_key(|(key, _)| key.as_u64());
assert_eq!(cells, expected);
}
#[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]);
}
}