use std::{
any::{Any, TypeId as StdTypeId},
collections::HashMap,
path::Path,
sync::Arc,
};
use parking_lot::ReentrantMutex;
use serde::Serialize;
use tracing::instrument;
use zerocopy::{FromBytes, IntoBytes};
use ahash::{AHashMap, AHashSet};
use crate::{
csr::{CsrCache, CsrSnapshot},
error::Error,
schema::{
AdjEntry, DirectedNeighborEntry, EdgeId, EdgeRecord, LabelId, Language, NeighborEntry,
NodeId, NodeRecord, PropKeyId, PropValue, TypeId, WeightedPath,
},
storage::{
Storage, fts,
ids::{
adjust_label_count, adjust_type_count, alloc_edge_id, alloc_node_id, get_label,
get_or_create_label, get_or_create_prop_key, get_or_create_type, get_prop_key,
get_prop_key_name, get_type,
},
props,
},
};
pub mod algo;
pub mod edge;
pub mod fts_mod;
pub mod index;
pub mod kernels;
pub mod node;
pub mod stats;
pub mod txn;
pub mod vector;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum DegreeDirection {
In,
Out,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum LinkPredictionMetric {
CommonNeighbors,
Jaccard,
AdamicAdar,
ResourceAllocation,
PreferentialAttachment,
}
#[derive(Debug, Clone, Default)]
pub struct TriangleCountSpec<'a> {
pub rel_types: [Option<&'a str>; 3],
pub labels: [Option<&'a str>; 3],
}
#[derive(Debug, Clone, Default)]
pub struct PathCountSpec<'a> {
pub rel_types: Vec<Option<&'a str>>,
pub labels: Vec<Option<&'a str>>,
pub vertex_allow: Vec<Option<Vec<NodeId>>>,
}
#[derive(Debug, Clone, Default)]
pub struct GroupedDegreeSpec<'a> {
pub rel_type: Option<&'a str>,
pub group_is_dst: bool,
pub group_label: Option<&'a str>,
pub counted_label: Option<&'a str>,
pub counted_allow: Option<&'a [NodeId]>,
pub counted_nonnull_prop: Option<&'a str>,
}
#[derive(Debug, Clone, Default)]
pub struct NeighborCountSpec<'a> {
pub rel_type: Option<&'a str>,
pub incoming: bool,
pub neighbor_labels: &'a [&'a str],
pub neighbor_allow: Option<&'a [NodeId]>,
pub neighbor_nonnull_prop: Option<&'a str>,
}
pub(super) type SchemaProbeMemo = (u64, AHashMap<(LabelId, TypeId, LabelId), Option<bool>>);
pub(super) fn composite_key(prefix: u32, id: u64) -> [u8; 12] {
let mut key = [0u8; 12];
key[..4].copy_from_slice(&prefix.to_be_bytes());
key[4..].copy_from_slice(&id.to_be_bytes());
key
}
pub(super) const ENCODED_NULL: u8 = 0x00;
const SORT_SIGN_BIT: u64 = 0x8000_0000_0000_0000;
pub(super) const MAX_INDEXED_STRING_LEN: usize = 480;
pub(super) fn encode_property_value(val: &serde_json::Value) -> Option<Vec<u8>> {
match val {
serde_json::Value::Null => Some(vec![ENCODED_NULL]),
serde_json::Value::Bool(false) => Some(vec![0x01]),
serde_json::Value::Bool(true) => Some(vec![0x02]),
serde_json::Value::Number(num) => {
let float_val = num.as_f64()?;
let bits = float_val.to_bits();
let masked = if (bits & SORT_SIGN_BIT) != 0 {
!bits
} else {
bits ^ SORT_SIGN_BIT
};
let int_disambig: u64 = if let Some(i) = num.as_i64() {
(i as u64) ^ SORT_SIGN_BIT
} else if float_val.fract() == 0.0
&& float_val >= i64::MIN as f64
&& float_val <= i64::MAX as f64
{
((float_val as i64) as u64) ^ SORT_SIGN_BIT
} else {
0
};
let mut buf = Vec::with_capacity(17);
buf.push(0x03);
buf.extend_from_slice(&masked.to_be_bytes());
buf.extend_from_slice(&int_disambig.to_be_bytes());
Some(buf)
}
serde_json::Value::String(s) => {
if s.len() > MAX_INDEXED_STRING_LEN {
return None;
}
let mut buf = Vec::with_capacity(1 + s.len() + 1);
buf.push(0x04);
buf.extend_from_slice(s.as_bytes());
buf.push(0x00);
Some(buf)
}
_ => None, }
}
pub(super) fn encoded_tag_family(tag: u8) -> u8 {
match tag {
0x02 => 0x01,
t => t,
}
}
#[allow(dead_code)]
pub(super) fn decode_property_value(bytes: &[u8]) -> Option<serde_json::Value> {
if bytes.is_empty() {
return None;
}
match bytes[0] {
0x00 => Some(serde_json::Value::Null),
0x01 => Some(serde_json::Value::Bool(false)),
0x02 => Some(serde_json::Value::Bool(true)),
0x03 => {
if bytes.len() < 17 {
return None;
}
let mut int_arr = [0u8; 8];
int_arr.copy_from_slice(&bytes[9..17]);
let int_val = (u64::from_be_bytes(int_arr) ^ SORT_SIGN_BIT) as i64;
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes[1..9]);
let masked = u64::from_be_bytes(arr);
let bits = if (masked & SORT_SIGN_BIT) == 0 {
!masked
} else {
masked ^ SORT_SIGN_BIT
};
let float_val = f64::from_bits(bits);
if (int_val as f64) == float_val {
Some(serde_json::Value::Number(int_val.into()))
} else {
serde_json::Number::from_f64(float_val).map(serde_json::Value::Number)
}
}
0x04 => {
let str_bytes = if bytes.ends_with(&[0x00]) {
&bytes[1..bytes.len() - 1]
} else {
&bytes[1..]
};
String::from_utf8(str_bytes.to_vec())
.ok()
.map(serde_json::Value::String)
}
_ => None,
}
}
pub(super) fn node_prop_index_key(
label_id: LabelId,
prop_key_id: PropKeyId,
encoded_val: &[u8],
node_id: NodeId,
) -> Vec<u8> {
let mut key = Vec::with_capacity(4 + 4 + encoded_val.len() + 8);
key.extend_from_slice(&label_id.to_be_bytes());
key.extend_from_slice(&prop_key_id.to_be_bytes());
key.extend_from_slice(encoded_val);
key.extend_from_slice(&node_id.to_be_bytes());
key
}
pub(super) fn edge_prop_index_key(
type_id: TypeId,
prop_key_id: PropKeyId,
encoded_val: &[u8],
edge_id: EdgeId,
) -> Vec<u8> {
let mut key = Vec::with_capacity(4 + 4 + encoded_val.len() + 8);
key.extend_from_slice(&type_id.to_be_bytes());
key.extend_from_slice(&prop_key_id.to_be_bytes());
key.extend_from_slice(encoded_val);
key.extend_from_slice(&edge_id.to_be_bytes());
key
}
pub(super) fn exact_prop_index_id(key: &[u8], encoded: &[u8]) -> Option<NodeId> {
if key.len() < 8 + 8 {
return None;
}
if &key[8..key.len() - 8] != encoded {
return None;
}
let id_bytes: [u8; 8] = key[key.len() - 8..].try_into().ok()?;
Some(u64::from_be_bytes(id_bytes))
}
pub(super) fn str_in_range(
s: &str,
lo: Option<&str>,
lo_inclusive: bool,
hi: Option<&str>,
hi_inclusive: bool,
) -> bool {
if let Some(lo) = lo {
if lo_inclusive {
if s < lo {
return false;
}
} else if s <= lo {
return false;
}
}
if let Some(hi) = hi {
if hi_inclusive {
if s > hi {
return false;
}
} else if s >= hi {
return false;
}
}
true
}
pub(super) fn fts_postings_key(label_id: LabelId, prop_key_id: PropKeyId, term: &str) -> Vec<u8> {
let mut key = Vec::with_capacity(8 + term.len());
key.extend_from_slice(&label_id.to_be_bytes());
key.extend_from_slice(&prop_key_id.to_be_bytes());
key.extend_from_slice(term.as_bytes());
key
}
pub(super) fn fts_posting_val(node_id: NodeId, frequency: u32) -> [u8; 12] {
let mut val = [0u8; 12];
val[0..8].copy_from_slice(&node_id.to_be_bytes());
val[8..12].copy_from_slice(&frequency.to_be_bytes());
val
}
pub(super) fn parse_fts_posting_val(bytes: &[u8]) -> Result<(NodeId, u32), Error> {
if bytes.len() != 12 {
return Err(Error::Corrupt("fts posting value must be 12 bytes"));
}
let node_id = NodeId::from_be_bytes(
bytes[0..8]
.try_into()
.map_err(|_| Error::Corrupt("fts posting: node_id slice wrong size"))?,
);
let frequency = u32::from_be_bytes(
bytes[8..12]
.try_into()
.map_err(|_| Error::Corrupt("fts posting: frequency slice wrong size"))?,
);
Ok((node_id, frequency))
}
pub(super) fn fts_doc_key(label_id: LabelId, prop_key_id: PropKeyId, node_id: NodeId) -> [u8; 16] {
let mut key = [0u8; 16];
key[0..4].copy_from_slice(&label_id.to_be_bytes());
key[4..8].copy_from_slice(&prop_key_id.to_be_bytes());
key[8..16].copy_from_slice(&node_id.to_be_bytes());
key
}
pub(super) fn parse_fts_doc_val(bytes: &[u8]) -> Result<u32, Error> {
if bytes.len() != 4 {
return Err(Error::Corrupt("fts doc val must be 4 bytes"));
}
Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
Error::Corrupt("fts doc val: slice wrong size")
})?))
}
pub(super) fn fts_stats_n_key(label_id: LabelId, prop_key_id: PropKeyId) -> String {
format!("fts_stats:node:l:{label_id}:p:{prop_key_id}:N")
}
pub(super) fn fts_stats_sum_dl_key(label_id: LabelId, prop_key_id: PropKeyId) -> String {
format!("fts_stats:node:l:{label_id}:p:{prop_key_id}:sum_dl")
}
#[derive(Clone)]
pub struct Graph {
pub(super) storage: Arc<Storage>,
pub(super) _write_lock: Arc<ReentrantMutex<()>>,
pub(super) csr_cache: Arc<CsrCache>,
pub(super) prop_columns: Arc<crate::columns::ColumnsCache<crate::columns::NodeSource>>,
pub(super) edge_columns: Arc<crate::columns::ColumnsCache<crate::columns::EdgeSource>>,
pub(super) edge_fanout: Arc<parking_lot::Mutex<Option<crate::graph::stats::EdgeFanout>>>,
pub(super) schema_probes: Arc<parking_lot::Mutex<SchemaProbeMemo>>,
pub(super) group_codes_by_id: Arc<parking_lot::Mutex<crate::columns::IdGroupCodesCache>>,
pub(super) label_scans: Arc<parking_lot::Mutex<index::LabelScanCache>>,
pub(super) n_threads: Arc<std::sync::atomic::AtomicI32>,
pub(crate) extensions: Arc<parking_lot::Mutex<AHashMap<StdTypeId, Box<dyn Any + Send + Sync>>>>,
#[cfg(test)]
pub(super) test_hooks: Arc<TestHooks>,
}
#[cfg(test)]
pub(super) type HookSlot = parking_lot::Mutex<Option<Box<dyn Fn() + Send>>>;
#[cfg(test)]
#[derive(Default)]
pub(super) struct TestHooks {
pub(super) after_commit_before_column_bookkeeping: HookSlot,
pub(super) before_schema_memoize: HookSlot,
}
#[cfg(test)]
impl TestHooks {
pub(super) fn fire(slot: &HookSlot) {
let hook = slot.lock().take();
if let Some(hook) = hook {
hook();
}
}
}
pub struct ReadTxn<'a> {
pub(super) graph: &'a Graph,
pub(super) rtxn: crate::storage::OwnedRoTxn<'a>,
}
pub struct WriteTxn<'a> {
pub(super) graph: &'a Graph,
pub(super) wtxn: crate::storage::RwTxn<'a>,
pub(super) mutations_count: usize,
pub(super) delta: crate::csr::GraphDelta,
pub(super) cache: WriteBatchCache,
}
#[derive(Default)]
pub(super) struct WriteBatchCache {
types: AHashMap<String, TypeId>,
edge_indexes: AHashMap<TypeId, Vec<(PropKeyId, u8)>>,
known_nodes: AHashSet<NodeId>,
}
impl WriteBatchCache {
fn knows_node(&self, id: NodeId) -> bool {
self.known_nodes.contains(&id)
}
fn remember_node(&mut self, id: NodeId) {
self.known_nodes.insert(id);
}
fn type_id(&self, name: &str) -> Option<TypeId> {
self.types.get(name).copied()
}
fn remember_type(&mut self, name: &str, id: TypeId) {
self.types.insert(name.to_string(), id);
}
pub(super) fn edge_indexes_or_insert<E>(
&mut self,
type_id: TypeId,
f: impl FnOnce() -> Result<Vec<(PropKeyId, u8)>, E>,
) -> Result<&[(PropKeyId, u8)], E> {
if !self.edge_indexes.contains_key(&type_id) {
let computed = f()?;
self.edge_indexes.insert(type_id, computed);
}
Ok(&self.edge_indexes[&type_id])
}
pub(super) fn invalidate_nodes(&mut self) {
self.known_nodes.clear();
}
}
thread_local! {
static IN_WRITE_TXN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
struct WriteTxnGuard {
previous: usize,
}
impl WriteTxnGuard {
fn enter(env_id: usize) -> Self {
let previous = IN_WRITE_TXN.with(|f| f.replace(env_id));
WriteTxnGuard { previous }
}
}
impl Drop for WriteTxnGuard {
fn drop(&mut self) {
IN_WRITE_TXN.with(|f| f.set(self.previous));
}
}
impl Graph {
fn write_txn_env_id(&self) -> usize {
Arc::as_ptr(&self.storage) as usize
}
fn debug_assert_not_in_write_txn(&self) {
debug_assert!(
IN_WRITE_TXN.with(|f| f.get()) != self.write_txn_env_id(),
"an auto-committing Graph method (or a nested Graph::update) was called while a \
WriteTxn from Graph::update was already open on this graph on this thread; call \
the WriteTxn method instead to avoid a same-thread deadlock on LMDB's \
single-writer lock"
);
}
}
impl Graph {
pub fn open(path: &Path, map_size_gb: usize) -> Result<Self, Error> {
let storage = Storage::open(path, map_size_gb)?;
let _ = std::fs::remove_file(path.join("csr_snapshot.bin"));
let storage = Arc::new(storage);
let csr_cache = Arc::new(CsrCache::new_unbuilt());
Ok(Self {
storage,
_write_lock: Arc::new(ReentrantMutex::new(())),
csr_cache,
prop_columns: Arc::new(crate::columns::ColumnsCache::default()),
edge_columns: Arc::new(crate::columns::ColumnsCache::default()),
edge_fanout: Arc::new(parking_lot::Mutex::new(None)),
schema_probes: Arc::new(parking_lot::Mutex::new((0, AHashMap::new()))),
group_codes_by_id: Arc::new(parking_lot::Mutex::new(
crate::columns::IdGroupCodesCache::default(),
)),
label_scans: Arc::new(parking_lot::Mutex::new(index::LabelScanCache::default())),
n_threads: Arc::new(std::sync::atomic::AtomicI32::new(0)),
extensions: Arc::new(parking_lot::Mutex::new(AHashMap::new())),
#[cfg(test)]
test_hooks: Arc::new(TestHooks::default()),
})
}
pub fn set_thread_count(&self, n: i32) -> Result<(), Error> {
self.n_threads
.store(n, std::sync::atomic::Ordering::Release);
Ok(())
}
pub fn node_prop_json(
&self,
id: NodeId,
prop: &str,
) -> Result<Option<serde_json::Value>, Error> {
if self.prop_columns.should_serve_directly(1) {
let Some(obj) = self.direct_node_props(id)? else {
return Ok(None);
};
return Ok(Some(
obj.get(prop).cloned().unwrap_or(serde_json::Value::Null),
));
}
self.prop_columns.with_fresh(&self.storage, |cols| {
cols.id_to_dense.get(&id).map(|&d| {
cols.cols
.get(prop)
.and_then(|c| c.get_json_opt(d as usize))
.unwrap_or(serde_json::Value::Null)
})
})
}
pub fn node_props_json_table(
&self,
ids: &[NodeId],
props: &[&str],
) -> Result<Vec<Vec<serde_json::Value>>, Error> {
if self.prop_columns.should_serve_directly(ids.len()) {
return self
.direct_node_props_many(ids)?
.into_iter()
.zip(ids)
.map(|(obj, &id)| {
let obj = obj.ok_or(Error::NodeNotFound(id))?;
Ok(props
.iter()
.map(|p| obj.get(*p).cloned().unwrap_or(serde_json::Value::Null))
.collect())
})
.collect();
}
self.prop_columns
.with_fresh(&self.storage, |cols| cols.props_table(ids, props))?
}
pub fn node_prop_json_column(
&self,
ids: &[NodeId],
prop: &str,
) -> Result<Vec<serde_json::Value>, Error> {
if self.prop_columns.should_serve_directly(ids.len()) {
return self
.direct_node_props_many(ids)?
.into_iter()
.zip(ids)
.map(|(obj, &id)| {
let obj = obj.ok_or(Error::NodeNotFound(id))?;
Ok(obj.get(prop).cloned().unwrap_or(serde_json::Value::Null))
})
.collect();
}
self.prop_columns
.with_fresh(&self.storage, |cols| cols.prop_column(ids, prop))?
}
fn direct_node_props(&self, id: NodeId) -> Result<Option<serde_json::Value>, Error> {
<crate::columns::NodeSource as crate::columns::ColumnSource>::fetch_one(&self.storage, id)
}
fn direct_node_props_many(
&self,
ids: &[NodeId],
) -> Result<Vec<Option<serde_json::Value>>, Error> {
<crate::columns::NodeSource as crate::columns::ColumnSource>::fetch_many(&self.storage, ids)
}
pub(super) fn nodes_prop_present(
&self,
ids: &[NodeId],
prop: &str,
) -> Result<Vec<bool>, Error> {
if self.prop_columns.should_serve_directly(ids.len()) {
return Ok(self
.direct_node_props_many(ids)?
.into_iter()
.map(|obj| obj.is_some_and(|o| o.get(prop).is_some_and(|v| !v.is_null())))
.collect());
}
self.prop_columns.with_fresh(&self.storage, |cols| {
ids.iter()
.map(|id| match (cols.id_to_dense.get(id), cols.cols.get(prop)) {
(Some(&d), Some(col)) => col.is_present(d as usize),
_ => false,
})
.collect()
})
}
pub fn nodes_prop_cmp_mask(
&self,
ids: &[NodeId],
prop: &str,
op: crate::columns::PropCmp,
rhs: &serde_json::Value,
) -> Result<Option<Vec<bool>>, Error> {
if self.prop_columns.should_serve_directly(ids.len()) {
return Ok(None);
}
self.prop_columns
.with_fresh(&self.storage, |cols| cols.cmp_mask(ids, prop, op, rhs))?
}
pub fn materialize_property_columns(&self) -> Result<(), Error> {
#[cfg(feature = "lmdb")]
{
let persisted_gen = {
let _guard = self._write_lock.lock();
let rtxn = self.storage.env.read_txn()?;
crate::storage::ids::commit_gen(&self.storage, &rtxn)?
};
let _quiet = crate::columns::MaterializingColumns::install();
self.prop_columns.with_fresh(&self.storage, |cols| {
let _ = crate::cache_file::save_columns(&self.storage, cols, persisted_gen);
})
}
#[cfg(not(feature = "lmdb"))]
{
let _quiet = crate::columns::MaterializingColumns::install();
self.prop_columns.with_fresh(&self.storage, |_| ())
}
}
pub fn node_prop_group_codes(
&self,
ids: &[NodeId],
prop: &str,
) -> Result<(Vec<u32>, Vec<serde_json::Value>), Error> {
if self.prop_columns.should_serve_directly(ids.len()) {
let fetched = self.direct_node_props_many(ids)?;
let items: Vec<(NodeId, serde_json::Value)> = ids
.iter()
.zip(fetched)
.filter_map(|(&id, obj)| obj.map(|o| (id, o)))
.collect();
let cols = crate::columns::PropColumns::<crate::columns::NodeSource>::from_items_for(
items,
Some(prop),
);
return cols.group_codes(ids, prop);
}
self.prop_columns
.with_fresh(&self.storage, |cols| cols.group_codes(ids, prop))?
}
pub fn node_prop_group_codes_by_id(
&self,
prop: &str,
) -> Result<std::sync::Arc<crate::columns::IdGroupCodes>, Error> {
let mut cache = self.group_codes_by_id.lock();
let generation = self.csr_cache.current_gen();
if cache.generation != generation {
cache.by_prop.clear();
cache.generation = generation;
}
if let Some(hit) = cache.by_prop.get(prop) {
return Ok(hit.clone());
}
let built = self.prop_columns.with_fresh(&self.storage, |cols| {
let (dense_codes, reps) = cols.group_codes(&cols.dense_to_id, prop)?;
let span = cols
.dense_to_id
.iter()
.copied()
.max()
.map_or(0, |m| m as usize + 1);
let mut codes = vec![crate::columns::ID_GROUP_ABSENT; span];
for (dense, &id) in cols.dense_to_id.iter().enumerate() {
codes[id as usize] = dense_codes[dense];
}
Ok::<_, Error>(crate::columns::IdGroupCodes {
codes,
reps: std::sync::Arc::new(reps),
})
})??;
let arc = std::sync::Arc::new(built);
cache.by_prop.insert(prop.to_string(), arc.clone());
Ok(arc)
}
pub fn materialize_edge_property_columns(&self) -> Result<(), Error> {
#[cfg(feature = "lmdb")]
{
let persisted_gen = {
let _guard = self._write_lock.lock();
let rtxn = self.storage.env.read_txn()?;
crate::storage::ids::commit_gen(&self.storage, &rtxn)?
};
let _quiet = crate::columns::MaterializingColumns::install();
self.edge_columns.with_fresh(&self.storage, |cols| {
let _ = crate::cache_file::save_columns(&self.storage, cols, persisted_gen);
})
}
#[cfg(not(feature = "lmdb"))]
{
let _quiet = crate::columns::MaterializingColumns::install();
self.edge_columns.with_fresh(&self.storage, |_| ())
}
}
pub fn edge_prop_json(
&self,
id: EdgeId,
prop: &str,
) -> Result<Option<serde_json::Value>, Error> {
self.edge_columns.with_fresh(&self.storage, |cols| {
cols.id_to_dense.get(&id).map(|&d| {
cols.cols
.get(prop)
.and_then(|c| c.get_json_opt(d as usize))
.unwrap_or(serde_json::Value::Null)
})
})
}
pub fn edge_props_json_table(
&self,
ids: &[EdgeId],
props: &[&str],
) -> Result<Vec<Vec<serde_json::Value>>, Error> {
self.edge_columns
.with_fresh(&self.storage, |cols| cols.props_table(ids, props))?
}
pub fn edge_prop_json_column(
&self,
ids: &[EdgeId],
prop: &str,
) -> Result<Vec<serde_json::Value>, Error> {
self.edge_columns
.with_fresh(&self.storage, |cols| cols.prop_column(ids, prop))?
}
pub fn edge_prop_group_codes(
&self,
ids: &[EdgeId],
prop: &str,
) -> Result<(Vec<u32>, Vec<serde_json::Value>), Error> {
self.edge_columns
.with_fresh(&self.storage, |cols| cols.group_codes(ids, prop))?
}
pub fn node_prop_min_max(
&self,
prop: &str,
) -> Result<Option<(serde_json::Value, serde_json::Value)>, Error> {
Ok(self
.prop_columns
.with_existing_mut(&self.storage, |cols| {
cols.prop_stats(prop)
.map(|s| (s.min.clone(), s.max.clone()))
})?
.flatten())
}
pub fn estimate_range_selectivity(
&self,
prop: &str,
lower: Option<&serde_json::Value>,
upper: Option<&serde_json::Value>,
) -> Result<Option<f64>, Error> {
Ok(self
.prop_columns
.with_existing_mut(&self.storage, |cols| {
cols.prop_stats(prop)
.map(|s| s.histogram.estimate_range_selectivity(lower, upper))
})?
.flatten())
}
pub fn estimate_equality_selectivity(
&self,
prop: &str,
val: &serde_json::Value,
) -> Result<Option<f64>, Error> {
Ok(self
.prop_columns
.with_existing_mut(&self.storage, |cols| {
cols.prop_stats(prop).map(|s| s.equality_selectivity(val))
})?
.flatten())
}
pub fn set_extension<T: Any + Send + Sync>(&self, val: Arc<T>) {
self.extensions
.lock()
.insert(StdTypeId::of::<T>(), Box::new(val));
}
pub fn get_extension<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
self.extensions
.lock()
.get(&StdTypeId::of::<T>())
.and_then(|b| b.downcast_ref::<Arc<T>>())
.cloned()
}
pub fn get_or_init_extension_with<T, E, F>(&self, init: F) -> Result<Arc<T>, E>
where
T: Any + Send + Sync,
F: FnOnce() -> Result<Arc<T>, E>,
{
if let Some(existing) = self.get_extension::<T>() {
return Ok(existing);
}
let value = init()?;
let mut ext = self.extensions.lock();
if let Some(existing) = ext
.get(&StdTypeId::of::<T>())
.and_then(|b| b.downcast_ref::<Arc<T>>())
{
return Ok(existing.clone());
}
ext.insert(StdTypeId::of::<T>(), Box::new(value.clone()));
Ok(value)
}
pub fn view<F, T>(&self, f: F) -> Result<T, Error>
where
F: FnOnce(&ReadTxn) -> Result<T, Error>,
{
let rtxn = self.storage.env.read_txn()?;
let txn = ReadTxn { graph: self, rtxn };
f(&txn)
}
pub fn update<F, T>(&self, f: F) -> Result<T, Error>
where
F: FnOnce(&mut WriteTxn) -> Result<T, Error>,
{
self.debug_assert_not_in_write_txn();
let _guard = self._write_lock.lock();
let wtxn = self.storage.env.write_txn()?;
let mut txn = WriteTxn {
graph: self,
wtxn,
mutations_count: 0,
delta: crate::csr::GraphDelta::default(),
cache: WriteBatchCache::default(),
};
let _txn_guard = WriteTxnGuard::enter(self.write_txn_env_id());
match f(&mut txn) {
Ok(val) => {
let WriteTxn {
wtxn,
mutations_count,
delta,
graph: _,
cache: _,
} = txn;
self.commit_and_publish(wtxn, mutations_count)?;
#[cfg(test)]
TestHooks::fire(&self.test_hooks.after_commit_before_column_bookkeeping);
if delta.force_full {
self.prop_columns.record_force_full();
} else {
self.prop_columns.record_touched_many(&delta.added_nodes);
self.prop_columns.record_touched_many(&delta.updated_nodes);
}
if delta.force_full || delta.removed_edge {
self.edge_columns.record_force_full();
} else {
self.edge_columns.record_touched_many(&delta.added_edge_ids);
self.edge_columns.record_touched_many(&delta.updated_edges);
}
if mutations_count > 0 {
self.maybe_spawn_rebuild_n(mutations_count);
}
Ok(val)
}
Err(err) => {
txn.wtxn.abort();
Err(err)
}
}
}
pub(super) fn commit_and_publish(
&self,
mut wtxn: crate::storage::RwTxn<'_>,
count: usize,
) -> Result<(), Error> {
if count > 0 {
crate::storage::ids::bump_commit_gen(&self.storage, &mut wtxn)?;
}
wtxn.commit()?;
self.csr_cache.advance_write_gen(count as u64);
Ok(())
}
pub fn with_write_lock<F, R>(&self, f: F) -> R
where
F: FnOnce() -> R,
{
let _guard = self._write_lock.lock();
f()
}
#[instrument(skip(self))]
pub fn rebuild_csr(&self) -> Result<(), Error> {
let _maint = self.csr_cache.maintenance.lock();
let built_gen = self.csr_cache.current_gen();
#[cfg(feature = "lmdb")]
let persisted_gen = {
let rtxn = self.storage.env.read_txn()?;
crate::storage::ids::commit_gen(&self.storage, &rtxn)?
};
let snap = self.build_snapshot_from_storage()?;
#[cfg(feature = "lmdb")]
let _ = crate::cache_file::save_csr(
self.storage.env.path(),
&snap,
self.storage.db_id,
persisted_gen,
);
self.csr_cache.install_full(snap, built_gen);
Ok(())
}
pub fn backup(&self, destination: &Path) -> Result<(), Error> {
self.storage.copy_to_file(destination, false)
}
pub fn backup_compact(&self, destination: &Path) -> Result<(), Error> {
self.storage.copy_to_file(destination, true)
}
pub fn restore(snapshot_file: &Path, dst_dir: &Path) -> Result<(), Error> {
Storage::restore_from_file(snapshot_file, dst_dir)
}
}
#[cfg(test)]
mod extension_tests {
use std::sync::Arc;
use tempfile::TempDir;
use super::Graph;
fn open_tmp() -> (TempDir, Graph) {
let dir = TempDir::new().unwrap();
let g = Graph::open(dir.path(), 1).unwrap();
(dir, g)
}
#[test]
fn extension_roundtrip_by_type() {
let (_dir, g) = open_tmp();
assert!(g.get_extension::<String>().is_none());
g.set_extension(Arc::new(String::from("cache")));
let got = g.get_extension::<String>().expect("extension must exist");
assert_eq!(*got, "cache");
assert!(g.get_extension::<u64>().is_none(), "distinct type slot");
g.set_extension(Arc::new(String::from("replaced")));
assert_eq!(*g.get_extension::<String>().unwrap(), "replaced");
}
#[test]
fn get_or_init_extension_initializes_once() {
let (_dir, g) = open_tmp();
let v1 = g
.get_or_init_extension_with::<u64, std::convert::Infallible, _>(|| Ok(Arc::new(7)))
.unwrap();
assert_eq!(*v1, 7);
let v2 = g
.get_or_init_extension_with::<u64, std::convert::Infallible, _>(|| Ok(Arc::new(9)))
.unwrap();
assert_eq!(*v2, 7, "second init must not replace the stored value");
}
#[test]
fn get_or_init_extension_propagates_init_error() {
let (_dir, g) = open_tmp();
let err = g
.get_or_init_extension_with::<u64, &str, _>(|| Err("init failed"))
.unwrap_err();
assert_eq!(err, "init failed");
assert!(g.get_extension::<u64>().is_none());
let v = g
.get_or_init_extension_with::<u64, &str, _>(|| Ok(Arc::new(7)))
.unwrap();
assert_eq!(*v, 7);
}
}
#[cfg(test)]
mod encode_tests {
use serde_json::json;
use super::{MAX_INDEXED_STRING_LEN, decode_property_value, encode_property_value};
#[test]
fn over_long_strings_are_not_indexed() {
let at_limit = json!("a".repeat(MAX_INDEXED_STRING_LEN));
let encoded = encode_property_value(&at_limit).expect("at-limit string indexes");
assert_eq!(decode_property_value(&encoded), Some(at_limit));
let too_long = json!("a".repeat(MAX_INDEXED_STRING_LEN + 1));
assert_eq!(
encode_property_value(&too_long),
None,
"a string over the bound must not be indexed",
);
}
#[test]
fn large_integers_do_not_collide() {
let a = encode_property_value(&json!(9_007_199_254_740_992_i64)).unwrap(); let b = encode_property_value(&json!(9_007_199_254_740_993_i64)).unwrap(); assert_ne!(a, b, "distinct large integers must encode distinctly");
}
#[test]
fn integer_and_equal_float_unify() {
assert_eq!(
encode_property_value(&json!(30)).unwrap(),
encode_property_value(&json!(30.0)).unwrap(),
);
assert_eq!(
encode_property_value(&json!(0)).unwrap(),
encode_property_value(&json!(0.0)).unwrap(),
);
}
#[test]
fn numeric_encoding_is_fixed_length() {
for v in [
json!(1),
json!(-1),
json!(0),
json!(i64::MAX),
json!(i64::MIN),
json!(3.5),
json!(-2.5e10),
] {
assert_eq!(encode_property_value(&v).unwrap().len(), 17, "value {v}");
}
}
#[test]
fn numeric_ordering_preserved() {
let ascending: Vec<i64> = vec![
i64::MIN,
-1_000,
-1,
0,
1,
1_000,
1 << 53,
(1 << 53) + 1,
i64::MAX,
];
let encoded: Vec<Vec<u8>> = ascending
.iter()
.map(|v| encode_property_value(&json!(v)).unwrap())
.collect();
let mut sorted = encoded.clone();
sorted.sort();
assert_eq!(encoded, sorted, "encodings must sort in numeric order");
}
#[test]
fn decode_round_trips_large_integer() {
for v in [
json!(0),
json!(-1),
json!(9_007_199_254_740_993_i64),
json!(i64::MAX),
] {
let enc = encode_property_value(&v).unwrap();
assert_eq!(decode_property_value(&enc), Some(v.clone()), "value {v}");
}
}
}
#[cfg(feature = "lmdb")]
#[cfg(test)]
mod restore_tests {
use serde_json::json;
use tempfile::TempDir;
use super::Graph;
#[test]
fn restore_refuses_an_existing_database() {
let src = TempDir::new().unwrap();
let snap_dir = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let snap = snap_dir.path().join("a.mdb");
{
let a = Graph::open(src.path(), 1).unwrap();
a.add_node("FromA", &json!({ "n": 1 })).unwrap();
a.backup(&snap).unwrap();
}
{
let b = Graph::open(dst.path(), 1).unwrap();
for i in 0..5 {
b.add_node("FromB", &json!({ "n": i })).unwrap();
}
}
let err = Graph::restore(&snap, dst.path()).unwrap_err();
assert!(
err.to_string().contains("already contains a database"),
"{err}"
);
let b = Graph::open(dst.path(), 1).unwrap();
assert_eq!(b.nodes_by_label("FromB").unwrap().len(), 5);
assert!(b.nodes_by_label("FromA").unwrap().is_empty());
let fresh = TempDir::new().unwrap();
let nested = fresh.path().join("new");
Graph::restore(&snap, &nested).unwrap();
let restored = Graph::open(&nested, 1).unwrap();
assert_eq!(restored.nodes_by_label("FromA").unwrap().len(), 1);
}
#[test]
fn restore_removes_leftover_cache_files() {
let old = TempDir::new().unwrap();
let snap_dir = TempDir::new().unwrap();
let snap = snap_dir.path().join("b.mdb");
{
let a = Graph::open(old.path(), 1).unwrap();
let n0 = a.add_node("N", &json!({ "x": 1 })).unwrap();
let n1 = a.add_node("N", &json!({ "x": 2 })).unwrap();
a.add_edge(n0, n1, "R", &json!({})).unwrap();
a.rebuild_csr().unwrap();
a.materialize_property_columns().unwrap();
}
{
let b_dir = TempDir::new().unwrap();
let b = Graph::open(b_dir.path(), 1).unwrap();
b.add_node("FromB", &json!({})).unwrap();
b.backup(&snap).unwrap();
}
std::fs::remove_file(old.path().join("data.mdb")).unwrap();
let _ = std::fs::remove_file(old.path().join("lock.mdb"));
assert!(old.path().join("csr.cache").exists());
Graph::restore(&snap, old.path()).unwrap();
let leftover: Vec<_> = std::fs::read_dir(old.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "cache"))
.collect();
assert!(leftover.is_empty(), "leftover cache files: {leftover:?}");
let restored = Graph::open(old.path(), 1).unwrap();
assert_eq!(restored.nodes_by_label("FromB").unwrap().len(), 1);
}
}
#[cfg(test)]
#[cfg(feature = "lmdb")]
mod stamp_race_tests {
use std::sync::mpsc;
use serde_json::json;
use tempfile::TempDir;
use super::Graph;
#[test]
fn a_concurrent_materialize_does_not_stamp_the_cache_file_ahead_of_its_content() {
let dir = TempDir::new().unwrap();
let node;
{
let g = Graph::open(dir.path(), 1).unwrap();
node = g.add_node("Person", &json!({ "v": 1 })).unwrap();
g.materialize_property_columns().unwrap();
let (reached_tx, reached_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
g.test_hooks
.after_commit_before_column_bookkeeping
.lock()
.replace(Box::new(move || {
reached_tx.send(()).unwrap();
release_rx.recv().unwrap();
}));
let writer = {
let g = g.clone();
std::thread::spawn(move || {
g.update(|txn| txn.update_node(node, &json!({ "v": 2 })))
.unwrap();
})
};
reached_rx.recv().unwrap();
let materializer = {
let g = g.clone();
std::thread::spawn(move || g.materialize_property_columns().unwrap())
};
std::thread::sleep(std::time::Duration::from_millis(100));
release_tx.send(()).unwrap();
writer.join().unwrap();
materializer.join().unwrap();
}
let g = Graph::open(dir.path(), 1).unwrap();
g.materialize_property_columns().unwrap();
assert_eq!(
g.node_prop_json(node, "v").unwrap(),
Some(json!(2)),
"the reopened graph must serve the committed value through the loaded columns"
);
}
}
#[cfg(test)]
mod publish_tests {
use serde_json::json;
use tempfile::TempDir;
use super::Graph;
#[test]
fn every_committing_mutation_publishes_the_write() {
let dir = TempDir::new().unwrap();
let g = Graph::open(dir.path(), 1).unwrap();
let a = g.add_node("P", &json!({ "n": 1 })).unwrap();
let b = g.add_node("P", &json!({ "n": 2 })).unwrap();
let edge = g.add_edge(a, b, "T", &json!({ "weight": 1.0 })).unwrap();
let victim_node = g.add_node("P", &json!({})).unwrap();
let victim_edge = g.add_edge(a, b, "T", &json!({})).unwrap();
let label_target = g.add_node("P", &json!({})).unwrap();
macro_rules! assert_publishes {
($name:literal, $body:block) => {{
g.rebuild_csr().unwrap();
assert!(
!g.csr_cache.snapshot_is_stale(),
concat!($name, ": a fresh rebuild must report current")
);
$body
assert!(
g.csr_cache.snapshot_is_stale(),
concat!($name, " committed without publishing the write generation")
);
}};
}
assert_publishes!("add_node", {
g.add_node("P", &json!({})).unwrap();
});
assert_publishes!("add_node_multi", {
g.add_node_multi(&["P", "Q"], &json!({})).unwrap();
});
assert_publishes!("add_edge", {
g.add_edge(a, b, "T", &json!({})).unwrap();
});
assert_publishes!("update_node", {
g.update_node(a, &json!({ "n": 9 })).unwrap();
});
assert_publishes!("update_edge", {
g.update_edge(edge, &json!({ "weight": 2.0 })).unwrap();
});
assert_publishes!("add_label", {
g.add_label(label_target, "R").unwrap();
});
assert_publishes!("remove_label", {
g.remove_label(label_target, "R").unwrap();
});
assert_publishes!("delete_edge", {
g.delete_edge(victim_edge).unwrap();
});
assert_publishes!("delete_node", {
g.delete_node(victim_node).unwrap();
});
assert_publishes!("update", {
g.update(|txn| {
txn.add_node("P", &json!({}))?;
Ok(())
})
.unwrap();
});
}
#[test]
fn a_mutation_free_update_publishes_nothing() {
let dir = TempDir::new().unwrap();
let g = Graph::open(dir.path(), 1).unwrap();
g.add_node("P", &json!({})).unwrap();
g.rebuild_csr().unwrap();
g.update(|txn| txn.get_node(1).map(|_| ())).unwrap();
assert!(
!g.csr_cache.snapshot_is_stale(),
"a read-only update must leave the caches current"
);
}
}
#[cfg(feature = "lmdb")]
#[cfg(test)]
mod lazy_open_tests {
use serde_json::json;
use tempfile::TempDir;
use super::Graph;
use crate::schema::NodeId;
fn seeded_dir() -> (TempDir, Vec<NodeId>) {
let dir = TempDir::new().unwrap();
let ids = {
let g = Graph::open(dir.path(), 1).unwrap();
let ids: Vec<_> = (0..80)
.map(|i| g.add_node("Person", &json!({ "n": i })).unwrap())
.collect();
for i in 0..ids.len() {
g.add_edge(ids[i], ids[(i + 1) % ids.len()], "FOLLOWS", &json!({}))
.unwrap();
}
g.add_edge(ids[0], ids[40], "LIKES", &json!({})).unwrap();
g.bfs(ids[0], 2).unwrap();
assert!(
!g.csr_cache.snapshot_is_stale(),
"seed handle must build the snapshot"
);
ids
};
(dir, ids)
}
#[test]
fn open_defers_the_csr_build() {
let (dir, _ids) = seeded_dir();
let g = Graph::open(dir.path(), 1).unwrap();
assert_eq!(
g.csr_cache.snapshot.load().dense_to_id.len(),
0,
"open must not build the CSR snapshot"
);
assert!(
g.csr_cache.snapshot_is_stale(),
"the unbuilt snapshot must report stale so a consumer rebuilds it"
);
}
#[test]
fn reopened_graph_serves_every_consumer_class() {
let (dir, ids) = seeded_dir();
let reopen = || Graph::open(dir.path(), 1).unwrap();
{
let g = reopen();
let wide = g.expand_bulk(&ids, Some("FOLLOWS"), false).unwrap();
assert_eq!(wide.len(), 80, "every ring edge must expand");
}
{
let g = reopen();
let narrow = g.expand_bulk(&ids[..4], Some("FOLLOWS"), false).unwrap();
assert_eq!(narrow.len(), 4);
}
{
let g = reopen();
assert_eq!(
g.bfs(ids[0], 1).unwrap().len(),
3,
"start plus both one-hop neighbors"
);
}
{
let g = reopen();
assert_eq!(g.dfs(ids[0], 1).unwrap().len(), 3);
}
{
let g = reopen();
assert_eq!(g.page_rank(5, 0.85).unwrap().len(), 80);
}
{
let g = reopen();
let spec = crate::PathCountSpec {
rel_types: vec![Some("FOLLOWS")],
labels: vec![Some("Person"), Some("Person")],
vertex_allow: Vec::new(),
};
assert_eq!(g.count_linear_paths(&spec).unwrap(), 80);
}
{
let g = reopen();
assert_eq!(g.out_neighbors(ids[0]).unwrap().len(), 2);
}
}
#[test]
fn first_algorithm_builds_what_open_skipped() {
let (dir, ids) = seeded_dir();
let g = Graph::open(dir.path(), 1).unwrap();
assert!(g.csr_cache.snapshot_is_stale());
assert_eq!(g.bfs(ids[0], 1).unwrap().len(), 3);
assert!(
!g.csr_cache.snapshot_is_stale(),
"the snapshot gate must build on first use"
);
}
#[test]
fn empty_database_opens_lazily_and_reads_empty() {
let dir = TempDir::new().unwrap();
{
Graph::open(dir.path(), 1).unwrap();
}
let g = Graph::open(dir.path(), 1).unwrap();
assert!(g.all_nodes().unwrap().is_empty());
assert!(g.connected_components().unwrap().is_empty());
assert!(g.page_rank(3, 0.85).unwrap().is_empty());
}
}